From b1f0eafbf7e226b29ecb211b2fe8cee1efce00e5 Mon Sep 17 00:00:00 2001 From: Hamza Date: Tue, 20 Oct 2026 16:29:32 +0500 Subject: [PATCH 01/24] MCP: inspect_document answers within what a client can read MCP stdio is newline-delimited JSON, so one tool result is one message and has to fit the client's read buffer whole; there is no streaming of part of one. The TypeScript SDK's stdio transport defaults to 10 MiB and hosts rarely raise it, so a document whose tree went past that killed the server: the client stopped reading, the pipe broke, and every later call in the session failed. The tree is now reported at the deepest depth that fits, found by bisection, with a `truncated` object giving the depth asked for, the depth used, both sizes, the limit and a pointer to get_node for reading one subtree deeply. `maxBytes` lets a caller that knows its own buffer say so; the default is 8 MiB, leaving the envelope 2 MiB of the usual 10. The size is measured as the transport sends it -- pretty-printed and then escaped inside the text block, which adds about a tenth (342,136 bytes of tree became 379,954 on the wire in the fixture). A kilobyte is reserved for the marker itself, which is added after the measurement. A limit below even the shallowest tree still answers, at depth 0, with the marker saying so: there is nothing smaller to send, and an error would leave the caller with nothing at all. The tool description now says that each level of depth multiplies the response and points at get_node, which the report notes would have been enough on its own to avoid the crash. Fixes #912. --- crates/mcp/src/tools.rs | 87 +++++++++++++++++++++- crates/mcp/tests/inspect_response_limit.rs | 72 ++++++++++++++++++ 2 files changed, 156 insertions(+), 3 deletions(-) create mode 100644 crates/mcp/tests/inspect_response_limit.rs diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 7714da5a..92773e03 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -173,9 +173,13 @@ pub fn tool_definitions() -> Vec { tool( "inspect_document", "Inspect document", - "The active document as JSON: name, path, dirty flag, current page, selection, the current page's layer tree (id, name, type, x/y relative to the parent frame (groups don't count), width, height, absoluteBounds in page coordinates, fills, strokes, effects, cornerRadius, layout, sizing, text properties, component links, overrides, bound variables), other pages summarised, styles, variable count and the undo history. Use it to verify every change.", + "The active document as JSON: name, path, dirty flag, current page, selection, the current page's layer tree (id, name, type, x/y relative to the parent frame (groups don't count), width, height, absoluteBounds in page coordinates, fills, strokes, effects, cornerRadius, layout, sizing, text properties, component links, overrides, bound variables), other pages summarised, styles, variable count and the undo history. Use it to verify every change. Each level of `depth` multiplies the response, and `allPages` multiplies it again: on a large document the tree is reported at a shallower depth than asked for, with a `truncated` object saying so. Use get_node to read one subtree deeply instead.", obj( - json!({"depth": int("Tree depth for the current page (default 4)"), "allPages": boolean("Expand every page, not only the current one")}), + json!({ + "depth": int("Tree depth for the current page (default 4)"), + "allPages": boolean("Expand every page, not only the current one"), + "maxBytes": int("Largest response to send, in bytes on the wire (default 8388608, which fits the usual 10 MiB client buffer)"), + }), &[], ), true, @@ -562,6 +566,27 @@ fn set_properties(b: &mut dyn Backend, a: &Map) -> Result Result<(Value, usize), String> { + let value = b.call("document.inspect", json!({"depth": depth, "allPages": all}))?; + let text = serde_json::to_string_pretty(&value).unwrap_or_default(); + let wire = serde_json::to_string(&text).map_or(text.len(), |s| s.len()); + Ok((value, wire)) +} + fn inspect_document(b: &mut dyn Backend, a: &Map) -> Result { let depth = n_arg(a, "depth")?.unwrap_or(4.0).clamp(0.0, 64.0) as u64; let all = match a.get("allPages") { @@ -569,7 +594,63 @@ fn inspect_document(b: &mut dyn Backend, a: &Map) -> Result *x, Some(_) => return Err("`allPages` must be a boolean".into()), }; - b.call("document.inspect", json!({"depth": depth, "allPages": all})).map(|v| ToolResult::json(&v)) + // A caller that knows its own buffer can say so; 1 KiB is below the shallowest tree, so the + // floor only keeps the arithmetic sane. + let max_bytes = match n_arg(a, "maxBytes")? { + Some(v) if v < 1024.0 => return Err("`maxBytes` must be at least 1024".into()), + Some(v) => v.min(usize::MAX as f64) as usize, + None => MAX_RESPONSE, + }; + + let (value, wire) = inspect_at(b, depth, all)?; + if wire <= max_bytes || depth == 0 { + return Ok(ToolResult::json(&value)); + } + + // The deepest tree that fits, by bisection: a deeper tree holds every node of a shallower one, + // so the size does not fall as the depth grows. Bisecting keeps this to about six more walks + // of the document rather than one per level. + let (mut lo, mut hi) = (0u64, depth - 1); + 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)?; + if w + MARKER_RESERVE <= max_bytes { + best = Some((mid, v, w)); + if mid == hi { + break; + } + lo = mid + 1; + } else if mid == 0 { + // Not even the shallowest tree fits. Send it anyway and say so: there is nothing + // smaller to send, and an error would leave the caller with nothing at all. + best = Some((0, v, w)); + break; + } else { + hi = mid - 1; + } + } + + let Some((used, mut value, used_wire)) = best else { + return Ok(ToolResult::json(&value)); + }; + if let Some(obj) = value.as_object_mut() { + obj.insert( + "truncated".into(), + json!({ + "requestedDepth": depth, + "depth": used, + "requestedDepthBytes": wire, + "bytes": used_wire, + "maxBytes": max_bytes, + "reason": format!( + "the tree at depth {depth} would be {wire} bytes on the wire, over the {max_bytes}-byte limit a client can read in one message; it is reported at depth {used} instead" + ), + "hint": "get_node({id, depth}) walks one subtree at a time, so a deep tree can be read in parts without a size limit", + }), + ); + } + Ok(ToolResult::json(&value)) } fn get_node(b: &mut dyn Backend, a: &Map) -> Result { diff --git a/crates/mcp/tests/inspect_response_limit.rs b/crates/mcp/tests/inspect_response_limit.rs new file mode 100644 index 00000000..0d9f9e6d --- /dev/null +++ b/crates/mcp/tests/inspect_response_limit.rs @@ -0,0 +1,72 @@ +//! `inspect_document` must not emit a message larger than a client can read in one piece: MCP +//! stdio is newline-delimited JSON, so one result is one message, and a client whose read buffer +//! it exceeds closes the pipe and kills the server for the rest of the session (#912). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod common; +use common::Client; +use serde_json::{Value, json}; + +/// A document whose tree is far larger than the limits used below. +fn busy() -> Client { + let mut c = Client::new(); + for i in 0..40 { + let frame = c.create(json!({"type": "frame", "x": i * 10, "y": 0, "width": 200, "height": 200})); + for j in 0..12 { + c.create(json!({"type": "rectangle", "parent": frame, "x": j * 5, "y": 5, "width": 20, "height": 20})); + } + } + c +} + +/// The response and what its text block takes on the wire, escaped as the transport sends it. +fn inspect(c: &mut Client, args: Value) -> (Value, usize) { + let r = c.call("inspect_document", args.clone()); + assert_eq!(r["isError"], false, "{args}: {}", r["content"][0]["text"]); + let text = r["content"][0]["text"].as_str().expect("a text block"); + let wire = serde_json::to_string(&text).unwrap().len(); + (serde_json::from_str(text).expect("the block is JSON"), wire) +} + +#[test] +fn a_tree_too_large_for_the_client_is_reported_shallower_and_says_so() { + let mut c = busy(); + let (full, full_wire) = inspect(&mut c, json!({"depth": 6, "allPages": true})); + assert!(full["truncated"].is_null(), "a tree within the limit is not truncated: {}", full["truncated"]); + assert!(full_wire > 30_000, "the fixture has to outgrow the limits below: {full_wire}"); + + // Asked for more than the caller can read: a shallower tree, inside the limit, and a marker. + let (cut, cut_wire) = inspect(&mut c, json!({"depth": 6, "allPages": true, "maxBytes": 30_000})); + assert!(cut_wire <= 30_000, "the response still exceeds maxBytes: {cut_wire}"); + let t = &cut["truncated"]; + assert_eq!(t["requestedDepth"], 6, "{t}"); + assert!(t["depth"].as_u64().unwrap() < 6, "the depth was reduced: {t}"); + assert_eq!(t["maxBytes"], 30_000, "{t}"); + assert_eq!(t["requestedDepthBytes"], full_wire, "the marker says what the full tree would have taken: {t}"); + // The marker points at the way to read the rest. + assert!(t["hint"].as_str().unwrap_or("").contains("get_node"), "{t}"); + // Still a usable document, not just a marker: the page and its top-level layers are there. + assert!(!cut["currentPage"].is_null(), "{cut}"); + assert!(cut["pages"][0]["children"].as_array().is_some_and(|c| !c.is_empty()), "{cut}"); + + // The deepest tree that fits is the one sent: a limit that admits the whole tree is not cut. + let (whole, _) = inspect(&mut c, json!({"depth": 6, "allPages": true, "maxBytes": full_wire})); + assert!(whole["truncated"].is_null(), "a limit at the exact size is not a truncation: {}", whole["truncated"]); +} + +#[test] +fn a_limit_below_the_shallowest_tree_still_answers() { + // Nothing smaller than depth 0 exists, so it is sent with the marker rather than an error: + // an error would leave the caller with nothing at all. + let mut c = busy(); + let (v, _) = inspect(&mut c, json!({"depth": 6, "allPages": true, "maxBytes": 1024})); + assert_eq!(v["truncated"]["depth"], 0, "{}", v["truncated"]); + assert_eq!(v["truncated"]["requestedDepth"], 6, "{}", v["truncated"]); +} + +#[test] +fn maxbytes_is_checked() { + let mut c = Client::new(); + let r = c.call("inspect_document", json!({"depth": 1, "maxBytes": 10})); + assert_eq!(r["isError"], true, "{r}"); + assert!(r["content"][0]["text"].as_str().unwrap_or("").contains("at least 1024"), "{r}"); +} From 3df5673627026deba90fcfbf089c0ac1fa0d49dc Mon Sep 17 00:00:00 2001 From: Hamza Date: Tue, 20 Oct 2026 20:56:08 +0500 Subject: [PATCH 02/24] MCP: maxBytes is the whole message, and depth 0 says when it is over Two things the limit got wrong, both reported by CodeRabbit on #915. maxBytes was compared with the escaped text block alone, but what goes on the wire is that block inside a content object, inside the JSON-RPC result, with the request id and the newline the transport writes: a block measured at exactly maxBytes left as a larger message than the caller said it could read. A kilobyte of the caller's figure is now kept for that wrapping. About ninety bytes of it is fixed and the rest is the request id, so the figure holds for any id up to roughly 900 bytes -- every id in practice, a number or a UUID. Sizing the real envelope here would mean handing the transport's request id to every tool, and the assumption is written down instead, in the schema and at the constant. A depth: 0 request over the limit returned early, so the one response that cannot be made any smaller was the one sent without the truncated marker -- while a bisection that walked down to depth 0 sent the same tree with it. Depth 0 now takes the same path as the bisection: the tree goes out, over the limit, with the marker saying so. Without the first fix the test reports a limit at the exact size of the tree as no truncation; without the second the depth-0 marker is absent (left: (None, None), right: (Some(0), Some(0))). --- crates/mcp/src/tools.rs | 68 ++++++++++++++-------- crates/mcp/tests/inspect_response_limit.rs | 29 ++++++++- 2 files changed, 70 insertions(+), 27 deletions(-) diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 92773e03..d04ea620 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -178,7 +178,9 @@ pub fn tool_definitions() -> Vec { json!({ "depth": int("Tree depth for the current page (default 4)"), "allPages": boolean("Expand every page, not only the current one"), - "maxBytes": int("Largest response to send, in bytes on the wire (default 8388608, which fits the usual 10 MiB client buffer)"), + "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" + ), }), &[], ), @@ -578,6 +580,14 @@ const MAX_RESPONSE: usize = 8 << 20; /// otherwise be able to push a response that only just fitted back over the limit. const MARKER_RESERVE: usize = 1 << 10; +/// Room kept for everything the text block is wrapped in: the content object and `isError`, the +/// JSON-RPC envelope (`jsonrpc`, `id`, `result`) and the newline the transport writes after it. +/// About ninety bytes of that is fixed and the rest is the request id, so `maxBytes` holds for +/// any id up to roughly 900 bytes -- which is every id in practice, a number or a UUID. An id +/// larger than that is the one case where the message can pass the caller's figure, and sizing +/// the real envelope here would mean handing the transport's request id to every tool. +const ENVELOPE_RESERVE: usize = 1 << 10; + /// 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> { @@ -602,37 +612,45 @@ fn inspect_document(b: &mut dyn Backend, a: &Map) -> Result MAX_RESPONSE, }; + // What the tree itself may take: the caller's figure is for the whole message. + let budget = max_bytes.saturating_sub(ENVELOPE_RESERVE); + let (value, wire) = inspect_at(b, depth, all)?; - if wire <= max_bytes || depth == 0 { + if wire <= budget { return Ok(ToolResult::json(&value)); } - // The deepest tree that fits, by bisection: a deeper tree holds every node of a shallower one, - // so the size does not fall as the depth grows. Bisecting keeps this to about six more walks - // of the document rather than one per level. - let (mut lo, mut hi) = (0u64, depth - 1); - 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)?; - if w + MARKER_RESERVE <= max_bytes { - best = Some((mid, v, w)); - if mid == hi { + let (used, mut value, used_wire) = if depth == 0 { + // Depth 0 is the shallowest tree there is, so there is nothing smaller to send: it goes + // out over the limit with the marker saying so, the same as a bisection that reaches + // depth 0. An error would leave the caller with nothing at all. + (0, value, wire) + } else { + // The deepest tree that fits, by bisection: a deeper tree holds every node of a shallower + // one, so the size does not fall as the depth grows. Bisecting keeps this to about six + // more walks of the document rather than one per level. + let (mut lo, mut hi) = (0u64, depth - 1); + 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)?; + if w + MARKER_RESERVE <= budget { + best = Some((mid, v, w)); + if mid == hi { + break; + } + lo = mid + 1; + } else if mid == 0 { + best = Some((0, v, w)); break; + } else { + hi = mid - 1; } - lo = mid + 1; - } else if mid == 0 { - // Not even the shallowest tree fits. Send it anyway and say so: there is nothing - // smaller to send, and an error would leave the caller with nothing at all. - best = Some((0, v, w)); - break; - } else { - hi = mid - 1; } - } - - let Some((used, mut value, used_wire)) = best else { - return Ok(ToolResult::json(&value)); + match best { + Some(found) => found, + None => return Ok(ToolResult::json(&value)), + } }; if let Some(obj) = value.as_object_mut() { obj.insert( diff --git a/crates/mcp/tests/inspect_response_limit.rs b/crates/mcp/tests/inspect_response_limit.rs index 0d9f9e6d..5e36b5a9 100644 --- a/crates/mcp/tests/inspect_response_limit.rs +++ b/crates/mcp/tests/inspect_response_limit.rs @@ -6,6 +6,10 @@ mod common; use common::Client; use serde_json::{Value, json}; +/// The allowance `inspect_document` keeps for the content object, the JSON-RPC envelope and the +/// request id (`ENVELOPE_RESERVE`). +const ENVELOPE: usize = 1024; + /// A document whose tree is far larger than the limits used below. fn busy() -> Client { let mut c = Client::new(); @@ -49,8 +53,29 @@ fn a_tree_too_large_for_the_client_is_reported_shallower_and_says_so() { assert!(cut["pages"][0]["children"].as_array().is_some_and(|c| !c.is_empty()), "{cut}"); // The deepest tree that fits is the one sent: a limit that admits the whole tree is not cut. - let (whole, _) = inspect(&mut c, json!({"depth": 6, "allPages": true, "maxBytes": full_wire})); - assert!(whole["truncated"].is_null(), "a limit at the exact size is not a truncation: {}", whole["truncated"]); + // maxBytes is the whole message, so the tree fits under it with room for the envelope, not + // at it: a text block of exactly maxBytes would be sent inside a larger message than that. + let (whole, _) = inspect(&mut c, json!({"depth": 6, "allPages": true, "maxBytes": full_wire + ENVELOPE})); + assert!(whole["truncated"].is_null(), "a limit that leaves room for the envelope is not a truncation: {}", whole["truncated"]); + let (tight, _) = inspect(&mut c, json!({"depth": 6, "allPages": true, "maxBytes": full_wire})); + assert_eq!(tight["truncated"]["requestedDepth"], 6, "a limit at the exact size of the tree leaves none for the envelope: {tight}"); +} + +/// Every over-limit response carries the marker, however the depth was arrived at: a caller that +/// asked for depth 0 has no other way to learn that its limit was passed. +#[test] +fn a_depth_0_response_over_the_limit_says_so_too() { + let mut c = busy(); + let (deep, _) = inspect(&mut c, json!({"depth": 0, "allPages": true})); + assert!(deep["truncated"].is_null(), "within the default limit: {}", deep["truncated"]); + let (v, wire) = inspect(&mut c, json!({"depth": 0, "allPages": true, "maxBytes": 1024})); + let t = &v["truncated"]; + assert_eq!((t["requestedDepth"].as_u64(), t["depth"].as_u64()), (Some(0), Some(0)), "{t}"); + // `bytes` is the tree, measured before the marker was added to it, so the message that goes + // out is larger still -- there being nothing smaller to send. + assert_eq!(t["bytes"], t["requestedDepthBytes"], "{t}"); + assert!(wire > 1024, "the depth-0 tree is sent over the limit, with the marker saying so: {wire}"); + assert!(!v["pages"].is_null(), "the tree is still sent, there being nothing smaller: {v}"); } #[test] From 1aa1d61bd4aeb2aa93bfc04e2a3156227631bab4 Mon Sep 17 00:00:00 2001 From: Hamza Date: Wed, 21 Oct 2026 00:29:14 +0500 Subject: [PATCH 03/24] MCP: get_node keeps to the limit too, and windows a wide node inspect_document sends a shallower tree when the document's is too large and points at get_node for the rest, so get_node was the way out of that limit -- and it had none of its own. Measured against a document with a frame of 15,000 children, through figcraft-cli mcp: inspect_document depth 64, allPages 14.29 MiB (8.00 MiB cap not applied before #915) get_node depth 64 10.51 MiB Both are over the 10 MiB buffer the report is about, and for get_node no depth makes it smaller: the width is at depth 1, so depth 1 is already 9.12 MiB of text and depth 0 has no children at all. get_node now takes the same maxBytes, cuts the subtree by depth as the document's tree is cut, and then takes a window over the children when even depth 1 is too large. The marker says how many children were shown of how many, and the offset to ask for next; `offset` is a new argument for reading the rest. After this: get_node depth 64 8.00 MiB, children 11427 of 15000 from 0, next 11427 The hint in inspect_document's marker no longer promises that get_node has no size limit, since that was the hole: it now says a wide node answers with a window and an offset. The test pages through a windowed node and asserts every child is reported exactly once, in order, with every page inside the limit. Unbounded, the first page is 23,003 bytes against a 7,667-byte limit. --- crates/mcp/src/tools.rs | 185 +++++++++++++++++++-- crates/mcp/tests/inspect_response_limit.rs | 57 +++++++ 2 files changed, 224 insertions(+), 18 deletions(-) diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index d04ea620..19f2be35 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -189,8 +189,18 @@ pub fn tool_definitions() -> Vec { tool( "get_node", "Get layer", - "One layer as JSON (same fields as inspect_document) with its children to `depth`.", - obj(json!({"id": id_schema("Layer id"), "depth": int("Child depth (default 1)")}), &["id"]), + "One layer as JSON (same fields as inspect_document) with its children to `depth`. A subtree too large for one message is reported at a shallower depth, or with a window of the children, and a `truncated` object says so and gives the `offset` to ask for next.", + obj( + json!({ + "id": id_schema("Layer id"), + "depth": int("Child depth (default 1)"), + "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" + ), + }), + &["id"], + ), true, ), tool( @@ -588,12 +598,38 @@ const MARKER_RESERVE: usize = 1 << 10; /// the real envelope here would mean handing the transport's request id to every tool. const ENVELOPE_RESERVE: usize = 1 << 10; +/// What the caller says it can read, as the bytes its *tree* may take: the figure is for the whole +/// message, so the envelope's share comes off it here. +fn budget(a: &Map) -> Result<(usize, usize), String> { + // A caller that knows its own buffer can say so; 1 KiB is below the shallowest tree, so the + // floor only keeps the arithmetic sane. + let max_bytes = match n_arg(a, "maxBytes")? { + Some(v) if v < 1024.0 => return Err("`maxBytes` must be at least 1024".into()), + Some(v) => v.min(usize::MAX as f64) as usize, + None => MAX_RESPONSE, + }; + Ok((max_bytes, max_bytes.saturating_sub(ENVELOPE_RESERVE))) +} + +/// What a value's text block would take on the wire. It is sent as a text block, so it is measured +/// pretty-printed *and* escaped -- escaping adds about a tenth. +fn wire_size(value: &Value) -> usize { + let text = serde_json::to_string_pretty(value).unwrap_or_default(); + serde_json::to_string(&text).map_or(text.len(), |s| s.len()) +} + /// 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}))?; - let text = serde_json::to_string_pretty(&value).unwrap_or_default(); - let wire = serde_json::to_string(&text).map_or(text.len(), |s| s.len()); + 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}))?; + let wire = wire_size(&value); Ok((value, wire)) } @@ -604,16 +640,7 @@ fn inspect_document(b: &mut dyn Backend, a: &Map) -> Result *x, Some(_) => return Err("`allPages` must be a boolean".into()), }; - // A caller that knows its own buffer can say so; 1 KiB is below the shallowest tree, so the - // floor only keeps the arithmetic sane. - let max_bytes = match n_arg(a, "maxBytes")? { - Some(v) if v < 1024.0 => return Err("`maxBytes` must be at least 1024".into()), - Some(v) => v.min(usize::MAX as f64) as usize, - None => MAX_RESPONSE, - }; - - // What the tree itself may take: the caller's figure is for the whole message. - let budget = max_bytes.saturating_sub(ENVELOPE_RESERVE); + let (max_bytes, budget) = budget(a)?; let (value, wire) = inspect_at(b, depth, all)?; if wire <= budget { @@ -664,21 +691,143 @@ fn inspect_document(b: &mut dyn Backend, a: &Map) -> Result) -> Result { 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 v = b.call("node.get", json!({"id": id, "depth": depth}))?; - if v.is_null() { + let offset = n_arg(a, "offset")?.unwrap_or(0.0).max(0.0).min(u32::MAX as f64) as usize; + let (max_bytes, budget) = budget(a)?; + + let (value, wire) = node_at(b, id, depth)?; + if value.is_null() { return Err(format!("no such layer {id}")); } - Ok(ToolResult::json(&v)) + if wire <= budget && offset == 0 { + return Ok(ToolResult::json(&value)); + } + + // The deepest subtree that fits, by bisection, as the document's tree is cut. A node asked for + // at a depth of its own children only (1) cannot be made shallower without losing them all, + // so that is where the window takes over. + let (mut used, mut value, mut used_wire) = (depth, value, wire); + if depth > 1 && offset == 0 { + 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)?; + if w + MARKER_RESERVE <= budget { + (used, value, used_wire) = (mid, v, w); + if mid == hi { + break; + } + lo = mid + 1; + } else if mid == 1 { + (used, value, used_wire) = (1, v, w); + break; + } else { + hi = mid - 1; + } + } + if used_wire + MARKER_RESERVE <= budget { + return Ok(ToolResult::json(&with_marker(value, depth, used, wire, used_wire, max_bytes, None))); + } + } + + // Still over, or a window was asked for: the children from `offset` that fit. + let total = value.get("children").and_then(Value::as_array).map_or(0, Vec::len); + let window = children_window(&mut value, offset, budget.saturating_sub(MARKER_RESERVE)); + let shown = value.get("children").and_then(Value::as_array).map_or(0, Vec::len); + let next = (offset + shown < total).then_some(offset + shown); + let used_wire = wire_size(&value); + let mut out = with_marker( + value, + depth, + used, + wire, + used_wire, + max_bytes, + Some(json!({ + "from": offset, + "shown": shown, + "total": total, + "nextOffset": next, + })), + ); + if window && next.is_none() && offset == 0 && used == depth { + // Nothing was left out after all (an offset of zero that fitted whole). + out.as_object_mut().map(|o| o.remove("truncated")); + } + Ok(ToolResult::json(&out)) +} + +/// Keep the children from `offset` that fit in `budget`, and say whether any were left out. The +/// window is found by bisection: a node with thousands of children would otherwise be serialised +/// once per child. +fn children_window(value: &mut Value, offset: usize, budget: usize) -> bool { + let Some(all) = value.get("children").and_then(Value::as_array).cloned() else { return false }; + let rest: Vec = all.iter().skip(offset).cloned().collect(); + let fits = |value: &mut Value, n: usize| { + if let Some(obj) = value.as_object_mut() { + obj.insert("children".into(), Value::Array(rest.iter().take(n).cloned().collect())); + } + wire_size(value) <= budget + }; + if fits(value, rest.len()) { + return rest.len() != all.len(); + } + // The largest window that fits, between none and all of the rest. + let (mut lo, mut hi) = (0usize, rest.len()); + while lo < hi { + let mid = lo + (hi - lo).div_ceil(2); + if fits(value, mid) { lo = mid } else { hi = mid - 1 } + } + fits(value, lo); + true +} + +/// `value` with the `truncated` object saying how it was cut. `children`: the window, when the +/// children were cut as well as (or instead of) the depth. +fn with_marker(mut value: Value, asked: u64, used: u64, asked_wire: usize, used_wire: usize, max_bytes: usize, children: Option) -> Value { + if let Some(obj) = value.as_object_mut() { + let mut marker = json!({ + "requestedDepth": asked, + "depth": used, + "requestedDepthBytes": asked_wire, + "bytes": used_wire, + "maxBytes": max_bytes, + }); + match &children { + Some(window) => { + marker["children"] = window.clone(); + marker["reason"] = json!(format!( + "the subtree at depth {asked} would be {asked_wire} bytes on the wire, over the {max_bytes}-byte limit a client can read in one message; its children are reported from {} instead", + window.get("from").and_then(Value::as_u64).unwrap_or(0) + )); + marker["hint"] = json!("ask again with the `offset` in `children.nextOffset` for the rest, or get_node({id, depth}) on one child"); + } + None => { + marker["reason"] = json!(format!( + "the subtree at depth {asked} would be {asked_wire} bytes on the wire, over the {max_bytes}-byte limit a client can read in one message; it is reported at depth {used} instead" + )); + marker["hint"] = json!("get_node({id, depth}) on a child reads deeper, one subtree at a time"); + } + } + obj.insert("truncated".into(), marker); + } + value } fn select(b: &mut dyn Backend, a: &Map) -> Result { diff --git a/crates/mcp/tests/inspect_response_limit.rs b/crates/mcp/tests/inspect_response_limit.rs index 5e36b5a9..b6d8b92e 100644 --- a/crates/mcp/tests/inspect_response_limit.rs +++ b/crates/mcp/tests/inspect_response_limit.rs @@ -95,3 +95,60 @@ fn maxbytes_is_checked() { assert_eq!(r["isError"], true, "{r}"); assert!(r["content"][0]["text"].as_str().unwrap_or("").contains("at least 1024"), "{r}"); } + +/// `inspect_document` sends a shallower tree and points at `get_node` for the rest, so `get_node` +/// must keep to the same limit -- it was unbounded. Measured on `main`: a frame with 15,000 +/// children answers with a 10.5 MiB message at depth 1, over the 10 MiB buffer the report is +/// about, and no depth makes that smaller because the width is at depth 1. The children are +/// windowed instead, with the offset to ask for next. +#[test] +fn a_node_with_more_children_than_fit_is_windowed() { + let mut c = Client::new(); + let frame = c.create(json!({"type": "frame", "x": 0, "y": 0, "width": 400, "height": 400})); + for i in 0..40 { + c.create(json!({"type": "rectangle", "parent": frame, "x": i * 5, "y": 5, "width": 20, "height": 20})); + } + + // Whole, to measure against: every child, no marker. + let (whole, whole_wire) = get_node(&mut c, json!({"id": frame, "depth": 1})); + let total = whole["children"].as_array().expect("children").len(); + assert_eq!(total, 40, "{whole}"); + assert!(whole["truncated"].is_null(), "within the default limit: {}", whole["truncated"]); + + // A limit that holds only some of them: a window, and the marker says how many and what next. + let limit = whole_wire / 3; + assert!(limit >= 1024, "the fixture has to outgrow the floor: {whole_wire}"); + let (cut, cut_wire) = get_node(&mut c, json!({"id": frame, "depth": 1, "maxBytes": limit})); + assert!(cut_wire <= limit, "the response still exceeds maxBytes: {cut_wire} > {limit}"); + let window = &cut["truncated"]["children"]; + let shown = cut["children"].as_array().expect("children").len(); + assert_eq!(window["from"], json!(0), "{window}"); + assert_eq!(window["shown"], json!(shown), "{window}"); + assert_eq!(window["total"], json!(total), "{window}"); + assert!(shown > 0 && shown < total, "some children, not all: {shown} of {total}"); + assert_eq!(window["nextOffset"], json!(shown), "the offset to ask for next: {window}"); + // The layer itself is still there, not just a marker. + assert_eq!(cut["id"], whole["id"]); + + // Paging through with that offset reaches every child exactly once, in order. + let mut seen: Vec = Vec::new(); + let mut offset = Some(0u64); + while let Some(at) = offset { + let (page, wire) = get_node(&mut c, json!({"id": frame, "depth": 1, "maxBytes": limit, "offset": at})); + assert!(wire <= limit, "page at {at} exceeds maxBytes: {wire}"); + seen.extend(page["children"].as_array().cloned().unwrap_or_default()); + offset = page["truncated"]["children"]["nextOffset"].as_u64(); + assert!(seen.len() <= total, "a page repeated a child: {} of {total}", seen.len()); + } + assert_eq!(seen.len(), total, "every child was reported once"); + assert_eq!(seen, whole["children"].as_array().cloned().unwrap_or_default(), "in the same order"); +} + +/// The response and what its text block takes on the wire, for one `get_node` call. +fn get_node(c: &mut Client, args: Value) -> (Value, usize) { + let r = c.call("get_node", args.clone()); + assert_eq!(r["isError"], false, "{args}: {}", r["content"][0]["text"]); + let text = r["content"][0]["text"].as_str().expect("a text block"); + let wire = serde_json::to_string(&text).unwrap().len() + ENVELOPE; + (serde_json::from_str(text).expect("the block is JSON"), wire) +} From 3146ad2b2f87331f3d3c90bed51ee4fc997e1848 Mon Sep 17 00:00:00 2001 From: Kameron Date: Wed, 21 Oct 2026 11:03:08 -0700 Subject: [PATCH 04/24] node.reparent: index is the layer's final position (#992) `node.reparent {ids, parent, index}` read `index` against the parent's children as they were before the move, so within the same parent moving a layer up one place did nothing and asking for two places moved it one. `index` now counts the parent's children after the move, the way `node.reorder` already does, in the same parent or a new one; several layers still land together in their stacking order. The layers panel was the only caller passing an index other than 0. It drops on a gap between rows counted before the move, so it now subtracts the dragged layers below that gap and drags behave as before. Documents `index` in the command's params and in docs/mcp.md. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit.rs | 15 +++++-- crates/engine/src/panels_behaviour_tests.rs | 44 +++++++++++++++++---- crates/ui-egui/src/layers.rs | 6 ++- crates/ui-egui/src/layers_drag_tests.rs | 23 +++++++++++ docs/mcp.md | 6 +++ 5 files changed, 80 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index a16fbb38..4ec26887 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -216,7 +216,13 @@ pub fn specs() -> Vec { let i = req_id("node.get", p, "id")?; Ok(crate::inspect::node_json(s.doc()?, i, 1)) }), - spec!("node.reparent", "Move layer into", "{ids?, parent, index?}", true, reparent), + spec!( + "node.reparent", + "Move layer into", + "{ids?, parent, index? (final position among parent's children, bottom first; default top)}", + true, + reparent + ), spec!("node.reorder", "Reorder layer", "{id, index}", true, |s, p| { let i = req_id("node.reorder", p, "id")?; let idx = req_num("node.reorder", p, "index")?.max(0.0) as usize; @@ -1302,9 +1308,10 @@ fn reparent(s: &mut Session, p: &Value) -> Result { let index = num(p, "index").map(|i| i.max(0.0) as usize); let doc = s.doc_mut()?; let ids = z_sorted(doc, &roots(doc, &ids)); - // The layers land together under the first sibling at or above `index` that stays put (none: on - // top), wherever they came from. - let above = index.and_then(|at| doc.get(parent).ok()?.children.iter().skip(at).find(|c| !ids.contains(c)).copied()); + // `index` is where the first moved layer ends up among the parent's children (bottom first), + // counted after the move, the way `node.reorder` counts it. The layers land together, keeping + // their order, under the sibling that stays put and ends up just after them (none: on top). + let above = index.and_then(|at| doc.get(parent).ok()?.children.iter().filter(|c| !ids.contains(c)).nth(at).copied()); for i in &ids { // Counted with the layer still in place, as `reparent` expects. let at = above.and_then(|a| doc.node(parent)?.children.iter().position(|c| *c == a)); diff --git a/crates/engine/src/panels_behaviour_tests.rs b/crates/engine/src/panels_behaviour_tests.rs index d299b0df..9870aa31 100644 --- a/crates/engine/src/panels_behaviour_tests.rs +++ b/crates/engine/src/panels_behaviour_tests.rs @@ -99,16 +99,18 @@ fn scene(s: &mut Session) -> Scene { #[test] fn dragging_in_the_layers_panel_reorders_by_the_documented_rules() { - // The panel drops layers with `node.reparent {ids, parent, index}`: `index` counts the - // parent's children bottom first, as they are before the move. + // The panel drops layers with `node.reparent {ids, parent, index}`: `index` is where the first + // moved layer ends up among the parent's children, bottom first, counted after the move. // (moved, index in F, F's children after (bottom first), derivation) - let rows: [(&[&str], usize, [&str; 4], &str); 6] = [ - (&["a"], 4, ["b", "c", "d", "a"], "a layer dragged to the top of its parent's list comes to the front"), + let rows: [(&[&str], usize, [&str; 4], &str); 8] = [ + (&["a"], 3, ["b", "c", "d", "a"], "a layer dragged to the top of its parent's list comes to the front"), + (&["a"], 9, ["b", "c", "d", "a"], "an index past the end is the front"), (&["d"], 0, ["d", "a", "b", "c"], "a layer dragged to the bottom of the list goes to the back"), - (&["a"], 2, ["b", "a", "c", "d"], "a layer dropped between two layers lands between them"), - (&["a", "b"], 4, ["c", "d", "a", "b"], "several selected layers move together, keeping their order"), - (&["a", "d"], 2, ["b", "a", "d", "c"], "several layers from apart land together where they are dropped"), - (&["b", "c"], 1, ["a", "b", "c", "d"], "silent: dropping a block of layers onto itself changes nothing (pinned)"), + (&["a"], 1, ["b", "a", "c", "d"], "a layer moved up one place goes up one place (#992)"), + (&["a"], 0, ["a", "b", "c", "d"], "silent: a layer asked to stay where it is stays (pinned)"), + (&["a", "b"], 2, ["c", "d", "a", "b"], "several selected layers move together, keeping their order"), + (&["a", "d"], 1, ["b", "a", "d", "c"], "several layers from apart land together where they are asked to"), + (&["b", "c"], 1, ["a", "b", "c", "d"], "silent: moving a block of layers to where it is changes nothing (pinned)"), ]; for (moved, index, after, why) in rows { let mut s = Session::new(); @@ -172,6 +174,32 @@ fn dragging_into_frames_groups_and_auto_layout() { ); } +#[test] +fn reparent_index_is_the_final_position_in_auto_layout_and_across_parents() { + // #992: `index` is the layer's place among the parent's children after the move. + let mut s = Session::new(); + let row = id(&ex(&mut s, "node.create", json!({"type": "frame", "name": "Row", "props": {"layout": "horizontal"}}))); + let text = |s: &mut Session, t: &str, parent: NodeId| id(&ex(s, "node.create", json!({"type": "text", "name": t, "text": t, "parent": parent}))); + let (first, second, third) = (text(&mut s, "first", row), text(&mut s, "second", row), text(&mut s, "third", row)); + let names = |s: &Session, p: NodeId| kids(s, p).iter().map(|i| name(s, *i)).collect::>(); + ex(&mut s, "node.reparent", json!({"ids": [first], "parent": row, "index": 1})); + assert_eq!(names(&s, row), ["second", "first", "third"], "moved down one place"); + ex(&mut s, "node.reparent", json!({"ids": [first], "parent": row, "index": 2})); + assert_eq!(names(&s, row), ["second", "third", "first"], "moved to the end"); + ex(&mut s, "node.reparent", json!({"ids": [first], "parent": row, "index": 0})); + assert_eq!(names(&s, row), ["first", "second", "third"], "moved to the start"); + let x = |s: &Session, i: NodeId| s.doc().unwrap().node(i).unwrap().transform.as_coeffs()[4]; + assert!(x(&s, first) < x(&s, second) && x(&s, second) < x(&s, third), "the flow follows the new order"); + // Into another parent, `index` means the same thing. + let other = id(&ex(&mut s, "node.create", json!({"type": "frame", "name": "Other", "props": {"layout": "horizontal"}}))); + let (p, q) = (text(&mut s, "p", other), text(&mut s, "q", other)); + ex(&mut s, "node.reparent", json!({"ids": [second], "parent": other, "index": 1})); + assert_eq!(kids(&s, other), [p, second, q], "lands at index 1 of the new parent"); + ex(&mut s, "node.reparent", json!({"ids": [first, third], "parent": other, "index": 3})); + assert_eq!(kids(&s, other), [p, second, q, first, third], "several layers start at index 3, keeping their order"); + assert!(kids(&s, row).is_empty()); +} + #[test] fn renaming_several_layers_by_the_documented_codes() { // Three layers named `names`, bottom to top; (names, params, new names, derivation). diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index f172b309..81d3fbcb 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -1075,8 +1075,10 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { // Above in the list = higher z = after the target among siblings. if let Some(parent) = doc.node(target).and_then(|n| n.parent) { let idx = doc.index_in_parent(target).unwrap_or(0); - let index = if z < 0 { idx + 1 } else { idx }; - app.run("node.reparent", json!({"ids": ids, "parent": parent, "index": index})); + let gap = if z < 0 { idx + 1 } else { idx }; + // `index` is the final position: the dragged layers below the gap leave it. + let below = doc.node(parent).map_or(0, |p| p.children.iter().take(gap).filter(|c| ids.contains(c)).count()); + app.run("node.reparent", json!({"ids": ids, "parent": parent, "index": gap - below})); } } } diff --git a/crates/ui-egui/src/layers_drag_tests.rs b/crates/ui-egui/src/layers_drag_tests.rs index 37002f9f..fb74e155 100644 --- a/crates/ui-egui/src/layers_drag_tests.rs +++ b/crates/ui-egui/src/layers_drag_tests.rs @@ -165,3 +165,26 @@ fn dropping_layers_on_another_page_moves_them_there() { let doc = u.app.session.doc().unwrap(); assert_eq!(doc.ancestors(rect).last().copied(), Some(p2), "moved to page 2"); } + +#[test] +fn dropping_a_layer_one_place_over_moves_it_one_place() { + // #992: the panel drops on a gap between rows; the layer ends up in that gap. + let mut s = Session::new(); + let rect = + |s: &mut Session, x: i32| id(s.execute("node.create", &json!({"type": "rectangle", "x": x, "y": 0, "width": 10, "height": 10})).unwrap()); + let (a, b, c) = (rect(&mut s, 0), rect(&mut s, 20), rect(&mut s, 40)); + s.execute("select.none", &json!({})).unwrap(); + let page = s.page().unwrap(); + let mut u = Ui::new(s); + let kids = |u: &Ui| u.app.session.doc().unwrap().node(page).unwrap().children.clone(); + // The list shows c, b, a top to bottom: `a` dragged onto the top edge of `b` goes just above it. + let (from, to) = (u.row_at(a), u.row_at(b) - vec2(0.0, 12.0)); + u.drag_to(from, to); + u.button(to, false); + assert_eq!(kids(&u), [b, a, c], "dropped between b and c"); + // And `c` dragged onto the bottom edge of `a` goes just below it. + let (from, to) = (u.row_at(c), u.row_at(a) + vec2(0.0, 12.0)); + u.drag_to(from, to); + u.button(to, false); + assert_eq!(kids(&u), [b, c, a], "dropped between a and b"); +} diff --git a/docs/mcp.md b/docs/mcp.md index dfea95cc..64ff024e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -190,6 +190,12 @@ Or in a project's `.mcp.json`: the CSS `fr` unit: a track never gets less than its content (a fixed-size child bigger than its share keeps it; the other `fr` tracks share the rest). `inspect` reports a grid child's resolved `gridCell`. +- Layer order: `node.reparent {ids?, parent, index?}` moves layers into `parent` (keeping their place + on the canvas, or taking their place in its auto layout flow) and `node.reorder {id, index}` moves + one within its parent. `index` counts the parent's children bottom first (in auto layout, the + first in the flow) and is where the (first) moved layer ends up after the move, in the same parent + or a new one; several layers land together in their stacking order. No `index`, or one past the + end, puts them on top (last in the flow). - **Every menu action is a command** (`list_commands`), and most commands act on `ids` when given, else on the selection. New layers become the selection. - Colours are `"#RRGGBB"` or `"#RRGGBBAA"`. Inside fills and strokes, `color` may also be a hex From afdc31b7275a97e730b77b920f6ffcdc58172331 Mon Sep 17 00:00:00 2001 From: Kameron Date: Wed, 21 Oct 2026 11:21:24 -0700 Subject: [PATCH 05/24] Let node.set replace a vector's path node.set took no `path`, so redrawing a vector meant deleting and recreating it and losing its id, place in the layer list, paints, styles, bindings and step references. `path` now takes SVG path data for vector layers and replaces the geometry through the same reader node.create uses (path_network, shared with create). width/height in the same call scale it as create does. The path's own box goes at the layer's current origin, so rotation and flips stay; with x/y in the same call it goes at x/y plus the box's offset, which is where create puts it. On several layers, layers that aren't vectors are skipped with a warning, as auto layout keys already are (the skip logic is shared now). A single non-vector is refused, and so is a vector inside an instance: there is no geometry override, so the next sync would undo it. Data that doesn't parse, draws nothing or has non-finite bounds is refused cleanly; create now refuses non-finite bounds too. Tests cover keeping the layer, placement and scaling against create, rotation, undo/redo, invalid data, non-vectors, instances, boolean operands, edit mode, and a junk-path sweep. The engine-wide junk sweep has node.set path entries. Fixes #1001 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/create.rs | 57 +++-- crates/engine/src/cmd/edit.rs | 57 +++-- crates/engine/src/cmd/props.rs | 39 ++- crates/engine/src/lib.rs | 2 + crates/engine/src/tests.rs | 4 + crates/engine/src/vector_path_set_tests.rs | 263 +++++++++++++++++++++ docs/mcp.md | 11 + 7 files changed, 391 insertions(+), 42 deletions(-) create mode 100644 crates/engine/src/vector_path_set_tests.rs diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 3a9f6d67..013b7f56 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -166,6 +166,42 @@ pub fn parse_path_data(d: &str) -> Option { figcraft_geom::BezPath::from_svg(d).ok() } +/// A vector's geometry made from SVG path data (see [`path_network`]). +pub(crate) struct PathShape { + /// The network, with the path's own box moved to the origin and scaled to the size asked for. + pub network: VectorNetwork, + pub width: f64, + pub height: f64, + /// Where the path's own box starts in its coordinates (unscaled). + pub offset: (f64, f64), +} + +/// The network SVG path data `d` draws, for `node.create` and `node.set`: the path's own box moves +/// to the origin, and `width`/`height` scale it as [`vector_scale`] says. Data that doesn't parse +/// or draws nothing is refused. +pub(crate) fn path_network(cmd: &str, d: &str, width: Option, height: Option) -> Result { + use figcraft_geom::Shape; + let empty = || bad(cmd, "`path` draws nothing: a vector needs a visible line or area"); + let mut path = parse_path_data(d).ok_or_else(|| bad(cmd, "`path` is not valid SVG path data"))?; + let bb = path.bounding_box(); + if !(bb.x0.is_finite() && bb.y0.is_finite() && bb.width().is_finite() && bb.height().is_finite()) { + return Err(bad(cmd, "`path` has coordinates too large to draw")); + } + if bb.width() <= 1e-9 && bb.height() <= 1e-9 { + return Err(empty()); + } + path.apply_affine(figcraft_geom::Affine::translate((-bb.x0, -bb.y0))); + let (sx, sy) = vector_scale(width, height, bb.width(), bb.height(), "a path").map_err(|e| bad(cmd, e))?; + if sx != 1.0 || sy != 1.0 { + path.apply_affine(figcraft_geom::Affine::scale_non_uniform(sx, sy)); + } + let network = VectorNetwork::from_path(&path); + if network.segments.is_empty() { + return Err(empty()); + } + Ok(PathShape { network, width: bb.width() * sx, height: bb.height() * sy, offset: (bb.x0, bb.y0) }) +} + /// How much a vector's path is scaled to take the size asked for: with both sides, each scales /// independently (as resizing a vector does); with one, the other keeps its proportion. A size on /// an axis the geometry has none of is an error unless it is 0 (a line's height), and a size of 0 @@ -228,27 +264,12 @@ pub fn create(s: &mut Session, p: &Value) -> Result { let asked = (num(p, "width"), num(p, "height")); if let NodeKind::Vector { network } = &mut kind { if let Some(d) = string(p, "path") { - let mut path = parse_path_data(&d).ok_or_else(|| bad(cmd, "`path` is not valid SVG path data"))?; - use figcraft_geom::Shape; - let bb = path.bounding_box(); - if bb.width() <= 1e-9 && bb.height() <= 1e-9 { - return Err(bad(cmd, "`path` draws nothing: a vector needs a visible line or area")); - } - path.apply_affine(figcraft_geom::Affine::translate((-bb.x0, -bb.y0))); - let (sx, sy) = vector_scale(asked.0, asked.1, bb.width(), bb.height(), "a path").map_err(|e| bad(cmd, e))?; - if sx != 1.0 || sy != 1.0 { - path.apply_affine(figcraft_geom::Affine::scale_non_uniform(sx, sy)); - } - *network = VectorNetwork::from_path(&path); - if network.segments.is_empty() { - return Err(bad(cmd, "`path` draws nothing: a vector needs a visible line or area")); - } + let shape = path_network(cmd, &d, asked.0, asked.1)?; + *network = shape.network; if boolean(p, "closed") == Some(false) { network.regions.clear(); } - w = bb.width() * sx; - h = bb.height() * sy; - offset = (bb.x0, bb.y0); + (w, h, offset) = (shape.width, shape.height, shape.offset); } else if let Some(Value::Array(pts)) = get(p, "points") { let pts: Vec = pts.iter().filter_map(|v| Some(Point::new(f64_of(v.get(0)?)?, f64_of(v.get(1)?)?))).collect(); if pts.len() < 2 { diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index a16fbb38..422c75eb 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -4,7 +4,7 @@ use std::collections::{BTreeMap, BTreeSet}; use figcraft_doc::*; use figcraft_geom::Affine; -use serde_json::{Value, json}; +use serde_json::{Map, Value, json}; use super::{CommandSpec, ok}; use crate::params::*; @@ -1159,6 +1159,9 @@ fn find_replace(s: &mut Session, p: &Value) -> Result { Ok(json!({"matches": hits})) } +/// Keys only some layer types take, the test for those layers, and why the others skip them. +type TypedKeys<'a> = (&'a [&'a str], &'a dyn Fn(&Node) -> bool, &'a str); + fn set(s: &mut Session, p: &Value) -> Result { let ids = req_targets("node.set", s, p)?; let props = match get(p, "props") { @@ -1189,34 +1192,44 @@ fn set(s: &mut Session, p: &Value) -> Result { None => Vec::new(), }; let mut warnings = font_style_warnings(&s.ds()?.doc, &ids, &props); - // Auto layout keys go to the layers that can have auto layout; with none among the targets, - // the first refuses them (`props::layout_mut`). + // Type-specific keys go to the layers that take them and are skipped, with a warning, on the + // others: auto layout keys to frame-like layers, `path` to vectors. With none among the targets + // that take them, the first refuses them (`props::layout_mut`, `props::apply`). + let doc = &s.ds()?.doc; let layout_keys: Vec<&str> = props.keys().map(String::as_str).filter(|k| super::props::LAYOUT_KEYS.contains(k)).collect(); - let mut without_layout = props.clone(); - let mut skipped = BTreeSet::new(); - if !layout_keys.is_empty() { - let doc = &s.ds()?.doc; - let frame_like = |i: &NodeId| doc.node(*i).is_some_and(|n| n.kind.is_frame_like()); - if ids.iter().any(frame_like) { - skipped = ids.iter().copied().filter(|i| !frame_like(i)).collect(); - without_layout.retain(|k, _| !layout_keys.contains(&k.as_str())); + let path_keys: Vec<&str> = props.keys().map(String::as_str).filter(|k| *k == "path").collect(); + let frame_like = |n: &Node| n.kind.is_frame_like(); + let vector = |n: &Node| matches!(n.kind, NodeKind::Vector { .. }); + let typed: [TypedKeys; 2] = [ + (&layout_keys, &frame_like, "only frames, components and instances have auto layout"), + (&path_keys, &vector, "only vector layers have a path"), + ]; + let mut skipped: BTreeMap> = BTreeMap::new(); + for (keys, takes, why) in typed { + let takes = |i: &NodeId| doc.node(*i).is_some_and(takes); + if keys.is_empty() || !ids.iter().any(takes) { + continue; } - if !skipped.is_empty() { - let names: Vec = - skipped.iter().take(10).filter_map(|i| doc.node(*i)).map(|n| format!("{} “{}” ({})", n.id, n.name, n.kind.type_name())).collect(); - let more = if skipped.len() > names.len() { format!(" and {} more", skipped.len() - names.len()) } else { String::new() }; - warnings.push(format!( - "{} skipped on {}{more}: only frames, components and instances have auto layout", - layout_keys.iter().map(|k| format!("`{k}`")).collect::>().join(", "), - names.join(", ") - )); + let others: Vec = ids.iter().copied().filter(|i| !takes(i)).collect(); + if others.is_empty() { + continue; } + for i in &others { + skipped.entry(*i).or_insert_with(|| props.clone()).retain(|k, _| !keys.contains(&k.as_str())); + } + let names: Vec = + others.iter().take(10).filter_map(|i| doc.node(*i)).map(|n| format!("{} “{}” ({})", n.id, n.name, n.kind.type_name())).collect(); + let more = if others.len() > names.len() { format!(" and {} more", others.len() - names.len()) } else { String::new() }; + warnings.push(format!( + "{} skipped on {}{more}: {why}", + keys.iter().map(|k| format!("`{k}`")).collect::>().join(", "), + names.join(", ") + )); } let doc = s.doc_mut()?; let before = super::styles::bound_before(doc, &ids); for i in ids { - let props = if skipped.contains(&i) { &without_layout } else { &props }; - super::props::apply("node.set", doc, i, props)?; + super::props::apply("node.set", doc, i, skipped.get(&i).unwrap_or(&props))?; } super::styles::detach_edited(doc, &before); if let Some(o) = pivot { diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index f180b53c..67e22834 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -26,7 +26,9 @@ fontSize, lineHeight (auto|px|\"150%\"), letterSpacing (px|\"2%\"), textAlign (l textDecorationColor (colour, or auto/null: the text's), textCase (original|upper|lower|title|smallCaps), paragraphSpacing, paragraphIndent, \ listStyle (none|bulleted|numbered; with start/end: the paragraphs those chars touch), indentation (list nesting level 0-8, same range rule), \ listSpacing (px between list items, in place of paragraphSpacing), hangingPunctuation, hangingList (markers outside the box), leadingTrim (vertical trim: none|capHeight), maxLines, features ({tag: bool}, e.g. {\"liga\": false, \"tnum\": true}), \ -fontVariations / variationSettings ({axis: number}, e.g. {\"wght\": 550, \"wdth\": 85}; null = none), pointCount, innerRadius, arcStart, arcSweep, \ +fontVariations / variationSettings ({axis: number}, e.g. {\"wght\": 550, \"wdth\": 85}; null = none), \ +path (a vector's SVG path data: replaces its geometry, keeping the layer, its paints, styles and bindings; width/height in the same call scale it as \ +node.create does; its own box goes at the layer's x/y, or with x/y given, at x + its left edge and y + its top as node.create places it), pointCount, innerRadius, arcStart, arcSweep, \ arcInner, booleanOperation (a boolean group's operation, or an operand's own; null: the group's), operandOperation (a layer in a boolean \ group: how it combines with the operands below it; null: the group's), constrainProportions, description, interactions, overflow, pluginData {k: v}"; @@ -376,7 +378,7 @@ fn set_paints(n: &mut Node, fills: bool, paints: Vec) { /// bindings last so paints set in the same call don't detach them. fn apply_order(k: &str) -> u8 { match k { - "width" | "height" | "w" | "h" => 0, + "path" | "width" | "height" | "w" | "h" => 0, "rotation" => 2, "x" | "y" => 3, "boundVariables" => 4, @@ -389,11 +391,17 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map = props.iter().collect(); keys.sort_by_key(|(k, _)| apply_order(k)); + // Where a new `path` had its own box, for `x`/`y` in the same call (as `node.create` places it). + let mut path_offset = (0.0, 0.0); for (k, v) in keys { let k = k.as_str(); match k { + "path" => path_offset = set_path(cmd, doc, id, v, props)?, + // A new path takes the size asked for as its scale (`set_path`). + "width" | "height" | "w" | "h" if props.contains_key("path") => {} "x" | "y" => { let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?; + let val = val + if k == "x" { path_offset.0 } else { path_offset.1 }; // X/Y are the node's own origin (its rotated top-left for rotated nodes), in the // frame it is in (groups have no space of their own). let n = doc.get(id)?; @@ -920,6 +928,33 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map) -> Result<(f64, f64)> { + let d = v.as_str().ok_or_else(|| bad(cmd, "`path` must be SVG path data (a string)"))?; + let n = doc.get(id)?; + if !matches!(n.kind, NodeKind::Vector { .. }) { + return Err(bad(cmd, format!("`path` applies to vector layers, and layer {} “{}” is a {}", n.id, n.name, n.kind.type_name()))); + } + if crate::components::inside_instance(doc, id) { + return Err(bad(cmd, format!("`path`: layer {} “{}” is inside an instance: edit its main component, or detach the instance", n.id, n.name))); + } + let size = |long: &str, short: &str| -> Result> { + props.get(long).or_else(|| props.get(short)).map(|v| f64_of(v).ok_or_else(|| bad(cmd, format!("`{long}` must be a number")))).transpose() + }; + let shape = super::create::path_network(cmd, d, size("width", "w")?, size("height", "h")?)?; + let n = doc.get_mut(id)?; + if let NodeKind::Vector { network } = &mut n.kind { + *network = shape.network; + } + n.width = shape.width; + n.height = shape.height; + Ok(shape.offset) +} + /// The override group a property key belongs to. pub fn override_group(k: &str) -> &'static str { match k { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 79e7be0d..bfc7f3c2 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1891,6 +1891,8 @@ mod vector_multi_behaviour_tests; #[cfg(test)] mod vector_offset_tests; #[cfg(test)] +mod vector_path_set_tests; +#[cfg(test)] mod version_preview_tests; #[cfg(test)] mod version_tests; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 646c7c4b..b5cdd7fa 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -425,6 +425,10 @@ fn every_command_survives_junk_params() { // Rotation origins and placing with a tool as junk. json!({"tool": [1], "x": 1e308, "y": "NaN", "reset": "maybe", "on": {"x": 1}}), json!({"tool": "frame", "x": -1e308, "y": 1e308, "reset": true, "on": "yes"}), + // Vector paths (`node.set {path}`) as junk. (Not a string `path` at the top: file commands + // 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}}), ]; for spec in all_commands() { if matches!( diff --git a/crates/engine/src/vector_path_set_tests.rs b/crates/engine/src/vector_path_set_tests.rs new file mode 100644 index 00000000..7e714853 --- /dev/null +++ b/crates/engine/src/vector_path_set_tests.rs @@ -0,0 +1,263 @@ +//! `node.set {path}` replaces a vector's geometry and keeps the layer (#1001): its id, name, place in +//! the layer list, paints, effects, styles, bindings and constraints. The path is read as +//! `node.create` reads it (`width`/`height` scale it), its own box goes at the layer's origin (or at +//! `x`/`y` plus the box's offset, as create places it), undo brings the old path back, and data that +//! can't draw, layers that aren't vectors and layers inside an instance are refused. + +use figcraft_doc::{NodeId, NodeKind, VectorNetwork}; +use serde_json::{Value, json}; + +use crate::{EngineError, Session}; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +fn id(v: &Value) -> NodeId { + crate::params::id_of(&v["id"]).unwrap() +} + +fn vector(s: &mut Session, p: Value) -> NodeId { + let mut p = p; + p["type"] = json!("vector"); + id(&ex(s, "node.create", p)) +} + +fn net(s: &Session, v: NodeId) -> VectorNetwork { + crate::vectoredit::network(s.doc().unwrap(), v).unwrap().clone() +} + +/// The layer's x, y, width and height as `get_node` reports them. +fn frame(s: &Session, v: NodeId) -> (f64, f64, f64, f64) { + let d = crate::cmd::props::describe(s.doc().unwrap(), v); + let f = |k: &str| d[k].as_f64().unwrap(); + (f("x"), f("y"), f("width"), f("height")) +} + +fn bad_msg(r: crate::Result) -> String { + match r { + Err(EngineError::BadParams { msg, .. }) => msg, + other => panic!("expected BadParams, got {other:?}"), + } +} + +#[test] +fn a_new_path_keeps_the_layer() { + let mut s = Session::new(); + ex(&mut s, "variable.createCollection", json!({"name": "T"})); + ex(&mut s, "variable.create", json!({"collection": "T", "name": "brand", "type": "color", "value": "#FF0000"})); + let below = vector(&mut s, json!({"path": "M0 0 L5 5"})); + let v = vector(&mut s, json!({"name": "Chart line", "path": "M0 0 L10 10", "x": 40, "y": 30})); + let above = vector(&mut s, json!({"path": "M0 0 L5 5"})); + ex( + &mut s, + "node.set", + json!({"id": v, "props": {"stroke": "#00FF00", "strokeWeight": 3, "opacity": 0.5, + "effects": [{"type": "dropShadow", "color": "#00000040", "offset": [0, 2], "radius": 4}], + "constraints": {"horizontal": "right", "vertical": "bottom"}}}), + ); + ex(&mut s, "variable.bind", json!({"ids": [v], "field": "fill", "variable": "brand"})); + let before = s.doc().unwrap().node(v).unwrap().clone(); + + let out = ex(&mut s, "node.set", json!({"ids": [v], "props": {"path": "M0 0 L20 0 L20 8 Z"}})); + assert!(out.get("warnings").is_none(), "{out}"); + let doc = s.doc().unwrap(); + let n = doc.node(v).unwrap(); + assert_eq!((n.id, n.name.as_str()), (v, "Chart line")); + assert_eq!((&n.fills, &n.strokes, &n.stroke, &n.effects), (&before.fills, &before.strokes, &before.stroke, &before.effects)); + assert_eq!((n.opacity, &n.bound, &n.styles, &n.constraints), (before.opacity, &before.bound, &before.styles, &before.constraints)); + assert!(n.bound.contains_key("fill"), "the fill stays bound"); + let page = doc.node(n.parent.unwrap()).unwrap(); + assert_eq!(page.children, [below, v, above], "same place in the layer list"); + // The new geometry, at the layer's origin. + assert_eq!(frame(&s, v), (40.0, 30.0, 20.0, 8.0)); + let net = net(&s, v); + assert_eq!((net.vertices.len(), net.regions.len()), (3, 1), "a closed triangle: {net:?}"); +} + +#[test] +fn width_height_and_x_y_follow_node_create() { + // Each row: props given with the path, and the frame node.set gives a layer at (100, 50). + let rows: [(Value, (f64, f64, f64, f64)); 6] = [ + // The path's own box goes at the layer's origin: its offset (5, 5) doesn't move it. + (json!({}), (100.0, 50.0, 10.0, 20.0)), + // Both sides scale independently; one keeps the proportion. + (json!({"width": 30, "height": 10}), (100.0, 50.0, 30.0, 10.0)), + (json!({"w": 20}), (100.0, 50.0, 20.0, 40.0)), + // With x/y the path's offset is added, as create places it. + (json!({"x": 0, "y": 0}), (5.0, 5.0, 10.0, 20.0)), + (json!({"x": 10, "height": 10}), (15.0, 50.0, 5.0, 10.0)), + (json!({"x": 10, "y": 20, "width": 20, "height": 10}), (15.0, 25.0, 20.0, 10.0)), + ]; + for (extra, want) in rows { + let mut s = Session::new(); + let v = vector(&mut s, json!({"path": "M0 0 L1 1", "x": 100, "y": 50})); + let mut props = json!({"path": "M5 5 L15 5 L15 25 Z"}); + props.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + ex(&mut s, "node.set", json!({"ids": [v], "props": props})); + assert_eq!(frame(&s, v), want, "{extra}"); + // With both x and y, create makes the same layer. (With one, the other axis keeps the + // layer's current place.) + if extra.get("x").is_some() && extra.get("y").is_some() { + let c = vector(&mut s, props.clone()); + assert_eq!(frame(&s, c), want, "create {extra}"); + } + } +} + +#[test] +fn rotation_stays_and_turns_about_the_new_box() { + let mut s = Session::new(); + let v = vector(&mut s, json!({"path": "M0 0 L10 10", "x": 0, "y": 0})); + ex(&mut s, "node.set", json!({"ids": [v], "props": {"rotation": 90}})); + let t = s.doc().unwrap().node(v).unwrap().transform; + ex(&mut s, "node.set", json!({"ids": [v], "props": {"path": "M0 0 L40 0 L40 20 Z"}})); + let n = s.doc().unwrap().node(v).unwrap(); + assert_eq!(n.transform, t, "the layer keeps its origin and rotation"); + assert_eq!((n.width, n.height), (40.0, 20.0)); +} + +#[test] +fn undo_brings_the_old_path_back() { + let mut s = Session::new(); + let v = vector(&mut s, json!({"path": "M0 0 L10 10", "x": 3, "y": 4})); + let (old_net, old_frame) = (net(&s, v), frame(&s, v)); + ex(&mut s, "node.set", json!({"ids": [v], "props": {"path": "M0 0 C10 0 20 10 20 20", "width": 50}})); + let (new_net, new_frame) = (net(&s, v), frame(&s, v)); + assert_ne!(new_net, old_net); + ex(&mut s, "edit.undo", json!({})); + assert_eq!((net(&s, v), frame(&s, v)), (old_net, old_frame)); + ex(&mut s, "edit.redo", json!({})); + assert_eq!((net(&s, v), frame(&s, v)), (new_net, new_frame)); +} + +#[test] +fn data_that_cant_draw_is_refused_and_nothing_changes() { + let mut s = Session::new(); + let v = vector(&mut s, json!({"path": "M0 0 L10 10", "name": "V"})); + let before = s.doc().unwrap().node(v).unwrap().clone(); + for (props, says) in [ + (json!({"path": "not a path"}), "not valid SVG path data"), + (json!({"path": "M0 0 L10"}), "not valid SVG path data"), + (json!({"path": ""}), "draws nothing"), + (json!({"path": "M5 5"}), "draws nothing"), + (json!({"path": "M5 5 L5 5 Z"}), "draws nothing"), + (json!({"path": "M0 0 L1e308 0 L-1e308 0"}), "too large"), + (json!({"path": 5}), "must be SVG path data"), + (json!({"path": null}), "must be SVG path data"), + (json!({"path": "M0 0 L10 10", "width": 0}), "positive"), + (json!({"path": "M0 0 L10 10", "height": -4}), "positive"), + (json!({"path": "M0 0 L10 10", "width": "wide"}), "must be a number"), + // A size on an axis the path has none of. + (json!({"path": "M0 0 L10 0", "height": 5}), "no vertical extent"), + (json!({"path": "M0 0 L10 10", "name": "renamed", "width": -1}), "positive"), + ] { + let msg = bad_msg(s.execute("node.set", &json!({"ids": [v], "props": props}))); + assert!(msg.contains(says), "{props}: {msg}"); + assert_eq!(s.doc().unwrap().node(v).unwrap(), &before, "{props} changed nothing"); + } + // A horizontal line can still be given height 0, as create allows. + ex(&mut s, "node.set", json!({"ids": [v], "props": {"path": "M0 0 L10 0", "height": 0}})); + assert_eq!(frame(&s, v).2, 10.0); +} + +#[test] +fn layers_that_arent_vectors_are_refused_alone_and_skipped_among_vectors() { + let mut s = Session::new(); + let r = id(&ex(&mut s, "node.create", json!({"type": "rectangle", "name": "Box"}))); + let star = id(&ex(&mut s, "node.create", json!({"type": "star"}))); + let msg = bad_msg(s.execute("node.set", &json!({"ids": [r], "props": {"path": "M0 0 L5 5"}}))); + assert!(msg.contains("vector") && msg.contains("Box"), "{msg}"); + assert!(s.execute("node.set", &json!({"ids": [r, star], "props": {"path": "M0 0 L5 5"}})).is_err(), "no vector among them"); + assert!(matches!(s.doc().unwrap().node(r).unwrap().kind, NodeKind::Rectangle)); + + let v = vector(&mut s, json!({"path": "M0 0 L10 10"})); + let out = ex(&mut s, "node.set", json!({"ids": [r, v], "props": {"path": "M0 0 L30 0", "opacity": 0.5}})); + let w = out["warnings"][0].as_str().unwrap(); + assert!(w.contains("`path` skipped on") && w.contains("Box") && w.contains("vector"), "{w}"); + let doc = s.doc().unwrap(); + assert_eq!((doc.node(r).unwrap().width, doc.node(r).unwrap().opacity), (100.0, 0.5), "the rectangle gets the other keys"); + assert_eq!(frame(&s, v).2, 30.0); +} + +#[test] +fn a_vector_inside_an_instance_is_refused_and_its_main_reaches_it() { + let mut s = Session::new(); + let f = id(&ex(&mut s, "node.create", json!({"type": "frame", "width": 50, "height": 50}))); + let v = vector(&mut s, json!({"path": "M0 0 L10 10", "parent": f})); + let comp = id(&ex(&mut s, "component.create", json!({"ids": [f]}))); + let inst = id(&ex(&mut s, "instance.create", json!({"component": comp, "x": 200, "y": 0}))); + let copy = s.doc().unwrap().node(inst).unwrap().children[0]; + assert!(matches!(s.doc().unwrap().node(copy).unwrap().kind, NodeKind::Vector { .. })); + let msg = bad_msg(s.execute("node.set", &json!({"ids": [copy], "props": {"path": "M0 0 L30 0"}}))); + assert!(msg.contains("inside an instance"), "{msg}"); + // The main's vector takes it, and the instance follows. + let main_vector = s.doc().unwrap().node(comp).unwrap().children[0]; + assert_eq!(main_vector, v); + ex(&mut s, "node.set", json!({"ids": [main_vector], "props": {"path": "M0 0 L30 0 L30 12 Z"}})); + assert_eq!(net(&s, copy), net(&s, main_vector)); + assert_eq!((frame(&s, copy).2, frame(&s, copy).3), (30.0, 12.0)); +} + +#[test] +fn a_boolean_operand_takes_a_new_path_and_the_group_follows() { + let mut s = Session::new(); + let a = vector(&mut s, json!({"path": "M0 0 L40 0 L40 40 L0 40 Z"})); + let b = vector(&mut s, json!({"path": "M0 0 L40 0 L40 40 L0 40 Z", "x": 20, "y": 20})); + let g = id(&ex(&mut s, "bool.union", json!({"ids": [a, b]}))); + assert_eq!(s.doc().unwrap().node(b).unwrap().parent, Some(g)); + ex(&mut s, "node.set", json!({"ids": [b], "props": {"path": "M0 0 L100 0 L100 100 L0 100 Z"}})); + let bounds = s.doc().unwrap().world_bounds(g).unwrap(); + assert_eq!((bounds.width(), bounds.height()), (120.0, 120.0), "{bounds:?}"); +} + +#[test] +fn junk_paths_never_panic() { + let long = "M0 0 ".to_string() + &"L1 1 ".repeat(5000); + let junk = [ + json!("M"), + json!("Z"), + json!("M0 0 Z Z Z"), + json!("M NaN 0 L 1 1"), + json!("M inf 0 L 1 1"), + json!("M0 0 L1e308 1e308"), + json!("M-1e308 -1e308 L1e308 1e308"), + json!("M0 0 A 1e308 1e308 0 1 1 10 10"), + json!("M0 0 A 0 0 0 0 0 10 10"), + json!("m0 0 l1 1 h-1e308 v1e308 z"), + json!("M0 0 Q 5 5"), + json!("M0,0L10,10\u{0}"), + json!("\u{1F4A5}"), + json!(long), + json!([1, 2]), + json!({"d": "M0 0 L1 1"}), + json!(true), + ]; + let sizes = [json!(null), json!(1e308), json!(-1e308), json!(1e-308), json!("NaN"), json!([1])]; + let mut s = Session::new(); + let v = vector(&mut s, json!({"path": "M0 0 L10 10"})); + for path in &junk { + for size in &sizes { + for props in [json!({"path": path, "width": size}), json!({"path": path, "height": size, "x": size, "rotation": size})] { + if let Err(EngineError::Internal { msg, .. }) = s.execute("node.set", &json!({"ids": [v], "props": props})) { + panic!("node.set panicked on {props}: {msg}"); + } + let n = s.doc().unwrap().node(v).unwrap(); + assert!(n.width.is_finite() && n.height.is_finite() && n.transform.as_coeffs().iter().all(|c| c.is_finite()), "{props}"); + } + } + } +} + +#[test] +fn a_new_path_while_editing_drops_points_that_are_gone() { + let mut s = Session::new(); + let v = vector(&mut s, json!({"path": "M0 0 L10 0 L10 10 L0 10 Z"})); + ex(&mut s, "vector.edit.begin", json!({"id": v})); + ex(&mut s, "vector.edit.selectAll", json!({})); + ex(&mut s, "node.set", json!({"ids": [v], "props": {"path": "M0 0 L20 0"}})); + let ve = s.ds().unwrap().vector_edit.clone().expect("still editing"); + let n = net(&s, v); + assert!(ve.vertices.iter().all(|i| *i < n.vertices.len()) && ve.segments.iter().all(|i| *i < n.segments.len()), "{ve:?}"); + ex(&mut s, "vector.edit.state", json!({})); +} diff --git a/docs/mcp.md b/docs/mcp.md index dfea95cc..ac3be60e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -448,6 +448,17 @@ its proportion). A path or point set that draws nothing is refused instead of ma 0×0 layer, as is a size on an axis the geometry has none of (except 0, such as a horizontal line's height) and a size of 0 or less on an axis it has, which would flatten it. +`node.set {path}` (or `set_properties`) replaces a vector's geometry with new SVG path data and +keeps the layer: its id, name, place in the layer list, paints, effects, styles, variable bindings +and constraints. The path is read as `vector.create` reads it, and `width`/`height` in the same call +scale it the same way. The path's own box goes at the layer's current x/y (rotation and flips +stay); with `x`/`y` in the same call it goes at `x` + its left edge and `y` + its top, as +`vector.create` places it. Points' own settings (per-point corner radius, caps, joins) go with the +old geometry. On several layers, layers that aren't vectors are skipped with a warning; on a +single one, or when none is a vector, the call is refused (convert shapes with +`vector.edit.begin` or Flatten first). A layer inside an instance is refused too: its geometry +comes from its main component, so edit that or detach the instance. Undo brings the old path back. + Vector edit mode is driven with `run_command` (or `pointer`/`key` for gestures): `vector.edit.begin {id? | ids?}` (default the selection; converts shapes to vectors; several layers are edited at once, the first active; `key` Enter with shapes selected does the same), From 014098b6c95294a92cfbc63cef9577bcb5896668 Mon Sep 17 00:00:00 2001 From: Kameron Date: Wed, 21 Oct 2026 11:27:46 -0700 Subject: [PATCH 06/24] fix(components): a boolean property's default is its bound layers' visibility on the main (#993) component.addProperty {type: "boolean", default: false, layer} hid the bound layer in new instances but left it showing in the main component, so the main didn't show its own default. On the main, a boolean property's value is now the visibility of the layers bound to it: - addProperty, bindProperty {field: "visible"} and editProperty {default} show or hide the main's bound layers to match the default (every variant of a set for editProperty). Without a default, addProperty takes the bound layer's current visibility instead of always `true`. - Showing or hiding a bound layer on the main sets the default, and the property's other bound layers (in every variant) follow. Edits inside instances stay overrides. - Existing instances keep their stored values; documents open as saved (no normalisation on load). Fixes #993 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/component.rs | 18 ++++-- crates/engine/src/components.rs | 64 ++++++++++++++++++++ crates/engine/src/components_tests.rs | 84 +++++++++++++++++++++++++++ docs/mcp.md | 8 +++ 4 files changed, 170 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index 6a5b1012..6b0e3e17 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -217,14 +217,14 @@ pub fn specs() -> Vec { spec!( "component.addProperty", "Add component property", - "{id? (component, set, or a layer inside a component: it gets bound), name, type: boolean|text|instanceSwap|variant|slot, default?, layer? (node bound to it; a frame for slots), preferred? (instance swap and slot properties: component or set ids offered first)}", + "{id? (component, set, or a layer inside a component: it gets bound), name, type: boolean|text|instanceSwap|variant|slot, default? (boolean: the bound layer's visibility in the main; without it, the layer's current visibility), layer? (node bound to it; a frame for slots), preferred? (instance swap and slot properties: component or set ids offered first)}", true, add_prop ), spec!( "component.editProperty", "Edit component property", - "{id? (component or set), name, newName?, default? (variant properties: that variant takes the top-left place), value? + newValue? (rename a variant value), index? (variant properties: move to this place; the variants' names follow), preferred? (instance swap and slot properties: component or set ids offered first)}", + "{id? (component or set), name, newName?, default? (variant properties: that variant takes the top-left place; boolean: the main's bound layers show or hide to match), value? + newValue? (rename a variant value), index? (variant properties: move to this place; the variants' names follow), preferred? (instance swap and slot properties: component or set ids offered first)}", true, edit_prop ), @@ -1141,7 +1141,8 @@ fn add_prop_for(cmd: &str, s: &mut Session, p: &Value) -> Result { } let layer_node = layer.and_then(|l| doc.node(l)).cloned(); let def = match t.to_ascii_lowercase().as_str() { - "boolean" | "bool" => PropDef::Boolean { default: boolean(p, "default").unwrap_or(true) }, + // Without a default, a bound layer keeps showing as it does: its visibility is the default. + "boolean" | "bool" => PropDef::Boolean { default: boolean(p, "default").or_else(|| layer_node.as_ref().map(|n| n.visible)).unwrap_or(true) }, "text" => PropDef::Text { default: string(p, "default") .or_else(|| layer_node.as_ref().and_then(|n| n.kind.text()).map(|t| t.characters.clone())) @@ -1245,7 +1246,12 @@ fn bind(cmd: &str, doc: &mut Document, layer: NodeId, property: Option<&str>, fi } _ => {} } - doc.get_mut(layer)?.prop_refs.insert(field.to_string(), property.to_string()); + let n = doc.get_mut(layer)?; + n.prop_refs.insert(field.to_string(), property.to_string()); + // On the main, a boolean property's value is the visibility of the layers it controls. + if let PropDef::Boolean { default } = def { + n.visible = *default; + } Ok(()) } @@ -1414,6 +1420,10 @@ fn edit_prop(s: &mut Session, p: &Value) -> Result { rename_prop_refs(doc, *c, &name, Some(nn))?; } } + // The main's bound layers show the new default. + if let PropDef::Boolean { default } = new_def { + set_boolean_default(doc, &comps, new_name.as_deref().unwrap_or(&name), default); + } ok() } diff --git a/crates/engine/src/components.rs b/crates/engine/src/components.rs index 67259d03..98fd85f1 100644 --- a/crates/engine/src/components.rs +++ b/crates/engine/src/components.rs @@ -1110,9 +1110,73 @@ fn geometry_derived(doc: &Document, before: &Document, id: NodeId, group: &str) false } +/// The components sharing the properties of component `c`: the variants of its set, or `c` alone. +pub fn property_owners(doc: &Document, c: NodeId) -> Vec { + match variant_set(doc, c).and_then(|s| doc.node(s)) { + Some(set) => set.children.iter().copied().filter(|v| doc.node(*v).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. }))).collect(), + None => vec![c], + } +} + +/// Make `value` the default of boolean property `name` on components `comps`, showing or hiding +/// their layers bound to it: on a main component, a boolean property's value is the visibility of +/// its layers. Returns the components and layers it changed. +pub fn set_boolean_default(doc: &mut Document, comps: &[NodeId], name: &str, value: bool) -> Vec { + let mut out = Vec::new(); + for &c in comps { + let Some(NodeKind::Component { props, .. }) = doc.node(c).map(|n| &n.kind) else { continue }; + let Some(PropDef::Boolean { default }) = props.get(name) else { continue }; + if *default != value + && let Some(NodeKind::Component { props, .. }) = doc.node_mut(c).map(|n| &mut n.kind) + { + props.insert(name.to_string(), PropDef::Boolean { default: value }); + out.push(c); + } + // The main's own layers (not those of instances nested in it, which follow their mains). + let layers: Vec = doc + .subtree(c) + .into_iter() + .filter(|d| *d != c && !inside_instance(doc, *d)) + .filter(|d| doc.node(*d).is_some_and(|n| n.visible != value && n.prop_refs.get("visible").map(String::as_str) == Some(name))) + .collect(); + for d in layers { + if let Some(n) = doc.node_mut(d) { + n.visible = value; + out.push(d); + } + } + } + out +} + +/// A main component's layer bound to a boolean property was shown or hidden: that is the +/// property's new default, which its other bound layers (in every variant) take too. Returns the +/// components and layers that changed as a result. +fn follow_boolean_defaults(doc: &mut Document, before: &Document, changed: &[NodeId]) -> Vec { + let mut out = Vec::new(); + for &id in changed { + let (Some(n), Some(b)) = (doc.node(id), before.node(id)) else { continue }; + if n.visible == b.visible || inside_instance(doc, id) { + continue; + } + let Some(name) = n.prop_refs.get("visible").cloned() else { continue }; + let visible = n.visible; + let Some(c) = owning_component(doc, id).filter(|c| *c != id) else { continue }; + out.extend(set_boolean_default(doc, &property_owners(doc, c), &name, visible)); + } + out +} + /// After an edit: record overrides inside instances, then rebuild instances whose main changed, /// whose properties or main were switched, or whose overrides were reset. pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId], touched: &BTreeSet) { + let mut changed = changed.to_vec(); + for id in follow_boolean_defaults(doc, before, &changed) { + if !changed.contains(&id) { + changed.push(id); + } + } + let changed = changed.as_slice(); // 0. A slot whose layers were added, removed or reordered holds the instance's own content // from now on; copies of instance layers placed elsewhere stop mirroring their main. for &id in changed { diff --git a/crates/engine/src/components_tests.rs b/crates/engine/src/components_tests.rs index 209560cd..30254651 100644 --- a/crates/engine/src/components_tests.rs +++ b/crates/engine/src/components_tests.rs @@ -429,6 +429,90 @@ fn component_properties_apply_to_instances() { healthy(&s); } +/// The default of boolean property `name` on component `c`. +fn bool_default(s: &Session, c: NodeId, name: &str) -> Option { + match &node(s, c).kind { + NodeKind::Component { props, .. } => match props.get(name) { + Some(PropDef::Boolean { default }) => Some(*default), + _ => None, + }, + _ => None, + } +} + +#[test] +fn boolean_property_default_is_the_mains_visibility() { + let mut s = Session::new(); + let (c, bg, title, dot) = card(&mut s); + // #993: a `false` default hides the bound layer in the main, not only in instances. + ex(&mut s, "component.addProperty", json!({"id": c, "name": "Show dot", "type": "boolean", "default": false, "layer": dot})); + assert!(!node(&s, dot).visible); + let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 300, "y": 0}))); + assert!(!node(&s, copy_of(&s, i, dot)).visible); + // Without a default, the property takes the layer's visibility. + ex(&mut s, "node.set", json!({"id": bg, "props": {"visible": false}})); + ex(&mut s, "component.addProperty", json!({"id": c, "name": "Show bg", "type": "boolean", "layer": bg})); + assert_eq!(bool_default(&s, c, "Show bg"), Some(false)); + assert!(!node(&s, bg).visible); + // Binding a layer to an existing property shows it as the default says. + ex(&mut s, "component.bindProperty", json!({"layer": title, "property": "Show dot"})); + assert!(!node(&s, title).visible); + // A new default shows or hides every bound layer of the main; instances keep their values. + ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Show dot": false}})); + ex(&mut s, "component.editProperty", json!({"id": c, "name": "Show dot", "default": true})); + assert!(node(&s, dot).visible && node(&s, title).visible); + assert!(!node(&s, copy_of(&s, i, dot)).visible, "the instance's own value stays"); + // Renamed with a new default in one go. + ex(&mut s, "component.editProperty", json!({"id": c, "name": "Show dot", "newName": "Dot", "default": false})); + assert!(!node(&s, dot).visible && !node(&s, title).visible); + assert_eq!(bool_default(&s, c, "Dot"), Some(false)); + // Showing a bound layer on the main is the property's new default, for its other layers too. + ex(&mut s, "node.set", json!({"id": dot, "props": {"visible": true}})); + assert_eq!(bool_default(&s, c, "Dot"), Some(true)); + assert!(node(&s, title).visible); + let j = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 600, "y": 0}))); + assert!(node(&s, copy_of(&s, j, dot)).visible && node(&s, copy_of(&s, j, title)).visible); + assert!(!node(&s, copy_of(&s, i, dot)).visible, "existing instances keep their values"); + // Hiding a layer inside an instance is an override there, not a new default. + let jdot = copy_of(&s, j, dot); + ex(&mut s, "node.set", json!({"id": jdot, "props": {"visible": false}})); + assert_eq!(bool_default(&s, c, "Dot"), Some(true)); + assert!(node(&s, dot).visible); + // Undo puts the default and the layers back together. + s.undo().unwrap(); + s.undo().unwrap(); + s.undo().unwrap(); + assert_eq!(bool_default(&s, c, "Dot"), Some(false)); + assert!(!node(&s, dot).visible && !node(&s, title).visible); + healthy(&s); +} + +#[test] +fn boolean_property_default_follows_across_variants() { + let mut s = Session::new(); + let mut variants = Vec::new(); + let mut labels = Vec::new(); + for (k, state) in ["Default", "Hover"].iter().enumerate() { + let f = + idv(&ex(&mut s, "frame.create", json!({"x": k as f64 * 150.0, "y": 0, "width": 100, "height": 40, "name": format!("State={state}")}))); + labels.push(idv(&ex(&mut s, "text.create", json!({"text": *state, "x": 10, "y": 10, "parent": f, "props": {"name": "Label"}})))); + variants.push(idv(&ex(&mut s, "component.create", json!({"ids": [f]})))); + } + let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": variants.clone()}))); + ex(&mut s, "component.addProperty", json!({"id": set, "name": "Show label", "type": "boolean", "default": false, "layer": labels[0]})); + ex(&mut s, "component.bindProperty", json!({"layer": labels[1], "property": "Show label"})); + assert!(labels.iter().all(|l| !node(&s, *l).visible)); + ex(&mut s, "component.editProperty", json!({"id": set, "name": "Show label", "default": true})); + assert!(labels.iter().all(|l| node(&s, *l).visible)); + // Hiding the label in one variant is the set's default: the other variant's label follows. + ex(&mut s, "node.set", json!({"id": labels[1], "props": {"visible": false}})); + assert!(variants.iter().all(|v| bool_default(&s, *v, "Show label") == Some(false))); + assert!(!node(&s, labels[0]).visible); + let i = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 200}))); + assert!(!node(&s, kids(&s, i)[0]).visible); + healthy(&s); +} + #[test] fn push_changes_to_main() { let mut s = Session::new(); diff --git a/docs/mcp.md b/docs/mcp.md index dfea95cc..3d412727 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -713,6 +713,14 @@ most when it can't be reached). moves the chosen variant there, `{name, index}` reorders variant properties (the variants' names follow). `component.properties` on a set reports `conflicts`: variants sharing one combination of values. +- **Boolean properties**: on the main component, a boolean property's value is the visibility of + the layers bound to it. `component.addProperty {type: "boolean", default, layer}`, + `component.bindProperty {field: "visible"}` and `component.editProperty {name, default}` show or + hide the main's bound layers to match the default (without `default`, `addProperty` takes the + bound layer's visibility). Showing or hiding a bound layer on the main sets the default, and the + property's other bound layers (in every variant of a set) follow. New instances start from the + default; existing instances keep the values they have. Files open as saved: a main whose bound + layer disagrees with the default stays that way until one of them is edited. - `component.pushChanges {id?}` copies the instance's overrides into its main (from a layer of a nested instance too: always into the outer instance's main). From 196e3432dd558e68cc256f2b39e4173bc1fb38ba Mon Sep 17 00:00:00 2001 From: vetlucasmartins Date: Wed, 21 Oct 2026 19:54:47 +0100 Subject: [PATCH 07/24] data.fill and text.fit: fill copies of a frame from rows of data - data.fill {id, rows, gap?, formats?, dir?, format?, scale?}: repeats a frame once per row in one undo step; each row key names layers in the copy (text, or {text?, fill?, visible?, fit?}). `formats` makes each row once per size (feed, story, square), the copy resized so its auto layout reflows. With `dir`, each copy is exported under a unique file name; a failed export is reported in `errors` and keeps the copies. - text.fit {ids?, maxLines?, minSize?, orphans?}: the largest font size, down to a floor, at which fixed-width text stays within N lines and its box; avoids a one-word last line; `fits` comes from the final text. - Plugins can't call data.fill: it runs other commands inside, past the plugin policy, like command.batch. --- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/datafill.rs | 171 ++++++++++++++++++++++++++++ crates/engine/src/datafill_tests.rs | 142 +++++++++++++++++++++++ crates/engine/src/lib.rs | 6 + crates/engine/src/plugins.rs | 2 + crates/engine/src/plugins_tests.rs | 1 + crates/engine/src/textfit.rs | 115 +++++++++++++++++++ crates/engine/src/textfit_tests.rs | 104 +++++++++++++++++ crates/ui-egui/locales/es.toml | 2 + crates/ui-egui/locales/pt-BR.toml | 2 + docs/mcp.md | 32 ++++++ 11 files changed, 579 insertions(+) create mode 100644 crates/engine/src/datafill.rs create mode 100644 crates/engine/src/datafill_tests.rs create mode 100644 crates/engine/src/textfit.rs create mode 100644 crates/engine/src/textfit_tests.rs diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index f011238d..1d73cc4c 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -109,6 +109,8 @@ fn registry() -> &'static Registry { list.extend(crate::online::specs()); list.extend(crate::invite_links::specs()); list.extend(crate::templates::specs()); + list.extend(crate::datafill::specs()); + list.extend(crate::textfit::specs()); let mut index = HashMap::new(); for (i, s) in list.iter().enumerate() { if index.insert(s.id, i).is_some() { diff --git a/crates/engine/src/datafill.rs b/crates/engine/src/datafill.rs new file mode 100644 index 00000000..d0226936 --- /dev/null +++ b/crates/engine/src/datafill.rs @@ -0,0 +1,171 @@ +//! `data.fill`: one frame repeated per row of data (a carousel, a set of posts, cards from a +//! list): each copy gets its row's texts, colours and visibility on the layers named by the row's +//! keys, in one undo step, and optionally each copy exported as a file. Agents make a series with +//! one call instead of a duplicate, a lookup and an edit per layer and an export per copy. + +use serde_json::{Value, json}; + +use crate::cmd::CommandSpec; +use crate::params::*; +use crate::{Result, Session, bad, spec}; + +const CMD: &str = "data.fill"; +const MAX_ROWS: usize = 500; + +pub fn specs() -> Vec { + vec![spec!( + "data.fill", + "Fill copies with data", + "{id (the frame to repeat), rows: [{: text | {text?, fill? (#rrggbb), visible?, fit? (true or text.fit's {maxLines?, minSize?, orphans?}: size the text to its box)}, name? (the copy's name)}] (every layer with that name in the copy; up to 500 rows), gap? (between copies, placed in a row to the right; default 100), formats? ([{name?, width, height}]: each row once per size, the copy resized so its auto layout reflows and named -; e.g. feed 1080×1350, story 1080×1920, square 1080×1080), dir? (export each copy there as .), format? (png|jpg|webp|svg|pdf; default png), scale? (default 1)} one undo step → {ids (the copies, in row order), missing (keys no layer is named), files? (paths written, one per copy: same file names get a numbered suffix), errors? ([{id, path, error}] for exports that failed; the copies stay)}", + false, + fill + )] +} + +/// A file name from a layer name: letters, digits, spaces, `-` and `_` kept, the rest `-`. +fn file_name(name: &str) -> String { + let s: String = name.chars().take(80).map(|c| if c.is_alphanumeric() || matches!(c, ' ' | '-' | '_') { c } else { '-' }).collect(); + let s = s.trim().to_string(); + if s.is_empty() { "copy".into() } else { s } +} + +fn fill(s: &mut Session, p: &Value) -> Result { + let src = id(p, "id").ok_or_else(|| bad(CMD, "`id` (the frame to repeat) required"))?; + let rows = match get(p, "rows") { + Some(Value::Array(a)) if a.iter().all(Value::is_object) && a.len() <= MAX_ROWS => a.clone(), + _ => return Err(bad(CMD, "rows: an array of objects (up to 500)")), + }; + let gap = num_or(p, "gap", 100.0); + if !gap.is_finite() { + return Err(bad(CMD, "gap: a number")); + } + let format = string(p, "format").unwrap_or_else(|| "png".into()).to_ascii_lowercase(); + if !["png", "jpg", "webp", "svg", "pdf"].contains(&format.as_str()) { + return Err(bad(CMD, "format: png|jpg|webp|svg|pdf")); + } + let (width, base) = { + let doc = s.doc()?; + let n = doc.node(src).ok_or_else(|| bad(CMD, "no such layer"))?; + (n.width, n.name.clone()) + }; + // `formats`: each row once per size, the copy resized so its auto layout reflows. + let formats: Vec> = match get(p, "formats") { + None | Some(Value::Null) => vec![None], + Some(Value::Array(a)) if !a.is_empty() && a.len() <= 12 => a + .iter() + .map(|f| { + let (w, h) = (f.get("width").and_then(Value::as_f64), f.get("height").and_then(Value::as_f64)); + let name = f.get("name").and_then(Value::as_str).unwrap_or_default().trim().to_string(); + match (w, h) { + (Some(w), Some(h)) if w.is_finite() && h.is_finite() && (1.0..=20_000.0).contains(&w) && (1.0..=20_000.0).contains(&h) => { + Ok(Some((if name.is_empty() { format!("{}x{}", w as i64, h as i64) } else { name }, w, h))) + } + _ => Err(bad(CMD, "formats: [{name?, width, height}] with sizes from 1 to 20000")), + } + }) + .collect::>()?, + Some(_) => return Err(bad(CMD, "formats: [{name?, width, height}] (1 to 12 sizes)")), + }; + let column = formats.iter().flatten().map(|f| f.1).fold(width, f64::max); + let mut missing: Vec = Vec::new(); + let ids = s.grouped("Fill copies with data", |s| { + let mut ids = Vec::with_capacity(rows.len() * formats.len()); + for (i, row) in rows.iter().enumerate() { + let mut dy = 0.0; + for fmt in &formats { + // Rows go right; a row's formats stack downwards. + let dx = (column + gap) * (i as f64 + 1.0); + let dup = s.execute("edit.duplicate", &json!({"ids": [src], "dx": dx, "dy": dy}))?; + let copy = dup["ids"].get(0).and_then(id_of).ok_or_else(|| bad(CMD, "the frame couldn't be duplicated"))?; + let Some(fields) = row.as_object() else { continue }; + let name = fields.get("name").and_then(Value::as_str).map_or_else(|| format!("{base} {}", i + 1), str::to_string); + let name = match fmt { + Some((suffix, w, h)) => { + s.execute("node.set", &json!({"id": copy, "props": {"width": w, "height": h}}))?; + dy += h + gap; + format!("{name}-{suffix}") + } + None => name, + }; + s.execute("node.set", &json!({"id": copy, "props": {"name": name}}))?; + for (key, value) in fields.iter().filter(|(k, _)| k.as_str() != "name") { + let doc = s.doc()?; + let targets: Vec<_> = + doc.subtree(copy).into_iter().filter(|t| *t != copy && doc.node(*t).is_some_and(|n| n.name == *key)).collect(); + if targets.is_empty() { + if !missing.contains(key) { + missing.push(key.clone()); + } + continue; + } + let (text, fill, visible, fit) = match value { + Value::Object(o) => ( + o.get("text").map(text_of), + o.get("fill").and_then(Value::as_str), + o.get("visible").and_then(Value::as_bool), + o.get("fit").cloned().filter(|f| !matches!(f, Value::Bool(false) | Value::Null)), + ), + v => (Some(text_of(v)), None, None, None), + }; + let mut texts = Vec::new(); + for t in targets { + let is_text = s.doc()?.node(t).is_some_and(|n| matches!(n.kind, figcraft_doc::NodeKind::Text(_))); + if let Some(text) = text.as_ref().filter(|_| is_text) { + s.execute("text.set", &json!({"id": t, "characters": text}))?; + } + if is_text { + texts.push(t.0); + } + if let Some(f) = fill { + s.execute("node.set", &json!({"id": t, "props": {"fills": [f]}}))?; + } + if let Some(v) = visible { + s.execute("node.set", &json!({"id": t, "props": {"visible": v}}))?; + } + } + // `fit: true` or `{maxLines?, minSize?, orphans?}`: size the text to its box. + if let Some(f) = fit.filter(|_| !texts.is_empty()) { + let mut params = if f.is_object() { f } else { json!({}) }; + params["ids"] = json!(texts); + s.execute("text.fit", ¶ms)?; + } + } + ids.push((copy, name)); + } + } + Ok(ids) + })?; + let mut out = json!({"ids": ids.iter().map(|(i, _)| i.0).collect::>(), "missing": missing}); + // Exports after the edits landed: a failed edit writes no files. A failed export keeps the + // copies (one undo step removes them) and is reported with the files that were written. + if let Some(dir) = string(p, "dir") { + let (mut files, mut errors, mut used) = (Vec::new(), Vec::new(), Vec::::new()); + for (copy, name) in &ids { + // One file per copy: same or same-looking names get " 2", " 3"… (case-insensitively, + // as macOS and Windows file systems compare names). + let base = file_name(name); + let stem = + (1..).map(|k| if k == 1 { base.clone() } else { format!("{base} {k}") }).find(|n| !used.contains(&n.to_lowercase())).unwrap_or(base); + used.push(stem.to_lowercase()); + let path = std::path::Path::new(&dir).join(format!("{stem}.{format}")).to_string_lossy().to_string(); + match s.execute("file.export", &json!({"ids": [copy.0], "format": format, "scale": num_or(p, "scale", 1.0), "path": path})) { + Ok(_) => files.push(path), + Err(e) => errors.push(json!({"id": copy.0, "path": path, "error": e.to_string()})), + } + } + out["files"] = json!(files); + if !errors.is_empty() { + out["errors"] = json!(errors); + } + } + Ok(out) +} + +/// A cell as text: strings as they are, numbers and booleans as written, null as empty. +fn text_of(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + Value::Null => String::new(), + v => v.to_string(), + } +} diff --git a/crates/engine/src/datafill_tests.rs b/crates/engine/src/datafill_tests.rs new file mode 100644 index 00000000..bbb5d4b6 --- /dev/null +++ b/crates/engine/src/datafill_tests.rs @@ -0,0 +1,142 @@ +//! `data.fill`: a frame repeated per row with that row's texts, fills and visibility, in one undo +//! step, optionally exported. + +use crate::Session; +use crate::components::tests::{ex, idv}; +use figcraft_doc::*; +use serde_json::json; + +/// A 1080×1350 "Post" with "Title" and "Price" texts and a "Badge" rectangle. Returns the frame. +fn post(s: &mut Session) -> NodeId { + let f = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 1080, "height": 1350, "name": "Post"}))); + ex(s, "text.create", json!({"text": "Title", "x": 80, "y": 80, "parent": f, "props": {"name": "Title"}})); + ex(s, "text.create", json!({"text": "R$ 0", "x": 80, "y": 1200, "parent": f, "props": {"name": "Price"}})); + ex(s, "shape.rectangle", json!({"x": 80, "y": 40, "width": 200, "height": 32, "parent": f, "props": {"name": "Badge", "fill": "#3DDC97"}})); + f +} + +fn text(s: &Session, frame: NodeId, name: &str) -> String { + let doc = s.doc().unwrap(); + doc.subtree(frame) + .into_iter() + .filter_map(|i| doc.node(i)) + .find(|n| n.name == name) + .and_then(|n| match &n.kind { + NodeKind::Text(t) => Some(t.characters.clone()), + _ => None, + }) + .unwrap_or_default() +} + +#[test] +fn each_row_fills_a_copy_in_one_undo_step() { + let mut s = Session::new(); + let f = post(&mut s); + let r = ex( + &mut s, + "data.fill", + json!({"id": f, "rows": [ + {"name": "Slide 1", "Title": "Site em 7 dias", "Price": 5500}, + {"name": "Slide 2", "Title": "WhatsApp com IA", "Badge": {"visible": false}}, + {"Title": "Agenda sozinha", "Badge": {"fill": "#7C5CFF"}, "Subtitle": "nope"} + ]}), + ); + let ids: Vec = r["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect(); + assert_eq!(ids.len(), 3); + assert_eq!(r["missing"], json!(["Subtitle"])); + assert_eq!(text(&s, ids[0], "Title"), "Site em 7 dias"); + assert_eq!(text(&s, ids[0], "Price"), "5500"); + assert_eq!(text(&s, ids[2], "Title"), "Agenda sozinha"); + assert_eq!(text(&s, f, "Title"), "Title", "the source frame is untouched"); + let doc = s.doc().unwrap(); + assert_eq!(doc.node(ids[0]).unwrap().name, "Slide 1"); + assert_eq!(doc.node(ids[2]).unwrap().name, "Post 3"); + // Copies sit in a row to the right, 100 apart. + let x = |i: NodeId| doc.node(i).unwrap().transform.as_coeffs()[4]; + assert_eq!((x(ids[0]), x(ids[1])), (1180.0, 2360.0)); + let badge = |i: NodeId| doc.subtree(i).into_iter().filter_map(|k| doc.node(k)).find(|n| n.name == "Badge").cloned().unwrap(); + assert!(!badge(ids[1]).visible && badge(ids[0]).visible); + assert_eq!(badge(ids[2]).fills[0].solid_color().map(|c| c.to_hex()), Some("7C5CFF".into())); + // One undo removes every copy. + ex(&mut s, "edit.undo", json!({})); + assert!(ids.iter().all(|i| !s.doc().unwrap().contains(*i))); +} + +#[test] +fn copies_export_to_a_folder_and_bad_input_is_refused() { + let mut s = Session::new(); + let f = post(&mut s); + let dir = std::env::temp_dir().join(format!("figcraft-datafill-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let r = ex( + &mut s, + "data.fill", + json!({"id": f, "rows": [{"name": "Post/1: hero", "Title": "A"}, {"Title": "B"}], "dir": dir.to_string_lossy(), "scale": 0.25}), + ); + let files: Vec = r["files"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect(); + assert_eq!(files.len(), 2); + assert!(files[0].ends_with("Post-1- hero.png"), "{files:?}"); + assert!(files.iter().all(|p| std::fs::metadata(p).is_ok_and(|m| m.len() > 0))); + let _ = std::fs::remove_dir_all(&dir); + for bad in [json!({"id": f}), json!({"id": f, "rows": [1, 2]}), json!({"id": f, "rows": [], "format": "gif"}), json!({"id": 999_999, "rows": []})] + { + assert!(s.execute("data.fill", &bad).is_err(), "{bad}"); + } +} + +#[test] +fn each_row_comes_in_every_format() { + let mut s = Session::new(); + let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 1080, "height": 1350, "name": "Post"}))); + ex(&mut s, "layout.add", json!({"ids": [f], "direction": "vertical"})); + ex(&mut s, "node.set", json!({"id": f, "props": {"sizingV": "fixed", "padding": 80}})); + let t = idv(&ex(&mut s, "text.create", json!({"text": "Título", "x": 0, "y": 0, "parent": f, "props": {"name": "Title", "fontSize": 96}}))); + ex(&mut s, "node.set", json!({"id": t, "props": {"sizingH": "fill", "textAutoResize": "height"}})); + let r = ex( + &mut s, + "data.fill", + json!({"id": f, "formats": [ + {"name": "feed", "width": 1080, "height": 1350}, {"name": "story", "width": 1080, "height": 1920}, {"width": 1080, "height": 1080} + ], "rows": [{"name": "capa", "Title": "Site em 7 dias"}, {"name": "prova", "Title": "Lighthouse 100"}]}), + ); + let ids: Vec = r["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect(); + assert_eq!(ids.len(), 6); + let doc = s.doc().unwrap(); + let names: Vec = ids.iter().map(|i| doc.node(*i).unwrap().name.clone()).collect(); + assert_eq!(names, ["capa-feed", "capa-story", "capa-1080x1080", "prova-feed", "prova-story", "prova-1080x1080"]); + let size = |i: NodeId| (doc.node(i).unwrap().width, doc.node(i).unwrap().height); + assert_eq!(size(ids[1]), (1080.0, 1920.0)); + assert_eq!(size(ids[2]), (1080.0, 1080.0)); + assert_eq!(text(&s, ids[4], "Title"), "Lighthouse 100"); + // Formats of a row stack downwards; rows go right without overlapping. + let at = |i: NodeId| (doc.node(i).unwrap().transform.as_coeffs()[4], doc.node(i).unwrap().transform.as_coeffs()[5]); + assert_eq!(at(ids[0]).0, at(ids[1]).0); + assert!(at(ids[1]).1 >= at(ids[0]).1 + 1350.0); + assert!(at(ids[3]).0 >= at(ids[0]).0 + 1080.0); + for bad in [json!([]), json!([{"width": 0, "height": 10}]), json!("feed")] { + assert!(s.execute("data.fill", &json!({"id": f, "rows": [{}], "formats": bad})).is_err(), "{bad}"); + } +} + +#[test] +fn review_export_names_are_unique_and_failures_are_reported() { + let mut s = Session::new(); + let f = post(&mut s); + let dir = std::env::temp_dir().join(format!("figcraft-datafill-names-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let r = ex( + &mut s, + "data.fill", + json!({"id": f, "rows": [{"name": "Post"}, {"name": "post"}, {"name": "a/b"}, {"name": "a:b"}], "dir": dir.to_string_lossy(), "scale": 0.1}), + ); + let files: Vec = r["files"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect(); + let lower: std::collections::HashSet = files.iter().map(|f| f.to_lowercase()).collect(); + assert_eq!(lower.len(), 4, "one file per copy: {files:?}"); + assert!(files.iter().all(|p| std::fs::metadata(p).is_ok()), "{files:?}"); + let _ = std::fs::remove_dir_all(&dir); + // A folder that can't be written: the copies stay and every failure is reported. + let r = ex(&mut s, "data.fill", json!({"id": f, "rows": [{"name": "x"}], "dir": "/nonexistent-figcraft-dir/out"})); + assert_eq!(r["ids"].as_array().unwrap().len(), 1); + assert_eq!(r["errors"].as_array().map(Vec::len), Some(1), "{r}"); + assert!(s.doc().unwrap().contains(NodeId(r["ids"][0].as_u64().unwrap()))); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 79e7be0d..1690d0a9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -22,6 +22,7 @@ pub mod codegen; pub mod collab; pub mod component_codegen; pub mod components; +pub mod datafill; pub mod dev_resources; pub mod devcompare; pub mod devinspect; @@ -59,6 +60,7 @@ pub mod synthetic; pub mod synthetic_ds; pub mod templates; pub mod textedit; +pub mod textfit; pub mod textpreview; pub mod tools; pub mod vectoredit; @@ -1643,6 +1645,8 @@ mod cursor_tests; #[cfg(test)] mod dark_page_text_tests; #[cfg(test)] +mod datafill_tests; +#[cfg(test)] mod design_paste_tests; #[cfg(test)] mod dev_resources_tests; @@ -1867,6 +1871,8 @@ mod text_style_picker_tests; #[cfg(test)] mod text_tail_tests; #[cfg(test)] +mod textfit_tests; +#[cfg(test)] mod theme_pages_tests; #[cfg(test)] mod tiff_tests; diff --git a/crates/engine/src/plugins.rs b/crates/engine/src/plugins.rs index be997079..be222293 100644 --- a/crates/engine/src/plugins.rs +++ b/crates/engine/src/plugins.rs @@ -111,6 +111,8 @@ const DENIED: &[&str] = &[ "edit.redo", "edit.repeat", "command.batch", + // Runs other commands inside (duplicate, edit, export), past this policy, like a batch. + "data.fill", // Network sessions, online files and their recovery copies on disk. "collab.", // Opens files in new windows. diff --git a/crates/engine/src/plugins_tests.rs b/crates/engine/src/plugins_tests.rs index 657b6825..7ff16cf3 100644 --- a/crates/engine/src/plugins_tests.rs +++ b/crates/engine/src/plugins_tests.rs @@ -169,6 +169,7 @@ fn files_dialogs_history_and_plugins_are_off_limits() { ("plugin.remove", json!({"id": "org.test.deny"})), ("edit.undo", json!({})), ("command.batch", json!({"commands": [{"command": "file.saveAs", "params": {"path": target}}]})), + ("data.fill", json!({"id": a, "rows": [{}], "dir": dir.to_string_lossy()})), ("variable.export", json!({"format": "css", "path": target})), ("codeConnect.export", json!({"path": target})), ("codeConnect.import", json!({"path": "/etc/passwd"})), diff --git a/crates/engine/src/textfit.rs b/crates/engine/src/textfit.rs new file mode 100644 index 00000000..f0cf3bba --- /dev/null +++ b/crates/engine/src/textfit.rs @@ -0,0 +1,115 @@ +//! `text.fit`: text sized to fit its box, for posts and cards whose copy changes (a carousel +//! filled from data, a translated headline). The largest font size, down to a floor, at which the +//! text stays within `maxLines` lines and its box's height; then a last line that is a single word +//! (an orphan) is avoided by shrinking a little more, else by keeping the last two words together +//! with a no-break space. Every style run scales by the same factor, so mixed sizes keep their +//! proportions. + +use figcraft_doc::*; +use serde_json::{Value, json}; + +use crate::cmd::CommandSpec; +use crate::params::*; +use crate::{Result, Session, bad, spec}; + +const CMD: &str = "text.fit"; + +pub fn specs() -> Vec { + vec![spec!( + "text.fit", + "Fit text to its box", + "{ids? (text layers; default the selection), maxLines? (default: no limit but the box height), minSize? (smallest font size, px; default 60% of the current), orphans? (avoid a one-word last line; default true)} needs a fixed width (fixed or auto-height text) → {fitted: [{id, fontSize, lines, fits, orphan}]}", + true, + fit + )] +} + +/// The layout of `t` with every run's font size times `k`, at `width`: (line count, height, +/// words on the last line). +fn measure(t: &TextData, k: f64, width: f64) -> (usize, f64, usize) { + let mut t = t.clone(); + let n = t.char_count(); + t.restyle(0, n, |s| s.font_size = (s.font_size * k).clamp(1.0, 5000.0)); + let l = figcraft_text::layout(&t, Some(width), None); + let last = l.lines.last().map_or(0, |line| { + t.characters.chars().skip(line.char_start).take(line.char_end.saturating_sub(line.char_start)).collect::().split_whitespace().count() + }); + (l.lines.len(), l.height, last) +} + +fn fit(s: &mut Session, p: &Value) -> Result { + let ids = req_targets(CMD, s, p)?; + let max_lines = num(p, "maxLines").filter(|v| v.is_finite() && *v >= 1.0).map(|v| v.min(1000.0) as usize); + let orphans = boolean(p, "orphans").unwrap_or(true); + let mut out = Vec::new(); + let doc = s.doc_mut()?; + for id in ids { + let n = doc.node(id).ok_or_else(|| bad(CMD, format!("no layer {id}")))?; + let NodeKind::Text(t) = &n.kind else { return Err(bad(CMD, format!("{id} is not text"))) }; + if t.auto_resize == TextAutoResize::WidthAndHeight { + return Err(bad(CMD, format!("{id} grows to its text: give it a fixed width (auto height or fixed) first"))); + } + let (width, height_cap) = (n.width, if t.auto_resize == TextAutoResize::None { n.height } else { f64::INFINITY }); + let size = t.style.font_size.max(1.0); + let min = num(p, "minSize").filter(|v| v.is_finite() && *v >= 1.0).unwrap_or(size * 0.6).min(size); + let fits = |m: (usize, f64, usize)| max_lines.is_none_or(|ml| m.0 <= ml) && m.1 <= height_cap + 0.5; + // The largest factor in [min/size, 1] that fits: binary search to a tenth of a pixel. + let (mut lo, mut hi) = (min / size, 1.0); + let k = if fits(measure(t, 1.0, width)) { + 1.0 + } else if !fits(measure(t, lo, width)) { + lo + } else { + for _ in 0..24 { + let mid = (lo + hi) / 2.0; + if fits(measure(t, mid, width)) { + lo = mid + } else { + hi = mid + } + if (hi - lo) * size < 0.1 { + break; + } + } + lo + }; + // Whole-tenth sizes read better in the panel. + let mut k = ((size * k * 10.0).floor() / 10.0) / size; + let mut m = measure(t, k, width); + let mut orphan = orphans && m.0 > 1 && m.2 == 1; + let mut nbsp = false; + if orphan { + // Up to 8% smaller (not below the floor) may pull a word down to the last line. + let mut tries = 0; + while orphan && tries < 8 && size * k * 0.99 >= min { + k *= 0.99; + m = measure(t, k, width); + orphan = m.0 > 1 && m.2 == 1; + tries += 1; + } + if orphan { + nbsp = true; + } + } + let n = doc.get_mut(id)?; + let NodeKind::Text(t) = &mut n.kind else { continue }; + let count = t.char_count(); + t.restyle(0, count, |st| st.font_size = ((st.font_size * k * 10.0).round() / 10.0).clamp(1.0, 5000.0)); + // Same character count, so style runs keep their ranges. + let joined = if nbsp && let Some(i) = t.characters.trim_end().rfind(' ') { + t.characters.replace_range(i..i + 1, "\u{a0}"); + Some(i) + } else { + None + }; + let mut after = measure(t, 1.0, width); + // Keeping the last words together must not break the limit: an orphan is better. + if let Some(i) = joined.filter(|_| !fits(after)) { + t.characters.replace_range(i..i + '\u{a0}'.len_utf8(), " "); + after = measure(t, 1.0, width); + } + // Reported from the final text (rounded sizes, the no-break space), which callers rely on. + out.push(json!({"id": id.0, "fontSize": (t.style.font_size * 10.0).round() / 10.0, "lines": after.0, "fits": fits(after), "orphan": orphans && after.0 > 1 && after.2 == 1})); + } + Ok(json!({"fitted": out})) +} diff --git a/crates/engine/src/textfit_tests.rs b/crates/engine/src/textfit_tests.rs new file mode 100644 index 00000000..8d0a2ee5 --- /dev/null +++ b/crates/engine/src/textfit_tests.rs @@ -0,0 +1,104 @@ +//! `text.fit`: the largest size that fits the box and a line limit, orphans avoided, and +//! data.fill sizing each copy's text. + +use crate::Session; +use crate::components::tests::{ex, idv}; +use figcraft_doc::*; +use serde_json::json; + +/// A fixed-width (auto-height) headline of `width` px at 64px. Returns its id. +fn headline(s: &mut Session, text: &str, width: f64) -> NodeId { + let t = idv(&ex(s, "text.create", json!({"text": text, "x": 0, "y": 0, "props": {"name": "Headline", "fontSize": 64}}))); + ex(s, "node.set", json!({"id": t, "props": {"width": width, "textAutoResize": "height"}})); + t +} + +fn size(s: &Session, id: NodeId) -> f64 { + match &s.doc().unwrap().node(id).unwrap().kind { + NodeKind::Text(t) => t.style.font_size, + _ => 0.0, + } +} + +#[test] +fn text_shrinks_to_its_line_limit_and_no_further() { + let mut s = Session::new(); + let t = headline(&mut s, "Seu site e seu WhatsApp com IA, prontos em 7 dias", 600.0); + let r = ex(&mut s, "text.fit", json!({"ids": [t], "maxLines": 2})); + let f = &r["fitted"][0]; + assert_eq!(f["lines"], 2, "{r}"); + assert!(f["fits"].as_bool().unwrap()); + let fitted = size(&s, t); + assert!((64.0 * 0.6..64.0).contains(&fitted), "{fitted}"); + // The largest that fits: a pixel more breaks the limit. + ex(&mut s, "node.set", json!({"id": t, "props": {"fontSize": fitted + 1.0}})); + let again = ex(&mut s, "text.fit", json!({"ids": [t], "maxLines": 2})); + assert!((again["fitted"][0]["fontSize"].as_f64().unwrap() - fitted).abs() <= 0.2, "{again}"); + // Text that already fits keeps its size; one undo restores the original. + let short = headline(&mut s, "7 dias", 600.0); + ex(&mut s, "text.fit", json!({"ids": [short], "maxLines": 1})); + assert_eq!(size(&s, short), 64.0); + // A floor that can't fit reports it instead of going below (fit never grows text). + ex(&mut s, "node.set", json!({"id": t, "props": {"fontSize": 64}})); + let r = ex(&mut s, "text.fit", json!({"ids": [t], "maxLines": 1, "minSize": 50})); + assert_eq!(r["fitted"][0]["fits"], false); + assert_eq!(size(&s, t), 50.0); + // Text that grows to its content has no box to fit. + let auto = idv(&ex(&mut s, "text.create", json!({"text": "x", "x": 0, "y": 0}))); + assert!(s.execute("text.fit", &json!({"ids": [auto]})).is_err()); +} + +#[test] +fn a_one_word_last_line_is_avoided() { + let mut s = Session::new(); + // At 64px in 520px this breaks with "1s." alone on the last line. + let t = headline(&mut s, "Lighthouse 100, abre em menos de 1s.", 520.0); + let r = ex(&mut s, "text.fit", json!({"ids": [t]})); + assert_eq!(r["fitted"][0]["orphan"], false, "{r}"); + let doc = s.doc().unwrap(); + let NodeKind::Text(tx) = &doc.node(t).unwrap().kind else { panic!() }; + let l = figcraft_text::layout(tx, Some(520.0), None); + let last = l.lines.last().unwrap(); + let words = tx.characters.chars().skip(last.char_start).take(last.char_end - last.char_start).collect::(); + assert!(l.lines.len() == 1 || words.split_whitespace().count() > 1 || words.contains('\u{a0}'), "{words:?}"); +} + +#[test] +fn data_fill_sizes_each_copys_text() { + let mut s = Session::new(); + let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 1080, "height": 1350, "name": "Post"}))); + let t = idv(&ex(&mut s, "text.create", json!({"text": "Curto", "x": 80, "y": 80, "parent": f, "props": {"name": "Headline", "fontSize": 96}}))); + ex(&mut s, "node.set", json!({"id": t, "props": {"width": 920, "textAutoResize": "height"}})); + let r = ex( + &mut s, + "data.fill", + json!({"id": f, "rows": [ + {"Headline": {"text": "Curto", "fit": {"maxLines": 2}}}, + {"Headline": {"text": "Landing page e atendente de WhatsApp com IA, prontos em sete dias úteis", "fit": {"maxLines": 2}}} + ]}), + ); + let ids: Vec = r["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect(); + let head = |s: &Session, frame: NodeId| s.doc().unwrap().node(frame).unwrap().children[0]; + assert_eq!(size(&s, head(&s, ids[0])), 96.0, "short copy keeps its size"); + assert!(size(&s, head(&s, ids[1])) < 96.0, "long copy shrinks"); + assert_eq!(size(&s, t), 96.0, "the source is untouched"); +} + +#[test] +fn review_fits_is_reported_from_the_final_text() { + let mut s = Session::new(); + for (text, width, lines) in [ + ("Seu site e seu WhatsApp com IA, prontos em 7 dias", 600.0, 2.0), + ("Lighthouse 100, abre em menos de 1s.", 520.0, 2.0), + ("Curto", 300.0, 1.0), + ] { + let t = headline(&mut s, text, width); + let r = ex(&mut s, "text.fit", json!({"ids": [t], "maxLines": lines})); + let f = &r["fitted"][0]; + let doc = s.doc().unwrap(); + let NodeKind::Text(tx) = &doc.node(t).unwrap().kind else { panic!() }; + let real = figcraft_text::layout(tx, Some(width), None).lines.len(); + assert_eq!(f["lines"].as_u64().unwrap() as usize, real, "{r}"); + assert_eq!(f["fits"].as_bool().unwrap(), real as f64 <= lines, "{r}"); + } +} diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 596feda6..1e6799a9 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -2171,6 +2171,8 @@ status = "draft — needs native review" "text.removeFromPath" = "Quitar texto del trazado" "text.rescanFonts" = "Volver a buscar fuentes instaladas" "text.set" = "Establecer texto" +"data.fill" = "Rellenar copias con datos" +"text.fit" = "Ajustar texto a su caja" "text.setFeatures" = "Funciones OpenType" "text.setVariations" = "Ejes de fuente variable" "text.sizeDown" = "Reducir tamaño de fuente" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index d8f857e0..9a816e2c 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -2155,6 +2155,8 @@ status = "draft — needs native review" "text.preview" = "Visualizar alteração de texto" "text.removeFromPath" = "Remover texto do caminho" "text.set" = "Definir texto" +"data.fill" = "Preencher cópias com dados" +"text.fit" = "Ajustar texto à caixa" "text.setFeatures" = "Recursos OpenType" "text.setVariations" = "Eixos de fonte variável" "text.sizeDown" = "Diminuir tamanho da fonte" diff --git a/docs/mcp.md b/docs/mcp.md index dfea95cc..5af124f9 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -330,6 +330,38 @@ tips; headless, it only sets the preference. {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"export","arguments":{"format":"png","path":"dashboard.png"}}} ``` +### Filling copies with data + +`data.fill {id, rows, gap?, dir?, format?, scale?}` repeats a frame once per row (a carousel, a set +of posts, a card per item) in one undo step: each key of a row names layers in the copy, and a +string (or number) sets their text, `{text?, fill?, visible?}` sets more; `name` names the copy +(default " "). Copies go in a row to the right, `gap` apart (default 100). With `dir`, each +copy is exported there as `.` (png by default) after the edits land. Returns `{ids, +missing (keys no layer is named), files?}`. + +Copy that changes length per row can size itself: `{"Headline": {"text": "…", "fit": {"maxLines": 2}}}` runs +`text.fit` on those layers after the text is set. + +`formats: [{name?, width, height}]` makes every row once per size: the copy is resized so its auto layout +reflows, named `-` (default `x`), and placed under the row's previous +format. Text `fit` runs after the resize. Use case: one call turns a 1080×1350 post into feed, story +(1080×1920) and square (1080×1080) files for each slide of a carousel; the frame must be auto layout (fixed- +position children keep their place). + +`text.fit {ids?, maxLines?, minSize?, orphans?}` gives a text layer with a fixed width (auto height or fixed size) +the largest font size, down to `minSize` (default 60% of the current), at which it stays within `maxLines` lines +and its box's height. Every style run scales by the same factor. A one-word last line (an orphan) is avoided by +shrinking up to 8% more, else by keeping the last two words together with a no-break space. Text never grows. +Returns `{fitted: [{id, fontSize, lines, fits, orphan}]}`. Use case: a carousel slide "Lighthouse 100. Loads in under +1s." at 96px broke into three lines with "1s." alone; with `fit: {maxLines: 3}` it is two balanced lines. + +```json +{"command": "data.fill", "params": {"id": 80, "dir": "out/carousel", "scale": 1, "rows": [ + {"name": "01-cover", "Headline": "Your website and AI chat assistant in 7 days", "Badge": {"visible": true}}, + {"name": "02-proof", "Headline": "Lighthouse 100, loads in under a second", "Badge": {"visible": false}} +]}} +``` + ### Sections, handoff and arrow keys `run_command` with `object.wrapInSection {ids?, name?}` (Wrap in new section: layers on the page or From 601a9701a535cf67e2abc9eab013598bf9989a44 Mon Sep 17 00:00:00 2001 From: vetlucasmartins Date: Sat, 24 Oct 2026 00:32:24 +0100 Subject: [PATCH 08/24] text.fit: keep sizes that fit, respect the floor, skip one-word paragraphs - Text that already fits keeps its exact size; shrunk sizes round to a tenth but never below minSize (per run, its share of it). - A last line after a hard break is not an orphan, and the no-break space is only placed inside the last paragraph. - docs: data.fill documents formats? and errors?. --- crates/engine/src/textfit.rs | 49 +++++++++++++++++++++--------- crates/engine/src/textfit_tests.rs | 27 ++++++++++++++++ docs/mcp.md | 8 +++-- 3 files changed, 67 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/textfit.rs b/crates/engine/src/textfit.rs index f0cf3bba..e4e84739 100644 --- a/crates/engine/src/textfit.rs +++ b/crates/engine/src/textfit.rs @@ -25,16 +25,28 @@ pub fn specs() -> Vec { } /// The layout of `t` with every run's font size times `k`, at `width`: (line count, height, -/// words on the last line). -fn measure(t: &TextData, k: f64, width: f64) -> (usize, f64, usize) { +/// whether the last line is an orphan: one word, wrapped from the line above rather than a +/// paragraph of its own after a hard break, which no size can fix). +fn measure(t: &TextData, k: f64, width: f64) -> (usize, f64, bool) { let mut t = t.clone(); let n = t.char_count(); t.restyle(0, n, |s| s.font_size = (s.font_size * k).clamp(1.0, 5000.0)); let l = figcraft_text::layout(&t, Some(width), None); - let last = l.lines.last().map_or(0, |line| { - t.characters.chars().skip(line.char_start).take(line.char_end.saturating_sub(line.char_start)).collect::().split_whitespace().count() - }); - (l.lines.len(), l.height, last) + let orphan = l.lines.len() > 1 + && l.lines.last().is_some_and(|line| { + let mut before = t.characters.chars().skip(line.char_start.saturating_sub(1)); + let wrapped = line.char_start > 0 && before.next() != Some('\n'); + wrapped + && t.characters + .chars() + .skip(line.char_start) + .take(line.char_end.saturating_sub(line.char_start)) + .collect::() + .split_whitespace() + .count() + == 1 + }); + (l.lines.len(), l.height, orphan) } fn fit(s: &mut Session, p: &Value) -> Result { @@ -52,7 +64,7 @@ fn fit(s: &mut Session, p: &Value) -> Result { let (width, height_cap) = (n.width, if t.auto_resize == TextAutoResize::None { n.height } else { f64::INFINITY }); let size = t.style.font_size.max(1.0); let min = num(p, "minSize").filter(|v| v.is_finite() && *v >= 1.0).unwrap_or(size * 0.6).min(size); - let fits = |m: (usize, f64, usize)| max_lines.is_none_or(|ml| m.0 <= ml) && m.1 <= height_cap + 0.5; + let fits = |m: (usize, f64, bool)| max_lines.is_none_or(|ml| m.0 <= ml) && m.1 <= height_cap + 0.5; // The largest factor in [min/size, 1] that fits: binary search to a tenth of a pixel. let (mut lo, mut hi) = (min / size, 1.0); let k = if fits(measure(t, 1.0, width)) { @@ -73,10 +85,11 @@ fn fit(s: &mut Session, p: &Value) -> Result { } lo }; - // Whole-tenth sizes read better in the panel. - let mut k = ((size * k * 10.0).floor() / 10.0) / size; + // Whole-tenth sizes read better in the panel, but never below the floor; text that fits + // keeps its exact size. + let mut k = if k < 1.0 { ((size * k * 10.0).floor() / 10.0).max(min) / size } else { 1.0 }; let mut m = measure(t, k, width); - let mut orphan = orphans && m.0 > 1 && m.2 == 1; + let mut orphan = orphans && m.2; let mut nbsp = false; if orphan { // Up to 8% smaller (not below the floor) may pull a word down to the last line. @@ -84,7 +97,7 @@ fn fit(s: &mut Session, p: &Value) -> Result { while orphan && tries < 8 && size * k * 0.99 >= min { k *= 0.99; m = measure(t, k, width); - orphan = m.0 > 1 && m.2 == 1; + orphan = m.2; tries += 1; } if orphan { @@ -94,9 +107,17 @@ fn fit(s: &mut Session, p: &Value) -> Result { let n = doc.get_mut(id)?; let NodeKind::Text(t) = &mut n.kind else { continue }; let count = t.char_count(); - t.restyle(0, count, |st| st.font_size = ((st.font_size * k * 10.0).round() / 10.0).clamp(1.0, 5000.0)); + if k < 1.0 { + // Each run rounds to a tenth, but not below its share of the floor. + t.restyle(0, count, |st| { + let floor = st.font_size * min / size; + st.font_size = ((st.font_size * k * 10.0).round() / 10.0).max(floor).clamp(1.0, 5000.0) + }); + } // Same character count, so style runs keep their ranges. - let joined = if nbsp && let Some(i) = t.characters.trim_end().rfind(' ') { + // Only inside the last paragraph: a space before a hard break joins nothing on the last line. + let para = t.characters.rfind('\n').map_or(0, |i| i + 1); + let joined = if nbsp && let Some(i) = t.characters.trim_end().get(para..).and_then(|p| p.rfind(' ')).map(|i| para + i) { t.characters.replace_range(i..i + 1, "\u{a0}"); Some(i) } else { @@ -109,7 +130,7 @@ fn fit(s: &mut Session, p: &Value) -> Result { after = measure(t, 1.0, width); } // Reported from the final text (rounded sizes, the no-break space), which callers rely on. - out.push(json!({"id": id.0, "fontSize": (t.style.font_size * 10.0).round() / 10.0, "lines": after.0, "fits": fits(after), "orphan": orphans && after.0 > 1 && after.2 == 1})); + out.push(json!({"id": id.0, "fontSize": (t.style.font_size * 10.0).round() / 10.0, "lines": after.0, "fits": fits(after), "orphan": orphans && after.2})); } Ok(json!({"fitted": out})) } diff --git a/crates/engine/src/textfit_tests.rs b/crates/engine/src/textfit_tests.rs index 8d0a2ee5..8ee0f11b 100644 --- a/crates/engine/src/textfit_tests.rs +++ b/crates/engine/src/textfit_tests.rs @@ -102,3 +102,30 @@ fn review_fits_is_reported_from_the_final_text() { assert_eq!(f["fits"].as_bool().unwrap(), real as f64 <= lines, "{r}"); } } + +#[test] +fn review_sizes_off_the_tenth_grid_keep_their_size_and_floor() { + let mut s = Session::new(); + // Fits already: 14.25px stays 14.25px, not 14.2 or 14.3. + let t = headline(&mut s, "Curto", 600.0); + ex(&mut s, "node.set", json!({"id": t, "props": {"fontSize": 14.25}})); + ex(&mut s, "text.fit", json!({"ids": [t], "maxLines": 1})); + assert_eq!(size(&s, t), 14.25); + // Can't fit: it stops at the floor, not a tenth below it. + let long = headline(&mut s, "Seu site e seu WhatsApp com IA, prontos em 7 dias", 200.0); + let r = ex(&mut s, "text.fit", json!({"ids": [long], "maxLines": 1, "minSize": 12.34})); + assert_eq!(r["fitted"][0]["fits"], false, "{r}"); + assert!(size(&s, long) >= 12.34, "{}", size(&s, long)); +} + +#[test] +fn review_a_one_word_paragraph_is_not_an_orphan() { + let mut s = Session::new(); + let t = headline(&mut s, "Two words\nOne", 600.0); + let r = ex(&mut s, "text.fit", json!({"ids": [t]})); + assert_eq!(r["fitted"][0]["orphan"], false, "{r}"); + assert_eq!(size(&s, t), 64.0, "nothing to fix, so nothing shrinks"); + let doc = s.doc().unwrap(); + let NodeKind::Text(tx) = &doc.node(t).unwrap().kind else { panic!() }; + assert_eq!(tx.characters, "Two words\nOne", "no no-break space in an earlier paragraph"); +} diff --git a/docs/mcp.md b/docs/mcp.md index 5af124f9..4bcfac09 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -332,12 +332,13 @@ tips; headless, it only sets the preference. ### Filling copies with data -`data.fill {id, rows, gap?, dir?, format?, scale?}` repeats a frame once per row (a carousel, a set +`data.fill {id, rows, gap?, formats?, dir?, format?, scale?}` repeats a frame once per row (a carousel, a set of posts, a card per item) in one undo step: each key of a row names layers in the copy, and a string (or number) sets their text, `{text?, fill?, visible?}` sets more; `name` names the copy (default " "). Copies go in a row to the right, `gap` apart (default 100). With `dir`, each copy is exported there as `.` (png by default) after the edits land. Returns `{ids, -missing (keys no layer is named), files?}`. +missing (keys no layer is named), files?, errors?}`. `errors` lists the exports that failed as +`[{id, path, error}]`; the copies stay, and the files that were written are in `files`. Copy that changes length per row can size itself: `{"Headline": {"text": "…", "fit": {"maxLines": 2}}}` runs `text.fit` on those layers after the text is set. @@ -351,7 +352,8 @@ position children keep their place). `text.fit {ids?, maxLines?, minSize?, orphans?}` gives a text layer with a fixed width (auto height or fixed size) the largest font size, down to `minSize` (default 60% of the current), at which it stays within `maxLines` lines and its box's height. Every style run scales by the same factor. A one-word last line (an orphan) is avoided by -shrinking up to 8% more, else by keeping the last two words together with a no-break space. Text never grows. +shrinking up to 8% more, else by keeping the last two words together with a no-break space. A one-word paragraph after a hard line break is not an orphan. Text never +grows, text that already fits keeps its exact size, and sizes round to a tenth without going below `minSize`. Returns `{fitted: [{id, fontSize, lines, fits, orphan}]}`. Use case: a carousel slide "Lighthouse 100. Loads in under 1s." at 96px broke into three lines with "1s." alone; with `fit: {maxLines: 3}` it is two balanced lines. From e1117d2a3cee6eded8bb9d3cdbcc3ad2d088d581 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 12:57:42 +0700 Subject: [PATCH 09/24] #1008: token_roles names its imports --- crates/engine/src/token_roles.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/token_roles.rs b/crates/engine/src/token_roles.rs index dc609640..3827ecbe 100644 --- a/crates/engine/src/token_roles.rs +++ b/crates/engine/src/token_roles.rs @@ -6,7 +6,7 @@ use std::collections::HashMap; -use figcraft_doc::*; +use figcraft_doc::{Document, Variable, VariableId, VariableType, VariableValue}; use crate::codegen::num; From 91af3782e05fd9ee4b7f606939600f890c83d895 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 12:57:58 +0700 Subject: [PATCH 10/24] #915: the inspect response-limit tests join the MCP crate's one test binary (tests/it, as on main since #1208) --- crates/mcp/tests/{ => it}/inspect_response_limit.rs | 4 ++-- crates/mcp/tests/it/main.rs | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) rename crates/mcp/tests/{ => it}/inspect_response_limit.rs (99%) diff --git a/crates/mcp/tests/inspect_response_limit.rs b/crates/mcp/tests/it/inspect_response_limit.rs similarity index 99% rename from crates/mcp/tests/inspect_response_limit.rs rename to crates/mcp/tests/it/inspect_response_limit.rs index b6d8b92e..8e52514b 100644 --- a/crates/mcp/tests/inspect_response_limit.rs +++ b/crates/mcp/tests/it/inspect_response_limit.rs @@ -1,8 +1,8 @@ //! `inspect_document` must not emit a message larger than a client can read in one piece: MCP //! stdio is newline-delimited JSON, so one result is one message, and a client whose read buffer //! it exceeds closes the pipe and kills the server for the rest of the session (#912). -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] -mod common; +use crate::common; + use common::Client; use serde_json::{Value, json}; diff --git a/crates/mcp/tests/it/main.rs b/crates/mcp/tests/it/main.rs index b5e3d89d..b16fc98e 100644 --- a/crates/mcp/tests/it/main.rs +++ b/crates/mcp/tests/it/main.rs @@ -13,6 +13,8 @@ mod export_hidden_roots; #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod garbage; #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod inspect_response_limit; +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod protocol; #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod screenshot_pixel_limit; From b6f5c29d190a530f362ba66a9ba8f7dd2dc7d7ff Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 12:58:10 +0700 Subject: [PATCH 11/24] #915: MCP get_node pages past a child too large for the limit and keeps the truncation marker --- crates/mcp/src/tools.rs | 7 +++-- crates/mcp/tests/it/inspect_response_limit.rs | 31 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 19f2be35..4feebbb8 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -750,7 +750,10 @@ fn get_node(b: &mut dyn Backend, a: &Map) -> Result) -> Result (Value, usize) { let wire = serde_json::to_string(&text).unwrap().len() + ENVELOPE; (serde_json::from_str(text).expect("the block is JSON"), wire) } + +/// A child too large for the limit on its own is passed over, one per page of just the marker, +/// so paging moves on and every page stays within the limit. +#[test] +fn children_too_large_to_fit_page_one_at_a_time() { + let mut c = Client::new(); + let frame = c.create(json!({"type": "frame", "x": 0, "y": 0, "width": 400, "height": 400})); + for i in 0..3 { + c.create(json!({"type": "rectangle", "parent": frame, "x": i * 5, "y": 5, "width": 20, "height": 20, "name": "n".repeat(3000)})); + } + let mut offset = Some(0u64); + let mut pages = 0; + while let Some(at) = offset { + let (page, wire) = get_node(&mut c, json!({"id": frame, "depth": 1, "maxBytes": 2500, "offset": at})); + assert!(wire <= 2500, "page at {at} exceeds maxBytes: {wire}"); + assert_eq!(page["children"].as_array().map_or(0, Vec::len), 0, "{}", page["truncated"]); + assert!(!page["truncated"].is_null(), "the marker stays: page at {at}"); + offset = page["truncated"]["children"]["nextOffset"].as_u64(); + assert!(offset.is_none_or(|o| o > at), "paging moves on from {at}"); + pages += 1; + assert!(pages <= 3); + } + assert_eq!(pages, 3); + + // A sole child passed over still says the answer was cut. + let single = c.create(json!({"type": "frame", "x": 500, "y": 0, "width": 40, "height": 40})); + c.create(json!({"type": "rectangle", "parent": single, "x": 0, "y": 0, "width": 20, "height": 20, "name": "n".repeat(3000)})); + let (page, _) = get_node(&mut c, json!({"id": single, "depth": 1, "maxBytes": 2500})); + assert_eq!(page["children"].as_array().map_or(0, Vec::len), 0); + assert_eq!(page["truncated"]["children"]["total"], json!(1), "{}", page["truncated"]); +} From b00d12b623891b942cff7fc7fcacedd3dbcd45ab Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 13:01:33 +0700 Subject: [PATCH 12/24] #915: get_node refuses a layer too large for maxBytes on its own; a window only an offset made says it is paging --- crates/mcp/src/tools.rs | 43 +++++++++++++++---- crates/mcp/tests/it/inspect_response_limit.rs | 33 ++++++++++++++ 2 files changed, 68 insertions(+), 8 deletions(-) diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 4feebbb8..32ad8832 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -742,7 +742,7 @@ fn get_node(b: &mut dyn Backend, a: &Map) -> Result) -> Result budget { + // Even with every child left out, the layer's own fields (a very long name, say) pass the + // limit: an answer would be larger than the caller can read, so there is none. + return Err(format!( + "layer {id} takes {used_wire} bytes on the wire without its children, more than fit in maxBytes {max_bytes}; ask again with a larger `maxBytes`" + )); + } + // Children left out after the window were cut by size; a window that only `offset` moved, of a + // subtree within the limit, is paging. + let over = wire > budget || next.is_some(); let mut out = with_marker( value, depth, @@ -762,6 +772,7 @@ fn get_node(b: &mut dyn Backend, a: &Map) -> Result) -> Result bool { true } -/// `value` with the `truncated` object saying how it was cut. `children`: the window, when the +/// `value` with the `truncated` object saying how it was cut. `over`: the subtree asked for passed +/// the limit (else the window is only the `offset` asked for). `children`: the window, when the /// children were cut as well as (or instead of) the depth. -fn with_marker(mut value: Value, asked: u64, used: u64, asked_wire: usize, used_wire: usize, max_bytes: usize, children: Option) -> Value { +fn with_marker( + mut value: Value, + asked: u64, + used: u64, + asked_wire: usize, + used_wire: usize, + max_bytes: usize, + over: bool, + children: Option, +) -> Value { if let Some(obj) = value.as_object_mut() { let mut marker = json!({ "requestedDepth": asked, @@ -815,10 +836,16 @@ fn with_marker(mut value: Value, asked: u64, used: u64, asked_wire: usize, used_ match &children { Some(window) => { marker["children"] = window.clone(); - marker["reason"] = json!(format!( - "the subtree at depth {asked} would be {asked_wire} bytes on the wire, over the {max_bytes}-byte limit a client can read in one message; its children are reported from {} instead", - window.get("from").and_then(Value::as_u64).unwrap_or(0) - )); + let from = window.get("from").and_then(Value::as_u64).unwrap_or(0); + marker["reason"] = json!(if over { + format!( + "the subtree at depth {asked} would be {asked_wire} bytes on the wire, over the {max_bytes}-byte limit a client can read in one message; its children are reported from {from} instead" + ) + } else { + format!( + "its children are reported from {from}, the `offset` asked for; the subtree at depth {asked} ({asked_wire} bytes) is within the {max_bytes}-byte limit" + ) + }); marker["hint"] = json!("ask again with the `offset` in `children.nextOffset` for the rest, or get_node({id, depth}) on one child"); } None => { diff --git a/crates/mcp/tests/it/inspect_response_limit.rs b/crates/mcp/tests/it/inspect_response_limit.rs index 764e6a50..347aa96e 100644 --- a/crates/mcp/tests/it/inspect_response_limit.rs +++ b/crates/mcp/tests/it/inspect_response_limit.rs @@ -127,6 +127,7 @@ fn a_node_with_more_children_than_fit_is_windowed() { assert_eq!(window["total"], json!(total), "{window}"); assert!(shown > 0 && shown < total, "some children, not all: {shown} of {total}"); assert_eq!(window["nextOffset"], json!(shown), "the offset to ask for next: {window}"); + assert!(cut["truncated"]["reason"].as_str().unwrap_or("").contains("over the"), "cut by size: {}", cut["truncated"]); // The layer itself is still there, not just a marker. assert_eq!(cut["id"], whole["id"]); @@ -183,3 +184,35 @@ fn children_too_large_to_fit_page_one_at_a_time() { assert_eq!(page["children"].as_array().map_or(0, Vec::len), 0); assert_eq!(page["truncated"]["children"]["total"], json!(1), "{}", page["truncated"]); } + +/// A layer whose own fields pass the limit with every child left out is refused: an answer would +/// be over maxBytes, and the marker would only make it larger. +#[test] +fn a_layer_too_large_on_its_own_is_refused() { + let mut c = Client::new(); + let rect = c.create(json!({"type": "rectangle", "x": 0, "y": 0, "width": 20, "height": 20, "name": "n".repeat(3000)})); + let r = c.call("get_node", json!({"id": rect, "maxBytes": 2500})); + assert_eq!(r["isError"], true, "{r}"); + assert!(r["content"][0]["text"].as_str().unwrap_or("").contains("maxBytes"), "{r}"); + // A layer of ordinary size answers within the same limit. + let small = c.create(json!({"type": "rectangle", "x": 0, "y": 0, "width": 20, "height": 20})); + let (v, wire) = get_node(&mut c, json!({"id": small, "maxBytes": 2500})); + assert!(wire <= 2500 && v["truncated"].is_null(), "{wire}: {v}"); +} + +/// A window that only an `offset` made, of a subtree within the limit, is paging: the marker +/// doesn't say the limit was passed. +#[test] +fn an_offset_alone_pages_without_claiming_the_limit() { + let mut c = Client::new(); + let frame = c.create(json!({"type": "frame", "x": 0, "y": 0, "width": 400, "height": 400})); + for i in 0..3 { + c.create(json!({"type": "rectangle", "parent": frame, "x": i * 5, "y": 5, "width": 20, "height": 20})); + } + let (page, _) = get_node(&mut c, json!({"id": frame, "depth": 1, "offset": 1})); + let t = &page["truncated"]; + assert_eq!(page["children"].as_array().map_or(0, Vec::len), 2, "{t}"); + assert!(t["children"]["nextOffset"].is_null(), "{t}"); + let reason = t["reason"].as_str().unwrap_or(""); + assert!(reason.contains("offset") && !reason.contains("over the"), "{reason}"); +} From bc0f61eeee3cf03b96d76f30117809738a5e1517 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 13:01:46 +0700 Subject: [PATCH 13/24] #1004: a size given with a new path still fixes its auto layout axis --- crates/engine/src/al_behaviour_tests.rs | 12 ++++++++++++ crates/engine/src/cmd/props.rs | 25 ++++++++++++++++--------- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/al_behaviour_tests.rs b/crates/engine/src/al_behaviour_tests.rs index 4659a597..a7a4d1d7 100644 --- a/crates/engine/src/al_behaviour_tests.rs +++ b/crates/engine/src/al_behaviour_tests.rs @@ -221,3 +221,15 @@ fn canvas_stacking_paints_and_exports_the_first_child_on_top() { s.undo().unwrap(); assert_eq!(pixel(&s, f, 40, 10), [0, 0, 255, 255]); } + +#[test] +fn a_size_given_with_a_new_path_fixes_that_axis() { + let mut s = Session::new(); + let f = al_frame(&mut s, json!({})); + let v = id(s.execute("node.create", &json!({"type": "vector", "path": "M0 0 L10 10", "closed": false, "parent": f})).unwrap()); + set(&mut s, v, json!({"sizingH": "fill"})); + assert_eq!(sizing(&s, v).0, Sizing::Fill); + set(&mut s, v, json!({"path": "M0 0 L20 5", "width": 30})); + assert_eq!(sizing(&s, v).0, Sizing::Fixed); + assert!((size(&s, v).0 - 30.0).abs() < 1e-6, "{:?}", size(&s, v)); +} diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index d0e95f80..b5889d8c 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -393,6 +393,18 @@ fn apply_order(k: &str) -> u8 { } } +/// An explicit width (`is_w`) or height makes hugging or filling on that axis fixed, like typing +/// into W or H. +fn fix_axis(doc: &mut Document, id: NodeId, is_w: bool) -> Result<()> { + let n = doc.get_mut(id)?; + if is_w { + n.child_layout.sizing_h = Sizing::Fixed; + } else { + n.child_layout.sizing_v = Sizing::Fixed; + } + Ok(()) +} + /// Apply a property map to one node. `x`/`y` are relative to the parent. pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map) -> Result<()> { let mut unknown = Vec::new(); @@ -404,8 +416,9 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map path_offset = set_path(cmd, doc, id, v, props)?, - // A new path takes the size asked for as its scale (`set_path`). - "width" | "height" | "w" | "h" if props.contains_key("path") => {} + // A new path takes the size asked for as its scale (`set_path`); the size still fixes + // its auto layout axis. + "width" | "height" | "w" | "h" if props.contains_key("path") => fix_axis(doc, id, k.starts_with('w'))?, "x" | "y" => { let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?; let val = val + if k == "x" { path_offset.0 } else { path_offset.1 }; @@ -450,13 +463,7 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map { let deg = f64_of(v).ok_or_else(|| bad(cmd, "`rotation` must be degrees"))?; From 5376e4d25d24eba5c41bc06d4f1dc5f0f33daea1 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 13:01:47 +0700 Subject: [PATCH 14/24] #1009: explicit imports --- crates/engine/src/datafill.rs | 2 +- crates/engine/src/datafill_tests.rs | 2 +- crates/engine/src/textfit.rs | 4 ++-- crates/engine/src/textfit_tests.rs | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/datafill.rs b/crates/engine/src/datafill.rs index d0226936..865276a7 100644 --- a/crates/engine/src/datafill.rs +++ b/crates/engine/src/datafill.rs @@ -6,7 +6,7 @@ use serde_json::{Value, json}; use crate::cmd::CommandSpec; -use crate::params::*; +use crate::params::{get, id, id_of, num_or, string}; use crate::{Result, Session, bad, spec}; const CMD: &str = "data.fill"; diff --git a/crates/engine/src/datafill_tests.rs b/crates/engine/src/datafill_tests.rs index bbb5d4b6..3e0cff7f 100644 --- a/crates/engine/src/datafill_tests.rs +++ b/crates/engine/src/datafill_tests.rs @@ -3,7 +3,7 @@ use crate::Session; use crate::components::tests::{ex, idv}; -use figcraft_doc::*; +use figcraft_doc::{NodeId, NodeKind}; use serde_json::json; /// A 1080×1350 "Post" with "Title" and "Price" texts and a "Badge" rectangle. Returns the frame. diff --git a/crates/engine/src/textfit.rs b/crates/engine/src/textfit.rs index e4e84739..26d59e93 100644 --- a/crates/engine/src/textfit.rs +++ b/crates/engine/src/textfit.rs @@ -5,11 +5,11 @@ //! with a no-break space. Every style run scales by the same factor, so mixed sizes keep their //! proportions. -use figcraft_doc::*; +use figcraft_doc::{NodeKind, TextAutoResize, TextData}; use serde_json::{Value, json}; use crate::cmd::CommandSpec; -use crate::params::*; +use crate::params::{boolean, num, req_targets}; use crate::{Result, Session, bad, spec}; const CMD: &str = "text.fit"; diff --git a/crates/engine/src/textfit_tests.rs b/crates/engine/src/textfit_tests.rs index 8ee0f11b..d9ffd5fd 100644 --- a/crates/engine/src/textfit_tests.rs +++ b/crates/engine/src/textfit_tests.rs @@ -3,7 +3,7 @@ use crate::Session; use crate::components::tests::{ex, idv}; -use figcraft_doc::*; +use figcraft_doc::{NodeId, NodeKind}; use serde_json::json; /// A fixed-width (auto-height) headline of `width` px at 64px. Returns its id. From 35fada1161d4a11d3cb02ae652162ec9e49cc2a8 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 13:01:48 +0700 Subject: [PATCH 15/24] #1009: data.fill and text.fit in smaller helpers (Sonar) --- crates/engine/src/datafill.rs | 230 ++++++++++++++++++++-------------- crates/engine/src/textfit.rs | 111 ++++++++-------- 2 files changed, 194 insertions(+), 147 deletions(-) diff --git a/crates/engine/src/datafill.rs b/crates/engine/src/datafill.rs index 865276a7..6b8376ef 100644 --- a/crates/engine/src/datafill.rs +++ b/crates/engine/src/datafill.rs @@ -3,6 +3,7 @@ //! keys, in one undo step, and optionally each copy exported as a file. Agents make a series with //! one call instead of a duplicate, a lookup and an edit per layer and an export per copy. +use figcraft_doc::NodeId; use serde_json::{Value, json}; use crate::cmd::CommandSpec; @@ -48,89 +49,18 @@ fn fill(s: &mut Session, p: &Value) -> Result { let n = doc.node(src).ok_or_else(|| bad(CMD, "no such layer"))?; (n.width, n.name.clone()) }; - // `formats`: each row once per size, the copy resized so its auto layout reflows. - let formats: Vec> = match get(p, "formats") { - None | Some(Value::Null) => vec![None], - Some(Value::Array(a)) if !a.is_empty() && a.len() <= 12 => a - .iter() - .map(|f| { - let (w, h) = (f.get("width").and_then(Value::as_f64), f.get("height").and_then(Value::as_f64)); - let name = f.get("name").and_then(Value::as_str).unwrap_or_default().trim().to_string(); - match (w, h) { - (Some(w), Some(h)) if w.is_finite() && h.is_finite() && (1.0..=20_000.0).contains(&w) && (1.0..=20_000.0).contains(&h) => { - Ok(Some((if name.is_empty() { format!("{}x{}", w as i64, h as i64) } else { name }, w, h))) - } - _ => Err(bad(CMD, "formats: [{name?, width, height}] with sizes from 1 to 20000")), - } - }) - .collect::>()?, - Some(_) => return Err(bad(CMD, "formats: [{name?, width, height}] (1 to 12 sizes)")), - }; + let formats = formats(p)?; let column = formats.iter().flatten().map(|f| f.1).fold(width, f64::max); + let grid = Grid { src, base, column, gap }; let mut missing: Vec = Vec::new(); let ids = s.grouped("Fill copies with data", |s| { let mut ids = Vec::with_capacity(rows.len() * formats.len()); for (i, row) in rows.iter().enumerate() { let mut dy = 0.0; for fmt in &formats { - // Rows go right; a row's formats stack downwards. - let dx = (column + gap) * (i as f64 + 1.0); - let dup = s.execute("edit.duplicate", &json!({"ids": [src], "dx": dx, "dy": dy}))?; - let copy = dup["ids"].get(0).and_then(id_of).ok_or_else(|| bad(CMD, "the frame couldn't be duplicated"))?; - let Some(fields) = row.as_object() else { continue }; - let name = fields.get("name").and_then(Value::as_str).map_or_else(|| format!("{base} {}", i + 1), str::to_string); - let name = match fmt { - Some((suffix, w, h)) => { - s.execute("node.set", &json!({"id": copy, "props": {"width": w, "height": h}}))?; - dy += h + gap; - format!("{name}-{suffix}") - } - None => name, - }; - s.execute("node.set", &json!({"id": copy, "props": {"name": name}}))?; - for (key, value) in fields.iter().filter(|(k, _)| k.as_str() != "name") { - let doc = s.doc()?; - let targets: Vec<_> = - doc.subtree(copy).into_iter().filter(|t| *t != copy && doc.node(*t).is_some_and(|n| n.name == *key)).collect(); - if targets.is_empty() { - if !missing.contains(key) { - missing.push(key.clone()); - } - continue; - } - let (text, fill, visible, fit) = match value { - Value::Object(o) => ( - o.get("text").map(text_of), - o.get("fill").and_then(Value::as_str), - o.get("visible").and_then(Value::as_bool), - o.get("fit").cloned().filter(|f| !matches!(f, Value::Bool(false) | Value::Null)), - ), - v => (Some(text_of(v)), None, None, None), - }; - let mut texts = Vec::new(); - for t in targets { - let is_text = s.doc()?.node(t).is_some_and(|n| matches!(n.kind, figcraft_doc::NodeKind::Text(_))); - if let Some(text) = text.as_ref().filter(|_| is_text) { - s.execute("text.set", &json!({"id": t, "characters": text}))?; - } - if is_text { - texts.push(t.0); - } - if let Some(f) = fill { - s.execute("node.set", &json!({"id": t, "props": {"fills": [f]}}))?; - } - if let Some(v) = visible { - s.execute("node.set", &json!({"id": t, "props": {"visible": v}}))?; - } - } - // `fit: true` or `{maxLines?, minSize?, orphans?}`: size the text to its box. - if let Some(f) = fit.filter(|_| !texts.is_empty()) { - let mut params = if f.is_object() { f } else { json!({}) }; - params["ids"] = json!(texts); - s.execute("text.fit", ¶ms)?; - } + if let Some(made) = copy_row(s, &grid, (i, row), fmt.as_ref(), &mut dy, &mut missing)? { + ids.push(made); } - ids.push((copy, name)); } } Ok(ids) @@ -139,28 +69,142 @@ fn fill(s: &mut Session, p: &Value) -> Result { // Exports after the edits landed: a failed edit writes no files. A failed export keeps the // copies (one undo step removes them) and is reported with the files that were written. if let Some(dir) = string(p, "dir") { - let (mut files, mut errors, mut used) = (Vec::new(), Vec::new(), Vec::::new()); - for (copy, name) in &ids { - // One file per copy: same or same-looking names get " 2", " 3"… (case-insensitively, - // as macOS and Windows file systems compare names). - let base = file_name(name); - let stem = - (1..).map(|k| if k == 1 { base.clone() } else { format!("{base} {k}") }).find(|n| !used.contains(&n.to_lowercase())).unwrap_or(base); - used.push(stem.to_lowercase()); - let path = std::path::Path::new(&dir).join(format!("{stem}.{format}")).to_string_lossy().to_string(); - match s.execute("file.export", &json!({"ids": [copy.0], "format": format, "scale": num_or(p, "scale", 1.0), "path": path})) { - Ok(_) => files.push(path), - Err(e) => errors.push(json!({"id": copy.0, "path": path, "error": e.to_string()})), - } - } - out["files"] = json!(files); - if !errors.is_empty() { - out["errors"] = json!(errors); - } + export_copies(s, &ids, &dir, (&format, num_or(p, "scale", 1.0)), &mut out); } Ok(out) } +/// A size the copies are made in: its name, width and height. +type Format = (String, f64, f64); + +/// `formats`: each row once per size, the copy resized so its auto layout reflows (`None`: the +/// frame's own size). +fn formats(p: &Value) -> Result>> { + match get(p, "formats") { + None | Some(Value::Null) => Ok(vec![None]), + Some(Value::Array(a)) if !a.is_empty() && a.len() <= 12 => a.iter().map(format_of).collect(), + Some(_) => Err(bad(CMD, "formats: [{name?, width, height}] (1 to 12 sizes)")), + } +} + +/// One `formats` entry, named `x` when it has no name. +fn format_of(f: &Value) -> Result> { + let (w, h) = (f.get("width").and_then(Value::as_f64), f.get("height").and_then(Value::as_f64)); + let name = f.get("name").and_then(Value::as_str).unwrap_or_default().trim().to_string(); + match (w, h) { + (Some(w), Some(h)) if w.is_finite() && h.is_finite() && (1.0..=20_000.0).contains(&w) && (1.0..=20_000.0).contains(&h) => { + Ok(Some((if name.is_empty() { format!("{}x{}", w as i64, h as i64) } else { name }, w, h))) + } + _ => Err(bad(CMD, "formats: [{name?, width, height}] with sizes from 1 to 20000")), + } +} + +/// Where the copies go: the frame repeated, its name, the column width and the gap. +struct Grid { + src: NodeId, + base: String, + column: f64, + gap: f64, +} + +/// Row `i` in format `fmt`: duplicate the frame (rows go right; a row's formats stack downwards +/// from `dy`), name the copy and fill its layers. The copy and its name. +fn copy_row( + s: &mut Session, + g: &Grid, + (i, row): (usize, &Value), + fmt: Option<&Format>, + dy: &mut f64, + missing: &mut Vec, +) -> Result> { + let dx = (g.column + g.gap) * (i as f64 + 1.0); + let dup = s.execute("edit.duplicate", &json!({"ids": [g.src], "dx": dx, "dy": *dy}))?; + let copy = dup["ids"].get(0).and_then(id_of).ok_or_else(|| bad(CMD, "the frame couldn't be duplicated"))?; + let Some(fields) = row.as_object() else { return Ok(None) }; + let name = fields.get("name").and_then(Value::as_str).map_or_else(|| format!("{} {}", g.base, i + 1), str::to_string); + let name = match fmt { + Some((suffix, w, h)) => { + s.execute("node.set", &json!({"id": copy, "props": {"width": w, "height": h}}))?; + *dy += h + g.gap; + format!("{name}-{suffix}") + } + None => name, + }; + s.execute("node.set", &json!({"id": copy, "props": {"name": name}}))?; + for (key, value) in fields.iter().filter(|(k, _)| k.as_str() != "name") { + fill_layers(s, copy, (key, value), missing)?; + } + Ok(Some((copy, name))) +} + +/// Give every layer named `key` in `copy` the row's `value`: its text, fill, visibility and fit. +/// A key no layer has goes on `missing` (once). +fn fill_layers(s: &mut Session, copy: NodeId, (key, value): (&str, &Value), missing: &mut Vec) -> Result<()> { + let doc = s.doc()?; + let targets: Vec<_> = doc.subtree(copy).into_iter().filter(|t| *t != copy && doc.node(*t).is_some_and(|n| n.name == key)).collect(); + if targets.is_empty() { + if !missing.iter().any(|m| m == key) { + missing.push(key.to_string()); + } + return Ok(()); + } + let (text, fill, visible, fit) = match value { + Value::Object(o) => ( + o.get("text").map(text_of), + o.get("fill").and_then(Value::as_str), + o.get("visible").and_then(Value::as_bool), + o.get("fit").cloned().filter(|f| !matches!(f, Value::Bool(false) | Value::Null)), + ), + v => (Some(text_of(v)), None, None, None), + }; + let mut texts = Vec::new(); + for t in targets { + let is_text = s.doc()?.node(t).is_some_and(|n| matches!(n.kind, figcraft_doc::NodeKind::Text(_))); + if let Some(text) = text.as_ref().filter(|_| is_text) { + s.execute("text.set", &json!({"id": t, "characters": text}))?; + } + if is_text { + texts.push(t.0); + } + if let Some(f) = fill { + s.execute("node.set", &json!({"id": t, "props": {"fills": [f]}}))?; + } + if let Some(v) = visible { + s.execute("node.set", &json!({"id": t, "props": {"visible": v}}))?; + } + } + // `fit: true` or `{maxLines?, minSize?, orphans?}`: size the text to its box. + if let Some(f) = fit.filter(|_| !texts.is_empty()) { + let mut params = if f.is_object() { f } else { json!({}) }; + params["ids"] = json!(texts); + s.execute("text.fit", ¶ms)?; + } + Ok(()) +} + +/// Export each copy into `dir` as `.` at `scale`, adding `files` (and `errors`) to +/// `out`. +fn export_copies(s: &mut Session, ids: &[(NodeId, String)], dir: &str, (format, scale): (&str, f64), out: &mut Value) { + let (mut files, mut errors, mut used) = (Vec::new(), Vec::new(), Vec::::new()); + for (copy, name) in ids { + // One file per copy: same or same-looking names get " 2", " 3"… (case-insensitively, + // as macOS and Windows file systems compare names). + let base = file_name(name); + let stem = + (1..).map(|k| if k == 1 { base.clone() } else { format!("{base} {k}") }).find(|n| !used.contains(&n.to_lowercase())).unwrap_or(base); + used.push(stem.to_lowercase()); + let path = std::path::Path::new(dir).join(format!("{stem}.{format}")).to_string_lossy().to_string(); + match s.execute("file.export", &json!({"ids": [copy.0], "format": format, "scale": scale, "path": path})) { + Ok(_) => files.push(path), + Err(e) => errors.push(json!({"id": copy.0, "path": path, "error": e.to_string()})), + } + } + out["files"] = json!(files); + if !errors.is_empty() { + out["errors"] = json!(errors); + } +} + /// A cell as text: strings as they are, numbers and booleans as written, null as empty. fn text_of(v: &Value) -> String { match v { diff --git a/crates/engine/src/textfit.rs b/crates/engine/src/textfit.rs index 26d59e93..4ab6c50e 100644 --- a/crates/engine/src/textfit.rs +++ b/crates/engine/src/textfit.rs @@ -65,45 +65,8 @@ fn fit(s: &mut Session, p: &Value) -> Result { let size = t.style.font_size.max(1.0); let min = num(p, "minSize").filter(|v| v.is_finite() && *v >= 1.0).unwrap_or(size * 0.6).min(size); let fits = |m: (usize, f64, bool)| max_lines.is_none_or(|ml| m.0 <= ml) && m.1 <= height_cap + 0.5; - // The largest factor in [min/size, 1] that fits: binary search to a tenth of a pixel. - let (mut lo, mut hi) = (min / size, 1.0); - let k = if fits(measure(t, 1.0, width)) { - 1.0 - } else if !fits(measure(t, lo, width)) { - lo - } else { - for _ in 0..24 { - let mid = (lo + hi) / 2.0; - if fits(measure(t, mid, width)) { - lo = mid - } else { - hi = mid - } - if (hi - lo) * size < 0.1 { - break; - } - } - lo - }; - // Whole-tenth sizes read better in the panel, but never below the floor; text that fits - // keeps its exact size. - let mut k = if k < 1.0 { ((size * k * 10.0).floor() / 10.0).max(min) / size } else { 1.0 }; - let mut m = measure(t, k, width); - let mut orphan = orphans && m.2; - let mut nbsp = false; - if orphan { - // Up to 8% smaller (not below the floor) may pull a word down to the last line. - let mut tries = 0; - while orphan && tries < 8 && size * k * 0.99 >= min { - k *= 0.99; - m = measure(t, k, width); - orphan = m.2; - tries += 1; - } - if orphan { - nbsp = true; - } - } + let k = largest_fit(t, width, (size, min), &fits); + let (k, nbsp) = if orphans { avoid_orphan(t, k, width, (size, min)) } else { (k, false) }; let n = doc.get_mut(id)?; let NodeKind::Text(t) = &mut n.kind else { continue }; let count = t.char_count(); @@ -114,23 +77,63 @@ fn fit(s: &mut Session, p: &Value) -> Result { st.font_size = ((st.font_size * k * 10.0).round() / 10.0).max(floor).clamp(1.0, 5000.0) }); } - // Same character count, so style runs keep their ranges. - // Only inside the last paragraph: a space before a hard break joins nothing on the last line. - let para = t.characters.rfind('\n').map_or(0, |i| i + 1); - let joined = if nbsp && let Some(i) = t.characters.trim_end().get(para..).and_then(|p| p.rfind(' ')).map(|i| para + i) { - t.characters.replace_range(i..i + 1, "\u{a0}"); - Some(i) - } else { - None - }; - let mut after = measure(t, 1.0, width); - // Keeping the last words together must not break the limit: an orphan is better. - if let Some(i) = joined.filter(|_| !fits(after)) { - t.characters.replace_range(i..i + '\u{a0}'.len_utf8(), " "); - after = measure(t, 1.0, width); - } + let after = if nbsp { keep_together(t, width, &fits) } else { measure(t, 1.0, width) }; // Reported from the final text (rounded sizes, the no-break space), which callers rely on. out.push(json!({"id": id.0, "fontSize": (t.style.font_size * 10.0).round() / 10.0, "lines": after.0, "fits": fits(after), "orphan": orphans && after.2})); } Ok(json!({"fitted": out})) } + +/// The largest factor in [min/size, 1] at which `t` fits: a binary search to a tenth of a pixel, +/// then rounded down to a whole-tenth size (which reads better in the panel) but never below the +/// floor. Text that fits keeps its exact size. +fn largest_fit(t: &TextData, width: f64, (size, min): (f64, f64), fits: &dyn Fn((usize, f64, bool)) -> bool) -> f64 { + let (mut lo, mut hi) = (min / size, 1.0); + if fits(measure(t, 1.0, width)) { + return 1.0; + } + if fits(measure(t, lo, width)) { + for _ in 0..24 { + let mid = (lo + hi) / 2.0; + if fits(measure(t, mid, width)) { + lo = mid + } else { + hi = mid + } + if (hi - lo) * size < 0.1 { + break; + } + } + } + ((size * lo * 10.0).floor() / 10.0).max(min) / size +} + +/// Up to 8% smaller than `k` (not below the floor) may pull a word down to an orphaned last line. +/// The factor, and whether an orphan is left (to be kept together with a no-break space). +fn avoid_orphan(t: &TextData, mut k: f64, width: f64, (size, min): (f64, f64)) -> (f64, bool) { + let mut orphan = measure(t, k, width).2; + let mut tries = 0; + while orphan && tries < 8 && size * k * 0.99 >= min { + k *= 0.99; + orphan = measure(t, k, width).2; + tries += 1; + } + (k, orphan) +} + +/// Keep the last two words together with a no-break space, unless that breaks the limit (an +/// orphan is better). Same character count, so style runs keep their ranges. The final layout. +fn keep_together(t: &mut TextData, width: f64, fits: &dyn Fn((usize, f64, bool)) -> bool) -> (usize, f64, bool) { + // Only inside the last paragraph: a space before a hard break joins nothing on the last line. + let para = t.characters.rfind('\n').map_or(0, |i| i + 1); + let Some(i) = t.characters.trim_end().get(para..).and_then(|p| p.rfind(' ')).map(|i| para + i) else { + return measure(t, 1.0, width); + }; + t.characters.replace_range(i..i + 1, "\u{a0}"); + let after = measure(t, 1.0, width); + if fits(after) { + return after; + } + t.characters.replace_range(i..i + '\u{a0}'.len_utf8(), " "); + measure(t, 1.0, width) +} From a7b7dfe1c49115c31e269fdf9192d2121d045be2 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 13:01:55 +0700 Subject: [PATCH 16/24] #1009: data.fill refused before it copies or writes in a read-only live file --- crates/engine/src/datafill.rs | 6 +++++- crates/engine/src/live_access_tests.rs | 7 +++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/datafill.rs b/crates/engine/src/datafill.rs index 6b8376ef..b93e5f59 100644 --- a/crates/engine/src/datafill.rs +++ b/crates/engine/src/datafill.rs @@ -8,7 +8,7 @@ use serde_json::{Value, json}; use crate::cmd::CommandSpec; use crate::params::{get, id, id_of, num_or, string}; -use crate::{Result, Session, bad, spec}; +use crate::{EngineError, Result, Session, bad, spec}; const CMD: &str = "data.fill"; const MAX_ROWS: usize = 500; @@ -31,6 +31,10 @@ fn file_name(name: &str) -> String { } fn fill(s: &mut Session, p: &Value) -> Result { + // Refused before any copy or file is made: a rollback afterwards wouldn't take files back. + if let Some(why) = s.live_read_only(s.ds()?.uid) { + return Err(EngineError::Disabled(CMD.into(), why.into())); + } let src = id(p, "id").ok_or_else(|| bad(CMD, "`id` (the frame to repeat) required"))?; let rows = match get(p, "rows") { Some(Value::Array(a)) if a.iter().all(Value::is_object) && a.len() <= MAX_ROWS => a.clone(), diff --git a/crates/engine/src/live_access_tests.rs b/crates/engine/src/live_access_tests.rs index c2c75bb5..52a08cfd 100644 --- a/crates/engine/src/live_access_tests.rs +++ b/crates/engine/src/live_access_tests.rs @@ -105,6 +105,13 @@ fn a_view_link_makes_the_file_read_only() { refused(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5}), VIEW_ONLY); refused(&mut s, "node.set", json!({"ids": [card.0], "props": {"name": "Mine"}}), VIEW_ONLY); refused(&mut s, "edit.delete", json!({"ids": [card.0]}), VIEW_ONLY); + // Refused before it makes copies or writes files, which no rollback takes back. + let dir = std::env::temp_dir().join(format!("figcraft-view-fill-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + refused(&mut s, "data.fill", json!({"id": card.0, "rows": [{"name": "Copy"}], "dir": dir.to_string_lossy()}), VIEW_ONLY); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "no file was written"); + let _ = std::fs::remove_dir_all(&dir); refused(&mut s, "tool.set", json!({"tool": "text"}), VIEW_ONLY); assert!(matches!(s.begin_interaction("Move"), Err(EngineError::Disabled(..)))); assert_eq!(*s.doc().unwrap(), before); From d9d9e1d978ff119c336f5396891b653e7eecd323 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 13:02:11 +0700 Subject: [PATCH 17/24] #1009: Dev Mode refuses data.fill, a design edit recorded as one grouped step --- crates/engine/src/devinspect.rs | 4 +++- crates/engine/src/devmode_behaviour_tests.rs | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/devinspect.rs b/crates/engine/src/devinspect.rs index 16f4bbdc..8d9b1e2e 100644 --- a/crates/engine/src/devinspect.rs +++ b/crates/engine/src/devinspect.rs @@ -224,9 +224,11 @@ pub struct DevItem { /// Revert to saved, which isn't an undo step but throws the design's unsaved edits away. pub fn dev_mode_allows(id: &str) -> bool { const OWN: [&str; 5] = ["annotation.", "dev.", "devResource.", "codeConnect.", "comment."]; + // Edits recorded as one grouped undo step, so their spec says no history of its own. + const GROUPED: [&str; 1] = ["data.fill"]; match id.strip_prefix("tool.") { Some(t) => matches!(t, "move" | "hand" | "comment"), - None if id == "file.revert" => false, + None if id == "file.revert" || GROUPED.contains(&id) => false, None => OWN.iter().any(|p| id.starts_with(p)) || crate::cmd::find_command(id).is_none_or(|c| !c.history), } } diff --git a/crates/engine/src/devmode_behaviour_tests.rs b/crates/engine/src/devmode_behaviour_tests.rs index 3d85fdb1..38d1a9fb 100644 --- a/crates/engine/src/devmode_behaviour_tests.rs +++ b/crates/engine/src/devmode_behaviour_tests.rs @@ -124,6 +124,7 @@ fn dev_mode_runs_its_own_edits_and_refuses_design_edits_from_menus_and_shortcuts "tool.place", "file.import", "file.revert", + "data.fill", ] { assert!(!dev_mode_allows(id), "{id} edits the design"); } From e3e39bbc4dc94d58a70775fae17ee0d7a486a95f Mon Sep 17 00:00:00 2001 From: BFlat Date: Sat, 24 Oct 2026 13:33:26 +0700 Subject: [PATCH 18/24] M6: Dev Mode measurements kept on the canvas, a read-only layers panel (#1248) * M6: Dev Mode measurements kept on the canvas, a read-only layers panel Measurements placed between two layers, along a side or to the layer around one (devMode.addMeasurement / removeMeasurement / measurements), stored on the layer they start from (saved, synced, undoable; old files open without them), drawn in Dev Mode as labelled redlines that follow the layers and listed, placed and removed in the Inspect tab. In Dev Mode the layers panel only selects and expands. * M6: Dev Mode measurements: explicit imports, a measurement row of its own (Sonar) --- ROADMAP.md | 6 +- crates/collab/src/change.rs | 1 + crates/collab/src/merge.rs | 12 +- crates/doc/src/lib.rs | 6 + crates/doc/src/node.rs | 15 ++ crates/engine/src/cmd/mod.rs | 1 + crates/engine/src/components.rs | 5 + crates/engine/src/dev_measurements.rs | 222 ++++++++++++++++++++ crates/engine/src/dev_measurements_tests.rs | 204 ++++++++++++++++++ crates/engine/src/devinspect.rs | 8 +- crates/engine/src/distance.rs | 2 +- crates/engine/src/inspect.rs | 3 + crates/engine/src/lib.rs | 3 + crates/engine/src/tests.rs | 4 + crates/format/src/fig/export.rs | 9 +- crates/ui-egui/locales/bn.toml | 13 ++ crates/ui-egui/locales/cs.toml | 13 ++ crates/ui-egui/locales/de.toml | 13 ++ crates/ui-egui/locales/en.toml | 10 + crates/ui-egui/locales/es-419.toml | 2 + crates/ui-egui/locales/es.toml | 13 ++ crates/ui-egui/locales/fi.toml | 13 ++ crates/ui-egui/locales/fr.toml | 13 ++ crates/ui-egui/locales/id.toml | 13 ++ crates/ui-egui/locales/ja.toml | 13 ++ crates/ui-egui/locales/ka.toml | 13 ++ crates/ui-egui/locales/ko.toml | 13 ++ crates/ui-egui/locales/nl.toml | 13 ++ crates/ui-egui/locales/pl.toml | 13 ++ crates/ui-egui/locales/pt-BR.toml | 13 ++ crates/ui-egui/locales/ru.toml | 13 ++ crates/ui-egui/locales/sv.toml | 13 ++ crates/ui-egui/locales/tr.toml | 13 ++ crates/ui-egui/locales/zh-Hans.toml | 13 ++ crates/ui-egui/src/canvas.rs | 1 + crates/ui-egui/src/dev_mode.rs | 130 +++++++++++- crates/ui-egui/src/devmode_tail_tests.rs | 78 +++++++ crates/ui-egui/src/icons.rs | 9 + crates/ui-egui/src/layers.rs | 24 ++- crates/ui-egui/src/measure_overlay.rs | 7 +- crates/ui-egui/src/menus.rs | 4 +- crates/ui-egui/src/right.rs | 2 + docs/control-protocol.md | 3 +- docs/dev-mode-parity.md | 15 +- docs/file-format-parity.md | 4 +- docs/gaps.md | 20 +- docs/mcp.md | 20 +- docs/target-app-parity.md | 2 +- 48 files changed, 1005 insertions(+), 48 deletions(-) create mode 100644 crates/engine/src/dev_measurements.rs create mode 100644 crates/engine/src/dev_measurements_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 05b7ed65..69d3bf73 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (boards: a board area row and progress entry; `fill`/`stroke` props accept `"none"` and `null` as no paint (#1036); community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Dev Mode measurements kept on the canvas and a read-only layers panel; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -86,7 +86,7 @@ native speakers' reviews of the nine language drafts need people. | Styles & variables | 95% | 76% | 2–4 | [components-parity](docs/components-parity.md) | | Prototyping | 92% | 74% | 2–5 | [prototyping-parity](docs/prototyping-parity.md) | | Import/export | 86% | 58% | 5–10 | [file-format-parity](docs/file-format-parity.md) | -| Dev Mode / inspect | 97% | 76% | 2–4 | [dev-mode-parity](docs/dev-mode-parity.md) | +| Dev Mode / inspect | 98% | 78% | 1–2 | [dev-mode-parity](docs/dev-mode-parity.md) | | Comments, versions, misc | 87% | 63% | 1–3 | [collaboration-parity](docs/collaboration-parity.md) | | Multiplayer & sharing | 17% | 5% | 42–84 | [collaboration-parity](docs/collaboration-parity.md) | | Plugins & widgets | 60% | 35% | 8–26 | [plugins](docs/plugins.md) | @@ -132,6 +132,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-24** · Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 98% / 78%. - **2026-10-24** · Boards (M12): File ▸ New board makes a board, a file kind of its own saved in `.figcraft` and carried to people joining a live session. Sticky notes (8 colours, text that shrinks as the note grows, the author's name, Shift+Cmd/Ctrl+Enter for the next one), shapes with text (7 outlines), connectors that re-route when what they join moves (computed in the engine's normalize pass, so local edits, undo and others' edits agree), marker, highlighter and eraser, stamps with the placer's name on hover; a board toolbar, a contextual bar over the selection and the Design panel hidden until asked for; prototyping and Dev Mode refuse on boards. Design files and boards have their own marks (a pen nib, a note) on tabs, the home screen and the Open files list. Checked in the app, with two app instances in one live session, and through MCP; the web build compiles ([boards-parity](docs/boards-parity.md)). - **2026-10-15** · Auto-save (M1, #305): Preferences ▸ Save files automatically (`pref.autoSave`, off by default, desktop only) saves each open file that has a path 3 s after its last change, never during a gesture, a live text preview or while a version is shown; the file is encoded off the UI thread, a failed save says why and is tried again a minute later. Numbers unchanged. - **2026-10-15** · A text box by dragging (M1, #363): a drag with the Text tool makes a fixed-size text box the size of the drag; a click still makes auto-width text. Numbers unchanged. @@ -328,6 +329,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 97% → 98% breadth, 76% → 78% ready, 2–4 h → 1–2 h; headline numbers unchanged at this rounding | | 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Boards row added outside the weighted numbers; headline numbers unchanged at this rounding | | 2026-10-15 | minor | Auto-save (#305, #296): Preferences ▸ Save files automatically (`pref.autoSave`, off by default; desktop only, the browser has no files to save to) saves each open file that has a path 3 s after its last change, so steady editing doesn't save every few seconds, and never during a gesture, a live text preview or while a version is shown. The file is encoded on a thread of its own and written as of that moment (edits made meanwhile stay unsaved); a failed save says why in a toast and is tried again a minute later, and version history checkpoints keep coming with it on. Numbers unchanged | | 2026-10-15 | minor | A text box by dragging (#363, #360): a drag with the Text tool makes a fixed-size text box the size of the drag (at least one line tall); a click, or a drag shorter than the drag threshold, still makes auto-width text. Numbers unchanged | diff --git a/crates/collab/src/change.rs b/crates/collab/src/change.rs index 57bb9cf2..b273ba34 100644 --- a/crates/collab/src/change.rs +++ b/crates/collab/src/change.rs @@ -181,6 +181,7 @@ node_props! { Annotations => annotations, CodeLinks => code_links, DevResources => dev_resources, + Measurements => measurements, Board => board, } diff --git a/crates/collab/src/merge.rs b/crates/collab/src/merge.rs index 5132ce75..acd40397 100644 --- a/crates/collab/src/merge.rs +++ b/crates/collab/src/merge.rs @@ -6,11 +6,12 @@ //! values, or a layer deleted on one side while the other side edited it or something inside it //! (including adding layers to it or moving layers into or out of it). The layer lists whose items //! stand on their own (fills, strokes, effects, layout grids, export settings, guides, prototype -//! interactions, accessibility annotations and Dev Mode resources) merge item by item: both sides -//! changing different items of the list (or one adding items at its end) is no conflict, only the -//! same item changed both ways or the list shortened on a side while the other changed it. Nothing -//! that conflicts is merged until it is resolved: each conflict is resolved for one side, and the -//! other side's changes to it are left out. Everything else from both sides is kept. +//! interactions, accessibility annotations, Dev Mode resources and measurements) merge item by +//! item: both sides changing different items of the list (or one adding items at its end) is no +//! conflict, only the same item changed both ways or the list shortened on a side while the other +//! changed it. Nothing that conflicts is merged until it is resolved: each conflict is resolved for +//! one side, and the other side's changes to it are left out. Everything else from both sides is +//! kept. use std::collections::{BTreeMap, BTreeSet}; use std::fmt; @@ -176,6 +177,7 @@ const PER_ITEM: &[Prop] = &[ // Not `CodeLinks`: there is one per language, and lining them up by position could leave two // for one language (the second hidden), so they merge whole. Prop::DevResources, + Prop::Measurements, ]; /// Both sides' writes `b` and `m` of list property `key` merged item by item against the base diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index d4c556b9..70797dd8 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -856,6 +856,12 @@ impl Document { { *component = *m; } + // A measurement copied with the layer it measures to measures to the copy; one to a + // layer left behind isn't copied. + c.measurements.retain_mut(|m| match m.to { + Some(t) => map.get(&t).map(|n| m.to = Some(*n)).is_some(), + None => true, + }); // A connector (or label) copied with what it joins joins the copies. if let Some(b) = c.board.as_mut() { b.remap(map); diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 649d5e56..ca06fe53 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -237,6 +237,17 @@ pub struct DevResource { pub name: String, } +/// A measurement kept on the canvas for developers, stored on the layer it starts from: to another +/// layer (`to`), or across one of the layer's own sides (`side`). Dev Mode draws it from the layers' +/// current bounds, so it follows them. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Measurement { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub to: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub side: Option, +} + /// A kind of annotation (Development, Accessibility…): its stable id, name and the colour its /// callouts are drawn in. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -518,6 +529,9 @@ pub struct Node { /// Dev resources: links for developers (docs, code, tickets…), in the order they were added. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub dev_resources: Vec, + /// Dev Mode measurements that start from this layer, in the order they were added. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub measurements: Vec, /// On a board: what this object is (a sticky note, a connector, a stamp…). #[serde(default, skip_serializing_if = "Option::is_none")] pub board: Option, @@ -631,6 +645,7 @@ impl Node { annotations: Vec::new(), code_links: Vec::new(), dev_resources: Vec::new(), + measurements: Vec::new(), board: None, } } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 60d08963..049c0d6f 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -99,6 +99,7 @@ fn registry() -> &'static Registry { list.extend(crate::code_connect::specs()); list.extend(crate::annotations::specs()); list.extend(crate::dev_resources::specs()); + list.extend(crate::dev_measurements::specs()); list.extend(crate::distance::specs()); list.extend(crate::collab::specs()); list.extend(crate::branches::specs()); diff --git a/crates/engine/src/components.rs b/crates/engine/src/components.rs index 91402b58..87496c37 100644 --- a/crates/engine/src/components.rs +++ b/crates/engine/src/components.rs @@ -302,6 +302,8 @@ pub fn create_instance_at(doc: &mut Document, component: NodeId, parent: NodeId, root.code_links.clear(); // The main's dev resources show on its instances without being copied. root.dev_resources.clear(); + // Measurements belong to the layer they were placed on. + root.measurements.clear(); // The main's interactions play on its instances in prototypes; the instance's own start empty. root.interactions.clear(); // So do the variable modes the main sets (see `Document::inherited_modes`). @@ -720,6 +722,7 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BT let prev = old.get(&nid); // Annotations belong to the layer they were added to, not to its copies. n.annotations = prev.map(|o| o.annotations.clone()).unwrap_or_default(); + n.measurements = prev.map(|o| o.measurements.clone()).unwrap_or_default(); if let Some(o) = prev { carry_overrides(o, &mut n, Carry::Values); keep_laid_out(doc, orig, o, &mut n); @@ -797,6 +800,7 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BT fresh.code_links.clear(); // An instance's own dev resources (its main's are listed with them, not copied). fresh.dev_resources = root.dev_resources.clone(); + fresh.measurements = root.measurements.clone(); fresh.locked = root.locked; fresh.visible = root.visible; fresh.var_modes = root.var_modes.clone(); @@ -1591,6 +1595,7 @@ fn copy_layers(doc: &mut Document, src: NodeId, parent: NodeId) -> Option, +} + +impl Placed { + /// Whether it starts or ends at layer `id`. + pub fn touches(&self, id: NodeId) -> bool { + self.from == id || self.measurement.to == Some(id) + } + + /// The distances it shows, as on its labels. + pub fn labels(&self) -> Vec<&str> { + self.lines.iter().filter_map(|g| g.label.as_deref()).collect() + } +} + +/// The line along side `side` of box `b`, labelled with that side's length. +fn side_line(b: Rect, side: Side) -> Vec { + let (p, q) = match side { + Side::Top => (Point::new(b.x0, b.y0), Point::new(b.x1, b.y0)), + Side::Bottom => (Point::new(b.x0, b.y1), Point::new(b.x1, b.y1)), + Side::Left => (Point::new(b.x0, b.y0), Point::new(b.x0, b.y1)), + Side::Right => (Point::new(b.x1, b.y0), Point::new(b.x1, b.y1)), + Side::Auto => return Vec::new(), + }; + let mut out = Vec::new(); + measured(&mut out, p, q); + out +} + +/// The lines measurement `m` from layer `from` draws, in page space (none when a layer is gone). +pub fn lines(doc: &Document, from: NodeId, m: Measurement) -> Vec { + let Some(a) = doc.world_bounds(from) else { return Vec::new() }; + match (m.to, m.side) { + (Some(to), _) => doc.world_bounds(to).map(|b| between(a, b)).unwrap_or_default(), + (None, Some(side)) => side_line(a, side), + (None, None) => Vec::new(), + } +} + +/// The measurements on `page` whose layers are both there, in layer order (bounded). +pub fn on_page(doc: &Document, page: NodeId) -> Vec { + let mut out = Vec::new(); + let mut stack = vec![page]; + let mut visited = 0usize; + while let Some(id) = stack.pop() { + visited += 1; + if out.len() >= MAX_LISTED || visited > 200_000 { + break; + } + let Some(n) = doc.node(id) else { continue }; + for (index, m) in n.measurements.iter().enumerate() { + if m.to.is_some_and(|t| t == id || doc.page_of(t) != Some(page)) { + continue; + } + out.push(Placed { from: id, index, measurement: *m, lines: lines(doc, id, *m) }); + } + stack.extend(n.children.iter().rev().copied()); + } + out +} + +fn placed_json(doc: &Document, p: &Placed) -> Value { + let name = |id: NodeId| doc.node(id).map(|n| n.name.clone()).unwrap_or_default(); + let mut j = json!({"from": p.from, "fromName": name(p.from), "index": p.index, "labels": p.labels(), "lines": p.lines}); + if let Some(t) = p.measurement.to { + j["to"] = json!(t); + j["toName"] = json!(name(t)); + } + if let Some(s) = p.measurement.side { + j["side"] = json!(s); + } + j +} + +/// The layer a command starts from: `from`, else the first selected layer; never a page. +fn from_layer(cmd: &str, s: &Session, p: &Value) -> Result { + let id = match opt_id(cmd, p, "from")? { + Some(id) => id, + None => s.selection().first().copied().ok_or_else(|| bad(cmd, "select a layer or pass `from`"))?, + }; + let n = s.doc()?.node(id).ok_or(EngineError::NoNode(id))?; + if n.is_page() { + return Err(bad(cmd, "measure from a layer, not a page")); + } + Ok(id) +} + +/// The `side` parameter: top, right, bottom or left. +fn side_param(cmd: &str, p: &Value) -> Result> { + match get(p, "side") { + None | Some(Value::Null) => Ok(None), + Some(Value::String(v)) => Side::from_id(v).filter(|s| *s != Side::Auto).map(Some).ok_or_else(|| bad(cmd, "side: top, right, bottom or left")), + Some(_) => Err(bad(cmd, "side: top, right, bottom or left")), + } +} + +fn add(s: &mut Session, p: &Value) -> Result { + let cmd = "devMode.addMeasurement"; + let from = from_layer(cmd, s, p)?; + let (to, side) = (opt_id(cmd, p, "to")?, side_param(cmd, p)?); + let doc = s.doc()?; + let n = doc.node(from).ok_or(EngineError::NoNode(from))?; + let to = match (to, side) { + (Some(_), Some(_)) => return Err(bad(cmd, "pass `to` or `side`, not both")), + (Some(t), None) => Some(t), + (None, Some(_)) => None, + // Neither: to the layer around it. + (None, None) => Some( + n.parent + .filter(|q| doc.node(*q).is_some_and(|q| !q.is_page())) + .ok_or_else(|| bad(cmd, "pass `to` or `side` (the layer is at the top of its page)"))?, + ), + }; + if let Some(t) = to { + let target = doc.node(t).ok_or(EngineError::NoNode(t))?; + if t == from { + return Err(bad(cmd, "measure to another layer, or across a `side` of this one")); + } + if target.is_page() || doc.page_of(t) != doc.page_of(from) { + return Err(bad(cmd, "measure to a layer on the same page")); + } + } + let m = Measurement { to, side }; + // The same two layers measured from the other end are the same measurement. + let back = to.and_then(|t| doc.node(t)).is_some_and(|t| t.measurements.contains(&Measurement { to: Some(from), side: None })); + if n.measurements.contains(&m) || back { + return Err(bad(cmd, "that measurement is already there")); + } + if n.measurements.len() >= MAX_PER_LAYER { + return Err(bad(cmd, format!("a layer starts at most {MAX_PER_LAYER} measurements"))); + } + let drawn = lines(doc, from, m); + let n = s.doc_mut()?.get_mut(from)?; + n.measurements.push(m); + Ok(json!({"from": from, "index": n.measurements.len() - 1, "lines": drawn})) +} + +fn remove(s: &mut Session, p: &Value) -> Result { + let cmd = "devMode.removeMeasurement"; + let from = from_layer(cmd, s, p)?; + let index = opt_index(cmd, p, "index")?; + let n = s.doc_mut()?.get_mut(from)?; + let count = n.measurements.len(); + if count == 0 { + return Err(bad(cmd, "no measurement starts from this layer")); + } + let removed = match index { + Some(i) if i < count => { + n.measurements.remove(i); + 1 + } + Some(i) => return Err(bad(cmd, format!("no measurement {i} (the layer starts {count})"))), + None => std::mem::take(&mut n.measurements).len(), + }; + Ok(json!({"removed": removed})) +} + +fn list(s: &mut Session, p: &Value) -> Result { + let cmd = "devMode.measurements"; + let doc = s.doc()?; + let page = match opt_id(cmd, p, "page")? { + Some(pg) if doc.node(pg).is_some_and(Node::is_page) => pg, + Some(_) => return Err(bad(cmd, "`page` must be a page id")), + None => s.page()?, + }; + let only = opt_id(cmd, p, "id")?; + Ok(Value::Array(on_page(doc, page).iter().filter(|m| only.is_none_or(|id| m.touches(id))).map(|m| placed_json(doc, m)).collect())) +} + +pub fn specs() -> Vec { + vec![ + spec!( + "devMode.addMeasurement", + "Add measurement", + "{from? (default the first selected layer), to? (a layer on the same page), side? (top|right|bottom|left)} keeps a measurement on the canvas: between `from` and `to`, across one side of `from`, or (neither) to the layer around it; Dev Mode draws it and it follows the layers → {from, index, lines}", + true, + add + ), + spec!( + "devMode.removeMeasurement", + "Remove measurement", + "{from? (default the first selected layer), index? (omit: every measurement starting from the layer)} → {removed}", + true, + remove + ), + spec!( + "devMode.measurements", + "List measurements", + "{page? (default the current page), id? (only those from or to this layer)} → [{from, fromName, index, to?, toName?, side?, labels, lines: [{x0, y0, x1, y1, label, dashed}]}] (page space)", + false, + list + ), + ] +} diff --git a/crates/engine/src/dev_measurements_tests.rs b/crates/engine/src/dev_measurements_tests.rs new file mode 100644 index 00000000..695b8a7e --- /dev/null +++ b/crates/engine/src/dev_measurements_tests.rs @@ -0,0 +1,204 @@ +//! Measurements kept on the canvas: placed between two layers, across a side or to the layer +//! around one, following the layers, removed, undoable, copied with what they measure, refused on +//! bad input, synced, saved (old files without them still open) and allowed in Dev Mode. + +use figcraft_collab::{Change, Prop, Replica}; +use figcraft_doc::NodeId; +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; + +fn ok(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")) +} + +fn rect(s: &mut Session, x: f64, w: f64, parent: Option) -> NodeId { + let mut p = json!({"x": x, "y": 20, "width": w, "height": 40}); + if let Some(parent) = parent { + p["parent"] = json!(parent); + } + id_of(&ok(s, "shape.rectangle", p)["id"]).unwrap() +} + +/// A frame with two rectangles 30 apart: (frame, a, b). +fn setup() -> (Session, NodeId, NodeId, NodeId) { + let mut s = Session::new(); + let f = id_of(&ok(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 100}))["id"]).unwrap(); + let a = rect(&mut s, 20.0, 50.0, Some(f)); + let b = rect(&mut s, 100.0, 60.0, Some(f)); + (s, f, a, b) +} + +fn labels(s: &mut Session, id: Option) -> Vec> { + let p = id.map_or(json!({}), |i| json!({"id": i})); + ok(s, "devMode.measurements", p) + .as_array() + .unwrap() + .iter() + .map(|m| m["labels"].as_array().unwrap().iter().map(|l| l.as_str().unwrap().to_string()).collect()) + .collect() +} + +#[test] +fn measurements_are_placed_follow_their_layers_and_are_removed() { + let (mut s, f, a, b) = setup(); + let m = ok(&mut s, "devMode.addMeasurement", json!({"from": a, "to": b})); + assert_eq!((&m["from"], &m["index"]), (&json!(a), &json!(0))); + assert_eq!(m["lines"][0]["label"], "30", "the gap between them"); + // Across a side: the side's length; with neither, to the frame around it. + ok(&mut s, "devMode.addMeasurement", json!({"from": b, "side": "top"})); + ok(&mut s, "select.set", json!({"ids": [a]})); + ok(&mut s, "devMode.addMeasurement", json!({})); + assert_eq!(labels(&mut s, None), [vec!["30"], vec!["20", "230", "20", "40"], vec!["60"]]); + let listed = ok(&mut s, "devMode.measurements", json!({"id": b})); + assert_eq!(listed.as_array().unwrap().len(), 2, "from or to the layer"); + assert_eq!((&listed[1]["side"], &listed[1]["to"]), (&json!("top"), &Value::Null)); + assert_eq!(listed[0]["toName"], s.doc().unwrap().node(b).unwrap().name.as_str()); + // They follow the layers. + ok(&mut s, "node.set", json!({"id": b, "props": {"x": 110, "width": 80}})); + assert_eq!(labels(&mut s, Some(b)), [vec!["40"], vec!["80"]]); + // Undo takes back an edit, and a placement. + ok(&mut s, "edit.undo", json!({})); + assert_eq!(labels(&mut s, Some(b)), [vec!["30"], vec!["60"]]); + ok(&mut s, "edit.undo", json!({})); + assert_eq!(labels(&mut s, None).len(), 2); + ok(&mut s, "edit.redo", json!({})); + // A deleted layer takes its measurements off the canvas; undo brings them back. + ok(&mut s, "edit.delete", json!({"ids": [b]})); + assert_eq!(labels(&mut s, None), [vec!["20", "230", "20", "40"]]); + ok(&mut s, "edit.undo", json!({})); + assert_eq!(labels(&mut s, None).len(), 3); + // Removed one by one, or all of a layer's. + assert_eq!(ok(&mut s, "devMode.removeMeasurement", json!({"from": a, "index": 1}))["removed"], 1); + assert_eq!(labels(&mut s, None), [vec!["30"], vec!["60"]]); + ok(&mut s, "devMode.addMeasurement", json!({"from": a, "side": "left"})); + assert_eq!(ok(&mut s, "devMode.removeMeasurement", json!({"from": a}))["removed"], 2); + assert!(s.execute("devMode.removeMeasurement", &json!({"from": a})).is_err(), "nothing left"); + assert_eq!(labels(&mut s, None), [vec!["60"]]); + let _ = f; +} + +#[test] +fn bad_measurements_are_refused() { + let (mut s, f, a, b) = setup(); + let page = s.page().unwrap(); + let other = id_of(&ok(&mut s, "page.add", json!({}))["id"]).unwrap(); + let far = rect(&mut s, 0.0, 10.0, Some(other)); + ok(&mut s, "page.select", json!({"id": page})); + let top = rect(&mut s, 400.0, 10.0, None); + ok(&mut s, "select.none", json!({})); + for p in [ + json!({"from": a, "to": a}), + json!({"from": a, "to": page}), + json!({"from": a, "to": far}), + json!({"from": a, "to": 99_999}), + json!({"from": page, "side": "top"}), + json!({"from": a, "to": b, "side": "top"}), + json!({"from": a, "side": "middle"}), + json!({"from": a, "side": "auto"}), + json!({"from": a, "side": 3}), + json!({"from": "x", "side": "top"}), + json!({"from": top}), + json!({"side": "top"}), + ] { + assert!(s.execute("devMode.addMeasurement", &p).is_err(), "{p}"); + } + ok(&mut s, "devMode.addMeasurement", json!({"from": a, "to": b})); + assert!(s.execute("devMode.addMeasurement", &json!({"from": a, "to": b})).is_err(), "already there"); + assert!(s.execute("devMode.addMeasurement", &json!({"from": b, "to": a})).is_err(), "the same two layers"); + assert!(s.execute("devMode.removeMeasurement", &json!({"from": a, "index": 5})).is_err()); + assert!(s.execute("devMode.measurements", &json!({"page": a})).is_err()); + assert_eq!(ok(&mut s, "devMode.measurements", json!({"page": other})), json!([])); + // A layer starts a bounded number. + for _ in 1..crate::dev_measurements::MAX_PER_LAYER { + let t = rect(&mut s, 200.0, 5.0, Some(f)); + ok(&mut s, "devMode.addMeasurement", json!({"from": a, "to": t})); + } + assert!(s.execute("devMode.addMeasurement", &json!({"from": a, "side": "top"})).is_err(), "capped"); + assert_eq!(s.doc().unwrap().node(a).unwrap().measurements.len(), crate::dev_measurements::MAX_PER_LAYER); +} + +#[test] +fn copies_measure_what_was_copied_with_them() { + let (mut s, f, a, b) = setup(); + ok(&mut s, "devMode.addMeasurement", json!({"from": a, "to": b})); + ok(&mut s, "devMode.addMeasurement", json!({"from": a, "side": "bottom"})); + // The frame copied: the copy of `a` measures to the copy of `b`. + ok(&mut s, "edit.duplicate", json!({"ids": [f]})); + let copy = s.selection()[0]; + let doc = s.doc().unwrap(); + let kids = doc.node(copy).unwrap().children.clone(); + let ca = doc.node(kids[0]).unwrap(); + assert_eq!(ca.measurements.len(), 2); + assert_eq!(ca.measurements[0].to, Some(kids[1])); + // `a` alone copied: only its side measurement comes along. + ok(&mut s, "edit.duplicate", json!({"ids": [a]})); + let lone = s.selection()[0]; + let m = &s.doc().unwrap().node(lone).unwrap().measurements; + assert_eq!((m.len(), m[0].to, m[0].side), (1, None, Some(figcraft_doc::Side::Bottom))); +} + +#[test] +fn measurements_sync_save_and_old_files_open() { + let (mut s, _, a, b) = setup(); + let before = s.doc().unwrap().clone(); + ok(&mut s, "devMode.addMeasurement", json!({"from": a, "to": b})); + ok(&mut s, "devMode.addMeasurement", json!({"from": b, "side": "right"})); + // A change collaborators receive. + let d = figcraft_collab::diff(&Replica::new(before), s.doc().unwrap()).unwrap(); + assert!(d.changes.iter().any(|c| matches!(c, Change::Set { id, prop: Prop::Measurements, .. } if *id == a)), "{:?}", d.changes); + // What agents inspect. + assert_eq!(crate::inspect::node_json(s.doc().unwrap(), b, 0)["measurements"], json!([{"side": "right"}])); + // Saved and opened again. + let doc = s.doc().unwrap(); + let bytes = figcraft_format::save_bytes(doc, None).unwrap(); + let (back, _) = figcraft_format::load_bytes(&bytes).unwrap(); + assert_eq!(back.node(a).unwrap().measurements, doc.node(a).unwrap().measurements); + assert_eq!(back.node(b).unwrap().measurements, doc.node(b).unwrap().measurements); + // A file from before measurements (no such field) opens with none. + fn strip(v: &mut Value) { + match v { + Value::Object(o) => { + o.remove("measurements"); + o.values_mut().for_each(strip); + } + Value::Array(a) => a.iter_mut().for_each(strip), + _ => {} + } + } + let mut old = figcraft_format::to_json(doc).unwrap(); + assert!(old.to_string().contains("\"measurements\"")); + strip(&mut old); + let (back, _) = figcraft_format::from_json(&old.to_string()).unwrap(); + assert!(back.node(a).unwrap().measurements.is_empty() && back.node(b).unwrap().name == doc.node(b).unwrap().name); +} + +#[test] +fn dev_mode_places_measurements() { + use crate::devinspect::dev_mode_allows; + for id in ["devMode.addMeasurement", "devMode.removeMeasurement", "devMode.measurements"] { + assert!(dev_mode_allows(id), "{id}"); + } +} + +#[test] +fn instances_keep_their_own_measurements_across_main_edits() { + let mut s = Session::new(); + let f = id_of(&ok(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 40}))["id"]).unwrap(); + let r = rect(&mut s, 5.0, 10.0, Some(f)); + ok(&mut s, "devMode.addMeasurement", json!({"from": r, "side": "top"})); + let main = id_of(&ok(&mut s, "component.create", json!({"ids": [f]}))["id"]).unwrap(); + let inst = id_of(&ok(&mut s, "instance.create", json!({"component": main, "x": 200, "y": 0}))["id"]).unwrap(); + let inner = s.doc().unwrap().node(inst).unwrap().children[0]; + // The main's measurements aren't copied onto its instances. + assert!(s.doc().unwrap().node(inner).unwrap().measurements.is_empty()); + ok(&mut s, "devMode.addMeasurement", json!({"from": inner})); + ok(&mut s, "devMode.addMeasurement", json!({"from": inst, "side": "left"})); + ok(&mut s, "node.set", json!({"id": r, "props": {"opacity": 0.5}})); + ok(&mut s, "node.set", json!({"id": main, "props": {"width": 120}})); + let doc = s.doc().unwrap(); + assert_eq!(doc.node(inner).unwrap().measurements[0].to, Some(inst), "kept across a main edit"); + assert_eq!(doc.node(inst).unwrap().measurements.len(), 1); + assert_eq!(doc.node(r).unwrap().measurements.len(), 1); +} diff --git a/crates/engine/src/devinspect.rs b/crates/engine/src/devinspect.rs index 16f4bbdc..7a15610f 100644 --- a/crates/engine/src/devinspect.rs +++ b/crates/engine/src/devinspect.rs @@ -219,11 +219,11 @@ pub struct DevItem { } /// Whether Dev Mode runs command `id` from the menus and shortcuts: commands that don't edit the -/// document, and Dev Mode's own edits (annotations, statuses, dev resources, code connections, -/// comments). Choosing a tool other than moving around and commenting is refused too, and so is -/// Revert to saved, which isn't an undo step but throws the design's unsaved edits away. +/// document, and Dev Mode's own edits (annotations, statuses, dev resources, measurements, code +/// connections, comments). Choosing a tool other than moving around and commenting is refused too, +/// and so is Revert to saved, which isn't an undo step but throws the design's unsaved edits away. pub fn dev_mode_allows(id: &str) -> bool { - const OWN: [&str; 5] = ["annotation.", "dev.", "devResource.", "codeConnect.", "comment."]; + const OWN: [&str; 6] = ["annotation.", "dev.", "devMode.", "devResource.", "codeConnect.", "comment."]; match id.strip_prefix("tool.") { Some(t) => matches!(t, "move" | "hand" | "comment"), None if id == "file.revert" => false, diff --git a/crates/engine/src/distance.rs b/crates/engine/src/distance.rs index c86aeaf0..62679e38 100644 --- a/crates/engine/src/distance.rs +++ b/crates/engine/src/distance.rs @@ -34,7 +34,7 @@ pub fn label(d: f64) -> String { } /// A labelled distance line, unless it has no length. -fn measured(out: &mut Vec, a: Point, b: Point) { +pub(crate) fn measured(out: &mut Vec, a: Point, b: Point) { let d = (b - a).hypot(); if d > EPS && d.is_finite() { out.push(Guide { label: Some(label(d)), ..Guide::new(a, b) }); diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index d30f5f51..33162314 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -144,6 +144,9 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value { if !n.code_links.is_empty() { o.insert("codeLinks".into(), camel(serde_json::to_value(&n.code_links).unwrap_or(Value::Null))); } + if !n.measurements.is_empty() { + o.insert("measurements".into(), serde_json::to_value(&n.measurements).unwrap_or(Value::Null)); + } let dev = crate::dev_resources::list_json(doc, n.id); if dev.as_array().is_some_and(|a| !a.is_empty()) { o.insert("devResources".into(), dev); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2cd224e7..3fde12eb 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -23,6 +23,7 @@ pub mod codegen; pub mod collab; pub mod component_codegen; pub mod components; +pub mod dev_measurements; pub mod dev_resources; pub mod devcompare; pub mod devinspect; @@ -1657,6 +1658,8 @@ mod dark_page_text_tests; #[cfg(test)] mod design_paste_tests; #[cfg(test)] +mod dev_measurements_tests; +#[cfg(test)] mod dev_resources_tests; #[cfg(test)] mod devcompare_tests; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 646c7c4b..14a6a3bc 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -395,6 +395,10 @@ fn every_command_survives_junk_params() { // Links (dev resources) and indexes as junk. json!({"id": 2, "url": "javascript:alert(1)", "index": 1e308, "name": {"x": 1}}), json!({"id": 2, "url": "https://\u{0}x", "index": -1, "name": ""}), + // Dev Mode measurements: layers, sides, pages and indexes as junk. + json!({"from": "x", "to": -1, "side": [1], "index": 1e308, "page": {"x": 1}}), + json!({"from": 2, "to": 2, "side": "middle", "index": -1, "page": 2, "id": "nope"}), + json!({"from": 2, "to": 1, "side": "auto"}), // Widgets: plugins, targets and fuel as junk. json!({"id": 1, "plugin": {"x": 1}, "target": [1], "fuel": -1e308, "x": 1e308}), // Plugin directories: ids, versions, servers and confirmations as junk. diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index 234e1459..dad09d95 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -1134,8 +1134,13 @@ impl<'d> Exporter<'d> { if !n.guides.is_empty() { self.warn("ruler guides were left out"); } - if !n.annotations.is_empty() || !n.dev_resources.is_empty() || !n.code_links.is_empty() || !n.dev_status.is_none() { - self.warn("Dev Mode annotations, statuses, resources and code links were left out"); + if !n.annotations.is_empty() + || !n.dev_resources.is_empty() + || !n.code_links.is_empty() + || !n.measurements.is_empty() + || !n.dev_status.is_none() + { + self.warn("Dev Mode annotations, statuses, resources, measurements and code links were left out"); } if !n.plugin_data.is_empty() { self.warn("plugin data was left out"); diff --git a/crates/ui-egui/locales/bn.toml b/crates/ui-egui/locales/bn.toml index bc7c948c..364c9632 100644 --- a/crates/ui-egui/locales/bn.toml +++ b/crates/ui-egui/locales/bn.toml @@ -55,6 +55,7 @@ status = "draft — needs native review" "Add library file…" = "লাইব্রেরি ফাইল যোগ করুন..." "Add max height" = "সর্বোচ্চ উচ্চতা যোগ করুন" "Add max width" = "সর্বোচ্চ প্রস্থ যোগ করুন" +"Add measurement" = "মাপ যোগ করুন" "Add min height" = "সর্বনিম্ন উচ্চতা যোগ করুন" "Add min width" = "সর্বনিম্ন প্রস্থ যোগ করুন" "Add mode" = "মোড যোগ করুন" @@ -94,6 +95,10 @@ status = "draft — needs native review" "All supported properties" = "সমর্থিত সব প্রপার্টি" "All the fonts this file uses are installed" = "এই ফাইলে ব্যবহৃত সব ফন্ট ইনস্টল করা আছে" "All variables" = "সব ভেরিয়েবল" +"Along the bottom" = "নিচের কিনারা বরাবর" +"Along the left" = "বাম কিনারা বরাবর" +"Along the right" = "ডান কিনারা বরাবর" +"Along the top" = "উপরের কিনারা বরাবর" "Alpha" = "আলফা" "Alphabetical" = "বর্ণানুক্রমিক" "Alt+drag" = "Alt+ড্র্যাগ" @@ -158,6 +163,7 @@ status = "draft — needs native review" "Basics" = "মৌলিক বিষয়" "Before" = "আগে" "Bend" = "বাঁক" +"Between the selected layers" = "নির্বাচিত লেয়ারগুলোর মাঝে" "Bevel" = "বেভেল" "Binary assets added" = "বাইনারি অ্যাসেট যোগ করা হয়েছে" "Binary assets removed" = "বাইনারি অ্যাসেট সরানো হয়েছে" @@ -683,6 +689,7 @@ status = "draft — needs native review" "Jun" = "জুন" "Just now" = "এইমাত্র" "Justify" = "উভয়মুখী সারিবদ্ধকরণ (Justify)" +"Keep a measurement on the canvas for developers" = "ডেভেলপারদের জন্য ক্যানভাসে একটি মাপ রাখুন" "Keep appearance" = "চেহারা অপরিবর্তিত রাখুন" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "চেহারা অপরিবর্তিত রাখলে প্রতিটি রঙের মান {profile}-এ রূপান্তরিত হয় যাতে রঙগুলো একই দেখায়। মান অপরিবর্তিত রাখলে সংখ্যাগুলো যেমন আছে তেমনই থাকে, ফলে রঙ পরিবর্তিত হয়।" "Keep tool selected after use" = "ব্যবহারের পরেও টুল নির্বাচিত রাখুন" @@ -772,6 +779,7 @@ status = "draft — needs native review" "Maximize" = "বড় করুন" "May" = "মে" "Measure to the selection" = "নির্বাচন পর্যন্ত পরিমাপ করুন" +"Measurements" = "মাপ" "Merge into main" = "প্রধান ফাইলের সাথে মার্জ করুন" "Merge regions" = "অঞ্চলগুলো মার্জ করুন" "Merge the branch into the main file (one undo step) and archive it" = "ব্রাঞ্চটিকে প্রধান ফাইলের সাথে মার্জ করুন (একটি আনডু ধাপ) এবং আর্কাইভ করুন" @@ -1087,6 +1095,7 @@ status = "draft — needs native review" "Remove guide" = "গাইড সরান" "Remove interaction" = "ইন্টারঅ্যাকশন সরান" "Remove library from this file" = "এই ফাইল থেকে লাইব্রেরি সরান" +"Remove measurement" = "মাপ সরান" "Remove min and max" = "সর্বনিম্ন এবং সর্বোচ্চ সরান" "Remove min and max height" = "সর্বনিম্ন এবং সর্বোচ্চ উচ্চতা সরান" "Remove min and max width" = "সর্বনিম্ন এবং সর্বোচ্চ প্রস্থ সরান" @@ -1420,6 +1429,7 @@ status = "draft — needs native review" "Title" = "শিরোনাম" "Title case" = "টাইটেল কেস" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "আপনার টিমের সাথে এই লাইব্রেরি শেয়ার করতে, ফাইলটি একটি সার্ভারে প্রকাশ করুন (Share ▸ Publish to server…), এরপর সেখানে লাইব্রেরি প্রকাশ করুন।" +"To the layer around it" = "চারপাশের লেয়ার পর্যন্ত" "Toggle" = "টগল" "Toggle light/dark theme" = "লাইট/ডার্ক থিম টগল করুন" "Toggle visibility" = "দৃশ্যমানতা টগল করুন" @@ -1776,6 +1786,9 @@ status = "draft — needs native review" "board.shape" = "লেখাসহ আকৃতি" "board.stamp" = "স্ট্যাম্প" "board.sticky" = "স্টিকি নোট" +"devMode.addMeasurement" = "মাপ যোগ করুন" +"devMode.measurements" = "মাপের তালিকা" +"devMode.removeMeasurement" = "মাপ সরান" "file.newBoard" = "নতুন বোর্ড" "tool.connector" = "সংযোগকারী" "tool.eraser" = "ইরেজার" diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index e7a2dec1..b05427b2 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -51,6 +51,7 @@ status = "draft — needs native review" "Add library file…" = "Přidat soubor knihovny…" "Add max height" = "Přidat maximální výšku" "Add max width" = "Přidat maximální šířku" +"Add measurement" = "Přidat měření" "Add min height" = "Přidat minimální výšku" "Add min width" = "Přidat minimální šířku" "Add mode" = "Přidat režim" @@ -88,6 +89,10 @@ status = "draft — needs native review" "All fonts" = "Všechna písma" "All supported properties" = "Všechny podporované vlastnosti" "All variables" = "Všechny proměnné" +"Along the bottom" = "Podél dolní hrany" +"Along the left" = "Podél levé hrany" +"Along the right" = "Podél pravé hrany" +"Along the top" = "Podél horní hrany" "Alpha" = "Alfa" "Alphabetical" = "Abecedně" "Alt+drag" = "Alt+tažení" @@ -152,6 +157,7 @@ status = "draft — needs native review" "Basics" = "Základy" "Before" = "Před" "Bend" = "Ohnout" +"Between the selected layers" = "Mezi vybranými vrstvami" "Bevel" = "Zkosený" "Binary assets added" = "Přidané binární soubory" "Binary assets removed" = "Odstraněné binární soubory" @@ -668,6 +674,7 @@ status = "draft — needs native review" "Jun" = "června" "Just now" = "Právě teď" "Justify" = "Do bloku" +"Keep a measurement on the canvas for developers" = "Ponechat měření na plátně pro vývojáře" "Keep appearance" = "Zachovat vzhled" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Zachovat vzhled převede všechny hodnoty barev do profilu {profile}, aby barvy vypadaly stejně. Zachovat hodnoty ponechá čísla beze změny, takže se barvy posunou." "Keep tool selected after use" = "Po použití ponechat nástroj vybraný" @@ -757,6 +764,7 @@ status = "draft — needs native review" "Maximize" = "Maximalizovat" "May" = "května" "Measure to the selection" = "Měřit k výběru" +"Measurements" = "Měření" "Merge into main" = "Sloučit do hlavního souboru" "Merge regions" = "Sloučit oblasti" "Merge the branch into the main file (one undo step) and archive it" = "Sloučit větev do hlavního souboru (jeden krok zpět) a archivovat ji" @@ -1066,6 +1074,7 @@ status = "draft — needs native review" "Remove guide" = "Odebrat vodítko" "Remove interaction" = "Odebrat interakci" "Remove library from this file" = "Odebrat knihovnu z tohoto souboru" +"Remove measurement" = "Odebrat měření" "Remove min and max" = "Odebrat minimum a maximum" "Remove min and max height" = "Odebrat minimální a maximální výšku" "Remove min and max width" = "Odebrat minimální a maximální šířku" @@ -1391,6 +1400,7 @@ status = "draft — needs native review" "Title" = "Název" "Title case" = "Velká počáteční písmena slov" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Chcete-li sdílet tuto knihovnu s týmem, publikujte soubor na server (Sdílet ▸ Publikovat na server…) a pak tam publikujte knihovnu." +"To the layer around it" = "K vrstvě, která ji obsahuje" "Toggle" = "Přepnout" "Toggle light/dark theme" = "Přepnout světlý/tmavý motiv" "Toggle visibility" = "Přepnout viditelnost" @@ -1840,6 +1850,9 @@ status = "draft — needs native review" "dev.focus" = "Zaměřený pohled" "dev.resolveChange" = "Vyřešit změnu" "dev.unfocus" = "Opustit zaměřený pohled" +"devMode.addMeasurement" = "Přidat měření" +"devMode.measurements" = "Vypsat měření" +"devMode.removeMeasurement" = "Odebrat měření" "devResource.add" = "Přidat zdroj pro vývojáře" "devResource.edit" = "Upravit zdroj pro vývojáře" "devResource.list" = "Vypsat zdroje pro vývojáře" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index 816fecf1..da2a177a 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -52,6 +52,7 @@ status = "draft — needs native review" "Add library file…" = "Bibliotheksdatei hinzufügen…" "Add max height" = "Max. Höhe hinzufügen" "Add max width" = "Max. Breite hinzufügen" +"Add measurement" = "Messung hinzufügen" "Add min height" = "Min. Höhe hinzufügen" "Add min width" = "Min. Breite hinzufügen" "Add mode" = "Modus hinzufügen" @@ -90,6 +91,10 @@ status = "draft — needs native review" "All fonts" = "Alle Schriften" "All supported properties" = "Alle unterstützten Eigenschaften" "All variables" = "Alle Variablen" +"Along the bottom" = "Entlang der Unterkante" +"Along the left" = "Entlang der linken Kante" +"Along the right" = "Entlang der rechten Kante" +"Along the top" = "Entlang der Oberkante" "Alpha" = "Alpha" "Alt+drag" = "Alt+Ziehen" "An invite link that opens FigCraft and joins" = "Ein Einladungslink, der FigCraft öffnet und beitritt" @@ -149,6 +154,7 @@ status = "draft — needs native review" "Basics" = "Grundlagen" "Before" = "Vorher" "Bend" = "Biegen" +"Between the selected layers" = "Zwischen den ausgewählten Ebenen" "Bevel" = "Abgeflacht" "Binary assets added" = "Binärdateien hinzugefügt" "Binary assets removed" = "Binärdateien entfernt" @@ -652,6 +658,7 @@ status = "draft — needs native review" "Join|stroke" = "Ecke" "Just now" = "Gerade eben" "Justify" = "Blocksatz" +"Keep a measurement on the canvas for developers" = "Eine Messung für Entwickler auf der Arbeitsfläche behalten" "Keep appearance" = "Aussehen beibehalten" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "„Aussehen beibehalten“ wandelt jeden Farbwert in {profile} um, damit die Farben gleich aussehen. „Werte beibehalten“ lässt die Zahlen unverändert, sodass sich die Farben verschieben." "Keep tool selected after use" = "Werkzeug nach Verwendung ausgewählt lassen" @@ -734,6 +741,7 @@ status = "draft — needs native review" "Max lines" = "Max. Zeilen" "Maximize" = "Maximieren" "Measure to the selection" = "Bis zur Auswahl messen" +"Measurements" = "Messungen" "Merge into main" = "In Hauptdatei zusammenführen" "Merge regions" = "Bereiche vereinen" "Merge the branch into the main file (one undo step) and archive it" = "Führt den Branch in die Hauptdatei zusammen (ein Rückgängig-Schritt) und archiviert ihn" @@ -1034,6 +1042,7 @@ status = "draft — needs native review" "Remove guide" = "Hilfslinie entfernen" "Remove interaction" = "Interaktion entfernen" "Remove library from this file" = "Bibliothek aus dieser Datei entfernen" +"Remove measurement" = "Messung entfernen" "Remove min and max" = "Min. und Max. entfernen" "Remove min and max height" = "Min. und max. Höhe entfernen" "Remove min and max width" = "Min. und max. Breite entfernen" @@ -1352,6 +1361,7 @@ status = "draft — needs native review" "Title" = "Titel" "Title case" = "Großschreibung am Wortanfang" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Um diese Bibliothek mit Ihrem Team zu teilen, veröffentlichen Sie die Datei auf einem Server (Teilen ▸ Auf Server veröffentlichen…) und veröffentlichen Sie dort dann die Bibliothek." +"To the layer around it" = "Bis zur umgebenden Ebene" "Toggle" = "Umschalten" "Toggle light/dark theme" = "Zwischen hellem und dunklem Design wechseln" "Toggle visibility" = "Sichtbarkeit umschalten" @@ -1795,6 +1805,9 @@ status = "draft — needs native review" "dev.focus" = "Fokusansicht" "dev.resolveChange" = "Änderung erledigen" "dev.unfocus" = "Fokusansicht verlassen" +"devMode.addMeasurement" = "Messung hinzufügen" +"devMode.measurements" = "Messungen auflisten" +"devMode.removeMeasurement" = "Messung entfernen" "devResource.add" = "Entwicklerressource hinzufügen" "devResource.edit" = "Entwicklerressource bearbeiten" "devResource.list" = "Entwicklerressourcen auflisten" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 83cfcbe1..e1ed336e 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -54,6 +54,7 @@ name = "English" "Add library file…" = "Add library file…" "Add max height" = "Add max height" "Add max width" = "Add max width" +"Add measurement" = "Add measurement" "Add min height" = "Add min height" "Add min width" = "Add min width" "Add mode" = "Add mode" @@ -93,6 +94,10 @@ name = "English" "All supported properties" = "All supported properties" "All the fonts this file uses are installed" = "All the fonts this file uses are installed" "All variables" = "All variables" +"Along the bottom" = "Along the bottom" +"Along the left" = "Along the left" +"Along the right" = "Along the right" +"Along the top" = "Along the top" "Alpha" = "Alpha" "Alphabetical" = "Alphabetical" "Alt+drag" = "Alt+drag" @@ -157,6 +162,7 @@ name = "English" "Basics" = "Basics" "Before" = "Before" "Bend" = "Bend" +"Between the selected layers" = "Between the selected layers" "Bevel" = "Bevel" "Binary assets added" = "Binary assets added" "Binary assets removed" = "Binary assets removed" @@ -688,6 +694,7 @@ name = "English" "Jun" = "Jun" "Just now" = "Just now" "Justify" = "Justify" +"Keep a measurement on the canvas for developers" = "Keep a measurement on the canvas for developers" "Keep appearance" = "Keep appearance" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." "Keep tool selected after use" = "Keep tool selected after use" @@ -778,6 +785,7 @@ name = "English" "Maximize" = "Maximize" "May" = "May" "Measure to the selection" = "Measure to the selection" +"Measurements" = "Measurements" "Merge into main" = "Merge into main" "Merge regions" = "Merge regions" "Merge the branch into the main file (one undo step) and archive it" = "Merge the branch into the main file (one undo step) and archive it" @@ -1097,6 +1105,7 @@ name = "English" "Remove guide" = "Remove guide" "Remove interaction" = "Remove interaction" "Remove library from this file" = "Remove library from this file" +"Remove measurement" = "Remove measurement" "Remove min and max" = "Remove min and max" "Remove min and max height" = "Remove min and max height" "Remove min and max width" = "Remove min and max width" @@ -1439,6 +1448,7 @@ name = "English" "Title" = "Title" "Title case" = "Title case" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." +"To the layer around it" = "To the layer around it" "Toggle" = "Toggle" "Toggle light/dark theme" = "Toggle light/dark theme" "Toggle visibility" = "Toggle visibility" diff --git a/crates/ui-egui/locales/es-419.toml b/crates/ui-egui/locales/es-419.toml index 7756fba7..6ef81633 100644 --- a/crates/ui-egui/locales/es-419.toml +++ b/crates/ui-egui/locales/es-419.toml @@ -35,6 +35,7 @@ status = "draft — needs native review" "Add library file…" = "Agregar archivo de biblioteca…" "Add max height" = "Agregar alto máximo" "Add max width" = "Agregar ancho máximo" +"Add measurement" = "Agregar medición" "Add min height" = "Agregar alto mínimo" "Add min width" = "Agregar ancho mínimo" "Add mode" = "Agregar modo" @@ -95,6 +96,7 @@ status = "draft — needs native review" "comment.add" = "Agregar comentario" "component.addProperty" = "Agregar propiedad de componente" "component.addVariant" = "Agregar variante" +"devMode.addMeasurement" = "Agregar medición" "devResource.add" = "Agregar recurso de desarrollo" "effect.add" = "Agregar efecto" "export.add" = "Agregar configuración de exportación" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index e172584c..29596514 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -8,7 +8,13 @@ name = "Español" status = "draft — needs native review" [ui] +"Add measurement" = "Añadir medición" +"Along the bottom" = "A lo largo del borde inferior" +"Along the left" = "A lo largo del borde izquierdo" +"Along the right" = "A lo largo del borde derecho" +"Along the top" = "A lo largo del borde superior" "Arrow at the end" = "Flecha al final" +"Between the selected layers" = "Entre las capas seleccionadas" "Check" = "Visto" "Chevron" = "Cheurón" "Compatibility notes" = "Notas de compatibilidad" @@ -689,6 +695,7 @@ status = "draft — needs native review" "Jun" = "jun" "Just now" = "Justo ahora" "Justify" = "Justificar" +"Keep a measurement on the canvas for developers" = "Mantén una medición en el lienzo para desarrolladores" "Keep appearance" = "Mantener apariencia" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Mantener apariencia convierte cada valor de color a {profile} para que los colores se vean igual. Mantener valores deja los números como están, así que los colores cambian." "Keep tool selected after use" = "Mantener la herramienta tras usarla" @@ -779,6 +786,7 @@ status = "draft — needs native review" "Maximize" = "Maximizar" "May" = "may" "Measure to the selection" = "Medir hasta la selección" +"Measurements" = "Mediciones" "Merge into main" = "Fusionar en la principal" "Merge regions" = "Fusionar regiones" "Merge the branch into the main file (one undo step) and archive it" = "Fusiona la rama en el archivo principal (un paso de deshacer) y la archiva" @@ -1098,6 +1106,7 @@ status = "draft — needs native review" "Remove guide" = "Quitar guía" "Remove interaction" = "Quitar interacción" "Remove library from this file" = "Quitar biblioteca de este archivo" +"Remove measurement" = "Quitar medición" "Remove min and max" = "Quitar mín. y máx." "Remove min and max height" = "Quitar alto mín. y máx." "Remove min and max width" = "Quitar ancho mín. y máx." @@ -1440,6 +1449,7 @@ status = "draft — needs native review" "Title" = "Título" "Title case" = "Tipo título" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Para compartir esta biblioteca con tu equipo, publica el archivo en un servidor (Compartir ▸ Publicar en servidor…) y luego publica allí la biblioteca." +"To the layer around it" = "Hasta la capa que la contiene" "Toggle" = "Activar/desactivar" "Toggle light/dark theme" = "Cambiar tema claro/oscuro" "Toggle visibility" = "Mostrar/ocultar" @@ -1904,6 +1914,9 @@ status = "draft — needs native review" "dev.focus" = "Vista de enfoque" "dev.resolveChange" = "Resolver cambio" "dev.unfocus" = "Salir de la vista de enfoque" +"devMode.addMeasurement" = "Añadir medición" +"devMode.measurements" = "Listar mediciones" +"devMode.removeMeasurement" = "Quitar medición" "devResource.add" = "Añadir recurso de desarrollo" "devResource.edit" = "Editar recurso de desarrollo" "devResource.list" = "Listar recursos de desarrollo" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index f18ffeaf..fe979678 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -56,6 +56,7 @@ status = "draft — needs native review" "Add library file…" = "Lisää kirjastotiedosto…" "Add max height" = "Lisää enimmäiskorkeus" "Add max width" = "Lisää enimmäisleveys" +"Add measurement" = "Lisää mittaus" "Add min height" = "Lisää vähimmäiskorkeus" "Add min width" = "Lisää vähimmäisleveys" "Add mode" = "Lisää tila" @@ -95,6 +96,10 @@ status = "draft — needs native review" "All supported properties" = "Kaikki tuetut ominaisuudet" "All the fonts this file uses are installed" = "Kaikki tämän tiedoston käyttämät fontit on asennettu" "All variables" = "Kaikki muuttujat" +"Along the bottom" = "Alareunaa pitkin" +"Along the left" = "Vasenta reunaa pitkin" +"Along the right" = "Oikeaa reunaa pitkin" +"Along the top" = "Yläreunaa pitkin" "Alpha" = "Alfa" "Alphabetical" = "Aakkosjärjestys" "Alt+drag" = "Alt+drag" @@ -159,6 +164,7 @@ status = "draft — needs native review" "Basics" = "Perusteet" "Before" = "Ennen" "Bend" = "Taivutus" +"Between the selected layers" = "Valittujen tasojen välillä" "Bevel" = "Viiste" "Binary assets added" = "Lisätyt binääriresurssit" "Binary assets removed" = "Poistetut binääriresurssit" @@ -685,6 +691,7 @@ status = "draft — needs native review" "Jun" = "kesäk." "Just now" = "Juuri nyt" "Justify" = "Tasaa molemmat reunat" +"Keep a measurement on the canvas for developers" = "Säilytä mittaus piirtoalueella kehittäjille" "Keep appearance" = "Säilytä ulkoasu" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Säilytä ulkoasu muuntaa kaikki väriarvot profiiliin {profile}, jotta värit näyttävät samoilta. Säilytä arvot jättää lukuarvot ennalleen, jolloin värit muuttuvat." "Keep tool selected after use" = "Pidä työkalu valittuna käytön jälkeen" @@ -774,6 +781,7 @@ status = "draft — needs native review" "Maximize" = "Suurenna" "May" = "toukok." "Measure to the selection" = "Mittaa valintaan" +"Measurements" = "Mittaukset" "Merge into main" = "Yhdistä päähaaraan" "Merge regions" = "Yhdistä alueet" "Merge the branch into the main file (one undo step) and archive it" = "Yhdistä haara päätiedostoon (yksi kumottava toiminto) ja arkistoi se" @@ -1091,6 +1099,7 @@ status = "draft — needs native review" "Remove guide" = "Poista apuviiva" "Remove interaction" = "Poista vuorovaikutus" "Remove library from this file" = "Poista kirjasto tästä tiedostosta" +"Remove measurement" = "Poista mittaus" "Remove min and max" = "Poista vähimmäis- ja enimmäisrajat" "Remove min and max height" = "Poista korkeuden vähimmäis- ja enimmäisrajat" "Remove min and max width" = "Poista leveyden vähimmäis- ja enimmäisrajat" @@ -1425,6 +1434,7 @@ status = "draft — needs native review" "Title" = "Otsikko" "Title case" = "Sanojen isot alkukirjaimet" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Jaa tämä kirjasto tiimillesi julkaisemalla tiedosto palvelimelle (Jaa ▸ Julkaise palvelimelle…) ja julkaisemalla kirjasto siellä." +"To the layer around it" = "Ympäröivään tasoon" "Toggle" = "Vaihda tila" "Toggle light/dark theme" = "Vaihda vaalea/tumma teema" "Toggle visibility" = "Vaihda näkyvyys" @@ -1883,6 +1893,9 @@ status = "draft — needs native review" "dev.focus" = "Kohdistusnäkymä" "dev.resolveChange" = "Ratkaise muutos" "dev.unfocus" = "Poistu kohdistusnäkymästä" +"devMode.addMeasurement" = "Lisää mittaus" +"devMode.measurements" = "Luettele mittaukset" +"devMode.removeMeasurement" = "Poista mittaus" "devResource.add" = "Lisää kehittäjäresurssi" "devResource.edit" = "Muokkaa kehittäjäresurssia" "devResource.list" = "Luettele kehittäjäresurssit" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index e5823983..bb771e9f 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -55,6 +55,7 @@ status = "draft — needs native review" "Add library file…" = "Ajouter un fichier de bibliothèque…" "Add max height" = "Ajouter une hauteur max." "Add max width" = "Ajouter une largeur max." +"Add measurement" = "Ajouter une mesure" "Add min height" = "Ajouter une hauteur min." "Add min width" = "Ajouter une largeur min." "Add mode" = "Ajouter un mode" @@ -93,6 +94,10 @@ status = "draft — needs native review" "All fonts" = "Toutes les polices" "All supported properties" = "Toutes les propriétés prises en charge" "All variables" = "Toutes les variables" +"Along the bottom" = "Le long du bord inférieur" +"Along the left" = "Le long du bord gauche" +"Along the right" = "Le long du bord droit" +"Along the top" = "Le long du bord supérieur" "Alpha" = "Alpha" "Alphabetical" = "Alphabétique" "Alt+drag" = "Alt+glisser" @@ -157,6 +162,7 @@ status = "draft — needs native review" "Basics" = "Essentiel" "Before" = "Avant" "Bend" = "Courber" +"Between the selected layers" = "Entre les calques sélectionnés" "Bevel" = "Biseau" "Binary assets added" = "Ressources binaires ajoutées" "Binary assets removed" = "Ressources binaires supprimées" @@ -685,6 +691,7 @@ status = "draft — needs native review" "Jun" = "juin" "Just now" = "À l’instant" "Justify" = "Justifier" +"Keep a measurement on the canvas for developers" = "Garder une mesure sur le canevas pour les développeurs" "Keep appearance" = "Conserver l’apparence" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Conserver l’apparence convertit chaque valeur de couleur en {profile} pour que les couleurs restent identiques. Conserver les valeurs laisse les nombres tels quels, si bien que les couleurs changent." "Keep tool selected after use" = "Garder l’outil sélectionné après usage" @@ -774,6 +781,7 @@ status = "draft — needs native review" "Maximize" = "Agrandir" "May" = "mai" "Measure to the selection" = "Mesurer jusqu’à la sélection" +"Measurements" = "Mesures" "Merge into main" = "Fusionner dans le principal" "Merge regions" = "Fusionner les régions" "Merge the branch into the main file (one undo step) and archive it" = "Fusionne la branche dans le fichier principal (une seule étape d’annulation) et l’archive" @@ -1091,6 +1099,7 @@ status = "draft — needs native review" "Remove guide" = "Retirer le repère" "Remove interaction" = "Retirer l’interaction" "Remove library from this file" = "Retirer la bibliothèque de ce fichier" +"Remove measurement" = "Retirer la mesure" "Remove min and max" = "Retirer min. et max." "Remove min and max height" = "Retirer la hauteur min. et max." "Remove min and max width" = "Retirer la largeur min. et max." @@ -1426,6 +1435,7 @@ status = "draft — needs native review" "Title" = "Titre" "Title case" = "Casse de titre" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Pour partager cette bibliothèque avec votre équipe, publiez le fichier sur un serveur (Partager ▸ Publier sur un serveur…), puis publiez-y la bibliothèque." +"To the layer around it" = "Jusqu’au calque qui le contient" "Toggle" = "Activer/désactiver" "Toggle light/dark theme" = "Basculer entre thème clair et sombre" "Toggle visibility" = "Afficher/masquer" @@ -1884,6 +1894,9 @@ status = "draft — needs native review" "dev.focus" = "Vue ciblée" "dev.resolveChange" = "Résoudre la modification" "dev.unfocus" = "Quitter la vue ciblée" +"devMode.addMeasurement" = "Ajouter une mesure" +"devMode.measurements" = "Lister les mesures" +"devMode.removeMeasurement" = "Retirer la mesure" "devResource.add" = "Ajouter une ressource de développement" "devResource.edit" = "Modifier la ressource de développement" "devResource.list" = "Lister les ressources de développement" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index c06f2dff..a516db9f 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -55,6 +55,7 @@ status = "draft — needs native review" "Add library file…" = "Tambah file pustaka…" "Add max height" = "Tambah tinggi maks" "Add max width" = "Tambah lebar maks" +"Add measurement" = "Tambah pengukuran" "Add min height" = "Tambah tinggi min" "Add min width" = "Tambah lebar min" "Add mode" = "Tambah mode" @@ -92,6 +93,10 @@ status = "draft — needs native review" "All fonts" = "Semua font" "All supported properties" = "Semua properti yang didukung" "All variables" = "Semua variabel" +"Along the bottom" = "Sepanjang sisi bawah" +"Along the left" = "Sepanjang sisi kiri" +"Along the right" = "Sepanjang sisi kanan" +"Along the top" = "Sepanjang sisi atas" "Alpha" = "Alfa" "Alphabetical" = "Alfabetis" "Alt+drag" = "Alt+seret" @@ -156,6 +161,7 @@ status = "draft — needs native review" "Basics" = "Dasar" "Before" = "Sebelum" "Bend" = "Lengkungan" +"Between the selected layers" = "Di antara layer yang dipilih" "Bevel" = "Bevel" "Binary assets added" = "Aset biner ditambahkan" "Binary assets removed" = "Aset biner dihapus" @@ -672,6 +678,7 @@ status = "draft — needs native review" "Jun" = "Jun" "Just now" = "Baru saja" "Justify" = "Rata kiri-kanan" +"Keep a measurement on the canvas for developers" = "Simpan pengukuran di kanvas untuk developer" "Keep appearance" = "Pertahankan tampilan" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Pertahankan tampilan mengonversi setiap nilai warna ke {profile} agar warna terlihat sama. Pertahankan nilai membiarkan angkanya apa adanya, sehingga warna bergeser." "Keep tool selected after use" = "Tetap pilih alat setelah dipakai" @@ -761,6 +768,7 @@ status = "draft — needs native review" "Maximize" = "Maksimalkan" "May" = "Mei" "Measure to the selection" = "Ukur ke pilihan" +"Measurements" = "Pengukuran" "Merge into main" = "Merge ke main" "Merge regions" = "Gabungkan area" "Merge the branch into the main file (one undo step) and archive it" = "Merge branch ke file utama (satu langkah urungkan) lalu arsipkan" @@ -1070,6 +1078,7 @@ status = "draft — needs native review" "Remove guide" = "Hapus garis bantu" "Remove interaction" = "Hapus interaksi" "Remove library from this file" = "Hapus pustaka dari file ini" +"Remove measurement" = "Hapus pengukuran" "Remove min and max" = "Hapus min dan maks" "Remove min and max height" = "Hapus tinggi min dan maks" "Remove min and max width" = "Hapus lebar min dan maks" @@ -1395,6 +1404,7 @@ status = "draft — needs native review" "Title" = "Judul" "Title case" = "Huruf Awal Kapital" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Untuk membagikan pustaka ini kepada tim Anda, publikasikan file ke server (Bagikan ▸ Publikasikan ke server…), lalu publikasikan pustaka di sana." +"To the layer around it" = "Ke layer yang membungkusnya" "Toggle" = "Alihkan" "Toggle light/dark theme" = "Alihkan tema terang/gelap" "Toggle visibility" = "Alihkan visibilitas" @@ -1844,6 +1854,9 @@ status = "draft — needs native review" "dev.focus" = "Tampilan fokus" "dev.resolveChange" = "Selesaikan perubahan" "dev.unfocus" = "Keluar dari tampilan fokus" +"devMode.addMeasurement" = "Tambah pengukuran" +"devMode.measurements" = "Daftar pengukuran" +"devMode.removeMeasurement" = "Hapus pengukuran" "devResource.add" = "Tambah sumber daya dev" "devResource.edit" = "Edit sumber daya dev" "devResource.list" = "Daftar sumber daya dev" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index d7ee7287..641ec1f6 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -57,6 +57,7 @@ status = "draft — needs native review" "Add library file…" = "ライブラリファイルを追加…" "Add max height" = "最大の高さを追加" "Add max width" = "最大の幅を追加" +"Add measurement" = "測定を追加" "Add min height" = "最小の高さを追加" "Add min width" = "最小の幅を追加" "Add mode" = "モードを追加" @@ -94,6 +95,10 @@ status = "draft — needs native review" "All fonts" = "すべてのフォント" "All supported properties" = "サポートされているすべてのプロパティ" "All variables" = "すべての変数" +"Along the bottom" = "下辺に沿って" +"Along the left" = "左辺に沿って" +"Along the right" = "右辺に沿って" +"Along the top" = "上辺に沿って" "Alpha" = "アルファ" "Alphabetical" = "名前順" "Alt+drag" = "Alt+ドラッグ" @@ -158,6 +163,7 @@ status = "draft — needs native review" "Basics" = "基本" "Before" = "変更前" "Bend" = "曲げ" +"Between the selected layers" = "選択したレイヤーの間" "Bevel" = "ベベル" "Binary assets added" = "追加されたバイナリアセット" "Binary assets removed" = "削除されたバイナリアセット" @@ -679,6 +685,7 @@ status = "draft — needs native review" "Jun" = "6月" "Just now" = "たった今" "Justify" = "両端揃え" +"Keep a measurement on the canvas for developers" = "開発者向けに測定をキャンバスに残す" "Keep appearance" = "見た目を保持" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "「見た目を保持」はすべてのカラー値を {profile} に変換し、色の見た目を保つ。「値を保持」は数値をそのまま残すため、色が変わる。" "Keep tool selected after use" = "使用後もツールを選択したままにする" @@ -768,6 +775,7 @@ status = "draft — needs native review" "Maximize" = "最大化" "May" = "5月" "Measure to the selection" = "選択範囲までの距離を測定" +"Measurements" = "測定" "Merge into main" = "メインにマージ" "Merge regions" = "領域を結合" "Merge the branch into the main file (one undo step) and archive it" = "ブランチをメインファイルにマージし(1 回の取り消し単位)、アーカイブする" @@ -1078,6 +1086,7 @@ status = "draft — needs native review" "Remove guide" = "ガイドを削除" "Remove interaction" = "インタラクションを削除" "Remove library from this file" = "このファイルからライブラリを削除" +"Remove measurement" = "測定を削除" "Remove min and max" = "最小と最大を削除" "Remove min and max height" = "最小と最大の高さを削除" "Remove min and max width" = "最小と最大の幅を削除" @@ -1409,6 +1418,7 @@ status = "draft — needs native review" "Title" = "タイトル" "Title case" = "タイトルケース" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "このライブラリをチームと共有するには、ファイルをサーバーに公開し(共有 ▸ サーバーに公開…)、そこでライブラリを公開する。" +"To the layer around it" = "親レイヤーまで" "Toggle" = "切り替え" "Toggle light/dark theme" = "ライト/ダークテーマを切り替え" "Toggle visibility" = "表示を切り替え" @@ -1864,6 +1874,9 @@ status = "draft — needs native review" "dev.focus" = "フォーカスビュー" "dev.resolveChange" = "変更を解決" "dev.unfocus" = "フォーカスビューを終了" +"devMode.addMeasurement" = "測定を追加" +"devMode.measurements" = "測定を一覧表示" +"devMode.removeMeasurement" = "測定を削除" "devResource.add" = "開発リソースを追加" "devResource.edit" = "開発リソースを編集" "devResource.list" = "開発リソースを一覧表示" diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml index 42f78be6..b556e8d7 100644 --- a/crates/ui-egui/locales/ka.toml +++ b/crates/ui-egui/locales/ka.toml @@ -57,6 +57,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Add library file…" = "ბიბლიოთეკის ფაილის დამატება…" "Add max height" = "მაქსიმალური სიმაღლის დამატება" "Add max width" = "მაქსიმალური სიგანის დამატება" +"Add measurement" = "გაზომვის დამატება" "Add min height" = "მინიმალური სიმაღლის დამატება" "Add min width" = "მინიმალური სიგანის დამატება" "Add mode" = "რეჟიმის დამატება" @@ -96,6 +97,10 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "All supported properties" = "ყველა მხარდაჭერილი თვისება" "All the fonts this file uses are installed" = "ამ ფაილის ყველა შრიფტი დაინსტალირებულია" "All variables" = "ყველა ცვლადი" +"Along the bottom" = "ქვედა კიდის გასწვრივ" +"Along the left" = "მარცხენა კიდის გასწვრივ" +"Along the right" = "მარჯვენა კიდის გასწვრივ" +"Along the top" = "ზედა კიდის გასწვრივ" "Alpha" = "ალფა" "Alphabetical" = "ანბანის მიხედვით" "Alt+drag" = "Alt+გადათრევა" @@ -160,6 +165,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Basics" = "საფუძვლები" "Before" = "მანამდე" "Bend" = "მოხრა" +"Between the selected layers" = "მონიშნულ შრეებს შორის" "Bevel" = "წაკვეთილი" "Binary assets added" = "დამატებული ბინარული ფაილები" "Binary assets removed" = "წაშლილი ბინარული ფაილები" @@ -685,6 +691,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Jun" = "ივნ" "Just now" = "ახლახან" "Justify" = "ორმხრივი სწორება" +"Keep a measurement on the canvas for developers" = "გაზომვის შენახვა ტილოზე დეველოპერებისთვის" "Keep appearance" = "იერსახის შენარჩუნება" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "„იერსახის შენარჩუნება“ ყველა ფერის მნიშვნელობას {profile}-ში გარდაქმნის, რომ ფერები იგივენაირად გამოჩნდეს. „მნიშვნელობების შენარჩუნება“ რიცხვებს უცვლელად ტოვებს, ამიტომ ფერები იცვლება." "Keep tool selected after use" = "ხელსაწყოს არჩეულად დატოვება გამოყენების შემდეგ" @@ -774,6 +781,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Maximize" = "გაშლა" "May" = "მაი" "Measure to the selection" = "მონიშნულამდე გაზომვა" +"Measurements" = "გაზომვები" "Merge into main" = "მთავარში შერწყმა" "Merge regions" = "არეების შერწყმა" "Merge the branch into the main file (one undo step) and archive it" = "განშტოების მთავარ ფაილთან შერწყმა (დაბრუნების ერთი ნაბიჯი) და მისი დაარქივება" @@ -1089,6 +1097,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Remove guide" = "დამხმარე ხაზის მოცილება" "Remove interaction" = "ურთიერთქმედების წაშლა" "Remove library from this file" = "ბიბლიოთეკის ამ ფაილიდან ამოშლა" +"Remove measurement" = "გაზომვის წაშლა" "Remove min and max" = "მინ. და მაქს. მოხსნა" "Remove min and max height" = "მინ. და მაქს. სიმაღლის მოხსნა" "Remove min and max width" = "მინ. და მაქს. სიგანის მოხსნა" @@ -1422,6 +1431,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Title" = "დასახელება" "Title case" = "სათაურის რეგისტრი" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "ამ ბიბლიოთეკის გუნდთან გასაზიარებლად გამოაქვეყნეთ ფაილი სერვერზე (გაზიარება ▸ სერვერზე გამოქვეყნება…), შემდეგ იქ გამოაქვეყნეთ ბიბლიოთეკა." +"To the layer around it" = "გარშემო მყოფ შრემდე" "Toggle" = "გადართვა" "Toggle light/dark theme" = "ღია/ბნელი თემის გადართვა" "Toggle visibility" = "ხილვადობის ჩართვა/გამორთვა" @@ -1879,6 +1889,9 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "dev.focus" = "ფოკუსის ხედი" "dev.resolveChange" = "ცვლილების გადაწყვეტა" "dev.unfocus" = "ფოკუსის ხედიდან გასვლა" +"devMode.addMeasurement" = "გაზომვის დამატება" +"devMode.measurements" = "გაზომვების სია" +"devMode.removeMeasurement" = "გაზომვის წაშლა" "devResource.add" = "დეველოპერის რესურსის დამატება" "devResource.edit" = "დეველოპერის რესურსის რედაქტირება" "devResource.list" = "დეველოპერის რესურსების სია" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 66eb54c2..053348f2 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -53,6 +53,7 @@ status = "draft — needs native review" "Add library file…" = "라이브러리 파일 추가…" "Add max height" = "최대 높이 추가" "Add max width" = "최대 너비 추가" +"Add measurement" = "측정 추가" "Add min height" = "최소 높이 추가" "Add min width" = "최소 너비 추가" "Add mode" = "모드 추가" @@ -90,6 +91,10 @@ status = "draft — needs native review" "All fonts" = "모든 글꼴" "All supported properties" = "지원되는 모든 속성" "All variables" = "모든 변수" +"Along the bottom" = "아래쪽 가장자리를 따라" +"Along the left" = "왼쪽 가장자리를 따라" +"Along the right" = "오른쪽 가장자리를 따라" +"Along the top" = "위쪽 가장자리를 따라" "Alpha" = "알파" "Alt+drag" = "Alt+드래그" "An invite link that opens FigCraft and joins" = "FigCraft를 열고 참여하는 초대 링크" @@ -149,6 +154,7 @@ status = "draft — needs native review" "Basics" = "기본" "Before" = "변경 전" "Bend" = "구부리기" +"Between the selected layers" = "선택한 레이어 사이" "Bevel" = "베벨" "Binary assets added" = "추가된 바이너리 에셋" "Binary assets removed" = "제거된 바이너리 에셋" @@ -648,6 +654,7 @@ status = "draft — needs native review" "Join|stroke" = "선 연결" "Just now" = "방금" "Justify" = "양쪽 정렬" +"Keep a measurement on the canvas for developers" = "개발자를 위해 캔버스에 측정 유지" "Keep appearance" = "모양 유지" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "\"모양 유지\"는 모든 색상 값을 {profile}(으)로 변환해 색이 똑같아 보이게 함. \"값 유지\"는 숫자를 그대로 두므로 색이 달라짐." "Keep tool selected after use" = "사용 후에도 도구 선택 유지" @@ -730,6 +737,7 @@ status = "draft — needs native review" "Max lines" = "최대 줄 수" "Maximize" = "최대화" "Measure to the selection" = "선택 항목까지 측정" +"Measurements" = "측정" "Merge into main" = "메인에 병합" "Merge regions" = "영역 병합" "Merge the branch into the main file (one undo step) and archive it" = "브랜치를 메인 파일에 병합(실행 취소 한 번)하고 보관" @@ -1028,6 +1036,7 @@ status = "draft — needs native review" "Remove guide" = "안내선 제거" "Remove interaction" = "인터랙션 제거" "Remove library from this file" = "이 파일에서 라이브러리 제거" +"Remove measurement" = "측정 제거" "Remove min and max" = "최소 및 최대 제거" "Remove min and max height" = "최소 및 최대 높이 제거" "Remove min and max width" = "최소 및 최대 너비 제거" @@ -1343,6 +1352,7 @@ status = "draft — needs native review" "Title" = "제목" "Title case" = "제목 대소문자" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "이 라이브러리를 팀과 공유하려면 파일을 서버에 게시(공유 ▸ 서버에 게시…)한 다음 그곳에서 라이브러리를 게시하세요." +"To the layer around it" = "감싸는 레이어까지" "Toggle" = "전환" "Toggle light/dark theme" = "라이트/다크 테마 전환" "Toggle visibility" = "표시 여부 전환" @@ -1786,6 +1796,9 @@ status = "draft — needs native review" "dev.focus" = "포커스 보기" "dev.resolveChange" = "변경 사항 해결" "dev.unfocus" = "포커스 보기 종료" +"devMode.addMeasurement" = "측정 추가" +"devMode.measurements" = "측정 목록" +"devMode.removeMeasurement" = "측정 제거" "devResource.add" = "개발 리소스 추가" "devResource.edit" = "개발 리소스 편집" "devResource.list" = "개발 리소스 목록" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index 84f13545..bc4e0cfa 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -56,6 +56,7 @@ status = "draft — needs native review" "Add library file…" = "Bibliotheekbestand toevoegen…" "Add max height" = "Maximale hoogte toevoegen" "Add max width" = "Maximale breedte toevoegen" +"Add measurement" = "Meting toevoegen" "Add min height" = "Minimale hoogte toevoegen" "Add min width" = "Minimale breedte toevoegen" "Add mode" = "Modus toevoegen" @@ -95,6 +96,10 @@ status = "draft — needs native review" "All supported properties" = "Alle ondersteunde eigenschappen" "All the fonts this file uses are installed" = "Alle lettertypen die dit bestand gebruikt zijn geïnstalleerd" "All variables" = "Alle variabelen" +"Along the bottom" = "Langs de onderkant" +"Along the left" = "Langs de linkerkant" +"Along the right" = "Langs de rechterkant" +"Along the top" = "Langs de bovenkant" "Alpha" = "Alpha" "Alphabetical" = "Alfabetisch" "Alt+drag" = "Alt+drag" @@ -159,6 +164,7 @@ status = "draft — needs native review" "Basics" = "Basis" "Before" = "Voor" "Bend" = "Buiging" +"Between the selected layers" = "Tussen de geselecteerde lagen" "Bevel" = "Afgeschuind" "Binary assets added" = "Binaire assets toegevoegd" "Binary assets removed" = "Verwijderde binaire assets" @@ -685,6 +691,7 @@ status = "draft — needs native review" "Jun" = "jun." "Just now" = "Zojuist" "Justify" = "Uitvullen" +"Keep a measurement on the canvas for developers" = "Een meting voor ontwikkelaars op het canvas houden" "Keep appearance" = "Uiterlijk behouden" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Kleur behouden converteert elke kleurwaarde naar {profile}, zodat kleuren er hetzelfde uitzien. Waarden behouden laat de getallen staan, waardoor kleuren verschuiven." "Keep tool selected after use" = "Tool na gebruik geselecteerd houden" @@ -774,6 +781,7 @@ status = "draft — needs native review" "Maximize" = "Maximaliseren" "May" = "mei" "Measure to the selection" = "Meten tot de selectie" +"Measurements" = "Metingen" "Merge into main" = "Samenvoegen in main" "Merge regions" = "Gebieden samenvoegen" "Merge the branch into the main file (one undo step) and archive it" = "Voeg de branch samen met het hoofdbestand (één keer ongedaan maken) en archiveer hem" @@ -1091,6 +1099,7 @@ status = "draft — needs native review" "Remove guide" = "Hulplijn verwijderen" "Remove interaction" = "Interactie verwijderen" "Remove library from this file" = "Bibliotheek uit dit bestand verwijderen" +"Remove measurement" = "Meting verwijderen" "Remove min and max" = "Min en max verwijderen" "Remove min and max height" = "Minimale en maximale hoogte verwijderen" "Remove min and max width" = "Minimale en maximale breedte verwijderen" @@ -1425,6 +1434,7 @@ status = "draft — needs native review" "Title" = "Titel" "Title case" = "Hoofdletter per woord" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Om deze bibliotheek met je team te delen, publiceer het bestand naar een server (Delen ▸ Publiceren naar server…), publiceer daarna de bibliotheek." +"To the layer around it" = "Tot de laag eromheen" "Toggle" = "Omschakelen" "Toggle light/dark theme" = "Wisselen tussen licht/donker thema" "Toggle visibility" = "Zichtbaarheid omschakelen" @@ -1882,6 +1892,9 @@ status = "draft — needs native review" "dev.focus" = "Focusweergave" "dev.resolveChange" = "Wijziging oplossen" "dev.unfocus" = "Focusweergave verlaten" +"devMode.addMeasurement" = "Meting toevoegen" +"devMode.measurements" = "Metingen weergeven" +"devMode.removeMeasurement" = "Meting verwijderen" "devResource.add" = "Dev-resource toevoegen" "devResource.edit" = "Dev-resource bewerken" "devResource.list" = "Dev-resources weergeven" diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index 7f301a6e..25f287f9 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -53,6 +53,7 @@ status = "draft — needs native review" "Add library file…" = "Dodaj plik biblioteki…" "Add max height" = "Dodaj maksymalną wysokość" "Add max width" = "Dodaj maksymalną szerokość" +"Add measurement" = "Dodaj pomiar" "Add min height" = "Dodaj minimalną wysokość" "Add min width" = "Dodaj minimalną szerokość" "Add mode" = "Dodaj tryb" @@ -92,6 +93,10 @@ status = "draft — needs native review" "All supported properties" = "Wszystkie obsługiwane właściwości" "All the fonts this file uses are installed" = "Wszystkie czcionki używane w tym pliku są zainstalowane" "All variables" = "Wszystkie zmienne" +"Along the bottom" = "Wzdłuż dolnej krawędzi" +"Along the left" = "Wzdłuż lewej krawędzi" +"Along the right" = "Wzdłuż prawej krawędzi" +"Along the top" = "Wzdłuż górnej krawędzi" "Alpha" = "Alfa" "Alphabetical" = "Alfabetycznie" "Alt+drag" = "Alt+przeciągnięcie" @@ -156,6 +161,7 @@ status = "draft — needs native review" "Basics" = "Podstawy" "Before" = "Przed" "Bend" = "Wygnij" +"Between the selected layers" = "Między zaznaczonymi warstwami" "Bevel" = "Ścięte" "Binary assets added" = "Dodane pliki binarne" "Binary assets removed" = "Usunięte pliki binarne" @@ -682,6 +688,7 @@ status = "draft — needs native review" "Jun" = "Cze" "Just now" = "Przed chwilą" "Justify" = "Wyjustuj" +"Keep a measurement on the canvas for developers" = "Zachowaj pomiar na płótnie dla programistów" "Keep appearance" = "Zachowaj wygląd" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Zachowanie wyglądu konwertuje każdą wartość koloru na {profile}, aby kolory wyglądały tak samo. Zachowanie wartości pozostawia liczby bez zmian, co może zmienić odcienie barw." "Keep tool selected after use" = "Pozostaw narzędzie wybrane po użyciu" @@ -771,6 +778,7 @@ status = "draft — needs native review" "Maximize" = "Maksymalizuj" "May" = "maja" "Measure to the selection" = "Zmierz odległość do zaznaczenia" +"Measurements" = "Pomiary" "Merge into main" = "Scal z gałęzią główną" "Merge regions" = "Scal obszary" "Merge the branch into the main file (one undo step) and archive it" = "Scal gałąź z plikiem głównym (jeden krok cofnięcia) i zarchiwizuj ją" @@ -1087,6 +1095,7 @@ status = "draft — needs native review" "Remove guide" = "Usuń linię pomocniczą" "Remove interaction" = "Usuń interakcję" "Remove library from this file" = "Usuń bibliotekę z tego pliku" +"Remove measurement" = "Usuń pomiar" "Remove min and max" = "Usuń min. i maks." "Remove min and max height" = "Usuń minimalną i maksymalną wysokość" "Remove min and max width" = "Usuń minimalną i maksymalną szerokość" @@ -1420,6 +1429,7 @@ status = "draft — needs native review" "Title" = "Tytuł" "Title case" = "Jak W Tytule" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Aby udostępnić tę bibliotekę zespołowi, opublikuj plik na serwerze (Udostępnij ▸ Publikuj na serwerze…), a następnie opublikuj tam bibliotekę." +"To the layer around it" = "Do warstwy nadrzędnej" "Toggle" = "Przełącz" "Toggle light/dark theme" = "Przełącz motyw jasny/ciemny" "Toggle visibility" = "Przełącz widoczność" @@ -1878,6 +1888,9 @@ status = "draft — needs native review" "dev.focus" = "Widok skupienia" "dev.resolveChange" = "Rozwiąż zmianę" "dev.unfocus" = "Wyjdź z widoku skupienia" +"devMode.addMeasurement" = "Dodaj pomiar" +"devMode.measurements" = "Lista pomiarów" +"devMode.removeMeasurement" = "Usuń pomiar" "devResource.add" = "Dodaj zasób programistyczny" "devResource.edit" = "Edytuj zasób programistyczny" "devResource.list" = "Lista zasobów programistycznych" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 22fffb17..b0b8dea0 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -51,6 +51,7 @@ status = "draft — needs native review" "Add library file…" = "Adicionar arquivo de biblioteca…" "Add max height" = "Adicionar altura máx." "Add max width" = "Adicionar largura máx." +"Add measurement" = "Adicionar medição" "Add min height" = "Adicionar altura mín." "Add min width" = "Adicionar largura mín." "Add mode" = "Adicionar modo" @@ -88,6 +89,10 @@ status = "draft — needs native review" "All fonts" = "Todas as fontes" "All supported properties" = "Todas as propriedades compatíveis" "All variables" = "Todas as variáveis" +"Along the bottom" = "Ao longo da borda inferior" +"Along the left" = "Ao longo da borda esquerda" +"Along the right" = "Ao longo da borda direita" +"Along the top" = "Ao longo da borda superior" "Alpha" = "Alfa" "Alt+drag" = "Alt+arrastar" "An invite link that opens FigCraft and joins" = "Um link de convite que abre o FigCraft e entra na sessão" @@ -147,6 +152,7 @@ status = "draft — needs native review" "Basics" = "Básico" "Before" = "Antes" "Bend" = "Curvar" +"Between the selected layers" = "Entre as camadas selecionadas" "Bevel" = "Chanfro" "Binary assets added" = "Arquivos binários adicionados" "Binary assets removed" = "Arquivos binários removidos" @@ -647,6 +653,7 @@ status = "draft — needs native review" "Join|stroke" = "Junção" "Just now" = "Agora mesmo" "Justify" = "Justificar" +"Keep a measurement on the canvas for developers" = "Manter uma medição no canvas para desenvolvedores" "Keep appearance" = "Manter aparência" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Manter aparência converte cada valor de cor para {profile} para que as cores pareçam iguais. Manter valores deixa os números como estão, então as cores mudam." "Keep tool selected after use" = "Manter a ferramenta selecionada após o uso" @@ -729,6 +736,7 @@ status = "draft — needs native review" "Max lines" = "Máx. de linhas" "Maximize" = "Maximizar" "Measure to the selection" = "Medir até a seleção" +"Measurements" = "Medições" "Merge into main" = "Mesclar no principal" "Merge regions" = "Mesclar regiões" "Merge the branch into the main file (one undo step) and archive it" = "Mescla a branch no arquivo principal (uma etapa de desfazer) e a arquiva" @@ -1027,6 +1035,7 @@ status = "draft — needs native review" "Remove guide" = "Remover guia" "Remove interaction" = "Remover interação" "Remove library from this file" = "Remover biblioteca deste arquivo" +"Remove measurement" = "Remover medição" "Remove min and max" = "Remover mín. e máx." "Remove min and max height" = "Remover altura mín. e máx." "Remove min and max width" = "Remover largura mín. e máx." @@ -1342,6 +1351,7 @@ status = "draft — needs native review" "Title" = "Título" "Title case" = "Iniciais maiúsculas" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Para compartilhar esta biblioteca com sua equipe, publique o arquivo em um servidor (Compartilhar ▸ Publicar no servidor…) e depois publique a biblioteca lá." +"To the layer around it" = "Até a camada que a contém" "Toggle" = "Alternar" "Toggle light/dark theme" = "Alternar tema claro/escuro" "Toggle visibility" = "Alternar visibilidade" @@ -1899,6 +1909,9 @@ status = "draft — needs native review" "dev.focus" = "Visualização em foco" "dev.resolveChange" = "Resolver alteração" "dev.unfocus" = "Sair da visualização em foco" +"devMode.addMeasurement" = "Adicionar medição" +"devMode.measurements" = "Listar medições" +"devMode.removeMeasurement" = "Remover medição" "devResource.add" = "Adicionar recurso de desenvolvimento" "devResource.edit" = "Editar recurso de desenvolvimento" "devResource.list" = "Listar recursos de desenvolvimento" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index baba56fd..ed1cc0eb 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -54,6 +54,7 @@ name = "Русский" "Add library file…" = "Добавить файл библиотеки…" "Add max height" = "Добавить максимальную высоту" "Add max width" = "Добавить максимальную ширину" +"Add measurement" = "Добавить измерение" "Add min height" = "Добавить минимальную высоту" "Add min width" = "Добавить минимальную ширину" "Add mode" = "Добавить режим" @@ -93,6 +94,10 @@ name = "Русский" "All supported properties" = "Все поддерживаемые свойства" "All the fonts this file uses are installed" = "Все шрифты, которые использует этот файл, установлены" "All variables" = "Все переменные" +"Along the bottom" = "Вдоль нижнего края" +"Along the left" = "Вдоль левого края" +"Along the right" = "Вдоль правого края" +"Along the top" = "Вдоль верхнего края" "Alpha" = "Альфа" "Alphabetical" = "По алфавиту" "Alt+drag" = "Alt+перетаскивание" @@ -157,6 +162,7 @@ name = "Русский" "Basics" = "Основы" "Before" = "До" "Bend" = "Изгиб" +"Between the selected layers" = "Между выбранными слоями" "Bevel" = "Скошенное" "Binary assets added" = "Добавлено бинарных ресурсов" "Binary assets removed" = "Удалено бинарных ресурсов" @@ -686,6 +692,7 @@ name = "Русский" "Jun" = "июн." "Just now" = "Только что" "Justify" = "По ширине" +"Keep a measurement on the canvas for developers" = "Оставить измерение на холсте для разработчиков" "Keep appearance" = "Сохранить внешний вид" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "«Сохранить внешний вид» преобразует все значения цветов в {profile}, сохраняя их вид. «Сохранить значения» оставляет числа без изменений, поэтому цвета меняются." "Keep tool selected after use" = "Оставлять инструмент выбранным после использования" @@ -776,6 +783,7 @@ name = "Русский" "Maximize" = "Развернуть окно" "May" = "май" "Measure to the selection" = "Измерить до выделения" +"Measurements" = "Измерения" "Merge into main" = "Объединить с основной" "Merge regions" = "Объединить области" "Merge the branch into the main file (one undo step) and archive it" = "Объединить ветку с основным файлом (один шаг отмены) и архивировать её" @@ -1094,6 +1102,7 @@ name = "Русский" "Remove guide" = "Удалить направляющую" "Remove interaction" = "Удалить взаимодействие" "Remove library from this file" = "Удалить библиотеку из этого файла" +"Remove measurement" = "Удалить измерение" "Remove min and max" = "Убрать минимум и максимум" "Remove min and max height" = "Убрать минимальную и максимальную высоту" "Remove min and max width" = "Убрать минимальную и максимальную ширину" @@ -1433,6 +1442,7 @@ name = "Русский" "Title" = "Заголовок" "Title case" = "Каждое Слово С Прописной" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Чтобы поделиться библиотекой с командой, опубликуйте файл на сервере («Поделиться» ▸ «Опубликовать на сервере…»), затем опубликуйте там библиотеку." +"To the layer around it" = "До родительского слоя" "Toggle" = "Переключить" "Toggle light/dark theme" = "Переключить светлую/тёмную тему" "Toggle visibility" = "Переключить видимость" @@ -1895,6 +1905,9 @@ name = "Русский" "dev.focus" = "Режим фокуса" "dev.resolveChange" = "Разрешить изменение" "dev.unfocus" = "Выйти из режима фокуса" +"devMode.addMeasurement" = "Добавить измерение" +"devMode.measurements" = "Список измерений" +"devMode.removeMeasurement" = "Удалить измерение" "devResource.add" = "Добавить ресурс для разработки" "devResource.edit" = "Изменить ресурс для разработки" "devResource.list" = "Список ресурсов для разработки" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index be25d38e..bcb819e5 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -54,6 +54,7 @@ name = "Svenska" "Add library file…" = "Lägg till biblioteksfil…" "Add max height" = "Lägg till maxhöjd" "Add max width" = "Lägg till maxbredd" +"Add measurement" = "Lägg till mått" "Add min height" = "Lägg till minhöjd" "Add min width" = "Lägg till minbredd" "Add mode" = "Lägg till läge" @@ -92,6 +93,10 @@ name = "Svenska" "All fonts" = "Alla typsnitt" "All supported properties" = "Alla egenskaper som stöds" "All variables" = "Alla variabler" +"Along the bottom" = "Längs nederkanten" +"Along the left" = "Längs vänsterkanten" +"Along the right" = "Längs högerkanten" +"Along the top" = "Längs överkanten" "Alpha" = "Alfa" "Alphabetical" = "I bokstavsordning" "Alt+drag" = "Alt+dra" @@ -156,6 +161,7 @@ name = "Svenska" "Basics" = "Grunder" "Before" = "Före" "Bend" = "Böj" +"Between the selected layers" = "Mellan de markerade lagren" "Bevel" = "Fasning" "Binary assets added" = "Binära resurser tillagda" "Binary assets removed" = "Binära resurser borttagna" @@ -679,6 +685,7 @@ name = "Svenska" "Jun" = "jun" "Just now" = "Nyss" "Justify" = "Marginaljustera" +"Keep a measurement on the canvas for developers" = "Behåll ett mått på arbetsytan för utvecklare" "Keep appearance" = "Behåll utseende" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Behåll utseende konverterar varje färgvärde till {profile} så att färgerna ser likadana ut. Behåll värden lämnar siffrorna som de är, så färgerna förskjuts." "Keep tool selected after use" = "Behåll verktyget markerat efter användning" @@ -768,6 +775,7 @@ name = "Svenska" "Maximize" = "Maximera" "May" = "Maj" "Measure to the selection" = "Mät avståndet till markeringen" +"Measurements" = "Mått" "Merge into main" = "Sammanfoga med main" "Merge regions" = "Sammanfoga regioner" "Merge the branch into the main file (one undo step) and archive it" = "Sammanfoga grenen med huvudfilen (ett ångrasteg) och arkivera den" @@ -1079,6 +1087,7 @@ name = "Svenska" "Remove guide" = "Ta bort stödlinje" "Remove interaction" = "Ta bort interaktion" "Remove library from this file" = "Ta bort biblioteket från den här filen" +"Remove measurement" = "Ta bort mått" "Remove min and max" = "Ta bort min och max" "Remove min and max height" = "Ta bort min- och maxhöjd" "Remove min and max width" = "Ta bort min- och maxbredd" @@ -1410,6 +1419,7 @@ name = "Svenska" "Title" = "Titel" "Title case" = "Inledande versaler" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Om du vill dela biblioteket med ditt team publicerar du filen på en server (Dela ▸ Publicera på server…) och publicerar sedan biblioteket där." +"To the layer around it" = "Till lagret runt det" "Toggle" = "Visa/dölj" "Toggle light/dark theme" = "Växla mellan ljust och mörkt tema" "Toggle visibility" = "Visa/dölj" @@ -1864,6 +1874,9 @@ name = "Svenska" "dev.focus" = "Fokusvy" "dev.resolveChange" = "Lös ändring" "dev.unfocus" = "Lämna fokusvy" +"devMode.addMeasurement" = "Lägg till mått" +"devMode.measurements" = "Lista mått" +"devMode.removeMeasurement" = "Ta bort mått" "devResource.add" = "Lägg till utvecklarresurs" "devResource.edit" = "Redigera utvecklarresurs" "devResource.list" = "Lista utvecklarresurser" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index 5d15c9c9..4275f06a 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -55,6 +55,7 @@ name = "Türkçe" "Add library file…" = "Kitaplık dosyası ekle…" "Add max height" = "En büyük yükseklik ekle" "Add max width" = "En büyük genişlik ekle" +"Add measurement" = "Ölçüm ekle" "Add min height" = "En küçük yükseklik ekle" "Add min width" = "En küçük genişlik ekle" "Add mode" = "Mod ekle" @@ -92,6 +93,10 @@ name = "Türkçe" "All fonts" = "Tüm yazı tipleri" "All supported properties" = "Desteklenen tüm özellikler" "All variables" = "Tüm değişkenler" +"Along the bottom" = "Alt kenar boyunca" +"Along the left" = "Sol kenar boyunca" +"Along the right" = "Sağ kenar boyunca" +"Along the top" = "Üst kenar boyunca" "Alpha" = "Alfa" "Alphabetical" = "Alfabetik" "Alt+drag" = "Alt+sürükle" @@ -156,6 +161,7 @@ name = "Türkçe" "Basics" = "Temel bilgiler" "Before" = "Önce" "Bend" = "Bük" +"Between the selected layers" = "Seçili katmanlar arasında" "Bevel" = "Eğimli" "Binary assets added" = "Eklenen ikili varlıklar" "Binary assets removed" = "Kaldırılan ikili varlıklar" @@ -672,6 +678,7 @@ name = "Türkçe" "Jun" = "Haz" "Just now" = "Az önce" "Justify" = "İki yana yasla" +"Keep a measurement on the canvas for developers" = "Geliştiriciler için tuvalde bir ölçüm tut" "Keep appearance" = "Görünümü koru" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Görünümü koru, renkler aynı görünsün diye her renk değerini {profile} profiline dönüştürür. Değerleri koru, sayıları olduğu gibi bırakır; bu yüzden renkler kayar." "Keep tool selected after use" = "Kullanımdan sonra aracı seçili tut" @@ -761,6 +768,7 @@ name = "Türkçe" "Maximize" = "Ekranı kapla" "May" = "May" "Measure to the selection" = "Seçime kadar ölç" +"Measurements" = "Ölçümler" "Merge into main" = "Ana dosyaya birleştir" "Merge regions" = "Bölgeleri birleştir" "Merge the branch into the main file (one undo step) and archive it" = "Dalı ana dosyaya birleştir (tek geri alma adımı) ve arşivle" @@ -1070,6 +1078,7 @@ name = "Türkçe" "Remove guide" = "Kılavuzu kaldır" "Remove interaction" = "Etkileşimi kaldır" "Remove library from this file" = "Kitaplığı bu dosyadan kaldır" +"Remove measurement" = "Ölçümü kaldır" "Remove min and max" = "En küçük ve en büyüğü kaldır" "Remove min and max height" = "En küçük ve en büyük yüksekliği kaldır" "Remove min and max width" = "En küçük ve en büyük genişliği kaldır" @@ -1395,6 +1404,7 @@ name = "Türkçe" "Title" = "Başlık" "Title case" = "Başlık biçimi" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Bu kitaplığı ekibinizle paylaşmak için dosyayı bir sunucuya yayımlayın (Paylaş ▸ Sunucuya yayımla…), sonra kitaplığı orada yayımlayın." +"To the layer around it" = "Çevreleyen katmana kadar" "Toggle" = "Aç/Kapat" "Toggle light/dark theme" = "Açık/koyu temayı değiştir" "Toggle visibility" = "Görünürlüğü değiştir" @@ -1844,6 +1854,9 @@ name = "Türkçe" "dev.focus" = "Odak görünümü" "dev.resolveChange" = "Değişikliği çöz" "dev.unfocus" = "Odak görünümünden çık" +"devMode.addMeasurement" = "Ölçüm ekle" +"devMode.measurements" = "Ölçümleri listele" +"devMode.removeMeasurement" = "Ölçümü kaldır" "devResource.add" = "Geliştirici kaynağı ekle" "devResource.edit" = "Geliştirici kaynağını düzenle" "devResource.list" = "Geliştirici kaynaklarını listele" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index 7add99c5..da070c0e 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -54,6 +54,7 @@ status = "draft — needs native review" "Add library file…" = "添加库文件…" "Add max height" = "添加最大高度" "Add max width" = "添加最大宽度" +"Add measurement" = "添加测量" "Add min height" = "添加最小高度" "Add min width" = "添加最小宽度" "Add mode" = "添加模式" @@ -91,6 +92,10 @@ status = "draft — needs native review" "All fonts" = "全部字体" "All supported properties" = "所有支持的属性" "All variables" = "全部变量" +"Along the bottom" = "沿底边" +"Along the left" = "沿左边" +"Along the right" = "沿右边" +"Along the top" = "沿顶边" "Alpha" = "Alpha" "Alt+drag" = "Alt+拖动" "An invite link that opens FigCraft and joins" = "可打开 FigCraft 并加入的邀请链接" @@ -150,6 +155,7 @@ status = "draft — needs native review" "Basics" = "基础" "Before" = "之前" "Bend" = "弯曲" +"Between the selected layers" = "所选图层之间" "Bevel" = "斜角" "Binary assets added" = "新增的二进制资源" "Binary assets removed" = "删除的二进制资源" @@ -649,6 +655,7 @@ status = "draft — needs native review" "Join|stroke" = "转角" "Just now" = "刚刚" "Justify" = "两端对齐" +"Keep a measurement on the canvas for developers" = "为开发者在画布上保留测量" "Keep appearance" = "保持外观" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "“保持外观”会将每个颜色值转换为 {profile},使颜色看起来不变。“保持数值”则保留数值不变,颜色会有偏移。" "Keep tool selected after use" = "使用后保持工具选中" @@ -731,6 +738,7 @@ status = "draft — needs native review" "Max lines" = "最大行数" "Maximize" = "最大化" "Measure to the selection" = "测量到所选项的距离" +"Measurements" = "测量" "Merge into main" = "合并到主文件" "Merge regions" = "合并区域" "Merge the branch into the main file (one undo step) and archive it" = "将分支合并到主文件(一步撤销)并归档" @@ -1029,6 +1037,7 @@ status = "draft — needs native review" "Remove guide" = "移除参考线" "Remove interaction" = "移除交互" "Remove library from this file" = "从此文件中移除库" +"Remove measurement" = "移除测量" "Remove min and max" = "移除最小值和最大值" "Remove min and max height" = "移除最小和最大高度" "Remove min and max width" = "移除最小和最大宽度" @@ -1344,6 +1353,7 @@ status = "draft — needs native review" "Title" = "标题" "Title case" = "首字母大写" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "要与团队共享此库,请先将文件发布到服务器(分享 ▸ 发布到服务器…),然后在那里发布库。" +"To the layer around it" = "到外层图层" "Toggle" = "切换显示" "Toggle light/dark theme" = "切换浅色/深色主题" "Toggle visibility" = "切换可见性" @@ -1787,6 +1797,9 @@ status = "draft — needs native review" "dev.focus" = "聚焦视图" "dev.resolveChange" = "解决更改" "dev.unfocus" = "退出聚焦视图" +"devMode.addMeasurement" = "添加测量" +"devMode.measurements" = "列出测量" +"devMode.removeMeasurement" = "移除测量" "devResource.add" = "添加开发资源" "devResource.edit" = "编辑开发资源" "devResource.list" = "列出开发资源" diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 0b13f5a8..5ced1edc 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -244,6 +244,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { let image_rect = presented_image_rect(tl, v, zoom, ppp, pixel_preview); painter.image(tex.id(), image_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } + crate::dev_mode::refresh_measurements(app); app.session.tool.labels = overlays(app, &painter, &t); crate::frame_labels::rename_field(app, ui); // The zoom tool's area. diff --git a/crates/ui-egui/src/dev_mode.rs b/crates/ui-egui/src/dev_mode.rs index dcece9b2..8783b0dd 100644 --- a/crates/ui-egui/src/dev_mode.rs +++ b/crates/ui-egui/src/dev_mode.rs @@ -3,22 +3,25 @@ //! categories and the filter of the callouts, the handoff status of the selected frames (with the //! focus view and Compare changes), the list of frames and sections that are ready for dev, the //! focus view's bar, Connect to code (a component's link to its code component), dev resources -//! (links for developers) and the plugins that only read the document. +//! (links for developers), measurements kept on the canvas and the plugins that only read the +//! document. use egui::{Align2, Color32, CornerRadius, Id, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use figcraft_doc::{Annotation, AnnotationCategory, CodeLink, DevStatus, Document, NodeId}; use figcraft_engine::annotations::{self, PINNABLE, Pinnable}; use figcraft_engine::code_connect; use figcraft_engine::codegen::{CodeLang, code_for}; +use figcraft_engine::dev_measurements::{self, Placed}; use figcraft_engine::devinspect::{self, DevItem, Prop}; use figcraft_engine::plugins::Access; +use figcraft_engine::snap::Guide; use figcraft_engine::{components, dev_resources}; use serde_json::json; use crate::a11y::Tip; use crate::i18n::{tr, tr_noop, trf, trn}; use crate::icons::{self, Icon}; -use crate::right::{menu_check, menu_item_with, menu_popup, prop_row, row}; +use crate::right::{menu_check, menu_item, menu_item_with, menu_popup, prop_row, row}; use crate::theme::{ACCENT, Tokens, doc_c32, regular, semibold}; use crate::widgets::{self, DropLead, PAD}; use crate::{FigApp, RightTab}; @@ -94,6 +97,9 @@ pub struct DevUi { pub compare: Option, annotated: Vec, annotated_key: Option<(u64, u64, NodeId)>, + /// The measurements kept on the current page, with their lines. + measured: Vec, + measured_key: Option<(u64, u64, NodeId)>, ready: Vec, ready_key: Option<(u64, u64, usize)>, /// Every ready-for-dev frame (the focus view's previous / next). @@ -190,6 +196,35 @@ pub fn draw(app: &mut FigApp, painter: &egui::Painter) { } } +/// Bring the current page's measurements up to date (when the document, its revision or the page +/// changed). +pub fn refresh_measurements(app: &mut FigApp) { + let Ok(ds) = app.session.ds() else { return }; + let key = (ds.uid, ds.revision, ds.page); + if app.ui.dev.measured_key != Some(key) { + app.ui.dev.measured = dev_measurements::on_page(&ds.doc, ds.page); + app.ui.dev.measured_key = Some(key); + } +} + +/// The lines of the measurements kept on the canvas, drawn with the hover measurements (Inspect +/// tab only; both layers shown; the focus view's frame only). +pub fn saved_lines(app: &FigApp) -> impl Iterator { + let ds = app.session.ds().ok().filter(|_| active(app)); + let focus = ds.and_then(figcraft_engine::DocState::focused); + app.ui + .dev + .measured + .iter() + .filter(move |m| { + ds.is_some_and(|ds| { + let shown = |id: NodeId| ds.doc.node(id).is_some_and(|n| n.visible); + shown(m.from) && m.measurement.to.is_none_or(shown) && focus.is_none_or(|f| ds.doc.is_ancestor(f, m.from)) + }) + }) + .flat_map(|m| &m.lines) +} + /// One annotation as a card: its category, its note (up to six lines) and its pinned properties /// with their values. struct Callout { @@ -690,6 +725,97 @@ pub fn dev_resources_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: ui.add_space(4.0); } +/// The menu label of measuring along `side`. +fn side_label(side: figcraft_doc::Side) -> &'static str { + use figcraft_doc::Side; + match side { + Side::Top => tr("Along the top"), + Side::Right => tr("Along the right"), + Side::Bottom => tr("Along the bottom"), + Side::Left | Side::Auto => tr("Along the left"), + } +} + +/// Measurements kept on the canvas: those from or to the selected layer (with nothing selected, +/// every one on the page), each with its distances; a click selects the layer it starts from and +/// − removes it. With a layer selected, a menu places a new one: between two selected layers, to +/// the layer around it or along one of its sides. +pub fn measurements_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { + use figcraft_doc::Side; + refresh_measurements(app); + let t = Tokens::get(ui.ctx()); + let first = sel.first().copied().filter(|id| doc.node(*id).is_some_and(|n| !n.is_page())); + let rows: Vec<&Placed> = app.ui.dev.measured.iter().filter(|m| first.is_none_or(|id| m.touches(id))).collect(); + if first.is_none() && rows.is_empty() { + return; + } + let mut action: Option<(&str, serde_json::Value)> = None; + widgets::separator(ui); + widgets::section_header(ui, tr("Measurements"), &[], rows.is_empty()); + for m in &rows { + if let Some(a) = measurement_row(ui, &t, doc, m) { + action = Some(a); + } + } + if let Some(id) = first { + let around = doc.node(id).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| !p.is_page()); + let w = ui.available_width() - PAD * 2.0; + row(ui, 32.0, |ui| { + let resp = widgets::dropdown(ui, tr("Add measurement"), w, Some(Icon::Measure)) + .on_hover_text(tr("Keep a measurement on the canvas for developers")); + menu_popup(&resp).show(|ui| { + if let [a, b, ..] = sel + && menu_item(ui, tr("Between the selected layers"), "") + { + action = Some(("devMode.addMeasurement", json!({"from": a, "to": b}))); + } + if around && menu_item(ui, tr("To the layer around it"), "") { + action = Some(("devMode.addMeasurement", json!({"from": id}))); + } + for side in [Side::Top, Side::Right, Side::Bottom, Side::Left] { + if menu_item(ui, side_label(side), "") { + action = Some(("devMode.addMeasurement", json!({"from": id, "side": side}))); + } + } + }); + }); + } + if let Some((cmd, params)) = action { + app.run(cmd, params); + } + ui.add_space(4.0); +} + +/// One measurement in the Inspect tab, on two lines: what it measures, and its distances below. A +/// click selects the layer it starts from, − removes it: the command to run. +fn measurement_row(ui: &mut Ui, t: &Tokens, doc: &Document, m: &Placed) -> Option<(&'static str, serde_json::Value)> { + let name = |id: NodeId| doc.node(id).map_or(String::new(), |n| n.name.clone()); + let mut action = None; + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 36.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r.shrink2(vec2(PAD / 2.0, 1.0)), CornerRadius::same(4), t.hover); + } + icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + PAD + 7.0, r.center().y), vec2(14.0, 14.0)), Icon::Measure, t.icon_secondary); + let what = match (m.measurement.to, m.measurement.side) { + (Some(to), _) => format!("{} → {}", name(m.from), name(to)), + (None, side) => format!("{} · {}", name(m.from), side.map_or("", side_label)), + }; + let value = m.labels().join(" · "); + let del = Rect::from_center_size(pos2(r.max.x - PAD - 4.0, r.center().y), vec2(22.0, 22.0)); + let x = r.min.x + PAD + 22.0; + let text_w = del.min.x - 6.0 - x; + ui.painter().text(pos2(x, r.center().y - 7.0), Align2::LEFT_CENTER, widgets::elide(ui, &what, text_w), regular(11.0), t.text); + ui.painter().text(pos2(x, r.center().y + 7.0), Align2::LEFT_CENTER, widgets::elide(ui, &value, text_w), regular(10.0), t.text_secondary); + crate::a11y::button(&resp, &format!("{what}: {value}")); + if resp.clicked() { + action = Some(("select.set", json!({"ids": [m.from]}))); + } + if widgets::icon_button_at(ui, Id::new(("measure_del", m.from, m.index)), del, Icon::Minus, tr("Remove measurement")) { + action = Some(("devMode.removeMeasurement", json!({"from": m.from, "index": m.index}))); + } + action +} + /// The category of annotation `a` as a dropdown: the one picked (`Value::Null` for none). fn category_menu(ui: &mut Ui, doc: &Document, id: Id, a: &Annotation) -> Option { let current = annotations::category_of(doc, a); diff --git a/crates/ui-egui/src/devmode_tail_tests.rs b/crates/ui-egui/src/devmode_tail_tests.rs index 7008e9e5..b7901083 100644 --- a/crates/ui-egui/src/devmode_tail_tests.rs +++ b/crates/ui-egui/src/devmode_tail_tests.rs @@ -108,3 +108,81 @@ fn dev_mode_pastes_only_a_comments_link() { crate::menus::paste(&mut app, None, None); assert_eq!(app.ui.comments.open, Some(c), "the link opens its thread"); } + +#[test] +fn dev_mode_layers_panel_selects_and_expands_but_edits_nothing() { + use egui::{Event, Pos2}; + let mut s = Session::new(); + let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 400, "name": "Login"})).unwrap()); + let a = id(&s.execute("shape.rectangle", &json!({"x": 20, "y": 30, "width": 120, "height": 40, "parent": f})).unwrap()); + let b = id(&s.execute("shape.rectangle", &json!({"x": 20, "y": 90, "width": 120, "height": 40, "parent": f})).unwrap()); + s.execute("select.none", &json!({})).unwrap(); + let mut u = crate::layers_drag_tests::Ui::new(s); + u.app.ui.right_tab = crate::RightTab::Inspect; + u.app.ui.expanded.insert(f); + u.frame(vec![]); + let order = |u: &crate::layers_drag_tests::Ui| u.app.session.doc().unwrap().node(f).unwrap().children.clone(); + let before = order(&u); + let (ra, rb) = (u.row_at(a), u.row_at(b)); + let click = |u: &mut crate::layers_drag_tests::Ui, p: Pos2| { + u.button(p, true); + u.button(p, false); + }; + let rest = |u: &mut crate::layers_drag_tests::Ui| { + for _ in 0..30 { + u.frame(vec![]); + } + }; + // A click selects. + click(&mut u, ra); + assert_eq!(u.app.session.selection(), [a]); + // A double click doesn't rename, and says why. + click(&mut u, ra); + assert!(u.app.ui.rename.is_none(), "no rename"); + assert!(u.app.ui.toast.as_ref().is_some_and(|(m, _)| m.contains("Shift+D"))); + rest(&mut u); + // The eye and padlock spots don't hide or lock (the row is selected instead). + let (eye, padlock) = (272.0, 250.0); + for x in [eye, padlock] { + u.frame(vec![Event::PointerMoved(Pos2::new(x, rb.y))]); + click(&mut u, Pos2::new(x, rb.y)); + rest(&mut u); + } + let doc = u.app.session.doc().unwrap(); + assert!(doc.node(b).unwrap().visible && !doc.node(b).unwrap().locked, "not hidden or locked"); + assert_eq!(u.app.session.selection(), [b]); + // Dragging a row doesn't reorder. + u.drag_to(ra, rb + egui::vec2(0.0, 6.0)); + u.button(rb + egui::vec2(0.0, 6.0), false); + rest(&mut u); + assert_eq!(order(&u), before, "not reordered"); + // Back in the design, the same double click renames. + u.app.ui.right_tab = crate::RightTab::Design; + u.frame(vec![]); + click(&mut u, ra); + click(&mut u, ra); + assert!(u.app.ui.rename.is_some_and(|r| r.id == a)); +} + +#[test] +fn measurements_kept_on_the_canvas_draw_in_dev_mode_and_follow_their_layers() { + let mut s = Session::new(); + let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 400})).unwrap()); + let a = id(&s.execute("shape.rectangle", &json!({"x": 20, "y": 30, "width": 120, "height": 40, "parent": f})).unwrap()); + let b = id(&s.execute("shape.rectangle", &json!({"x": 20, "y": 90, "width": 120, "height": 40, "parent": f})).unwrap()); + let mut u = Ui::new(s); + u.run("devMode.addMeasurement", json!({"from": a, "to": b})); + let labels = |u: &Ui| crate::dev_mode::saved_lines(&u.app).filter_map(|g| g.label.clone()).collect::>(); + u.frame(vec![]); + assert!(labels(&u).is_empty(), "the design doesn't show them"); + u.app.ui.right_tab = crate::RightTab::Inspect; + u.frame(vec![]); + assert_eq!(labels(&u), ["20"]); + u.run("node.set", json!({"id": b, "props": {"y": 110}})); + u.frame(vec![]); + assert_eq!(labels(&u), ["40"], "it follows the layer"); + // Hidden layers' measurements aren't drawn. + u.run("node.set", json!({"id": b, "props": {"visible": false}})); + u.frame(vec![]); + assert!(labels(&u).is_empty()); +} diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 8275df5e..2e5af4cb 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -209,6 +209,8 @@ icons! { Connector, Shapes, Stamp, + /// A measurement: a dimension line with arrowheads between two end ticks. + Measure, } /// Points along a circular arc from angle `a0` to `a1` (radians, y down: increasing is clockwise). @@ -1180,6 +1182,13 @@ fn draw(p: &P, icon: Icon) { p.circle(8.0, 8.0, 6.0); p.poly(&[(5.0, 8.3), (7.2, 10.5), (11.0, 6.0)], false); } + Measure => { + p.line(2.5, 4.0, 2.5, 12.0); + p.line(13.5, 4.0, 13.5, 12.0); + p.line(3.0, 8.0, 13.0, 8.0); + p.poly(&[(5.5, 5.5), (3.0, 8.0), (5.5, 10.5)], false); + p.poly(&[(10.5, 5.5), (13.0, 8.0), (10.5, 10.5)], false); + } Logo => { // FigCraft's own mark: the curled tail of the chameleon on the app icon. One bold // stroke: a shaft along the bottom, then three half turns, each tighter than the last. diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index f172b309..0973fd42 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -704,6 +704,11 @@ fn search_field(ui: &mut Ui, t: &Tokens, w: f32, id: Id, hint: &str, text: &mut fn layers(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); + // Dev Mode only inspects: rows select and expand, but don't rename, reorder, hide or lock. + let dev = app.ui.right_tab == crate::RightTab::Inspect; + if dev { + app.ui.rename = None; + } let w = ui.available_width(); let (hr, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover()); ui.painter().text(pos2(hr.min.x + 16.0, hr.center().y), Align2::LEFT_CENTER, tr("Layers"), semibold(11.0), t.text); @@ -825,7 +830,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { let ui = &mut band_ui; for row in rows.iter().take(last).skip(first) { let Some(n) = doc.node(row.id) else { continue }; - let (r, resp) = ui.allocate_exact_size(vec2(w, ROW), Sense::click_and_drag()); + let (r, resp) = ui.allocate_exact_size(vec2(w, ROW), if dev { Sense::click() } else { Sense::click_and_drag() }); let selected = sel.contains(&row.id); a11y::layer_row(&mut app.ui.nav, &resp); if sel.last() == Some(&row.id) { @@ -923,14 +928,17 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { // Lock / eye on hover (or when set); a layer in a locked one shows a faint padlock. let hidden = swept(false).unwrap_or(!n.visible); let locked = swept(true).unwrap_or(n.locked); - let show_lock = over || locked || row.locked_above; - let show_eye = over || hidden; + // In Dev Mode they only show what is set. + let show_lock = (over && !dev) || locked || row.locked_above; + let show_eye = (over && !dev) || hidden; let er = Rect::from_center_size(pos2(rr.max.x - 14.0, r.center().y), vec2(20.0, 20.0)); let lr = Rect::from_center_size(pos2(rr.max.x - 36.0, r.center().y), vec2(20.0, 20.0)); if show_eye { + icons::paint(ui.painter(), er.shrink(3.0), if hidden { Icon::EyeOff } else { Icon::Eye }, t.icon_secondary); + } + if show_eye && !dev { let eresp = ui.interact(er, ui.id().with(("eye", row.id)), Sense::click_and_drag()); a11y::toggle(&eresp, tr("Hidden"), hidden); - icons::paint(ui.painter(), er.shrink(3.0), if hidden { Icon::EyeOff } else { Icon::Eye }, t.icon_secondary); if eresp.clicked() { vis = Some(row.id); } @@ -939,10 +947,12 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { } } if show_lock { - let lresp = ui.interact(lr, ui.id().with(("lock", row.id)), Sense::click_and_drag()); - a11y::toggle(&lresp, tr("Locked"), locked || row.locked_above); let col = if locked || !row.locked_above { t.icon_secondary } else { t.text_tertiary }; icons::paint(ui.painter(), lr.shrink(3.0), if locked || row.locked_above { Icon::Lock } else { Icon::Unlock }, col); + } + if show_lock && !dev { + let lresp = ui.interact(lr, ui.id().with(("lock", row.id)), Sense::click_and_drag()); + a11y::toggle(&lresp, tr("Locked"), locked || row.locked_above); if lresp.clicked() { lock = Some(row.id); } @@ -953,7 +963,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { if resp.clicked() { clicked = Some((row.id, ui.input(|i| i.modifiers))); } - if resp.double_clicked() { + if resp.double_clicked() && !crate::menus::dev_mode_refuses(app, "object.rename") { app.ui.rename = Some(Rename { id: row.id, start: true }); } if resp.secondary_clicked() { diff --git a/crates/ui-egui/src/measure_overlay.rs b/crates/ui-egui/src/measure_overlay.rs index 053ce861..ed8f019b 100644 --- a/crates/ui-egui/src/measure_overlay.rs +++ b/crates/ui-egui/src/measure_overlay.rs @@ -12,8 +12,9 @@ const GAP: f32 = 4.0; /// Padding inside a label's pill. const PAD: Vec2 = vec2(4.0, 2.0); -/// Draw the tool overlay's guides, measurement labels and measured box; labels keep clear of -/// `avoid` (the selection's size label). +/// Draw the tool overlay's guides, measurement labels and measured box, and in Dev Mode the +/// measurements kept on the canvas; labels keep clear of `avoid` (the selection's size label) and +/// of one another. pub fn draw(app: &FigApp, painter: &egui::Painter, t: &Tokens, avoid: Option) { let ov = &app.session.tool.overlay; let line = Stroke::new(1.0, t.guide); @@ -21,7 +22,7 @@ pub fn draw(app: &FigApp, painter: &egui::Painter, t: &Tokens, avoid: Option = Vec::new(); - for g in &ov.guides { + for g in ov.guides.iter().chain(crate::dev_mode::saved_lines(app)) { let (a, b) = (app.to_screen(g.x0, g.y0), app.to_screen(g.x1, g.y1)); if g.x0 == g.x1 && g.y0 == g.y1 { for d in [vec2(4.0, 4.0), vec2(4.0, -4.0)] { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index c48fa401..eeb25e3d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -109,8 +109,8 @@ fn clipboard_png(app: &mut FigApp) -> Option<(Vec, u32, u32)> { /// Dev Mode (the Inspect tab) refuses command `id` from the menus, shortcuts and clipboard: it /// edits the design ([`figcraft_engine::devinspect::dev_mode_allows`]). Says so when it does. -/// shortcut: the layers panel (rename, reorder, hide, lock) and files dropped on the canvas still -/// edit in Dev Mode; route them through here if people trip over it. +/// shortcut: files dropped on the canvas still edit in Dev Mode; route them through here if people +/// trip over it. pub fn dev_mode_refuses(app: &mut FigApp, id: &str) -> bool { let refused = app.ui.right_tab == crate::RightTab::Inspect && !figcraft_engine::devinspect::dev_mode_allows(id); if refused { diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index 6738f300..e3aba95b 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -2243,6 +2243,7 @@ fn inspect(app: &mut FigApp, ui: &mut Ui) { widgets::note(ui, tr("Hover other layers to measure the distance"), t.text_secondary); crate::dev_mode::ready_list(app, ui); crate::dev_mode::categories_section(app, ui); + crate::dev_mode::measurements_section(app, ui, &doc, &sel); crate::dev_mode::plugins_section(app, ui); return; }; @@ -2251,6 +2252,7 @@ fn inspect(app: &mut FigApp, ui: &mut Ui) { crate::dev_mode::status(app, ui, &doc, &sel); crate::dev_mode::annotations_section(app, ui, &doc, first); crate::dev_mode::dev_resources_section(app, ui, &doc, first); + crate::dev_mode::measurements_section(app, ui, &doc, &sel); use figcraft_engine::devinspect::ColorFormat; let fmt_id = Id::new("inspect_color_format"); let fmt = ui.data(|d| d.get_temp::(fmt_id)).and_then(|i| ColorFormat::ALL.get(i).copied()).unwrap_or_default(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 3941bd8a..f86ce764 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -131,7 +131,8 @@ component/instance links, overrides, `slot` (a slot layer's property name), boun `devStatus` (handoff status), `devChanged` (changed since it was marked ready for dev), `annotations` (Dev Mode notes: `[{label, properties, category?}]`), `codeLinks` (a component's links to code: `[{language, importPath, name, template}]`), `devResources` (links for developers, -its own and inherited: `[{name, url, index?, from?}]`), `pluginData` (layer data written with +its own and inherited: `[{name, url, index?, from?}]`), `measurements` (Dev Mode measurements +starting from the layer: `[{to} | {side}]`), `pluginData` (layer data written with `node.set {props: {pluginData: {key: value}}}`, pages included: `{key: value}`, values as strings; only when there is some). The auto layout props that `node.set` accepts are listed in [mcp.md](mcp.md#conventions-agents-must-know). diff --git a/docs/dev-mode-parity.md b/docs/dev-mode-parity.md index bf3cc737..7f8a7fbe 100644 --- a/docs/dev-mode-parity.md +++ b/docs/dev-mode-parity.md @@ -1,16 +1,16 @@ # Dev Mode parity -> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (import lines in generated layer trees; compare with any saved version; Dev Mode behaviour suite: an inspect-only canvas, Changed in the ready list, statuses only on frames and sections; plugin panels from the Inspect tab) · **Target:** Figma Design Dev Mode (desktop app 126.10.7) +> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** minor (measurements kept on the canvas; a read-only layers panel in Dev Mode; earlier: import lines in generated layer trees; compare with any saved version; Dev Mode behaviour suite: an inspect-only canvas, Changed in the ready list, statuses only on frames and sections; plugin panels from the Inspect tab) · **Target:** Figma Design Dev Mode (desktop app 126.10.7) -**Breadth ~97%, ready ~76% (estimated).** The inspect surface is broad, components connect +**Breadth ~98%, ready ~78% (estimated).** The inspect surface is broad, components connect to the code components of a repository (with their imports, also inside generated layer trees), -changes compare with any saved version and the canvas only inspects in Dev Mode. Left: measurements -kept on the canvas, a read-only layers panel in Dev Mode, publishing mappings from the code -repository. Remaining: 2–4 h. +changes compare with any saved version, the canvas and the layers panel only inspect in Dev Mode, +and measurements stay on the canvas for everyone. Left: publishing mappings from the code +repository, third-party integrations. Remaining: 1–2 h. | Feature | Status | Notes | |---|---|---| -| Dev Mode toggle (Shift+D), Inspect panel | done | the canvas only inspects: presses select, pan and comment but never move, resize, draw or edit text and vectors; menus and shortcuts refuse design edits with a note (Dev Mode's annotations, statuses, dev resources, code connections and comments still work); a drawing tool chosen in the toolbar goes back to the design. The layers panel and dropped files still edit (ours to tighten) | +| Dev Mode toggle (Shift+D), Inspect panel | done | the canvas only inspects: presses select, pan and comment but never move, resize, draw or edit text and vectors; menus and shortcuts refuse design edits with a note (Dev Mode's annotations, statuses, dev resources, measurements, code connections and comments still work); a drawing tool chosen in the toolbar goes back to the design. The layers panel only selects and expands (no renaming, reordering, hiding or locking; a double click says why); files dropped on the canvas still edit (ours to tighten) | | Properties with their styles and variables; redlines on hover | done | positions from the frame around the layer (groups aren't frames), rotation, radius (CSS corner order), opacity, blend; colours in hex, RGB, CSS, HSL or HSB (`inspect.properties {colorFormat}`, the Inspect tab's Color format) | | Code: CSS, iOS (SwiftUI), Android (Compose) | done | plus HTML, React and Leptos (ours; Leptos: `#[component]` functions with `#[prop]` defaults, variant enums and `view!` markup); components take properties as parameters, instances are calls; CSS in px or rem (`inspect.code {unit, rootFontSize}`), with rotation, blend modes, gradient directions and kinds (linear, radial, conic), text alignment, case and decoration | | Assets list with export | done | layers with export settings, icons (small, vector shapes only: SVG) and images (PNG); the icon rule is ours | @@ -20,7 +20,7 @@ repository. Remaining: 2–4 h. | Compare changes (before/after side by side or overlaid, property diffs) | done | against the snapshot taken when a frame was first changed after it was marked ready, or any saved version (`dev.compare {version}`; the view's Compare with menu); a frame that isn't ready for dev compares with versions | | Code connect (map components to repository code) | done | per language (React, Leptos, SwiftUI, Compose): import path, name and a template with `{property}` and `{property ? "a" : "b"}` placeholders; instances (also inside generated layer trees) show the snippet with their values; edited in the Inspect panel with a live preview; `figcraft.connect.json` export/import; generated layer trees bring the import lines of the connected components they call (also in definitions, slots and swapped-in instances) once each at the top, per language. No CLI publishing mappings from the code repository | | Plugins in Dev Mode (Jira, GitHub, etc.) | partial | FigCraft's own WASM plugins that only read run from the Inspect tab, with their panels (callbacks can only read); no third-party integrations | -| Measurements (redlines on hover, Alt in the design) | partial | distances between the selection and the hovered layer, its parent or a guide, auto layout padding and gaps (Shift+Alt); measurements kept on the canvas for others aren't there | +| Measurements (redlines on hover, Alt in the design) | done | distances between the selection and the hovered layer, its parent or a guide, auto layout padding and gaps (Shift+Alt); measurements kept on the canvas (`devMode.addMeasurement` between two layers, along a side or to the layer around it; stored on the layer, saved, synced, undoable) drawn in Dev Mode as labelled redlines that follow the layers, listed, placed and removed in the Inspect tab; a measurement whose other layer was deleted isn't drawn until an undo brings it back | | VS Code extension | out of scope | FigCraft's MCP server and control channel already serve design data, code and connected snippets to agents and editors | | Dev resources links on layers | done | a URL and a name per link on any layer (`devResource.*`, saved, synced, undoable); instances list their main component's (and its set's) read-only; URLs checked as http/https and never fetched (no page titles: a link without a name is named after its URL); the Inspect tab lists, adds, edits and removes them; `get_node` returns them | @@ -28,6 +28,7 @@ repository. Remaining: 2–4 h. | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Breadth 97% → 98%, ready 76% → 78%, 2–4 h → 1–2 h | | 2026-10-15 | minor | Dev Mode tail (gap 16): generated layer trees (React, Leptos, SwiftUI, Compose) start with the import lines of the connected components they call, once each, also those in a component's definition, in slots an instance fills and placed by a connected template's instance swaps; Compare changes takes any saved version as the before (`dev.compare {version}`, the view's Compare with menu; frames that aren't ready for dev compare with versions). Behaviour suite (5 tests; 4 failed before the fixes): the Dev Mode canvas only inspects (no moving, resizing, drawing, text or vector editing; menus, shortcuts, nudges, opacity keys and paste refuse design edits with a note; toolbar drawing tools go back to the design), the ready list filters and badges Changed items, statuses are refused on layers inside frames. Read-only plugins run from the Inspect tab show their panels (the runtime's panels, reused). Breadth 96% → 97% (measurements kept on the canvas and the read-only layers panel counted as missing), ready 72% → 76%, 0–1 h → 2–4 h | | 2026-10-12 | minor | Dev resources (gap 16): links for developers (a URL and a name) on any layer: `devResource.add` / `edit` / `remove` / `list`, undoable, saved and synced; an instance also lists its main component's (and that component's set's) links read-only with where they come from, and layers inside an instance show their main layer's; URLs are checked as web links (http or https, a host, no spaces, at most 2,048 characters) and never fetched (a link added without a name is named after its URL), opened only when clicked; the Inspect tab lists, adds, edits and removes them; `get_node` and `inspect_document` return them as `devResources`. The editor extension stays out of scope (the MCP server serves agents and editors). Dev Mode breadth 93% → 96%, ready 70% → 72%, 1–3 h → 0–1 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~304–593 h → ~303–591 h | | 2026-10-11 | minor | Export and inspect behaviour suite written from the public documentation (139 cases in 19 tests; 47 failed before the fixes) and its fixes: an export covers what the layer paints, so a drop shadow adds room only where it falls, strokes on boxes and ellipses reach their real width (they added up to four times as much), hidden layers take no room and a mask group exports the mask's area; a slice exports everything under it; slashes in layer names export into folders; Copy as PNG copies the whole selection. Added: the export settings' contents only and include bounding box, and for SVG outline text (off writes ``), unique id attributes and simplified strokes (stroke attributes where exact); Inspect colour formats (hex, RGB, CSS, HSL, HSB), CSS in rem, a Blend row, positions measured from the frame around a layer (groups aren't frames), CSS for rotation, blend modes, gradient directions and kinds, text alignment, case and decoration; icons and images in the asset list (`file.exportLayers {assets}`). Dev Mode breadth 90% → 93%, ready 64% → 70%, hours unchanged (1–3, dev resources); feature breadth and ready for real work unchanged at this rounding | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ff796416..73fbc602 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -20,7 +20,7 @@ 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 (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, shapes with text, 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. 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; 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, filled areas with their own 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) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | | `.jam` (whiteboards), `.deck` (Slides) | native to other Figma products | no | no | `.jam`: not read or written (boards are FigCraft files, see [boards-parity](boards-parity.md)); `.deck` out of scope | — | | REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, slots (`SLOT` layers bound to their slot property; an instance's slot keeps its content as the instance's own unless its property value is the main's default content; slot limits and preferred components are warnings), styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | -| `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`); saves write a unique staging file and rename it into place (#284); optional auto-save of files with a path (`pref.autoSave`, #305); `kind: "board"` marks a board (left out for design files, so files from before boards are design files; the home screen reads it for the file's mark); board objects keep their `board` metadata on the layer | — | +| `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`); saves write a unique staging file and rename it into place (#284); optional auto-save of files with a path (`pref.autoSave`, #305); `kind: "board"` marks a board (left out for design files, so files from before boards are design files; the home screen reads it for the file's mark); board objects keep their `board` metadata on the layer; Dev Mode measurements are kept on the layer they start from (`measurements: [{to} | {side}]`, left out when there are none, so older files open with none) | — | | Local library files | team libraries in the cloud | yes (enable a `.figcraft` as a library) | yes (publish) | per-asset versions, updates with overrides kept | see [collaboration-parity](collaboration-parity.md) | ## Import (place / paste / drop) @@ -115,7 +115,7 @@ each. properties, arc fields, font weight: written as the values they give), text styles on part of a text (written as values), variables' publishing, code syntax and inheritance settings, extended collections (written as collections of their own), comments, Dev Mode annotations, statuses, - resources and code links, plugin data, ruler + resources, measurements and code links, plugin data, ruler guides, prototype device settings, slot properties and slot content, brush strokes (written plain), strokes wider on one side of their path (written as filled outlines, or with the width on both sides when the shape has a fill too), pattern fills, WebP export settings (written as PNG), diff --git a/docs/gaps.md b/docs/gaps.md index 40f97032..f4f29c07 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The @@ -29,7 +29,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | 13 | Vector tail: none left (boolean hit testing on the result landed 2026-10-11) | Feature | — | [target-app-parity](target-app-parity.md) | | 14 | Variables: extended collections checked on real libraries (the tail landed 2026-10-12) | Feature | 0–1 | [components-parity](components-parity.md) | | 15 | Prototyping: media triggers and actions; controllers on macOS, Linux and FreeBSD (controller buttons on key/gamepad triggers, widgets in prototypes landed) | Feature | 2–5 | [prototyping-parity](prototyping-parity.md) | -| 16 | Dev Mode: measurements kept on the canvas, a read-only layers panel, publishing mappings from the code repository; the editor extension is left to the MCP server (import lines, compare with versions and an inspect-only canvas landed 2026-10-15) | Feature | 2–4 | [dev-mode-parity](dev-mode-parity.md) | +| 16 | Dev Mode: publishing mappings from the code repository; the editor extension is left to the MCP server (measurements kept on the canvas and a read-only layers panel landed 2026-10-24) | Feature | 1–2 | [dev-mode-parity](dev-mode-parity.md) | | 17 | Branching: branches in live sessions, lists other than fills, strokes and effects merged whole (a branch's own version history and per-item list merges landed 2026-10-12) | Feature | 1–2 | [collaboration-parity](collaboration-parity.md) | | 18 | Display P3 on screen: blocked upstream (egui-wgpu picks an 8-bit sRGB surface); gamut warning and soft-proof sRGB landed, as did several windows | Hardware | 4–8 | [hardware-parity](hardware-parity.md) | | 19 | AI features | AI | 30–60 | [target-app-parity](target-app-parity.md) | @@ -386,11 +386,16 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe ready list filters and badges Changed items (`inspect.readyForDev {status: changed}`); statuses are refused on layers inside frames. Read-only plugins run from the Inspect tab show their panels. -- **Missing:** measurements kept on the canvas for others (only hover redlines); the layers panel - and files dropped on the canvas still edit in Dev Mode; publishing mappings from the code - repository with a CLI. An editor extension is out of scope: the MCP server already serves design - data, code and the connected snippets to agents and editors. -- **Estimate:** 2–4 h. +- **Landed (2026-10-24):** measurements kept on the canvas: between two layers, along one side of + a layer or to the layer around it (`devMode.addMeasurement` / `removeMeasurement` / + `measurements`), stored on the layer they start from (saved, synced, undoable; old files open + without them), drawn in Dev Mode as labelled redlines that follow the layers, and listed, placed + and removed in the Inspect tab. The layers panel is read-only in Dev Mode (rows select and + expand; no renaming, reordering, hiding or locking). +- **Missing:** files dropped on the canvas still edit in Dev Mode; publishing mappings from the + code repository with a CLI. An editor extension is out of scope: the MCP server already serves + design data, code and the connected snippets to agents and editors. +- **Estimate:** 1–2 h. ### 17. Branching and merging - **Landed (2026-10-11):** branches of a file (`branch.*`): File ▸ Create branch… (or the Branches @@ -471,6 +476,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Gap 16: Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Left: publishing mappings with a CLI; 2–4 h → 1–2 h | | 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. New gap 24 (the board tail, 30–60 h); the ranking above it is unchanged | | 2026-10-15 | minor | Gap 5: Turkish reviewed by a native speaker (#263), Czech (#469) and Indonesian (#477) drafts, the Japanese draft caught up (#288); Indonesian is no longer missing; counts re-measured (2,177 entries) and corrected: only Spanish is held to completeness, Russian included. Estimate unchanged at this rounding | | 2026-10-17 | minor | The 2026-10-17 landings. Gap 1: a new Landed entry (community batches #727 and #728, then #745: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments, images in the message, instance layers' size and resizing overrides, Hug where nothing is recorded; budgets from the file's size; the import report; a `.fig` copy from Save as; paste from another design app), its Missing list cut to what is still left, and the wrong "whether other apps open its files is unknown" replaced by what was checked; all on synthetic files, so the beta blocker and hours stand. Gap 2: server limits, log recovery and web join links in the evidence. Gap 4: menus, the Actions palette and the editor fixes (#733, #735, #736, #729), then Selection colors and smart-selection spacing (#753) landed, none compared side by side. Gap 5: catalog counts re-measured (2,272 entries; drafts at 95–99%; Indonesian, Czech, Turkish and Swedish listed). Gap 8: the Linux and BSD clipboard changes and Wayland window controls, not run there by the maintainers. Ranking and hours unchanged | diff --git a/docs/mcp.md b/docs/mcp.md index ccaeb401..8ffe773c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1392,6 +1392,22 @@ permissions: without it nothing is downloaded); `plugin.directory.publish {path are clipped to 200 characters; a layer holds at most 32 links. Links are data: never fetched or opened by the engine (the app opens one when it is clicked). `get_node` and `inspect_document` list them as `devResources`; the Inspect tab lists, adds, edits and removes them. +- **Measurements kept on the canvas:** distances a developer leaves on the canvas for everyone. + `devMode.addMeasurement {from? (default the first selected layer), to?, side?}` → `{from, index, + lines}` places one between `from` and `to` (a layer on the same page), along one side of `from` + (`side`: `top`, `right`, `bottom` or `left`: that side's length) or, with neither, to the layer + around `from`; `devMode.removeMeasurement {from?, index?}` → `{removed}` (no index: every one + starting from the layer); both undoable. `devMode.measurements {page?, id?}` → `[{from, fromName, + index, to?, toName?, side?, labels, lines: [{x0, y0, x1, y1, label, dashed}]}]` lists those on a + page (default the current one; `id`: only those from or to that layer), with their lines in page + space measured from the layers' current bounds, so they follow the layers. A measurement is + stored on the layer it starts from (`get_node` lists them as `measurements: [{to} | {side}]`), + saved with the file and synced; the same two layers are measured once (either way round), a + layer starts at most 64. One whose other layer was deleted or moved to another page isn't listed + or drawn (an undo brings it back); a copy keeps the side measurements and those to layers copied + with it. In the app, Dev Mode draws them with the hover redlines, and the Inspect tab lists them + (with nothing selected, every one on the page), selects a measurement's layer on a click, removes + one with −, and places new ones from its Add measurement menu. - **Annotations:** notes for developers on any layer, each with optional pinned properties that Dev Mode shows with their current values (and the style or variable behind them). `annotation.add {id?, label?, properties?}` (a note, pinned properties or both; returns `{id, @@ -1428,8 +1444,8 @@ permissions: without it nothing is downloaded); `plugin.directory.publish {path button to open it in the focus view). In the app's Dev Mode the canvas only inspects: presses select (and pan, and comment), never move, resize, draw or edit text and vectors, and the menus and shortcuts refuse commands that edit the design (Dev Mode's own annotations, statuses, dev - resources, code connections and comments still work); choosing a drawing tool in the toolbar - goes back to the design. Commands sent over MCP or the control channel aren't affected. + resources, measurements, code connections and comments still work); choosing a drawing tool in + the toolbar goes back to the design, and the layers panel only selects and expands. Commands sent over MCP or the control channel aren't affected. - **Compare changes:** when a layer marked Ready for dev first changes (it turns "Changed"), the file keeps a copy of it and everything in it as it was just before that edit (saved in the `.figcraft` file only while there is a change; resolving the change, marking it again, another diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a7d3402e..e196eb64 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -110,7 +110,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 12 | Styles & variables | 5 | 95% | 76% | Real-library QA (variables inside styles, behaviour suite from the documentation, style folders and order, mode order, code syntax, extended collections with their tail (new library variables as updates, chains through libraries) and variables import/export landed) ([components-parity](components-parity.md)) | 2–4 | | 13 | Prototyping | 6 | 92% | 74% | Video triggers and media actions, controllers on macOS, Linux and FreeBSD, real prototype QA (behaviour suite from the documentation, delays on mouse triggers, manual overlay offsets, key combinations, controller buttons on key/gamepad triggers (Windows and the browser), widgets reacting in prototypes, interactive components playing their variants, drag-driven animations, physical springs landed) ([prototyping-parity](prototyping-parity.md)) | 2–5 | | 14 | Import/export | 6 | 86% | 58% | `.fig` import's tail (best-effort import, editable vector networks, stand-ins for non-design layers and variable prototype actions landed, then (2026-10-17) grid tracks, slots, labels of connectors saved with their path, per-point joins, text on a path, image adjustments, budgets for large files, an import report and paste from another design app; all on synthetic files, no real-file checks yet), checking the rest of the experimental `.fig` export (landed; written files open and draw in other software) on effects, images, prototypes and real files, HEIC/video import, CMYK output intent ([file-format-parity](file-format-parity.md)) | 5–10 | -| 15 | Dev Mode / inspect | 2 | 96% | 72% | Import lines for connected instances inside generated layer trees; an editor extension is left to the MCP server (inspect behaviour suite with colour formats, rem and icon and image assets, code connect, plugins in Dev Mode and dev resources links landed) ([dev-mode-parity](dev-mode-parity.md)) | 0–1 | +| 15 | Dev Mode / inspect | 2 | 98% | 78% | Publishing mappings from the code repository; an editor extension is left to the MCP server (inspect behaviour suite with colour formats, rem and icon and image assets, code connect, plugins in Dev Mode, dev resources links, import lines, compare with versions, an inspect-only canvas and layers panel and measurements kept on the canvas landed) ([dev-mode-parity](dev-mode-parity.md)) | 1–2 | | 16 | Comments, versions, find, misc | 3 | 87% | 63% | Comments are local only; comparing two versions; branches in live sessions; text runs and other lists besides fills, strokes and effects merge whole (branches with review and merge, their own version history and per-item list merges, and a comments and version history behaviour suite from the documentation landed) | 1–3 | | 17 | Multiplayer & sharing | 8 | 17% | 5% | Core landed (`crates/collab`: ids, change sets, server ordering, convergence tested); live sessions (preview): WebSocket transport, hosting from the desktop app, joining from the app or the browser, invite links that open the app, cursors, selections and follow, cursor chat and spotlight, per-person undo, offline resume; a self-hosted `figcraft-server` with persistence and share links to edit or view, which can be open to the public (owner tokens, limits per address, expiry, moderation). Left: accounts and teams, audio ([collaboration-parity](collaboration-parity.md)) | 42–84 | | 18 | Plugins & widgets | 4 | 60% | 35% | Own sandboxed WASM plugins landed (#101: manifest, permissions, menu, Run last, Manage, SDK), with log byte/count limits checked before allocation and host forwarding ([logging limits](plugins.md#logging-limits)), plugin panels called back per button or changed value and per-plugin settings (ABI v2), then widgets (API 3: plugin-drawn layers on the canvas with state kept in the document, redrawn on clicks; a Counter example), panels side by side and a colour picker for plugin colour fields, then a plugin directory on `figcraft-server` (publish with the server key, browse, install after a permissions confirmation, hash-checked downloads, updates by version); layer data written with `node.set {props: {pluginData}}` reads back through `node.get` (#879). Left: a public catalogue, publisher accounts and signatures, ratings, background update checks, the directory in the browser; widgets take clicks only (no text input, property menus, hover or timers) and draw frames, text, shapes and images only; Figma's plugins are JavaScript and don't run (a JS engine would be an owner decision) ([plugins.md](plugins.md)) | 8–26 | From fdc4f7403450be575d65588aa312541ce5107eac Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 24 Oct 2026 04:28:06 -0400 Subject: [PATCH 19/24] chore: drop redundant 'static lifetimes in consts (Sonar S8863) (#1254) Co-authored-by: Claude Sonnet 5.5 --- crates/color/src/lib.rs | 2 +- crates/doc/src/props.rs | 2 +- crates/doc/src/proto.rs | 2 +- crates/ui-egui/src/home.rs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 1fff7667..f042e23a 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -355,7 +355,7 @@ impl ImageFilters { } /// The built-in adjustment presets: `(id, label, adjustments)`. "Reset" is the identity. - pub const PRESETS: [(&'static str, &'static str, ImageFilters); 7] = [ + pub const PRESETS: [(&str, &str, ImageFilters); 7] = [ ("vivid", "Vivid", ImageFilters { contrast: 0.2, saturation: 0.45, highlights: -0.1, shadows: 0.1, ..ImageFilters::NONE }), ("muted", "Muted", ImageFilters { contrast: -0.15, saturation: -0.45, ..ImageFilters::NONE }), ("warm", "Warm", ImageFilters { temperature: 0.5, tint: 0.1, saturation: 0.1, ..ImageFilters::NONE }), diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index a646ae62..f6187589 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -795,7 +795,7 @@ fn yes() -> bool { impl Effect { /// Effect types in the order the Design panel lists them: (label, type name). - pub const TYPES: [(&'static str, &'static str); 7] = [ + pub const TYPES: [(&str, &str); 7] = [ ("Drop shadow", "dropShadow"), ("Inner shadow", "innerShadow"), ("Layer blur", "layerBlur"), diff --git a/crates/doc/src/proto.rs b/crates/doc/src/proto.rs index a844774c..61b3628e 100644 --- a/crates/doc/src/proto.rs +++ b/crates/doc/src/proto.rs @@ -1003,7 +1003,7 @@ impl PrototypeSettings { ]; /// The device whose screen is [`custom_size`](Self::custom_size) (no body is drawn around it). - pub const CUSTOM_DEVICE: &'static str = "Custom size"; + pub const CUSTOM_DEVICE: &str = "Custom size"; /// A new custom size. pub const CUSTOM_DEFAULT: [f64; 2] = [1280.0, 720.0]; /// The largest side a custom size may have (points). diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index 326a3b14..c3f7ee3a 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -27,7 +27,7 @@ pub enum Section { impl Section { /// The sidebar, top to bottom. - const ALL: [(Section, Icon, &'static str); 3] = [ + const ALL: [(Section, Icon, &str); 3] = [ (Section::Recents, Icon::Home, tr_noop("Recents")), (Section::Online, Icon::Online, tr_noop("Online")), (Section::Templates, Icon::Assets, tr_noop("Templates")), From 996bf871cdcd9f7c37f261d84085f66ce4b5deaa Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 24 Oct 2026 04:36:46 -0400 Subject: [PATCH 20/24] fix(security): prevent symlink traversal in asset walker, safe cert tmpdir, and atomic icon copy (#1255) --- packaging/macos/import-cert.sh | 5 +++-- xtask/src/icon.rs | 8 ++++++-- xtask/src/main.rs | 5 +++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/packaging/macos/import-cert.sh b/packaging/macos/import-cert.sh index 77611537..aeb58fd4 100755 --- a/packaging/macos/import-cert.sh +++ b/packaging/macos/import-cert.sh @@ -17,12 +17,13 @@ if [[ -z "${APPLE_CERTIFICATE:-}" ]]; then exit 0 fi -TMP="${RUNNER_TEMP:-${TMPDIR:-/tmp}}" +TMP_BASE="${RUNNER_TEMP:-${TMPDIR:-/tmp}}" +TMP="$(mktemp -d "${TMP_BASE}/figcraft-signing.XXXXXX")" +trap 'rm -rf "$TMP"' EXIT KEYCHAIN="$TMP/figcraft-signing.keychain-db" CERT="$TMP/figcraft-signing.p12" KC_PASS="${KEYCHAIN_PASSWORD:-$(openssl rand -hex 24)}" -trap 'rm -f "$CERT"' EXIT printf '%s' "$APPLE_CERTIFICATE" | base64 --decode >"$CERT" security create-keychain -p "$KC_PASS" "$KEYCHAIN" security set-keychain-settings -lut 21600 "$KEYCHAIN" diff --git a/xtask/src/icon.rs b/xtask/src/icon.rs index b91fccee..91b51aef 100644 --- a/xtask/src/icon.rs +++ b/xtask/src/icon.rs @@ -67,11 +67,15 @@ pub fn generate(root: &Path) -> bool { for png in &pngs { ico.extend_from_slice(png); } - ok &= std::fs::write(dir.join("figcraft.ico"), ico).is_ok(); + let ico_path = dir.join("figcraft.ico"); + let _ = std::fs::remove_file(&ico_path); + ok &= std::fs::write(&ico_path, ico).is_ok(); // The lighter trace keeps the scalable theme icon cheap to render. let sd = dir.join("hicolor/scalable/apps"); let _ = std::fs::create_dir_all(&sd); - ok &= std::fs::copy(dir.join("figcraft-small.svg"), sd.join("ai.storyteller.figcraft.svg")).is_ok(); + let target = sd.join("ai.storyteller.figcraft.svg"); + let _ = std::fs::remove_file(&target); + ok &= std::fs::copy(dir.join("figcraft-small.svg"), &target).is_ok(); eprintln!("icon: wrote {}", dir.display()); ok } diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 313de3c6..e7f7b386 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -201,10 +201,11 @@ fn assets() -> bool { fn walk(dir: &std::path::Path, f: &mut dyn FnMut(&std::path::Path)) { let Ok(rd) = std::fs::read_dir(dir) else { return }; for e in rd.flatten() { + let Ok(ft) = e.file_type() else { continue }; let p = e.path(); - if p.is_dir() { + if ft.is_dir() && !ft.is_symlink() { walk(&p, f); - } else { + } else if !ft.is_dir() { f(&p); } } From 25d48530a915c1902825408d575a09e15f97cb70 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 24 Oct 2026 04:37:38 -0400 Subject: [PATCH 21/24] fix(security): owner-only recovery permissions, linear cli export suffixing, and web noopener (#1256) --- apps/figcraft-cli/src/files.rs | 17 +++++++++++------ apps/figcraft-web/src/web.rs | 2 +- apps/figcraft/src/recovery.rs | 19 ++++++++++++++++++- 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index b87aa431..3d36a7f2 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -272,14 +272,19 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult { return Err(CliError::Failed("the page is empty: nothing to export".into())); } let mut used = std::collections::BTreeSet::new(); + let mut next_suffix: std::collections::HashMap = std::collections::HashMap::new(); for id in ids { let base = sanitize(doc.node(id).map(|n| n.name.as_str()).unwrap_or("layer")); - let mut name = base.clone(); - let mut k = 2; - while !used.insert(name.to_lowercase()) { - name = format!("{base} {k}"); - k += 1; - } + let base_lower = base.to_lowercase(); + let next = next_suffix.entry(base_lower).or_insert(1); + let name = loop { + let k = *next; + *next += 1; + let candidate = if k == 1 { base.clone() } else { format!("{base} {k}") }; + if used.insert(candidate.to_lowercase()) { + break candidate; + } + }; let path = Path::new(out).join(format!("{name}.{}", fmt.ext())).to_string_lossy().into_owned(); write(&path, &bytes_of(&[id])?)?; println!("{path}"); diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs index f08ca90f..ddb876b0 100644 --- a/apps/figcraft-web/src/web.rs +++ b/apps/figcraft-web/src/web.rs @@ -117,7 +117,7 @@ impl Services for WebServices { } fn open_url(&mut self, url: &str) { let Some(window) = web_sys::window() else { return }; - if let Err(e) = window.open_with_url_and_target(url, "_blank") { + if let Err(e) = window.open_with_url_and_target_and_features(url, "_blank", "noopener,noreferrer") { log::error!("couldn't open {url}: {e:?}"); } } diff --git a/apps/figcraft/src/recovery.rs b/apps/figcraft/src/recovery.rs index 06b0cbeb..d8883529 100644 --- a/apps/figcraft/src/recovery.rs +++ b/apps/figcraft/src/recovery.rs @@ -338,7 +338,24 @@ fn stamp(s: &Session, d: &DocState) -> Vec { fn write_atomic(path: &Path, doc: &figcraft_engine::doc::Document, versions: &[Version], branches: &[Branch]) -> bool { let Ok(bytes) = figcraft_format::save_bytes_with_history(doc, None, versions, branches) else { return false }; let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, bytes).is_ok() && std::fs::rename(&tmp, path).is_ok() + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).create(true).truncate(true).mode(0o600); + let Ok(mut f) = opts.open(&tmp) else { return false }; + if std::io::Write::write_all(&mut f, &bytes).is_err() { + let _ = std::fs::remove_file(&tmp); + return false; + } + } + #[cfg(not(unix))] + { + if std::fs::write(&tmp, bytes).is_err() { + return false; + } + } + std::fs::rename(&tmp, path).is_ok() } /// [`recover_all`], for a start that keeps nothing waiting: how many documents came back as From 0cca56984fcb861117c8aed723486ec399464e01 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 24 Oct 2026 04:39:12 -0400 Subject: [PATCH 22/24] fix(security): bound decoration steps, text-on-path elements, and rename expansions (#1257) --- crates/engine/src/rename.rs | 6 ++++++ crates/text/src/layout.rs | 2 +- crates/text/src/onpath.rs | 9 ++++++++- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/rename.rs b/crates/engine/src/rename.rs index 8d44f5ab..b4952475 100644 --- a/crates/engine/src/rename.rs +++ b/crates/engine/src/rename.rs @@ -81,6 +81,9 @@ impl BatchRename { out.push_str(name.get(last..m.start()).unwrap_or_default()); let (before, after) = (name.get(..m.start()).unwrap_or_default(), name.get(m.end()..).unwrap_or_default()); expand(&self.replace, &Ctx { matched: m.as_str(), before, after, groups: Some(&caps), up, down }, &mut out); + if out.len() >= MAX_NAME * 4 { + break; + } last = m.end(); } out.push_str(name.get(last..).unwrap_or_default()); @@ -110,6 +113,9 @@ impl BatchRename { fn expand(template: &str, c: &Ctx, out: &mut String) { let mut it = template.chars().peekable(); while let Some(ch) = it.next() { + if out.len() >= MAX_NAME * 4 { + break; + } if ch != '$' { out.push(ch); continue; diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index f844b571..54bee7e3 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -100,7 +100,7 @@ pub struct Decoration { } /// Most dots or wave samples drawn for one decoration. -const MAX_DECO_STEPS: usize = 20_000; +const MAX_DECO_STEPS: usize = 2_500; impl Decoration { /// The line's outline: a bar, a row of dots or a wave, `thickness` thick. diff --git a/crates/text/src/onpath.rs b/crates/text/src/onpath.rs index 2b506237..49378504 100644 --- a/crates/text/src/onpath.rs +++ b/crates/text/src/onpath.rs @@ -23,7 +23,14 @@ impl OnPath { /// Text on `tp` whose layout x = 0 sits `shift` before the start point and whose layout y /// `baseline` sits on the path. `None` for an empty or unmeasurable path. pub fn new(tp: &TextPath, shift: f64, baseline: f64) -> Option { - let mut line = polylines(&tp.path())?.into_iter().next()?; + let p = tp.path(); + if p.elements().len() > 20_000 { + return None; + } + let mut line = polylines(&p)?.into_iter().next()?; + if line.points.len() > 40_000 { + return None; + } // A subpath ending where it starts (an ellipse) goes round like a closed one. line.closed |= matches!((line.points.first(), line.points.last()), (Some(a), Some(b)) if (*a - *b).hypot() < 1e-6); Some(Self { origin: tp.start() * line.length() - shift, line, flip: tp.flip, baseline }) From 3739b2863aca9a0a4668ff491f41305246b0f68e Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 24 Oct 2026 04:41:31 -0400 Subject: [PATCH 23/24] fix(security): cap COLR layers, PNG glyph dimensions, and layout cache capacity (#1264) * chore: explicit test imports in engine, part 1 (Sonar S2208) Co-Authored-By: Claude Sonnet 5.5 * fix(security): cap COLR layers, PNG glyph dimensions, and layout cache capacity --------- Co-authored-by: Claude Sonnet 5.5 --- crates/engine/src/design_paste_tests.rs | 2 +- crates/text/src/color.rs | 13 +++++++++++-- crates/text/src/lib.rs | 2 +- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/design_paste_tests.rs b/crates/engine/src/design_paste_tests.rs index 89de80ef..52dc4230 100644 --- a/crates/engine/src/design_paste_tests.rs +++ b/crates/engine/src/design_paste_tests.rs @@ -4,7 +4,7 @@ use base64::Engine as _; use serde_json::json; -use super::*; +use super::{NodeId, Session, Value, params}; use crate::cmd::edit::{CLIP_CLOSE, CLIP_OPEN, design_clipboard_buffer}; const B64: base64::engine::GeneralPurpose = base64::engine::general_purpose::STANDARD; diff --git a/crates/text/src/color.rs b/crates/text/src/color.rs index ca927a94..0eaca197 100644 --- a/crates/text/src/color.rs +++ b/crates/text/src/color.rs @@ -29,8 +29,10 @@ const MAX_RASTER_BYTES: usize = 32 * 1024 * 1024; const MAX_RASTER_ENTRIES: usize = 8192; /// COLR layer lists kept per face (24 bytes a layer, so a count is enough). const MAX_LAYERS: usize = 4096; +/// Most COLR layers read for one glyph. +const MAX_COLR_LAYERS: usize = 256; /// Widest and tallest PNG strike decoded (strikes are at most a few hundred pixels). -const MAX_PNG_SIDE: u32 = 4096; +const MAX_PNG_SIDE: u32 = 1024; /// Largest colour raster per em (device pixels). Past this the renderer scales one up instead. pub const MAX_PX_PER_EM: f64 = 512.0; @@ -171,6 +173,9 @@ impl Face { let colors = palette.as_ref().map(|p| p.colors()).unwrap_or(&[]); let mut out = Vec::new(); for index in range { + if out.len() >= MAX_COLR_LAYERS { + break; + } let (layer, color_index) = colr.v0_layer(index)?; let rgba = colors.get(usize::from(color_index)).map(|c| [c.red(), c.green(), c.blue(), c.alpha()]).unwrap_or([0, 0, 0, 255]); out.push(ColorLayer { gid: layer.to_u32(), rgba }); @@ -230,7 +235,11 @@ fn decode_png(png: &[u8]) -> Option<(Vec, u32, u32)> { if w == 0 || h == 0 || w > MAX_PNG_SIDE || h > MAX_PNG_SIDE { return None; } - let mut buf = vec![0u8; reader.output_buffer_size()?]; + let buf_size = reader.output_buffer_size()?; + if buf_size > 4 * 1024 * 1024 { + return None; + } + let mut buf = vec![0u8; buf_size]; let info = reader.next_frame(&mut buf).ok()?; let (width, height) = (info.width, info.height); let data = buf.get(..info.buffer_size())?; diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 50cd28dd..548a7b66 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -42,7 +42,7 @@ struct LayoutCache { /// Layouts kept before the cache starts over (a page of distinct texts stays cached while it /// renders, pans and zooms). -const LAYOUT_CACHE_MAX: usize = 32_768; +const LAYOUT_CACHE_MAX: usize = 4_096; /// Entries per fingerprint (texts that compare unequal to themselves, e.g. NaN sizes, can't pile up). const BUCKET_MAX: usize = 8; From cb38f68c0c880f1dad554e1e394c0ce406fa4890 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 24 Oct 2026 04:45:01 -0400 Subject: [PATCH 24/24] fix(security): linear docprops reordering and deduplicate restored deletion nodes (#1258) * fix(security): linear docprops reordering and deduplicate restored deletion nodes * style: format if-let condition --- crates/collab/src/docprops.rs | 9 +++------ crates/collab/src/server.rs | 5 ++++- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/collab/src/docprops.rs b/crates/collab/src/docprops.rs index 9b35e05d..e4ff32c2 100644 --- a/crates/collab/src/docprops.rs +++ b/crates/collab/src/docprops.rs @@ -132,18 +132,15 @@ impl List { let keys: Vec = value.read("order")?; let list = (self.get_mut)(doc); let mut slots: Vec> = std::mem::take(list).into_iter().map(Some).collect(); - let mut at: BTreeMap> = BTreeMap::new(); + let mut at: BTreeMap> = BTreeMap::new(); for (i, e) in slots.iter().enumerate() { if let Some(e) = e { - at.entry(e.key()).or_default().push(i); + at.entry(e.key()).or_default().push_back(i); } } for k in keys { let Some(idx) = at.get_mut(&k) else { continue }; - if idx.is_empty() { - continue; - } - let i = idx.remove(0); + let Some(i) = idx.pop_front() else { continue }; if let Some(e) = slots.get_mut(i).and_then(Option::take) { list.push(e); } diff --git a/crates/collab/src/server.rs b/crates/collab/src/server.rs index 38ed3de8..dd654efe 100644 --- a/crates/collab/src/server.rs +++ b/crates/collab/src/server.rs @@ -541,11 +541,14 @@ impl Server { nodes.extend(t.write(self.state.value(&t)?.as_ref())); } } + let mut restored = std::collections::BTreeSet::new(); for id in gone { match doc.node(id) { Some(_) => { for d in doc.subtree(id) { - if let Some(n) = doc.node(d) { + if restored.insert(d) + && let Some(n) = doc.node(d) + { nodes.push(create_change(n, self.state.key(d))?); } }