From f3cd0509cbf6a731ecab4bc77a7a307a242932e6 Mon Sep 17 00:00:00 2001 From: Piettro Castro Date: Wed, 14 Oct 2026 00:50:01 -0500 Subject: [PATCH 01/21] fix(engine): read the documented gradient shorthand on every route (#252) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `{"type":"linearGradient","stops":["#hex",…]}` was only expanded by the MCP tools, so `figcraft-cli run` and the control channel's `engine.execute` refused it ("`fills` must be paints or colours"). The expansion now lives in the engine (`params::normalize_paint`, used by `paint_of`), so node.set, node.create props, fill.add/set, strokes and paint styles all take it; MCP calls the engine's copy instead of its own. Tests: engine (every paint command, MCP-equal defaults, junk still refused), control channel `engine.execute`, and `figcraft-cli run` end to end. Co-Authored-By: Claude Opus 5.5 --- apps/figcraft-cli/tests/cli.rs | 21 ++++ crates/engine/src/cmd/props.rs | 2 +- crates/engine/src/gradient_shorthand_tests.rs | 107 ++++++++++++++++++ crates/engine/src/lib.rs | 2 + crates/engine/src/params.rs | 74 +++++++++++- crates/mcp/src/tools.rs | 68 +---------- .../ui-egui/src/gradient_shorthand_tests.rs | 21 ++++ crates/ui-egui/src/lib.rs | 2 + docs/control-protocol.md | 4 +- docs/mcp.md | 8 +- 10 files changed, 238 insertions(+), 71 deletions(-) create mode 100644 crates/engine/src/gradient_shorthand_tests.rs create mode 100644 crates/ui-egui/src/gradient_shorthand_tests.rs diff --git a/apps/figcraft-cli/tests/cli.rs b/apps/figcraft-cli/tests/cli.rs index 390e6414..9dd52203 100644 --- a/apps/figcraft-cli/tests/cli.rs +++ b/apps/figcraft-cli/tests/cli.rs @@ -78,6 +78,27 @@ fn run_export_info_convert() { std::fs::remove_dir_all(&d).ok(); } +#[test] +fn run_takes_the_documented_gradient_shorthand() { + // #252: only MCP used to accept it; `run` said "`fills` must be paints or colours". + let d = tmp("gradient"); + let script = d.join("s.json"); + std::fs::write( + &script, + r##"[{"command":"shape.rectangle","params":{"x":0,"y":0,"width":200,"height":100}}, + {"command":"node.set","params":{"ids":[2],"fills":[{"type":"linearGradient","stops":["#ff0000","#0000ff"],"from":[0,0.5],"to":[1,0.5]}]}}]"##, + ) + .unwrap(); + let file = d.join("g.figcraft"); + let out = bin().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap(); + assert!(out.status.success(), "{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr)); + let json = d.join("g.json"); + assert!(bin().arg("convert").arg(&file).arg(&json).status().unwrap().success()); + let doc = std::fs::read_to_string(&json).unwrap(); + assert!(doc.contains("\"kind\":\"linear\"") || doc.contains("\"kind\": \"linear\""), "{doc}"); + std::fs::remove_dir_all(&d).ok(); +} + #[test] fn mcp_over_stdio() { let mut child = bin().arg("mcp").stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap(); diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 257d1a12..dd2daa82 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -13,7 +13,7 @@ pub const STROKE_SIDES: [&str; 4] = ["strokeTopWeight", "strokeRightWeight", "st /// Keys `node.set` understands (documented for agents). pub const KEYS: &str = "name, visible, locked, opacity (0-1), blend, x, y (relative to the parent frame; groups don't count), width, height, rotation (degrees), \ -fills (paints or colours), fill (colour), strokes, stroke (colour), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \ +fills (paints or colours; gradients as {type: linearGradient|radialGradient|angularGradient|diamondGradient, stops: [\"#hex\", ...], from?, to?} too), fill (colour), strokes, stroke (colour), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \ strokeCap / startCap / endCap, strokeJoin, miterAngle (degrees: sharper joins bevel; or miterLimit as a ratio), dashes [..], widthProfile (preset uniform|wedge|taper|eye|rounded|pinch, [[t, scale], ...] or null), brush (preset id, {preset, spacing, sizeJitter, rotationJitter, scatter, seed} or null), cornerRadius (n or [tl,tr,br,bl]), cornerSmoothing (0-1), effects, \ clip, isMask, layout (null|horizontal|vertical|grid|{...}), gap, crossGap, padding (n or [t,r,b,l]), paddingTop/Right/Bottom/Left, \ mainAlign (min|center|max, or the auto gaps spaceBetween|spaceAround|spaceEvenly), crossAlign (min|center|max|baseline: first text baselines, horizontal layouts), contentAlign (wrapped lines: auto = packed where crossAlign says | spaceBetween), wrap (rows of a horizontal layout, columns of a vertical one), strokesIncluded (the frame's inside stroke takes up space), reverseZ (canvas stacking: true = first on top), \ diff --git a/crates/engine/src/gradient_shorthand_tests.rs b/crates/engine/src/gradient_shorthand_tests.rs new file mode 100644 index 00000000..3ea51a89 --- /dev/null +++ b/crates/engine/src/gradient_shorthand_tests.rs @@ -0,0 +1,107 @@ +//! The documented gradient shorthand (`{"type":"linearGradient","stops":["#hex",…]}`) works +//! through every route into the engine, not only MCP (#252): `figcraft-cli run` and the control +//! channel send params straight to `Session::execute`. + +use figcraft_color::Paint; +use figcraft_doc::NodeId; +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::{id_of, paint_of}; + +/// The red→blue left-to-right gradient as written in the docs. +fn short() -> Value { + json!({"type": "linearGradient", "stops": ["#ff0000", "#0000ff"], "from": [0, 0.5], "to": [1, 0.5]}) +} + +/// The same gradient in the engine's own form. +fn long() -> Value { + json!({"type": "gradient", "kind": "linear", + "stops": [{"position": 0, "color": {"r": 1, "g": 0, "b": 0, "a": 1}}, + {"position": 1, "color": {"r": 0, "g": 0, "b": 1, "a": 1}}], + "from": [0, 0.5], "to": [1, 0.5], "width": [0, 1.5]}) +} + +fn rect(s: &mut Session) -> NodeId { + id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap()["id"]).unwrap() +} + +fn fills(s: &Session, id: NodeId) -> Vec { + s.doc().unwrap().node(id).unwrap().fills.clone() +} + +#[test] +fn the_shorthand_reads_as_the_long_form() { + assert_eq!(paint_of(&short()).expect("the documented shorthand is a paint"), paint_of(&long()).unwrap()); +} + +#[test] +fn node_set_takes_the_shorthand() { + let mut s = Session::new(); + let r = rect(&mut s); + s.execute("node.set", &json!({"ids": [r], "fills": [short()]})).unwrap(); + s.execute("node.set", &json!({"ids": [r], "strokes": [short()]})).unwrap(); + let want = paint_of(&long()).unwrap(); + assert_eq!(fills(&s, r), vec![want.clone()]); + assert_eq!(s.doc().unwrap().node(r).unwrap().strokes, vec![want]); +} + +#[test] +fn node_create_fill_add_and_fill_set_take_the_shorthand() { + let want = paint_of(&long()).unwrap(); + let mut s = Session::new(); + let v = s.execute("node.create", &json!({"type": "rectangle", "width": 200, "height": 100, "props": {"fills": [short()]}})).unwrap(); + let r = id_of(&v["id"]).unwrap(); + assert_eq!(fills(&s, r), vec![want.clone()]); + s.execute("fill.add", &json!({"ids": [r], "paint": short()})).unwrap(); + assert_eq!(fills(&s, r), vec![want.clone(), want.clone()]); + let r2 = rect(&mut s); + s.execute("fill.set", &json!({"ids": [r2], "index": 0, "paint": short()})).unwrap(); + assert_eq!(fills(&s, r2), vec![want]); +} + +#[test] +fn shorthand_defaults_match_the_mcp_server() { + // No handles: top to bottom, with a square width handle. + let p = paint_of(&json!({"type": "linearGradient", "stops": ["#000000", "#ffffff"]})).unwrap(); + let v = serde_json::to_value(&p).unwrap(); + assert_eq!( + (v["kind"].clone(), v["from"].clone(), v["to"].clone(), v["width"].clone()), + (json!("linear"), json!([0.5, 0.0]), json!([0.5, 1.0]), json!([-0.5, 0.0])) + ); + // Plain colour stops are spread evenly. + let p = paint_of(&json!({"type": "radialGradient", "stops": ["#000000", "#808080", "#ffffff"]})).unwrap(); + let v = serde_json::to_value(&p).unwrap(); + assert_eq!(v["kind"], "radial"); + let pos: Vec = v["stops"].as_array().unwrap().iter().map(|s| s["position"].as_f64().unwrap()).collect(); + assert_eq!(pos, vec![0.0, 0.5, 1.0]); + for kind in ["angular", "diamond"] { + let p = paint_of(&json!({"type": format!("{kind}Gradient"), "stops": ["#000000", "#ffffff"]})).unwrap(); + assert_eq!(serde_json::to_value(&p).unwrap()["kind"], kind); + } + // A CSS colour inside a solid paint or a positioned stop is read too. + assert_eq!(paint_of(&json!({"type": "solid", "color": "#00ff00"})), paint_of(&json!("#00ff00"))); + let p = paint_of(&json!({"type": "gradient", "stops": [{"position": 0, "color": "#ff0000"}, {"position": 1, "color": "#0000ff"}], "from": [0, 0.5], "to": [1, 0.5]})).unwrap(); + assert_eq!(p, paint_of(&long()).unwrap()); +} + +#[test] +fn junk_is_still_refused() { + let mut s = Session::new(); + let r = rect(&mut s); + for junk in [ + json!({"type": "linearGradient", "stops": "red"}), + json!({"type": "linearGradient", "stops": [42]}), + json!({"type": "fooGradient", "stops": ["#ff0000"]}), + json!({"type": "linearGradient", "stops": ["#ff0000"], "from": "left"}), + ] { + let e = s.execute("node.set", &json!({"ids": [r], "fills": [junk.clone()]})).unwrap_err().to_string(); + assert!(e.contains("`fills` must be paints or colours"), "{junk}: {e}"); + } + // Deep nesting doesn't overflow the stack. + let mut deep = json!("#ff0000"); + for _ in 0..200 { + deep = json!({"type": "linearGradient", "stops": [{"position": 0, "color": deep}]}); + } + assert!(paint_of(&deep).is_none()); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3bc49a90..ac0ef938 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1467,6 +1467,8 @@ mod fx_behaviour_tests; #[cfg(test)] mod gesture_preview_tests; #[cfg(test)] +mod gradient_shorthand_tests; +#[cfg(test)] mod hide_publishing_tests; #[cfg(test)] mod history_behaviour_tests; diff --git a/crates/engine/src/params.rs b/crates/engine/src/params.rs index 4eca1444..bbaa42a4 100644 --- a/crates/engine/src/params.rs +++ b/crates/engine/src/params.rs @@ -3,7 +3,7 @@ use figcraft_color::{BlendMode, Color, Paint}; use figcraft_doc::NodeId; -use serde_json::Value; +use serde_json::{Value, json}; use crate::{EngineError, Result, Session, bad}; @@ -135,12 +135,80 @@ pub fn color(p: &Value, key: &str) -> Option { get(p, key).and_then(color_of) } -/// A paint from JSON: a colour string/object (solid) or a full paint object. +/// Read the friendlier paint/effect JSON that the docs and MCP tools describe as the engine's own +/// form: any `"color"` given as a CSS string ("#RRGGBB[AA]", "rgb(…)") becomes {r,g,b,a}, +/// `{"type":"linearGradient"}` becomes `{"type":"gradient","kind":"linear"}`, gradients get default +/// handles (top to bottom) when `from`/`to`/`width` are missing, and stops given as plain colours +/// are spread evenly. Anything else is left for serde to refuse. +pub fn normalize_paint(v: &mut Value) { + normalize_paintish(v, 0); +} + +fn normalize_paintish(v: &mut Value, depth: usize) { + if depth > 16 { + return; + } + match v { + Value::Array(a) => { + for x in a { + normalize_paintish(x, depth + 1); + } + } + Value::Object(o) => { + if let Some(c) = o.get("color").filter(|c| c.is_string()).and_then(color_of) { + o.insert("color".into(), serde_json::to_value(c).unwrap_or(Value::Null)); + } + let is_gradient = o.get("type").and_then(Value::as_str).is_some_and(|t| t.to_ascii_lowercase().contains("gradient")); + if is_gradient { + // Accept {"type":"linearGradient"} as well as {"type":"gradient","kind":"linear"}. + if let Some(t) = o.get("type").and_then(Value::as_str).map(str::to_ascii_lowercase) + && t != "gradient" + { + let kind = t.trim_end_matches("gradient").to_string(); + o.insert("type".into(), json!("gradient")); + o.entry("kind").or_insert(json!(if kind.is_empty() { "linear".to_string() } else { kind })); + } + o.entry("kind").or_insert(json!("linear")); + let linear = o.get("kind").and_then(Value::as_str) == Some("linear"); + let (from, to, width) = if linear { ([0.5, 0.0], [0.5, 1.0], [-0.5, 0.0]) } else { ([0.5, 0.5], [0.5, 1.0], [1.0, 0.5]) }; + o.entry("from").or_insert(json!(from)); + o.entry("to").or_insert(json!(to)); + // A linear gradient's width handle goes square to its axis (else its bands skew, + // or vanish when the handle lies on the axis). + let pt = |k: &str| o.get(k).and_then(Value::as_array).and_then(|a| Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?))); + if linear + && !o.contains_key("width") + && let (Some(f), Some(t)) = (pt("from"), pt("to")) + { + o.insert("width".into(), json!([f.0 - (t.1 - f.1), f.1 + (t.0 - f.0)])); + } + o.entry("width").or_insert(json!(width)); + if let Some(Value::Array(stops)) = o.get_mut("stops") { + let n = stops.len(); + for (i, s) in stops.iter_mut().enumerate() { + if let Some(c) = color_of(s).filter(|_| s.is_string()) { + let pos = if n > 1 { i as f64 / (n - 1) as f64 } else { 0.0 }; + *s = json!({"position": pos, "color": c}); + } else { + normalize_paintish(s, depth + 1); + } + } + } + } + } + _ => {} + } +} + +/// A paint from JSON: a colour string/object (solid), a full paint object or its shorthand +/// ([`normalize_paint`]). pub fn paint_of(v: &Value) -> Option { if let Some(c) = color_of(v) { return Some(Paint::solid(c)); } - let mut p: Paint = serde_json::from_value(v.clone()).ok()?; + let mut v = v.clone(); + normalize_paint(&mut v); + let mut p: Paint = serde_json::from_value(v).ok()?; p.opacity = if p.opacity.is_finite() { p.opacity.clamp(0.0, 1.0) } else { 1.0 }; Some(p) } diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index b8d3f3ad..9f4d5b8f 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -3,6 +3,7 @@ //! behave the same. use base64::Engine as _; +use figcraft_engine::params::normalize_paint; use serde_json::{Map, Value, json}; use crate::backend::{Backend, decode_data, page_children}; @@ -369,74 +370,17 @@ fn bounds_of(b: &mut dyn Backend, ids: &[u64]) -> Option<(f64, f64, f64, f64)> { acc } -/// Make paint/effect JSON friendlier than the engine's strict serde: any `"color"` given as a CSS -/// string ("#RRGGBB[AA]", "rgb(…)") becomes the engine's {r,g,b,a} object, gradient paints get -/// default handles (top-to-bottom) when `from`/`to`/`width` are missing, and gradient stops given -/// as plain colours are spread evenly. +/// Make paint/effect JSON friendlier than the engine's strict serde (see +/// [`figcraft_engine::params::normalize_paint`]). The engine reads paints this way itself; effects +/// still need it here. pub fn normalize_props(props: &mut Map) { for k in ["fills", "strokes", "effects"] { if let Some(v) = props.get_mut(k) { - normalize_paintish(v, 0); + normalize_paint(v); } } } -fn normalize_paintish(v: &mut Value, depth: usize) { - if depth > 16 { - return; - } - match v { - Value::Array(a) => { - for x in a { - normalize_paintish(x, depth + 1); - } - } - Value::Object(o) => { - if let Some(c) = o.get("color").filter(|c| c.is_string()).and_then(figcraft_engine::params::color_of) { - o.insert("color".into(), serde_json::to_value(c).unwrap_or(Value::Null)); - } - let is_gradient = o.get("type").and_then(Value::as_str).is_some_and(|t| t.to_ascii_lowercase().contains("gradient")); - if is_gradient { - // Accept {"type":"linearGradient"} as well as {"type":"gradient","kind":"linear"}. - if let Some(t) = o.get("type").and_then(Value::as_str).map(str::to_ascii_lowercase) - && t != "gradient" - { - let kind = t.trim_end_matches("gradient").to_string(); - o.insert("type".into(), json!("gradient")); - o.entry("kind").or_insert(json!(if kind.is_empty() { "linear".to_string() } else { kind })); - } - o.entry("kind").or_insert(json!("linear")); - let linear = o.get("kind").and_then(Value::as_str) == Some("linear"); - let (from, to, width) = if linear { ([0.5, 0.0], [0.5, 1.0], [-0.5, 0.0]) } else { ([0.5, 0.5], [0.5, 1.0], [1.0, 0.5]) }; - o.entry("from").or_insert(json!(from)); - o.entry("to").or_insert(json!(to)); - // A linear gradient's width handle goes square to its axis (else its bands skew, - // or vanish when the handle lies on the axis). - let pt = |k: &str| o.get(k).and_then(Value::as_array).and_then(|a| Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?))); - if linear - && !o.contains_key("width") - && let (Some(f), Some(t)) = (pt("from"), pt("to")) - { - o.insert("width".into(), json!([f.0 - (t.1 - f.1), f.1 + (t.0 - f.0)])); - } - o.entry("width").or_insert(json!(width)); - if let Some(Value::Array(stops)) = o.get_mut("stops") { - let n = stops.len(); - for (i, s) in stops.iter_mut().enumerate() { - if let Some(c) = figcraft_engine::params::color_of(s).filter(|_| s.is_string()) { - let pos = if n > 1 { i as f64 / (n - 1) as f64 } else { 0.0 }; - *s = json!({"position": pos, "color": c}); - } else { - normalize_paintish(s, depth + 1); - } - } - } - } - } - _ => {} - } -} - /// [`normalize_props`] for command params: `props`, flat node.set keys, `paint`, `effect`. fn friendly_params(p: &mut Value) { let Some(o) = p.as_object_mut() else { return }; @@ -446,7 +390,7 @@ fn friendly_params(p: &mut Value) { normalize_props(o); for k in ["paint", "effect"] { if let Some(v) = o.get_mut(k) { - normalize_paintish(v, 0); + normalize_paint(v); } } } diff --git a/crates/ui-egui/src/gradient_shorthand_tests.rs b/crates/ui-egui/src/gradient_shorthand_tests.rs new file mode 100644 index 00000000..db368f8e --- /dev/null +++ b/crates/ui-egui/src/gradient_shorthand_tests.rs @@ -0,0 +1,21 @@ +//! The control channel takes the documented gradient shorthand, as MCP does (#252). + +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; + +#[test] +fn engine_execute_takes_the_gradient_shorthand() { + let mut u = Ui::new(Session::new()); + let exec = |u: &mut Ui, command: &str, params: serde_json::Value| { + crate::control::handle(&mut u.app, &u.ctx.clone(), "engine.execute", &json!({"command": command, "params": params})) + }; + let r = exec(&mut u, "shape.rectangle", json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap()["id"].clone(); + let short = json!({"type": "linearGradient", "stops": ["#ff0000", "#0000ff"], "from": [0, 0.5], "to": [1, 0.5]}); + exec(&mut u, "node.set", json!({"ids": [r], "fills": [short]})).unwrap(); + let n = exec(&mut u, "node.get", json!({"id": r, "depth": 0})).unwrap(); + let f = &n["fills"][0]; + assert_eq!((f["type"].clone(), f["kind"].clone(), f["width"].clone()), (json!("gradient"), json!("linear"), json!([0.0, 1.5])), "{n}"); + assert_eq!(f["stops"].as_array().map(Vec::len), Some(2), "{n}"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 613f18f9..85fa092d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -84,6 +84,8 @@ mod frame_label_tests; #[cfg(test)] mod gap_handle_tests; #[cfg(test)] +mod gradient_shorthand_tests; +#[cfg(test)] mod home_recents_tests; #[cfg(test)] mod home_tests; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 9fc9937d..3a4d10ae 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -51,7 +51,9 @@ unknown id is an error. Run any command (the same ids as the menus, shortcuts, Actions palette, CLI and MCP). Programmatic calls never open dialogs: dialog requests are dropped. Returns the command's result (creation -commands return `{"id": N}`). +commands return `{"id": N}`). Paints (`fills`, `strokes`, `paint`) take a colour, the full paint +JSON or the gradient shorthand `{"type":"linearGradient","stops":["#hex","#hex"],"from"?,"to"?}` +(as in `docs/mcp.md`). ```json {"id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":10,"y":10,"width":100,"height":50}}} diff --git a/docs/mcp.md b/docs/mcp.md index 2b66657c..11f9c056 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -128,10 +128,10 @@ Or in a project's `.mcp.json`: resolved `gridCell`. - **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, strokes and effects passed through the MCP - tools, `color` may also be a hex string and gradients can be written - `{"type": "linearGradient", "stops": ["#7048E8", "#1098AD"]}`; the server converts them to the - engine's paint JSON. +- Colours are `"#RRGGBB"` or `"#RRGGBBAA"`. Inside fills and strokes, `color` may also be a hex + string and gradients can be written `{"type": "linearGradient", "stops": ["#7048E8", "#1098AD"]}`; + the engine reads them as its paint JSON, so `run_command`, `figcraft-cli run` and the control + channel take them too. Effects passed through the MCP tools may use hex `color` strings as well. - **Blend modes**: `blend` on a layer (`node.set`) or a paint (`fill.set` / `stroke.set`) is a menu label or CSS keyword (`"Normal"`, `"pass through"`, `"multiply"`, `"plusDarker"`, ...; `normal` is Normal). Groups and frames start as pass-through: their children blend with what is From 10dc38495f0ab020c3852e6f864de1cdb15be931 Mon Sep 17 00:00:00 2001 From: notdezzi Date: Wed, 14 Oct 2026 12:54:02 +0200 Subject: [PATCH 02/21] fix(engine): roll back failed commands during interactions too A command that fails part-way (node.set applies geometry keys before values like visible, which can fail validation) mutated the document even when an interaction was under way, because the rollback in Session::execute was gated on interaction.is_none(); drop the gate so the command-entry snapshot is restored during interactions as well, which is the interaction's last valid preview and leaves the gesture itself intact. --- .../engine/src/interaction_rollback_tests.rs | 67 +++++++++++++++++++ crates/engine/src/lib.rs | 7 +- 2 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 crates/engine/src/interaction_rollback_tests.rs diff --git a/crates/engine/src/interaction_rollback_tests.rs b/crates/engine/src/interaction_rollback_tests.rs new file mode 100644 index 00000000..3a738017 --- /dev/null +++ b/crates/engine/src/interaction_rollback_tests.rs @@ -0,0 +1,67 @@ +//! A command that fails part-way (a property that applies, then one that doesn't) leaves nothing +//! behind — also during an interaction, whose last valid preview and own snapshot survive it. + +use serde_json::json; + +use crate::Session; +use crate::params::id_of; + +fn rect(s: &mut Session) -> crate::NodeId { + id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 80, "height": 60})).unwrap()["id"]).unwrap() +} + +/// `width` applies (geometry keys go first), then `visible` fails validation. +fn partially_applicable(s: &mut Session, id: crate::NodeId) -> crate::Result { + s.execute("node.set", &json!({"id": id, "width": 123, "visible": "yes"})) +} + +#[test] +fn a_failed_command_during_an_interaction_keeps_the_prior_preview() { + let mut s = Session::new(); + let r = rect(&mut s); + s.begin_interaction("Move").unwrap(); + let props = serde_json::Map::from_iter([("x".to_string(), json!(20.0))]); + s.preview(|d, _| crate::cmd::props::apply("node.set", d, r, &props)).unwrap(); + let (previewed, selection, page) = { + let ds = s.ds().unwrap(); + (ds.doc.clone(), ds.selection.clone(), ds.page) + }; + let before_journal = s.journal.clone(); + let before_history = s.ds().unwrap().history.undo.len(); + + assert!(partially_applicable(&mut s, r).is_err()); + + // The rejected edit is gone: the command-entry state (the valid preview) is back. + let ds = s.ds().unwrap(); + assert_eq!(*ds.doc, *previewed, "the document is what the preview showed"); + assert_eq!(ds.doc.node(r).unwrap().width, 80.0); + assert_eq!(ds.selection, selection); + assert_eq!(ds.page, page); + assert_eq!(ds.history.undo.len(), before_history); + assert_eq!(s.journal, before_journal); + + // The interaction is alive and commits the preview, not the rejected mutation. + s.commit_interaction().unwrap(); + let n = s.doc().unwrap().node(r).unwrap(); + assert_eq!((n.width, n.visible), (80.0, true)); + assert_eq!(n.transform.as_coeffs()[4], 20.0, "the previewed move is committed"); + assert_eq!(s.undo_depth(), (before_history + 1, 0)); + assert_eq!(s.undo_labels(1), vec!["Move".to_string()]); + s.undo().unwrap(); + assert_eq!(s.doc().unwrap().node(r).unwrap().transform.as_coeffs()[4], 0.0); +} + +#[test] +fn a_failed_command_without_an_interaction_still_rolls_back() { + let mut s = Session::new(); + let r = rect(&mut s); + let before_journal = s.journal.clone(); + let before_history = s.ds().unwrap().history.undo.len(); + + assert!(partially_applicable(&mut s, r).is_err()); + + let n = s.doc().unwrap().node(r).unwrap(); + assert_eq!((n.width, n.visible), (80.0, true)); + assert_eq!(s.ds().unwrap().history.undo.len(), before_history); + assert_eq!(s.journal, before_journal); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3bc49a90..c3ff71ca 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -837,10 +837,11 @@ impl Session { } } Err(_) => { - // Roll back any partial edit. + // Roll back any partial edit — also during an interaction, where `before` is the + // interaction's last valid preview: restore it and leave the gesture itself (its + // own snapshot) alone. if let Some((doc, sel, page, uid)) = before && let Some(ds) = self.docs.iter_mut().find(|d| d.uid == uid) - && ds.interaction.is_none() { ds.doc = doc; ds.selection = sel; @@ -1477,6 +1478,8 @@ mod import_export_tests; #[cfg(test)] mod inspect_behaviour_tests; #[cfg(test)] +mod interaction_rollback_tests; +#[cfg(test)] mod invite_links_tests; #[cfg(test)] mod keymap_tests; From 661144021e2896587b5f9c6a9c740ff8aa6db45f Mon Sep 17 00:00:00 2001 From: notdezzi Date: Wed, 14 Oct 2026 13:11:28 +0200 Subject: [PATCH 03/21] fix(format): exclusive unique staging files for atomic save/export Saving and exporting wrote a truncating std::fs::write to a deterministic staging name (.tmp-figcraft, .figcraft.tmp) before renaming, so an unrelated file or a planted symlink at that name was clobbered or written through, and separate writers of one destination shared a staging file they could corrupt. Both paths now go through figcraft_format::write_file_atomic: a uniquely named sibling staging file is created exclusively (create_new) and written through its own handle, renamed over the destination, and removed again on any failure, so only the staging file a write created is ever touched. Tests cover pre-existing staging files and symlinks, concurrent writers, and injected rename and permission failures. --- crates/engine/src/cmd/file.rs | 134 +++++++++++++++++++++++++++-- crates/format/src/lib.rs | 51 +++++++++-- crates/format/src/staging_tests.rs | 125 +++++++++++++++++++++++++++ 3 files changed, 298 insertions(+), 12 deletions(-) create mode 100644 crates/format/src/staging_tests.rs diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 781ed6b0..922729f0 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -437,15 +437,12 @@ pub(crate) fn read_capped(path: &str, max: u64) -> Result> { read(path) } +/// Write `bytes` to `path` through a uniquely named, exclusively created staging file and one +/// atomic rename (see [`figcraft_format::write_file_atomic`]): a failed or interrupted write never +/// truncates `path`, and no file or symlink at a staging name is ever written through. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn write(path: &str, bytes: &[u8]) -> Result<()> { - let tmp = format!("{path}.tmp-figcraft"); - std::fs::write(&tmp, bytes).map_err(|e| EngineError::Other(format!("{tmp}: {e}")))?; - std::fs::rename(&tmp, path).map_err(|e| { - // Leave nothing behind when the file can't take its place. - let _ = std::fs::remove_file(&tmp); - EngineError::Other(format!("{path}: {e}")) - }) + figcraft_format::write_file_atomic(std::path::Path::new(path), bytes).map_err(|e| EngineError::Other(format!("{path}: {e}"))) } #[cfg(target_arch = "wasm32")] @@ -1327,3 +1324,126 @@ mod rest_import_tests { let _ = std::fs::remove_dir_all(&dir); } } + +#[cfg(test)] +mod staging_write_tests { + use super::*; + + fn dir(name: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("figcraft-engine-staging-{name}-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + d + } + + /// A symlink `from` → `to`; false when the platform won't make one (Windows needs privileges). + #[cfg(unix)] + fn symlink(from: &std::path::Path, to: &std::path::Path) -> bool { + std::os::unix::fs::symlink(to, from).is_ok() + } + + #[cfg(windows)] + fn symlink(from: &std::path::Path, to: &std::path::Path) -> bool { + std::os::windows::fs::symlink_file(to, from).is_ok() + } + + #[test] + fn an_existing_staging_file_is_never_written_through() { + let d = dir("existing"); + let path = d.join("shot.svg"); + // The old deterministic staging name, held by an unrelated file: the write must pick its + // own staging name instead of truncating this one. + let old = d.join("shot.svg.tmp-figcraft"); + std::fs::write(&old, b"not yours").unwrap(); + write(&path.to_string_lossy(), b"").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b""); + assert_eq!(std::fs::read(&old).unwrap(), b"not yours", "an unrelated file at a staging name is untouched"); + // Just the file and the untouched staging file: no new staging leftovers. + assert_eq!(std::fs::read_dir(&d).unwrap().count(), 2); + let _ = std::fs::remove_dir_all(&d); + } + + #[test] + fn a_staging_symlink_is_never_followed() { + let d = dir("symlink"); + let path = d.join("shot.svg"); + let victim = d.join("victim.txt"); + std::fs::write(&victim, b"innocent").unwrap(); + let old = d.join("shot.svg.tmp-figcraft"); + if !symlink(&old, &victim) { + return; // Making a symlink needs privileges on some Windows setups. + } + write(&path.to_string_lossy(), b"").unwrap(); + assert_eq!(std::fs::read(&victim).unwrap(), b"innocent", "the symlink was never written through"); + assert_eq!(std::fs::read(&path).unwrap(), b""); + let _ = std::fs::remove_dir_all(&d); + } + + #[test] + fn concurrent_writers_of_one_file_never_interleave() { + let d = dir("concurrent"); + let path = d.join("shot.svg"); + for round in 0..5u8 { + let mut handles = Vec::new(); + for w in 0u8..4 { + let (path, bytes) = (path.clone(), vec![b'0' + w; 8192]); + handles.push(std::thread::spawn(move || write(&path.to_string_lossy(), &bytes))); + } + let mut wins = 0; + for h in handles { + // On Windows a rename over a file another writer just replaced can lose the race. + if h.join().unwrap().is_ok() { + wins += 1; + } + } + assert!(wins > 0, "round {round}: every writer lost"); + // Whatever landed is one writer's complete payload, never a mix. + let got = std::fs::read(&path).unwrap(); + assert_eq!(got.len(), 8192, "round {round}: a complete payload landed"); + assert!(got.windows(2).all(|w| w[0] == w[1]), "round {round}: one writer's payload, not a mix"); + } + // No staging file was left stuck (a failed writer removes its own). + let left = std::fs::read_dir(&d).unwrap().count(); + assert!(left <= if cfg!(windows) { 5 } else { 1 }, "{left} files left in the staging directory"); + let _ = std::fs::remove_dir_all(&d); + } + + #[test] + fn a_failed_write_leaves_the_destination_alone_and_cleans_up() { + let d = dir("failure"); + // The destination is a directory: the rename fails, our staging file is removed and the + // directory is untouched. + let taken = d.join("over-a-directory"); + std::fs::create_dir_all(&taken).unwrap(); + assert!(write(&taken.to_string_lossy(), b"payload").is_err()); + assert!(taken.is_dir()); + assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1, "no staging file left behind"); + // A missing parent directory fails without creating anything anywhere. + assert!(write(&d.join("no").join("such.svg").to_string_lossy(), b"payload").is_err()); + assert!(!d.join("no").exists()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + // A read-only directory refuses the staging file, and the old destination stays. + let ro = d.join("read-only"); + std::fs::create_dir_all(&ro).unwrap(); + let dest = ro.join("keep.svg"); + std::fs::write(&dest, b"keep").unwrap(); + let mut perms = std::fs::metadata(&ro).unwrap().permissions(); + perms.set_mode(0o500); + std::fs::set_permissions(&ro, perms).unwrap(); + // Running as root would defeat the read-only directory: skip instead of failing. + let probe = ro.join(".probe"); + if std::fs::write(&probe, b"").is_ok() { + let _ = std::fs::remove_file(&probe); + } else { + assert!(write(&dest.to_string_lossy(), b"payload").is_err()); + assert_eq!(std::fs::read(&dest).unwrap(), b"keep"); + assert_eq!(std::fs::read_dir(&ro).unwrap().count(), 1, "no staging file left behind"); + } + let mut perms = std::fs::metadata(&ro).unwrap().permissions(); + perms.set_mode(0o700); + std::fs::set_permissions(&ro, perms).unwrap(); + } + let _ = std::fs::remove_dir_all(&d); + } +} diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index a989f3f6..56798d14 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -654,14 +654,53 @@ fn read_branches(z: &mut zip::ZipArchive, images: &BTreeMap) -> Result<(), FormatError> { let bytes = save_bytes(doc, thumbnail_png)?; - let tmp = path.with_extension("figcraft.tmp"); - std::fs::write(&tmp, &bytes).map_err(|e| FormatError::Io(format!("{}: {e}", tmp.display())))?; - std::fs::rename(&tmp, path).map_err(|e| FormatError::Io(format!("{}: {e}", path.display())))?; - Ok(()) + write_file_atomic(path, &bytes).map_err(|e| FormatError::Io(format!("{}: {e}", path.display()))) +} + +/// Write `bytes` to `path` through a staging file and one atomic rename, so a reader of `path` +/// sees either the previous file or the complete new one, never a partial write. The staging file +/// is a uniquely named sibling (`.tmp-figcraft--`) created exclusively +/// (`create_new`) and written through its own handle: a file or symlink that already exists at a +/// staging name is never truncated or followed, and separate writers (also of separate processes) +/// never share a staging file. On failure only the staging file this call created is removed, and +/// `path` is replaced only on success. The rename replaces an existing destination on every +/// platform (`std::fs::rename` uses `MoveFileEx` with `MOVEFILE_REPLACE_EXISTING` on Windows). +/// (No `sync_all` yet: nothing around here promises durability across a power cut.) +#[cfg(not(target_arch = "wasm32"))] +pub fn write_file_atomic(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> { + use std::sync::atomic::{AtomicU64, Ordering::Relaxed}; + + static STAGING: AtomicU64 = AtomicU64::new(0); + // Three tries at a fresh name: the counter is unique within this process, so only a leftover + // of a dead process that now shares our process id can collide, and the next counter won't. + for _ in 0..3 { + let mut name = path.as_os_str().to_os_string(); + name.push(format!(".tmp-figcraft-{}-{}", std::process::id(), STAGING.fetch_add(1, Relaxed))); + let tmp = std::path::PathBuf::from(name); + match std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp) { + Ok(mut file) => { + if let Err(e) = file.write_all(bytes) { + let _ = std::fs::remove_file(&tmp); + return Err(e); + } + return match std::fs::rename(&tmp, path) { + Ok(()) => Ok(()), + // Leave nothing behind when the file can't take its place. + Err(e) => { + let _ = std::fs::remove_file(&tmp); + Err(e) + } + }; + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e), + } + } + Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "no free staging name next to the file")) } #[cfg(not(target_arch = "wasm32"))] @@ -677,6 +716,8 @@ mod image_tests; #[cfg(test)] mod large_tests; #[cfg(test)] +mod staging_tests; +#[cfg(test)] mod thumbnail_tests; #[cfg(test)] mod version_tests; diff --git a/crates/format/src/staging_tests.rs b/crates/format/src/staging_tests.rs new file mode 100644 index 00000000..1bc199e2 --- /dev/null +++ b/crates/format/src/staging_tests.rs @@ -0,0 +1,125 @@ +//! The staging write behind `save_file`: a pre-existing file or symlink at a staging name is never +//! truncated or followed, concurrent savers of one file never corrupt it, and a failed write +//! cleans up after itself and leaves the destination alone. + +use figcraft_doc::Document; + +use crate::{save_file, write_file_atomic}; + +fn dir(name: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("figcraft-staging-tests-{name}-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + d +} + +/// A symlink `from` → `to`; false when the platform won't make one (Windows needs privileges). +#[cfg(unix)] +fn symlink(from: &std::path::Path, to: &std::path::Path) -> bool { + std::os::unix::fs::symlink(to, from).is_ok() +} + +#[cfg(windows)] +fn symlink(from: &std::path::Path, to: &std::path::Path) -> bool { + std::os::windows::fs::symlink_file(to, from).is_ok() +} + +#[test] +fn an_existing_staging_file_is_never_written_through() { + let d = dir("existing"); + let path = d.join("a.figcraft"); + // The old deterministic staging name, held by an unrelated file: saving must pick its own + // staging name instead of truncating this one. + let old = d.join("a.figcraft.tmp"); + std::fs::write(&old, b"not yours").unwrap(); + save_file(&Document::new("Mine"), &path, None).unwrap(); + assert_eq!(std::fs::read(&old).unwrap(), b"not yours", "an unrelated file at a staging name is untouched"); + assert_eq!(crate::load_file(&path).unwrap().0.name, "Mine"); + // Just the file and the untouched staging file: no new staging leftovers. + assert_eq!(std::fs::read_dir(&d).unwrap().count(), 2); + let _ = std::fs::remove_dir_all(&d); +} + +#[test] +fn a_staging_symlink_is_never_followed() { + let d = dir("symlink"); + let path = d.join("a.figcraft"); + let victim = d.join("victim.txt"); + std::fs::write(&victim, b"innocent").unwrap(); + let old = d.join("a.figcraft.tmp"); + if !symlink(&old, &victim) { + return; // Making a symlink needs privileges on some Windows setups. + } + save_file(&Document::new("Mine"), &path, None).unwrap(); + assert_eq!(std::fs::read(&victim).unwrap(), b"innocent", "the symlink was never written through"); + assert_eq!(crate::load_file(&path).unwrap().0.name, "Mine"); + let _ = std::fs::remove_dir_all(&d); +} + +#[test] +fn concurrent_savers_of_one_file_never_interleave() { + let d = dir("concurrent"); + let path = d.join("a.figcraft"); + let names = ["W0", "W1", "W2", "W3"]; + for round in 0..5 { + let mut handles = Vec::new(); + for name in names { + let (path, doc) = (path.clone(), Document::new(name)); + handles.push(std::thread::spawn(move || save_file(&doc, &path, None))); + } + for h in handles { + // On Windows a rename over a file another writer just replaced can lose the race. + if cfg!(windows) { + let _ = h.join().unwrap(); + } else { + h.join().unwrap().unwrap(); + } + } + // Whatever landed is one writer's complete file, never a mix: a torn archive wouldn't load. + let (doc, _) = crate::load_file(&path).unwrap(); + assert!(names.contains(&doc.name.as_str()), "round {round}: {:?}", doc.name); + } + // No staging file was left stuck. + assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1); + let _ = std::fs::remove_dir_all(&d); +} + +#[test] +fn a_failed_write_leaves_the_destination_alone_and_cleans_up() { + let d = dir("failure"); + // The destination is a directory: the rename fails, our staging file is removed, the + // directory is untouched. + let taken = d.join("folder.figcraft"); + std::fs::create_dir_all(&taken).unwrap(); + assert!(save_file(&Document::new("Mine"), &taken, None).is_err()); + assert!(write_file_atomic(&taken, b"payload").is_err()); + assert!(taken.is_dir()); + assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1, "no staging file left behind"); + // A missing parent directory fails without creating anything anywhere. + assert!(write_file_atomic(&d.join("no").join("such.figcraft"), b"payload").is_err()); + assert!(!d.join("no").exists()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + // A read-only directory refuses the staging file, and the old destination stays. + let ro = d.join("read-only"); + std::fs::create_dir_all(&ro).unwrap(); + let dest = ro.join("keep.figcraft"); + std::fs::write(&dest, b"keep").unwrap(); + let mut perms = std::fs::metadata(&ro).unwrap().permissions(); + perms.set_mode(0o500); + std::fs::set_permissions(&ro, perms).unwrap(); + // Running as root would defeat the read-only directory: skip instead of failing. + let probe = ro.join(".probe"); + if std::fs::write(&probe, b"").is_ok() { + let _ = std::fs::remove_file(&probe); + } else { + assert!(write_file_atomic(&dest, b"payload").is_err()); + assert_eq!(std::fs::read(&dest).unwrap(), b"keep"); + assert_eq!(std::fs::read_dir(&ro).unwrap().count(), 1, "no staging file left behind"); + } + let mut perms = std::fs::metadata(&ro).unwrap().permissions(); + perms.set_mode(0o700); + std::fs::set_permissions(&ro, perms).unwrap(); + } + let _ = std::fs::remove_dir_all(&d); +} From d1ac66a803f7b42d414cd44aed282e9a1847ba98 Mon Sep 17 00:00:00 2001 From: haranishi <223786623+haranishi@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:32:20 +0900 Subject: [PATCH 04/21] =?UTF-8?q?M9:=20=E6=97=A5=E6=9C=AC=E8=AA=9E?= =?UTF-8?q?=E3=81=AE=E3=83=86=E3=83=B3=E3=83=97=E3=83=AC=E3=83=BC=E3=83=88?= =?UTF-8?q?=E3=81=A8=E7=B7=A8=E9=9B=86=E6=93=8D=E4=BD=9C=E3=83=A9=E3=83=99?= =?UTF-8?q?=E3=83=AB=E3=82=92=E8=A3=9C=E5=AE=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ROADMAP.md | 15 +++--- crates/ui-egui/locales/ja.toml | 88 ++++++++++++++++++++++++++++++++++ docs/localization-parity.md | 32 +++++++------ 3 files changed, 113 insertions(+), 22 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 46c0fd29..8489fac5 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-14 · **Change:** minor (recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (Japanese draft catalog catches up with 88 missing entries; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) 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 @@ -99,16 +99,16 @@ native speakers' reviews of the six language drafts need people. | Language | Code | Strings | Status | |---|---|---|---| | English | en | 100% | full | -| Simplified Chinese | zh-Hans | 100% | draft (needs native review) | +| Simplified Chinese | zh-Hans | 96% (2,077 / 2,165) | draft (needs native review) | | Spanish | es, es-419 | 100% (es-419: an overlay of 98 entries) | draft (needs native review) | | Hindi | hi | 0% | none | | Arabic | ar | 0% | none | -| French | fr | 100% | draft (needs native review) | -| Portuguese (Brazil) | pt-BR | 100% | draft (needs native review) | +| French | fr | 96% (2,077 / 2,165) | draft (needs native review) | +| Portuguese (Brazil) | pt-BR | 96% (2,077 / 2,165) | draft (needs native review) | | Indonesian | id | 0% | none | -| Japanese | ja | 100% | draft (needs native review) | -| German | de | 100% | draft (needs native review) | -| Korean | ko | 100% | draft (needs native review) | +| Japanese | ja | 100% (2,165 / 2,165) | draft (needs native review) | +| German | de | 96% (2,077 / 2,165) | draft (needs native review) | +| Korean | ko | 96% (2,077 / 2,165) | draft (needs native review) | | Vietnamese | vi | 0% | none | No other languages. Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese @@ -312,6 +312,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Japanese draft catalog catches up with 75 UI strings, 11 command labels and 2 menu wordings for templates, Getting started, recent files and everyday editor controls: 2,165 entries. Native review is still pending; the other drafts retain 2,077 entries (96%). Localization grade and estimates unchanged; detail: [localization-parity](docs/localization-parity.md) | | 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | | 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged | | 2026-10-14 | minor | SVG import budgets (#190): gzipped SVG is unpacked by FigCraft within 64 MiB before parsing (it was unpacked without a limit), so the nesting check sees the real document; SVG documents embedded in `` data URIs share one 32 MiB budget and at most 4 levels, and one past a budget is left out. Linked files stay unread. Numbers unchanged | diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index f7b9cf9f..4b4ed489 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -19,7 +19,10 @@ status = "draft — needs native review" "1…9, 0" = "1…9, 0" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}:PR {prs} 件、コミット {commits} 件、+{added} / −{deleted} 行(Δ {delta})、バイナリアセット +{bin_added} / −{bin_deleted}、{first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "このファイルのコレクションとそのモード・変数:モードごとに値を上書きし、独自の変数を追加できる" +"A responsive product page: hero, features, figures, a quote and a footer" = "レスポンシブな製品ページ:ヒーロー、機能、数値、引用、フッター" "A spring plays until it settles: its mass, stiffness and damping set how long" = "スプリングは静止するまで再生。長さは質量・剛性・減衰で決まる" +"A title, an agenda, a big figure, a chart and a closing slide" = "タイトル、目次、大きな数値、グラフ、締めのスライド" +"A to Z" = "昇順(A→Z)" "About" = "情報" "About FigCraft" = "FigCraft について" "About this server" = "このサーバーについて" @@ -90,7 +93,9 @@ status = "draft — needs native review" "All supported properties" = "サポートされているすべてのプロパティ" "All variables" = "すべての変数" "Alpha" = "アルファ" +"Alphabetical" = "名前順" "Alt+drag" = "Alt+ドラッグ" +"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "グリッドに沿った分析画面:数値、ベクターで描いたグラフ、表" "An invite link that opens FigCraft and joins" = "FigCraft を開いて参加する招待リンク" "Anchor" = "アンカー" "Android" = "Android" @@ -109,6 +114,8 @@ status = "draft — needs native review" "Apply variable" = "変数を適用" "Apply variable to text" = "テキストに変数を適用" "Apply variable to visibility" = "表示に変数を適用" +"Apps" = "アプリ" +"Apr" = "4月" "Arc" = "円弧" "Arc ratio" = "円弧の比率" "Arc start" = "円弧の開始" @@ -120,6 +127,7 @@ status = "draft — needs native review" "As typed" = "入力どおり" "Asking the server…" = "サーバーに問い合わせ中…" "Assets" = "アセット" +"Aug" = "8月" "Auto" = "自動" "Auto (space around)" = "自動(周囲に間隔)" "Auto (space between)" = "自動(間に間隔)" @@ -164,6 +172,7 @@ status = "draft — needs native review" "Bottom" = "下" "Bottom left radius" = "左下の半径" "Bottom right radius" = "右下の半径" +"Bound to {name}. Click to detach the variable." = "「{name}」を適用中。クリックで変数の割り当てを解除。" "Branch" = "ブランチ" "Branch archived" = "ブランチをアーカイブ済み" "Branch merged" = "ブランチをマージ済み" @@ -250,6 +259,7 @@ status = "draft — needs native review" "Color values in this file are in this color space." = "このファイルのカラー値はこのカラースペースで表される。" "Color variables" = "カラー変数" "Colour" = "カラー" +"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "カラー・テキストスタイル、ライト・ダークの変数、ボタン、入力欄、カード" "Column" = "列" "Column gap" = "列の間隔" "Column sizes" = "列のサイズ" @@ -316,6 +326,7 @@ status = "draft — needs native review" "Create property…" = "プロパティを作成…" "Create style" = "スタイルを作成" "Create style from the selected text" = "選択したテキストからスタイルを作成" +"Created" = "作成日" "Crop" = "切り抜き" "Crop marks" = "トンボ" "Ctrl+arrows" = "Ctrl+矢印キー" @@ -329,6 +340,9 @@ status = "draft — needs native review" "Dark" = "ダーク" "Darken" = "比較(暗)" "Dash" = "破線" +"Dashboard" = "ダッシュボード" +"Date created" = "作成日" +"Dec" = "12月" "Decoration style" = "装飾のスタイル" "Decoration" = "装飾" "Deep select" = "深い階層を選択" @@ -355,6 +369,7 @@ status = "draft — needs native review" "Description" = "説明" "Description (optional)" = "説明(任意)" "Design" = "デザイン" +"Design system starter" = "デザインシステムの雛形" "Desktop" = "デスクトップ" "Destination" = "遷移先" "Detach alias" = "エイリアスを解除" @@ -390,6 +405,7 @@ status = "draft — needs native review" "Drafts" = "下書き" "Drag the stroke to add a width point" = "線をドラッグして幅のポイントを追加" "Draw" = "描画" +"Draw a frame" = "フレームを描く" "Drawing tools" = "描画ツール" "Drop shadow" = "ドロップシャドウ" "Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "MIT または Apache-2.0 のデュアルライセンス。ArtCraft チームとコミュニティが制作。" @@ -408,6 +424,7 @@ status = "draft — needs native review" "Edit style" = "スタイルを編集" "Edit variable" = "変数を編集" "Edit with the builder" = "ビルダーで編集" +"Edited {when}" = "最終編集:{when}" "Effect settings" = "エフェクトの設定" "Effect styles" = "エフェクトスタイル" "Effects" = "エフェクト" @@ -438,6 +455,7 @@ status = "draft — needs native review" "Expand" = "展開" "Export" = "書き出し" "Export all" = "すべて書き出し" +"Export frames and layers as PNG, JPG, SVG or PDF" = "フレームやレイヤーを PNG、JPG、SVG、PDF で書き出し" "Export page as PNG" = "ページを PNG で書き出し" "Export settings" = "書き出し設定" "Export variables" = "変数を書き出し" @@ -457,9 +475,11 @@ status = "draft — needs native review" "F" = "F" "Fade" = "フェード" "False" = "false" +"Feb" = "2月" "Fetching from the server…" = "サーバーから取得中…" "File" = "ファイル" "File name" = "ファイル名" +"File not found" = "ファイルが見つからない" "File published" = "ファイルを公開済み" "Files aren't end-to-end encrypted: the server's operator can read them." = "ファイルはエンドツーエンドで暗号化されない:サーバーの運営者は内容を読める。" "Files nobody opens for {n} days are deleted." = { other = "{n} 日間誰も開かなかったファイルは削除される。" } @@ -506,6 +526,7 @@ status = "draft — needs native review" "Font size" = "フォントサイズ" "Font style" = "フォントスタイル" "Forget on this computer" = "このコンピューターで記録を消去" +"Four screens of a trail app with components and a prototype flow" = "コンポーネントとプロトタイプのフローを備えた登山アプリの4画面" "Fractions" = "分数" "Frame" = "フレーム" "Frame selection" = "選択範囲をフレームで囲む" @@ -519,6 +540,7 @@ status = "draft — needs native review" "Gap between columns" = "列の間隔" "Gap between items" = "アイテムの間隔" "Gap between rows" = "行の間隔" +"Getting started" = "使い始めるには" "Give the address of a server open to the public, or a server's address with its key (`figcraft-server key` prints it)." = "一般公開されているサーバーのアドレス、またはキー付きのサーバーのアドレスを入力(`figcraft-server key` で表示)。" "Glass" = "ガラス" "Go to main component" = "メインコンポーネントへ移動" @@ -527,6 +549,7 @@ status = "draft — needs native review" "Gradient" = "グラデーション" "Gray" = "グレー" "Green" = "緑" +"Greyscale components and two example screens to sketch a flow with" = "フローを考えるためのグレースケールのコンポーネントと2つの画面例" "Grid" = "グリッド" "Grid ({size})" = "グリッド({size})" "Grid styles" = "グリッドスタイル" @@ -548,6 +571,8 @@ status = "draft — needs native review" "Hide contents" = "コンテンツを非表示" "Hide from publishing" = "公開対象から除外" "Hide section contents" = "セクションのコンテンツを非表示" +"Hide these tips (Getting started in the Help menu shows them again)" = "ヒントを非表示(ヘルプメニューの「使い始めるには」で再表示)" +"Hide when publishing" = "公開時に非表示" "Highlight layers on hover" = "ホバー時にレイヤーをハイライト" "Highlights" = "ハイライト" "Hold Alt" = "Alt を押したまま" @@ -569,6 +594,8 @@ status = "draft — needs native review" "Hue" = "色相" "Hug" = "ハグ" "Hug contents" = "内容に合わせる" +"Icon components on a 24 px grid with keylines and a size preview" = "24 px グリッドのアイコンコンポーネント、キーライン、サイズのプレビュー" +"Icon set starter" = "アイコンセットの雛形" "If" = "条件" "If {condition}" = "条件:{condition}" "Ignore auto layout (absolute position)" = "オートレイアウトを無視(絶対位置)" @@ -613,6 +640,7 @@ status = "draft — needs native review" "Invite link" = "招待リンク" "Invite link copied" = "招待リンクをコピー済み" "It asks permission to:" = "次の権限を求めている:" +"Jan" = "1月" "Join" = "参加" "Join code copied" = "参加コードをコピー済み" "Join code or invite link" = "参加コードまたは招待リンク" @@ -623,6 +651,8 @@ status = "draft — needs native review" "Joined {file}" = "{file} に参加済み" "Joining…" = "参加中…" "Join|stroke" = "角の形状" +"Jul" = "7月" +"Jun" = "6月" "Just now" = "たった今" "Justify" = "両端揃え" "Keep appearance" = "見た目を保持" @@ -632,11 +662,14 @@ status = "draft — needs native review" "Key/Gamepad" = "キー/ゲームパッド" "Key/Gamepad {key}" = "キー/ゲームパッド {key}" "Keyboard shortcuts" = "キーボードショートカット" +"Kits" = "キット" +"Landing page" = "ランディングページ" "Language" = "言語" "Lasso" = "なげなわ" "Last" = "最後" "Last change: {note}" = "最後の変更:{note}" "Last commit" = "最後のコミット" +"Last modified" = "更新日" "Last on top" = "最後を上に" "Layer blur" = "レイヤーのぼかし" "Layer name" = "レイヤー名" @@ -675,6 +708,7 @@ status = "draft — needs native review" "Link" = "リンク" "Link copied" = "リンクをコピー済み" "Link docs, code or tickets for developers." = "開発者向けにドキュメント、コード、チケットをリンク。" +"Link frames on the Prototype tab, then present the flow" = "プロトタイプタブでフレームをつなぎ、フローを再生" "Link this component to its code component: instances then show your real snippet with their values." = "このコンポーネントをコードコンポーネントにリンクすると、インスタンスに実際のスニペットがその値とともに表示される。" "List spacing" = "リストの間隔" "List style" = "リストのスタイル" @@ -692,7 +726,9 @@ status = "draft — needs native review" "Main" = "メイン" "Main component deleted" = "メインコンポーネントは削除済み" "Main menu" = "メインメニュー" +"Make a component" = "コンポーネントを作成" "Make slot" = "スロットにする" +"Mar" = "3月" "Margin" = "マージン" "Mark all as read" = "すべて既読にする" "Mark as resolved" = "解決済みにする" @@ -705,6 +741,7 @@ status = "draft — needs native review" "Max W" = "最大 W" "Max lines" = "最大行数" "Maximize" = "最大化" +"May" = "5月" "Measure to the selection" = "選択範囲までの距離を測定" "Merge into main" = "メインにマージ" "Merge regions" = "領域を結合" @@ -726,6 +763,7 @@ status = "draft — needs native review" "Mixed" = "混在" "Mixed fonts" = "混在フォント" "Mixed interactions: click + to replace them" = "混在したインタラクション:+ をクリックして置き換え" +"Mobile app" = "モバイルアプリ" "Mode" = "モード" "Model" = "モデル" "Models" = "モデル" @@ -755,6 +793,7 @@ status = "draft — needs native review" "Navigate to" = "移動先" "Nested instance" = "ネストされたインスタンス" "New design file" = "新規デザインファイル" +"Newest first" = "新しい順" "Next or previous control in the focused region" = "フォーカス中の領域で次または前のコントロール" "Next ready for dev" = "次の開発準備完了" "Next size option" = "次のサイズオプション" @@ -797,6 +836,7 @@ status = "draft — needs native review" "No string variables yet. Create them in Local variables." = "文字列変数はまだない。ローカル変数で作成。" "No styles yet." = "スタイルはまだない。" "No styles yet. Click + to create one from the selection." = "スタイルはまだない。+ をクリックして選択範囲から作成。" +"No templates match your search" = "検索に一致するテンプレートなし" "No text styles yet. Click + to create one from the selected text." = "テキストスタイルはまだない。+ をクリックして選択したテキストから作成。" "No variables fit here." = "ここに使える変数はない。" "No variables yet. Create them in Local variables." = "変数はまだない。ローカル変数で作成。" @@ -816,6 +856,7 @@ status = "draft — needs native review" "Nothing here yet. Set a frame's status to list it." = "まだ何もない。フレームのステータスを設定すると一覧に表示される。" "Nothing online yet. Use Share on a file to put it online and it is listed here." = "オンラインのファイルはまだない。ファイルの「共有」からオンラインにすると、ここに表示される。" "Nothing to export" = "書き出すものがない" +"Nov" = "11月" "Now" = "現在" "Nudge (Shift: big nudge)" = "微調整(Shift:大きく微調整)" "Nudge amount" = "微調整の量" @@ -824,13 +865,16 @@ status = "draft — needs native review" "Number ↓" = "番号 ↓" "Numbered list" = "番号付きリスト" "Object" = "オブジェクト" +"Oct" = "10月" "Off" = "オフ" "Offered first when filling the slot (any content is allowed)" = "スロットを埋めるときに最初に提示(どのコンテンツも使用可)" "Offered first when swapping the instance (any component is allowed)" = "インスタンスを入れ替えるときに最初に提示(どのコンポーネントも使用可)" "Offset" = "オフセット" +"Oldest first" = "古い順" "On click" = "クリック時" "On drag" = "ドラッグ時" "On this page" = "このページ" +"One announcement in five common sizes: square, portrait, story, link and banner" = "1つのお知らせを5種類の一般的なサイズで:正方形、縦長、ストーリー、リンク、バナー" "Online" = "オンライン" "Online files" = "オンラインファイル" "Online sharing is off: files are shared on the local network only." = "オンライン共有はオフ:ファイルはローカルネットワークでのみ共有される。" @@ -857,6 +901,7 @@ status = "draft — needs native review" "Open|comments" = "未解決" "Options" = "オプション" "Orange" = "オレンジ" +"Order" = "並び順" "Other apps may not open every .fig file exported here." = "ここで書き出した .fig ファイルは、他のアプリで開けない場合がある。" "Others lose access and keep their copy; the file stays on the server for you" = "他の人はアクセスできなくなり、各自のコピーは残る。ファイルはあなた用にサーバーに残る" "Others type this under Share ▸ Join with a code" = "他の人は「共有 ▸ コードで参加」でこれを入力" @@ -903,6 +948,7 @@ status = "draft — needs native review" "Pick color" = "色を抽出" "Pick color (I)" = "色を抽出(I)" "Pick one font family first" = "先にフォントファミリーを 1 つ選択" +"Pick the Frame tool, then drag on the canvas or choose a device size" = "フレームツールを選び、キャンバスをドラッグするか端末のサイズを選択" "Pin a property" = "プロパティを固定" "Pinch" = "すぼめる" "Pixel grid" = "ピクセルグリッド" @@ -911,6 +957,7 @@ status = "draft — needs native review" "Place a shape or frame (its tool chosen)" = "シェイプまたはフレームを配置(ツール選択中)" "Place automatically" = "自動で配置" "Place image" = "画像を配置" +"Playground" = "プレイグラウンド" "Plugins" = "プラグイン" "Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "プラグインは FigCraft のサンドボックス内で、ファイル、ネットワーク、時計なしで実行される。信頼できるプラグインのみをインストールすること。" "Plus darker" = "プラス(暗)" @@ -928,6 +975,7 @@ status = "draft — needs native review" "Present or preview" = "プレゼンテーションまたはプレビュー" "Present this flow" = "このフローをプレゼンテーション" "Presentation" = "プレゼンテーション" +"Presentation slides" = "プレゼンテーションのスライド" "Presenting: comments" = "プレゼンテーション中:コメント" "Presenting: cycle the size options" = "プレゼンテーション中:サイズオプションを切り替え" "Presenting: full screen" = "プレゼンテーション中:全画面表示" @@ -983,6 +1031,7 @@ status = "draft — needs native review" "Remove auto layout" = "オートレイアウトを解除" "Remove dev resource" = "開発リソースを削除" "Remove export setting" = "書き出し設定を削除" +"Remove from recents" = "最近使ったファイルから削除" "Remove from server" = "サーバーから削除" "Remove from server…" = "サーバーから削除…" "Remove guide" = "ガイドを削除" @@ -1092,15 +1141,18 @@ status = "draft — needs native review" "Search layers" = "レイヤーを検索" "Search plugins" = "プラグインを検索" "Search shortcuts" = "ショートカットを検索" +"Search templates" = "テンプレートを検索" "Section" = "セクション" "Section color" = "セクションの色" "Sections" = "セクション" +"See all templates" = "すべてのテンプレートを見る" "Select a frame and add a starting point to present it as a flow." = "フレームを選択して開始点を追加すると、フローとしてプレゼンテーションできる。" "Select a frame to preview it" = "フレームを選択するとプレビュー" "Select a layer to see its properties and code" = "レイヤーを選択するとプロパティとコードを表示" "Select all" = "すべて選択" "Select all with same" = "同じ項目をすべて選択" "Select layer" = "レイヤーを選択" +"Select layers and they space themselves as their content changes" = "レイヤーを選択すると、内容の変化に合わせて間隔を自動調整" "Select layers to apply the style to" = "スタイルを適用するレイヤーを選択" "Select overlapping shapes" = "重なったシェイプを選択" "Select points to edit their position, radius and mirroring" = "ポイントを選択すると位置、半径、対称を編集できる" @@ -1111,6 +1163,7 @@ status = "draft — needs native review" "Send backward" = "背面へ" "Send report" = "報告を送信" "Send to back" = "最背面へ" +"Sep" = "9月" "Server address" = "サーバーのアドレス" "Server address (ws://host:47900/)" = "サーバーのアドレス(ws://host:47900/)" "Server for online sharing" = "オンライン共有用のサーバー" @@ -1125,6 +1178,7 @@ status = "draft — needs native review" "Shape tools" = "シェイプツール" "Share" = "共有" "Share this file" = "このファイルを共有" +"Share with a code" = "コードで共有" "Share: live session, export" = "共有:ライブセッション、書き出し" "Shared" = "共有中" "Shared: copy its code from the file's menu" = "共有済み:コードはファイルのメニューからコピー" @@ -1134,6 +1188,7 @@ status = "draft — needs native review" "Sharing online · {people}" = "オンラインで共有中 · {people}" "Sharing stopped: the file stays on the server, open only to you" = "共有を停止:ファイルはサーバーに残り、開けるのはあなただけ" "Show" = "表示" +"Show all templates" = "すべてのテンプレートを表示" "Show as grid" = "グリッドで表示" "Show as list" = "リストで表示" "Show autosaves" = "自動保存を表示" @@ -1142,6 +1197,9 @@ status = "draft — needs native review" "Show device frame" = "デバイスフレームを表示" "Show dimensions on objects" = "オブジェクトに寸法を表示" "Show hotspot hints" = "ホットスポットのヒントを表示" +"Show in Explorer" = "エクスプローラーで表示" +"Show in Finder" = "Finder で表示" +"Show in folder" = "フォルダーで表示" "Show only this on the canvas" = "キャンバスにこれだけを表示" "Show open" = "未解決を表示" "Show section contents" = "セクションのコンテンツを表示" @@ -1166,13 +1224,17 @@ status = "draft — needs native review" "Small caps" = "スモールキャップ" "Smart animate" = "スマートアニメート" "Snap to pixel grid" = "ピクセルグリッドにスナップ" +"Social and slides" = "SNS・スライド" "Social media" = "ソーシャルメディア" +"Social post sizes" = "SNS投稿サイズ" "Soft light" = "ソフトライト" "Solid" = "実線" "Some variants have the same property values: give each one a unique combination" = "同じプロパティ値を持つバリアントがある:それぞれ固有の組み合わせにする" "Sort" = "並べ替え" +"Sort by" = "並べ替えの基準" "Sort by date" = "日付で並べ替え" "Sort by page" = "ページで並べ替え" +"Sort by {column}" = "{column}で並べ替え" "Space around" = "周囲に均等配置" "Space between" = "間に均等配置" "Space drag" = "Space ドラッグ" @@ -1191,9 +1253,11 @@ status = "draft — needs native review" "Start" = "開始" "Start ascending from / stop descending at" = "昇順の開始値 / 降順の終了値" "Start from a blank canvas" = "空白のキャンバスから始める" +"Start from a template" = "テンプレートから始める" "Start on path" = "パス上の開始位置" "Start point" = "始点" "Start sharing" = "共有を開始" +"Start sharing with the Share button, then pass on the code it gives" = "共有ボタンで共有を開始し、表示されたコードを相手に伝える" "Starting to share…" = "共有を開始中…" "Status" = "ステータス" "Sticky" = "固定" @@ -1242,6 +1306,7 @@ status = "draft — needs native review" "Tell the server's operator what is wrong with it" = "何が問題かをサーバーの運営者に伝える" "Temperature" = "色温度" "Template" = "テンプレート" +"Templates" = "テンプレート" "Terms" = "利用規約" "Text" = "テキスト" "Text align" = "テキストの配置" @@ -1299,6 +1364,8 @@ status = "draft — needs native review" "Truncate with an ellipsis" = "省略記号で切り詰める" "Try again" = "再試行" "Try another search or filter." = "別の検索語やフィルターを試す。" +"Try another word, or look at every kind." = "別の単語で検索するか、すべての種類を表示。" +"Turn a layer into a component, reuse it, and change every copy at once" = "レイヤーをコンポーネントに変換して再利用し、すべてのコピーを一括で変更" "Two digits" = "2 桁" "Type" = "種類" "Type settings" = "文字の設定" @@ -1334,6 +1401,7 @@ status = "draft — needs native review" "Use the layer's fill" = "レイヤーの塗りを使用" "Used in {files} · {instances}" = "{files}で使用 · {instances}" "Username" = "ユーザー名" +"Uses variables: each layer shows them in its own mode" = "変数を使用:各レイヤーに設定されたモードで表示" "Value" = "値" "Variable" = "変数" "Variable collections" = "変数コレクション" @@ -1371,12 +1439,14 @@ status = "draft — needs native review" "Web" = "Web" "WebSocket URL" = "WebSocket URL" "WebSocket URL copied" = "WebSocket URL をコピー済み" +"Websites" = "Webサイト" "Wedge" = "くさび" "Weight" = "太さ" "What changed since it was marked ready for dev" = "開発準備完了にしてからの変更内容" "What is wrong with this file? The server's operator gets your report." = "このファイルの何が問題ですか? 報告はサーバーの運営者に届く。" "What it's for" = "用途" "What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "変更前の状態は不明:FigCraft が開発準備完了フレームのコピーを保存するようになる前に変更された。変更を解決する(または再度準備完了にする)と、今後はそこから比較する。" +"What you try here changes this preview and the code below, not the file" = "ここでの操作はプレビューと下のコードに反映。ファイルは変更されない" "While hovering" = "ホバー中" "While pressing" = "押している間" "White" = "白" @@ -1386,6 +1456,7 @@ status = "draft — needs native review" "Width" = "幅" "Width point" = "幅のポイント" "Width profile" = "幅プロファイル" +"Wireframe kit" = "ワイヤーフレームキット" "With the layers' export settings" = "レイヤーの書き出し設定で" "Wrap" = "折り返し" "Wrap in new section" = "新しいセクションで囲む" @@ -1400,6 +1471,7 @@ status = "draft — needs native review" "Your name in comments" = "コメントに表示する名前" "Your system asks for reduced motion: animations stay off." = "システムで動きを減らす設定がオンのため、アニメーションはオフのまま。" "Z" = "Z" +"Z to A" = "降順(Z→A)" "Zoom" = "ズーム" "Zoom around the pointer" = "ポインターを中心にズーム" "Zoom device to fill window" = "デバイスをウィンドウいっぱいにズーム" @@ -1426,6 +1498,7 @@ status = "draft — needs native review" "from {server}" = "{server} から" "hidden" = "非表示" "iOS" = "iOS" +"just now" = "たった今" "locked" = "ロック中" "no bleed" = "裁ち落としなし" "nobody else here yet" = "まだ他に誰もいない" @@ -1437,9 +1510,11 @@ status = "draft — needs native review" "reads the document" = "ドキュメントを読み取る" "the plugin" = "このプラグイン" "this session only" = "このセッションのみ" +"yesterday" = "昨日" "{instances} in {files}" = "{files}内の{instances}" "{language}: draft translation, waiting for a native speaker's review." = "{language}:下書きの翻訳。ネイティブスピーカーによるレビュー待ち。" "{length} bleed" = "裁ち落とし {length}" +"{month} {day}, {year}" = "{year}年{month}{day}日" "{name}\nClick to apply to the selection" = "{name}\nクリックして選択範囲に適用" "{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nキャンバスにドラッグするか、クリックしてビューの中央に配置。\nインスタンスの上にドロップするときに Alt(Option)を押したままにすると入れ替え。" "{name} (from {library})" = "{name}({library} から)" @@ -1601,8 +1676,10 @@ status = "draft — needs native review" "component.editProperty" = "コンポーネントプロパティを編集" "component.exposeInstance" = "ネストされたインスタンスを公開" "component.goToMain" = "メインコンポーネントへ移動" +"component.hideWhenPublishing" = "公開時に非表示" "component.list" = "コンポーネントを一覧表示" "component.makeSlot" = "スロットにする" +"component.playground" = "コンポーネントを試す" "component.properties" = "コンポーネントプロパティ" "component.pushChanges" = "変更をメインコンポーネントにプッシュ" "component.removeSlot" = "スロットを削除" @@ -1660,6 +1737,7 @@ status = "draft — needs native review" "file.exportSvg" = "選択範囲を SVG で書き出し" "file.import" = "インポート" "file.new" = "新規デザインファイル" +"file.newFromTemplate" = "テンプレートから新規作成" "file.open" = "開く" "file.openRecent" = "最近使ったファイルを開く" "file.placeImage" = "画像を配置" @@ -1687,6 +1765,7 @@ status = "draft — needs native review" "guide.remove" = "ガイドを削除" "guide.select" = "ガイドを選択" "help.about" = "FigCraft について" +"help.gettingStarted" = "使い始めるには" "help.page" = "ヘルプページ" "help.shortcuts" = "キーボードショートカット" "image.adjust" = "画像を調整" @@ -1740,6 +1819,8 @@ status = "draft — needs native review" "node.reparent" = "レイヤーを中に移動" "node.set" = "プロパティを設定" "object.batchRename" = "一括で名前を変更" +"object.convertToFrame" = "フレームに変換" +"object.convertToGroup" = "グループに変換" "object.flipH" = "左右反転" "object.flipV" = "上下反転" "object.frameSelection" = "選択範囲をフレームで囲む" @@ -1855,6 +1936,7 @@ status = "draft — needs native review" "stroke.set" = "線を設定" "stroke.widthProfile" = "幅プロファイル" "style.apply" = "スタイルを適用" +"style.bind" = "スタイルに変数を割り当て" "style.create" = "スタイルを作成" "style.delete" = "スタイルを削除" "style.detach" = "スタイルを切り離す" @@ -1863,6 +1945,7 @@ status = "draft — needs native review" "style.move" = "スタイルを移動" "style.renameFolder" = "スタイルフォルダー名を変更" "style.update" = "スタイルを編集" +"template.list" = "テンプレートを一覧表示" "text.alignCenter" = "中央揃え" "text.alignJustify" = "両端揃え" "text.alignLeft" = "左揃え" @@ -2028,8 +2111,11 @@ status = "draft — needs native review" "view.nextPage" = "次のページ" "view.nextRegion" = "次の領域にフォーカス" "view.outlines" = "アウトライン" +"view.outlinesHiddenLayers" = "アウトラインに非表示のレイヤーを含める" +"view.outlinesObjectBounds" = "アウトラインにオブジェクトの境界を含める" "view.outlinesSelection" = "選択範囲のアウトライン" "view.pan" = "ビューをパン" +"view.panelWidths" = "パネルの幅をリセット" "view.pixelGrid" = "ピクセルグリッド" "view.pixelPreview" = "ピクセルプレビュー" "view.prevFrame" = "前のフレームにズーム" @@ -2087,6 +2173,8 @@ status = "draft — needs native review" "view.design" = "デザイン" "view.layers" = "レイヤー" "view.outlines" = "アウトラインを表示" +"view.outlinesHiddenLayers" = "非表示のレイヤーを含める" +"view.outlinesObjectBounds" = "オブジェクトの境界を含める" "view.outlinesSelection" = "選択範囲のアウトラインを表示" "view.prototype" = "プロトタイプ" "view.themeDark" = "ダーク" diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 99f0f1a7..656ff662 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (Japanese draft catches up with templates, recent files and editor commands) · **Target:** Figma Design (desktop app 126.10.7) FigCraft's interface has a localization layer with an English catalog and Spanish (with a Latin American overlay), French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese @@ -17,16 +17,16 @@ browser's page translation. The `.lproj` folders in `/Applications/Figma.app` (6 Electron/Chromium locale packs, not Figma's own UI languages. Text *content* in any script is supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no vertical text. -## What FigCraft has (measured 2026-10-12, from source) +## What FigCraft has (measured 2026-10-14, from source) - **Layer:** one TOML catalog per language in `crates/ui-egui/locales/`, baked into the binary by the UI crate's build script (desktop and web; nothing read at run time). Keys are the English text (`tr("Fill")`, `trf` for `{named}` placeholders, `trn` for counts with one/other plural forms), so a missing translation shows English, never a raw key. One English text that needs two translations takes a context (`"Fill|sizing"`). -- **Strings:** 1,417 UI keys in `en.toml` (36 with plural forms; ~110 are labels from engine and - document data, such as tool names, blend modes and Dev Mode properties) plus 536 command labels - and 29 menu wordings by command id: 1,982 entries. Command ids, the CLI, MCP and the control +- **Strings:** 1,577 UI keys in `en.toml` (38 with plural forms; some are labels from engine and + document data, such as tool names, blend modes and Dev Mode properties) plus 557 command labels + and 31 menu wordings by command id: 2,165 entries. Command ids, the CLI, MCP and the control channel stay English. Accessibility names come from the same strings. - **Language preference:** Preferences ▸ Language (System, English, Deutsch, Español, Español (Latinoamérica), Français, 日本語, 한국어, Português (Brasil), 简体中文), applied at once without a @@ -44,8 +44,9 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v lacks, keys no code uses, unknown command ids, placeholders that differ from English, overlay entries that read as in their base language and, for complete languages (Spanish), anything untranslated. The other drafts are not held to it: a string added later shows English there - until someone translates it. Every draft translates every string as of 2026-10-12 (checked by - adding each to `COMPLETE` locally and running the check). + until someone translates it. Japanese and Spanish contain all 2,165 current catalog entries; + the other drafts contain 2,077 (88 newer entries still read English). Japanese remains a draft + awaiting native review; its catalog coverage does not include the unlocalized engine messages. - **Not yet localized:** messages the engine writes (errors, a few toasts, Dev Mode property values such as "inside"), number formatting (decimal points stay `.`), and plural forms beyond one/other (French and Brazilian Portuguese take the singular for 0 as well; Japanese, Korean and Chinese @@ -78,17 +79,17 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v | Language | Code | UI strings translated | Dialogs / tooltips / help | Script support | Native review | Status | Hours to full | |---|---|---|---|---|---|---|---| -| English | en | 2,076 / 2,076 (100%, source) | yes | Latin | yes (source) | full | 0 | -| Simplified Chinese (Mandarin) | zh-Hans | 2,076 / 2,076 (100%, draft) | yes | CJK text and IME in documents; UI text in an installed Chinese face, ahead of Japanese ones (web: the `FIGCRAFT_EMBED_CJK` build); no Traditional Chinese catalog (zh-TW, zh-HK read English) | no | draft | 1–2 (after native review) | -| Spanish | es, es-419 | 2,076 / 2,076 (100%, draft); es-419 overlays 98 Latin American wordings (draft) | yes | Latin | no | draft | 0–1 (after native review) | +| English | en | 2,165 / 2,165 (100%, source) | yes | Latin | yes (source) | full | 0 | +| Simplified Chinese (Mandarin) | zh-Hans | 2,077 / 2,165 (96%, draft) | partial | CJK text and IME in documents; UI text in an installed Chinese face, ahead of Japanese ones (web: the `FIGCRAFT_EMBED_CJK` build); no Traditional Chinese catalog (zh-TW, zh-HK read English) | no | draft | 1–2 (after native review) | +| Spanish | es, es-419 | 2,165 / 2,165 (100%, draft); es-419 overlays 98 Latin American wordings (draft) | yes | Latin | no | draft | 0–1 (after native review) | | Hindi | hi | 0 (0%) | no | Devanagari shaping works in documents; UI font fallback to check | no | none | 4–7 | | Arabic | ar | 0 (0%) | no | Bidi, shaping and RTL names work; the UI does not mirror | no | none | 9–15 | -| French | fr | 2,076 / 2,076 (100%, draft) | yes | Latin | no | draft | 1–2 (after native review) | -| Portuguese | pt-BR | 2,076 / 2,076 (100%, draft) | yes | Latin | no | draft | 1–2 (after native review) | +| French | fr | 2,077 / 2,165 (96%, draft) | partial | Latin | no | draft | 1–2 (after native review) | +| Portuguese | pt-BR | 2,077 / 2,165 (96%, draft) | partial | Latin | no | draft | 1–2 (after native review) | | Indonesian | id | 0 (0%) | no | Latin | no | none | 3–5 | -| Japanese | ja | 2,076 / 2,076 (100%, draft) | yes | CJK text and IME in documents; UI text in an installed Japanese face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) | -| German | de | 2,076 / 2,076 (100%, draft) | yes | Latin; long words elide in narrow fields | no | draft | 1–2 (after native review) | -| Korean | ko | 2,076 / 2,076 (100%, draft) | yes | Hangul text and IME in documents; UI text in an installed Korean face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) | +| Japanese | ja | 2,165 / 2,165 (100%, draft) | yes | CJK text and IME in documents; UI text in an installed Japanese face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) | +| German | de | 2,077 / 2,165 (96%, draft) | partial | Latin; long words elide in narrow fields | no | draft | 1–2 (after native review) | +| Korean | ko | 2,077 / 2,165 (96%, draft) | partial | Hangul text and IME in documents; UI text in an installed Korean face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) | | Vietnamese | vi | 0 (0%) | no | Latin with stacked diacritics | no | none | 3–5 | Other languages: none shipped (Figma ships none beyond the eight above). @@ -107,6 +108,7 @@ Vietnamese and Arabic. Traditional Chinese is not planned (no catalog; its users | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Japanese draft catches up with 75 UI strings (template names, descriptions and tips; recent-file sorting, dates and actions; component playground and variable hints), 11 command labels and 2 menu wordings. Existing entries and placeholders are preserved; Japanese dates use the already translated month. 2,165 entries (1,577 UI, 557 commands, 31 menu); native review is still pending. Other drafts retain 2,077 entries; localization grade and estimates unchanged | | 2026-10-12 | minor | Drafts catch up again, and the counts with them: French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese translate the 71 UI strings and 5 command labels they lacked since sharing with a join code and open hosting (the Share popover, join codes, owner tokens, reports, the .fig export notes), and with Spanish the 26 strings and 3 command labels of your online files (Home ▸ Online, its menu and dialogs, the marks on tabs); machine-drafted in each file's conventions, still drafts. The Latin American overlay adds computadora and agrega where the new Spanish says ordenador and añade (98 entries). 2,076 entries per catalog (1,503 UI strings, 544 command labels, 29 menu wordings) | | 2026-10-12 | minor | Draft catalogs catch up: French, German, Brazilian Portuguese, Japanese and Korean translate the 66 UI strings and 17 command labels added since they were drafted (cursor chat and spotlight, the plugin directory, Rename layers, presentation and snapping keys, list levels, the rotation origin, placing with Enter…), Simplified Chinese the 46 and 12 it lacked; machine-drafted from the English in each file's conventions, still drafts (not in `COMPLETE`). The Latin American overlay adds Control for a gamepad controller and presiona in the plugin directory hint (90 entries, with Enter for the keycap). String counts re-measured: 1,417 UI keys, 536 command labels and 29 menu wordings (1,982 entries), every draft at 100%. Localization grade unchanged | | 2026-10-12 | minor | Simplified Chinese draft catalog (gap 5): `zh-Hans.toml` translates every UI string (1,371 keys; plurals give only `other`, and `plural_one` never picks "one"), command label (524) and menu wording (29), machine-drafted in a concise UI style with full-width punctuation and marked as a draft awaiting native review; Preferences ▸ Language lists 简体中文, System maps zh, zh-CN, zh-SG and zh-Hans to it, and Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and stays English (or the next OS language), never Simplified. A Chinese UI draws Han in a Chinese face first (Microsoft YaHei, PingFang SC, Noto Sans CJK SC), ahead of the craft-fonts build's Japanese BIZ UDPGothic, and changing the UI language lays the CJK faces out again (switching from Japanese to Chinese no longer keeps the Japanese face in front). String counts re-measured: 1,371 UI keys, 524 command labels and 29 menu wordings (1,924 entries); the French, German, Brazilian Portuguese, Japanese and Korean drafts have 1,899 of them (the 25 newer strings read English). Localization 36% → 39%, 31–54 h → 29–51 h | From 310cb40af9cf3bb0364112aafd8d045a9d261195 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 13:58:37 +0200 Subject: [PATCH 05/21] M1: open .figcraft files from Finder, Open With and the Dock on macOS (fixes #289) --- Cargo.lock | 12 ++++++ apps/figcraft/Cargo.toml | 7 +++ apps/figcraft/src/apple_events.rs | 57 +++++++++++++++++++++++++ apps/figcraft/src/apple_events_tests.rs | 30 +++++++++++++ apps/figcraft/src/main.rs | 30 ++++++++++++- 5 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 apps/figcraft/src/apple_events.rs create mode 100644 apps/figcraft/src/apple_events_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 7055f3cf..066be876 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1418,6 +1418,7 @@ dependencies = [ "figcraft-format", "figcraft-text", "figcraft-ui-egui", + "fmv-macos-events", "image", "log", "rfd", @@ -1750,6 +1751,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "fmv-macos-events" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90642cf66dde7f205dc8b42dfe57b47d0f3a29f571b832742883ef512e23fe55" +dependencies = [ + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", +] + [[package]] name = "fnv" version = "1.0.7" diff --git a/apps/figcraft/Cargo.toml b/apps/figcraft/Cargo.toml index b64b68d8..bafb6da1 100644 --- a/apps/figcraft/Cargo.toml +++ b/apps/figcraft/Cargo.toml @@ -31,6 +31,13 @@ env_logger = { version = "0.11", default-features = false, features = ["auto-col [target.'cfg(windows)'.dependencies] windows = { version = "0.62", default-features = false, features = ["UI_ViewManagement", "System_UserProfile", "Foundation_Collections", "Gaming_Input"] } +# Finder double-clicks, Open With, Dock drops and `open -a FigCraft file.figcraft` arrive as a +# kAEOpenDocuments Apple event, which winit 0.30 doesn't handle. This crate (also used by GridCraft, +# EffectCraft and PhotoCraft) registers the handler behind a safe API (Objective-C interop needs +# unsafe, which the workspace forbids). Pinned exactly: review the diff before bumping. +[target.'cfg(target_os = "macos")'.dependencies] +fmv-macos-events = "=0.1.0" + [dev-dependencies] wat = { workspace = true } diff --git a/apps/figcraft/src/apple_events.rs b/apps/figcraft/src/apple_events.rs new file mode 100644 index 00000000..6b792094 --- /dev/null +++ b/apps/figcraft/src/apple_events.rs @@ -0,0 +1,57 @@ +//! macOS open-documents and quit Apple events (#289). +//! +//! Finder double-clicks, Open With, drops on the Dock icon and `open -a FigCraft file.figcraft` +//! don't pass paths on the command line: LaunchServices sends the running (or just-launched) app a +//! `kAEOpenDocuments` ('odoc') Apple event. winit 0.30 doesn't handle it and owns the +//! `NSApplicationDelegate`, so AppKit answered "FigCraft cannot open files in the FigCraft Document +//! format". Handling it ourselves needs Objective-C class declarations, i.e. `unsafe`, which this +//! workspace forbids. The `fmv-macos-events` crate (also used by GridCraft, EffectCraft and +//! PhotoCraft) wraps exactly that, an `NSAppleEventManager` handler registered before Finder's +//! launch event that leaves winit's delegate alone, behind a safe main-thread API. + +use std::path::PathBuf; + +use figcraft_ui_egui::FigApp; +use fmv_macos_events::{Event, Inbox, Registration}; +use serde_json::json; + +/// Keeps the Apple-event handlers registered; hold it until the event loop returns. +pub struct AppleEvents { + _registration: Registration, + inbox: Inbox, +} + +impl AppleEvents { + /// Register the handlers. Call on the main thread before the event loop starts, so the event + /// that launched the app (a Finder double-click) is caught too. + pub fn install() -> Self { + let (registration, inbox) = Registration::install(); + Self { _registration: registration, inbox } + } + + /// The queue the app drains every frame ([`poll`]); events arriving later wake `ctx`. + pub fn connect(&self, ctx: &egui::Context) -> Inbox { + let ctx = ctx.clone(); + self.inbox.set_wake(move || ctx.request_repaint()); + self.inbox.clone() + } +} + +/// Open the documents and act on the quit requests that arrived since the last frame. +pub fn poll(inbox: &Inbox, fig: &mut FigApp, ctx: &egui::Context) { + for e in inbox.drain() { + match e { + Event::Open(paths) => open_documents(fig, paths), + Event::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close), + } + } +} + +/// Open `paths` as File › Open does; a file that fails shows its error and the others still open. +pub fn open_documents(fig: &mut FigApp, paths: Vec) { + for p in paths { + if fig.run("file.open", json!({"path": p.to_string_lossy()})).is_some() { + fig.ui.home = false; + } + } +} diff --git a/apps/figcraft/src/apple_events_tests.rs b/apps/figcraft/src/apple_events_tests.rs new file mode 100644 index 00000000..173f5385 --- /dev/null +++ b/apps/figcraft/src/apple_events_tests.rs @@ -0,0 +1,30 @@ +//! Documents macOS hands over in an open-documents Apple event (a Finder double-click, Open With, +//! a drop on the Dock icon) open as File › Open opens them (#289). + +use std::path::PathBuf; + +use figcraft_engine::Session; +use figcraft_ui_egui::{FigApp, NoServices}; +use serde_json::json; + +use crate::apple_events::open_documents; + +#[test] +fn documents_from_finder_open_and_leave_the_home_screen() { + let dir = std::env::temp_dir().join(format!("figcraft-apple-events-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("BLA-001.figcraft"); + let mut s = Session::new(); + s.execute("file.saveAs", &json!({"path": file.to_string_lossy()})).unwrap(); + + let mut app = FigApp::new(Session::empty(), Box::new(NoServices)); + app.ui.home = true; + // A file that can't be read is reported and doesn't keep the others from opening. + open_documents(&mut app, vec![dir.join("Missing.figcraft"), file.clone()]); + let paths: Vec<_> = app.session.docs.iter().map(|d| d.path.clone().map(PathBuf::from)).collect(); + assert_eq!(paths, [Some(file)]); + assert!(!app.ui.home, "the opened document shows"); + assert!(app.ui.last_error.as_deref().is_some_and(|e| e.contains("Missing.figcraft")), "{:?}", app.ui.last_error); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index 0332f7d8..7b14d8e2 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -9,6 +9,8 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] +#[cfg(target_os = "macos")] +mod apple_events; mod args; mod control_server; mod gamepad; @@ -16,6 +18,8 @@ mod graphics; mod recovery; mod services; +#[cfg(all(test, target_os = "macos"))] +mod apple_events_tests; #[cfg(test)] mod args_tests; #[cfg(test)] @@ -43,6 +47,9 @@ struct App { title: String, /// Shared with the services, which write the copies after a panic. recovery: recovery::Shared, + /// Documents and quit requests from Finder, the Dock and Open With. + #[cfg(target_os = "macos")] + apple_events: fmv_macos_events::Inbox, } impl eframe::App for App { @@ -55,6 +62,8 @@ impl eframe::App for App { } let title = with_app(|fig| { control_server::drain_queue(&self.queue, &mut fig.control_queue); + #[cfg(target_os = "macos")] + apple_events::poll(&self.apple_events, fig, ctx); // Closing the window asks about unsaved changes (and keeps the app running while other // windows are open). figcraft_ui_egui::windows::on_close_request(fig, ctx); @@ -188,5 +197,24 @@ fn main() -> eframe::Result { let mut options = eframe::NativeOptions { viewport, renderer: eframe::Renderer::Wgpu, ..Default::default() }; graphics::configure(&mut options.wgpu_options); figcraft_ui_egui::windows::install(fig); - eframe::run_native("FigCraft", options, Box::new(move |_cc| Ok(Box::new(App { queue, ctx_slot, title: String::new(), recovery })))) + // Registered before the event loop starts, so it catches the Finder event that launched the app + // as well as later ones. Lives until the event loop returns; the app creator only borrows it. + #[cfg(target_os = "macos")] + let apple_events = apple_events::AppleEvents::install(); + #[cfg(target_os = "macos")] + let apple_events = &apple_events; + eframe::run_native( + "FigCraft", + options, + Box::new(move |_cc| { + Ok(Box::new(App { + queue, + ctx_slot, + title: String::new(), + recovery, + #[cfg(target_os = "macos")] + apple_events: apple_events.connect(&_cc.egui_ctx), + })) + }), + ) } From 4193a82dfbb0cbc1d28e93a7df6337dbd63d6e05 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:08:47 +0200 Subject: [PATCH 06/21] Count a recovery revision as copied only after its write succeeds Fixes #186 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- apps/figcraft/src/recovery.rs | 63 ++++++++++++++++++++++------- apps/figcraft/src/recovery_tests.rs | 48 ++++++++++++++++++++++ contributors/people.toml | 4 ++ 3 files changed, 101 insertions(+), 14 deletions(-) diff --git a/apps/figcraft/src/recovery.rs b/apps/figcraft/src/recovery.rs index 93335394..0885c68e 100644 --- a/apps/figcraft/src/recovery.rs +++ b/apps/figcraft/src/recovery.rs @@ -41,14 +41,40 @@ pub struct Recovery { dir: PathBuf, /// Held while the app runs: a free lock means the run that wrote the folder is gone. _lock: File, - /// The revision each document was last copied at, and the write in flight. - written: HashMap>)>, + /// What was copied of each document, and the write in flight. + written: HashMap, /// When (UI clock) the next copies are due. next: f64, /// Copies of online files from earlier runs, kept until their outcome is known. waiting: Vec, } +/// The state of the copy of one document: only a write that succeeded counts as a copy. +#[derive(Default)] +struct Copy { + /// The revision whose copy was written. + done: Option, + /// The write in flight: the revision it copies, and whether it succeeded once it ends. + flight: Option<(u64, JoinHandle)>, +} + +impl Copy { + /// Take in the result of the write in flight once it ended; a failed one leaves its revision uncopied. + fn reap(&mut self) { + if self.flight.as_ref().is_some_and(|f| f.1.is_finished()) + && let Some((revision, h)) = self.flight.take() + && h.join().unwrap_or(false) + { + self.done = Some(revision); + } + } + + /// Whether `revision` has neither been copied nor is being copied. + fn pending(&self, revision: u64) -> bool { + self.done != Some(revision) && self.flight.as_ref().is_none_or(|f| f.0 != revision) + } +} + /// A copy of one of your online files whose file is being joined again as session `session`. pub struct Waiting { session: u64, @@ -86,15 +112,21 @@ impl Recovery { /// an idle window can sleep). pub fn tick(&mut self, s: &Session, now: f64) -> Option { self.settle(s); + self.written.values_mut().for_each(Copy::reap); // A join on its way settles within its timeouts: look again soon. let settling = (!self.waiting.is_empty()).then_some(0.5); - let pending = s.docs.iter().any(|d| needs_copy(d) && self.written.get(&d.uid).is_none_or(|w| w.0 != d.revision)); if now < self.next { - return pending.then_some(self.next - now).or(settling); + return self.pending(s).then_some(self.next - now).or(settling); } self.write(s, false); self.next = now + INTERVAL; - settling + // A copy that failed is tried again at the next turn, without another edit. + settling.or(self.pending(s).then_some(INTERVAL)) + } + + /// Whether a document with unsaved changes has a revision that is neither copied nor being copied. + fn pending(&self, s: &Session) -> bool { + s.docs.iter().any(|d| needs_copy(d) && self.written.get(&d.uid).is_none_or(|w| w.pending(d.revision))) } /// Remove earlier runs' copies whose outcome is known, once what they became is copied here. @@ -131,9 +163,10 @@ impl Recovery { } continue; } - let entry = self.written.entry(d.uid).or_insert((0, None)); - let busy = entry.1.as_ref().is_some_and(|h| !h.is_finished()); - if entry.0 == d.revision || busy { + let entry = self.written.entry(d.uid).or_default(); + entry.reap(); + // A write still in flight (for any revision) is waited for; a failed one is tried again. + if entry.done == Some(d.revision) || entry.flight.is_some() { continue; } let doc = d.doc.clone(); @@ -144,20 +177,22 @@ impl Recovery { let online = s.online_of(d.uid).map(|(server, file, at)| format!("{server}\n{file}\n{} {} {}", at.session, at.client_id, at.seq)); let job = move || { if !write_atomic(&path, &doc) { - return; + return false; } let _ = match online { Some(text) => std::fs::write(¬e, text), None => std::fs::remove_file(¬e), }; + true }; + // The revision counts as copied only once its write succeeded. if wait { - job(); - entry.1 = None; + if job() { + entry.done = Some(d.revision); + } } else { - entry.1 = std::thread::Builder::new().name("recovery".into()).spawn(job).ok(); + entry.flight = std::thread::Builder::new().name("recovery".into()).spawn(job).ok().map(|h| (d.revision, h)); } - entry.0 = d.revision; } // Closed documents. let open: Vec = s.docs.iter().map(|d| d.uid).collect(); @@ -177,7 +212,7 @@ impl Recovery { pub fn finish(self) { let Self { dir, _lock, written, .. } = self; // Windows removes a folder only once nothing in it is open: copies being written first. - for h in written.into_values().filter_map(|w| w.1) { + for (_, h) in written.into_values().filter_map(|w| w.flight) { let _ = h.join(); } drop(_lock); diff --git a/apps/figcraft/src/recovery_tests.rs b/apps/figcraft/src/recovery_tests.rs index 95560319..18d18f6a 100644 --- a/apps/figcraft/src/recovery_tests.rs +++ b/apps/figcraft/src/recovery_tests.rs @@ -62,3 +62,51 @@ fn copies_follow_saves_and_a_normal_quit_leaves_nothing() { assert!(!dir.exists(), "a normal quit removes the copies"); let _ = std::fs::remove_dir_all(&root); } + +/// The files named `*.figcraft` in the run folder under `root`. +fn copy_count(root: &std::path::Path) -> usize { + let Some(dir) = std::fs::read_dir(root).unwrap().flatten().next().map(|e| e.path()) else { return 0 }; + std::fs::read_dir(dir).unwrap().flatten().filter(|f| f.path().extension().is_some_and(|e| e == "figcraft")).count() +} + +#[test] +fn a_copy_that_failed_is_tried_again_without_another_edit() { + let root = root("retry"); + let s = edited(); + let mut r = Recovery::start(&root).unwrap(); + let dir = std::fs::read_dir(&root).unwrap().flatten().next().unwrap().path(); + let moved = root.join("moved"); + // The run folder is gone while the copy is written: it fails. + std::fs::rename(&dir, &moved).unwrap(); + r.write(&s, true); + std::fs::rename(&moved, &dir).unwrap(); + // Nothing was edited since: the same revision still has to be copied. + r.write(&s, true); + assert_eq!(copy_count(&root), 1, "the failed revision was copied again"); + r.finish(); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn a_background_copy_that_failed_is_tried_again_without_another_edit() { + let root = root("retry-bg"); + let s = edited(); + let mut r = Recovery::start(&root).unwrap(); + let dir = std::fs::read_dir(&root).unwrap().flatten().next().unwrap().path(); + let moved = root.join("moved"); + std::fs::rename(&dir, &moved).unwrap(); + r.write(&s, false); + // Let the failing write end before the folder comes back. + std::thread::sleep(std::time::Duration::from_millis(300)); + std::fs::rename(&moved, &dir).unwrap(); + for _ in 0..50 { + r.write(&s, false); + if copy_count(&root) == 1 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } + assert_eq!(copy_count(&root), 1, "the failed revision was copied again"); + r.finish(); + let _ = std::fs::remove_dir_all(&root); +} diff --git a/contributors/people.toml b/contributors/people.toml index e61cfd31..db1f2b54 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -16,3 +16,7 @@ # # To remove your names, delete your entry (or set fields empty) in a PR. The next # contributors.py run, and the next build, drop them. + +[people.Silvertree2010] +display_name = "Netter" +sync_github_name = false From 6eeac4fa04c72c74836d27848f2836227c737597 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:14:35 +0200 Subject: [PATCH 07/21] Keep the redo steps when a failed command batch rolls back (#196) --- crates/engine/src/batch_rollback_tests.rs | 22 ++++++++++++++++++++++ crates/engine/src/lib.rs | 5 ++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/batch_rollback_tests.rs b/crates/engine/src/batch_rollback_tests.rs index db0b7962..12d958cd 100644 --- a/crates/engine/src/batch_rollback_tests.rs +++ b/crates/engine/src/batch_rollback_tests.rs @@ -61,3 +61,25 @@ fn failed_plugin_run_restores_each_document_without_restoring_unrelated_state() assert_eq!(s.journal, before_journal); assert_eq!(s.message, before_message); } + +#[test] +fn failed_group_keeps_the_redo_steps() { + let mut s = Session::new(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + assert!(s.undo().unwrap()); + assert_eq!(s.undo_depth().1, 1); + + let result = s.execute( + "command.batch", + &json!({ + "commands": [ + {"command": "shape.rectangle", "params": {"x": 20, "y": 20, "width": 10, "height": 10}}, + {"command": "node.set", "params": {"id": 999999, "props": {"name": "must fail"}}} + ] + }), + ); + + assert!(result.is_err()); + assert_eq!(s.undo_depth().1, 1, "the redo step survives the failed batch"); + assert!(s.redo().unwrap()); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3bc49a90..0d9eddf4 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1082,6 +1082,8 @@ impl Session { self.ds()?; let before: Vec<_> = self.docs.iter().map(|ds| (ds.uid, ds.doc.clone(), ds.selection.clone(), ds.page, ds.history.undo.len(), ds.history.limit)).collect(); + // An edit inside clears the redo steps; a failed group has to bring them back. + let redo_before: Vec<_> = self.docs.iter().map(|ds| ds.history.redo.clone()).collect(); let active = self.active; let journal_len = self.journal.len(); @@ -1099,8 +1101,9 @@ impl Session { if result.is_err() { self.docs.retain(|ds| before.iter().any(|(uid, _, _, _, _, _)| *uid == ds.uid)); - for (uid, doc, selection, page, undo_len, limit) in &before { + for ((uid, doc, selection, page, undo_len, limit), redo) in before.iter().zip(redo_before) { if let Some(ds) = self.docs.iter_mut().find(|ds| ds.uid == *uid) { + ds.history.redo = redo; ds.doc = doc.clone(); ds.selection = selection.clone(); ds.page = *page; From 4f321fb68de399c8b5993806903893f03fe8305f Mon Sep 17 00:00:00 2001 From: Barzabel Date: Wed, 14 Oct 2026 18:21:08 +0300 Subject: [PATCH 08/21] M1: creating a variable collection no longer closes the app (#224) Creating a collection (and adding a mode or a variable, double-clicking a row, or Rename in its menu) asked egui to focus the new name's field in the same frame, but that field is only drawn from the next frame on. For one frame the AccessKit update named a focused node that wasn't in the tree, and the platform adapter aborts on that, outside the app's frame guard; Windows keeps AccessKit on through UI Automation far more often, hence the silent close there. A rename now leaves a flag and its field takes the focus in the frame it's drawn, through widgets::rename_field, as the layer rows do. --- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/variables_panel.rs | 42 +++++----- crates/ui-egui/src/variables_rename_tests.rs | 82 ++++++++++++++++++++ 3 files changed, 108 insertions(+), 18 deletions(-) create mode 100644 crates/ui-egui/src/variables_rename_tests.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 613f18f9..65af6eb3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -148,6 +148,8 @@ mod text_enter_tests; #[cfg(test)] mod titlebar_tests; #[cfg(test)] +mod variables_rename_tests; +#[cfg(test)] mod vector_tail_ui_tests; #[cfg(test)] mod windows_qa_tests; diff --git a/crates/ui-egui/src/variables_panel.rs b/crates/ui-egui/src/variables_panel.rs index 240a51a5..20137482 100644 --- a/crates/ui-egui/src/variables_panel.rs +++ b/crates/ui-egui/src/variables_panel.rs @@ -47,6 +47,8 @@ pub struct Editor { collection: Option, query: String, rename: Option, + /// The rename just started: its field takes the focus in the frame it's first drawn. + rename_start: bool, /// The variable whose details (name, description, scopes) are open. details: Option, /// The group the table shows ("space" or "color/brand"); None = every variable. @@ -240,15 +242,19 @@ fn rename_id(target: &Rename) -> Id { Id::new(("var_rename", target)) } -fn start_rename(ui: &Ui, st: &mut Editor, target: Rename) { - ui.memory_mut(|m| m.request_focus(rename_id(&target).with("te"))); +/// Start renaming `target`. The field isn't drawn yet (a new collection, mode or variable shows next +/// frame), and focusing it now would put a node that isn't there in the screen reader tree, which +/// AccessKit aborts on (#224): the field takes the focus when drawn. +fn start_rename(st: &mut Editor, target: Rename) { st.rename = Some(target); + st.rename_start = true; } /// The inline editor of a rename in progress: the new name once committed. The rename ends when /// the editor closes. fn rename_field(ui: &mut Ui, st: &mut Editor, target: &Rename, value: &str, rect: Rect, font: egui::FontId) -> Option { - let (out, open) = widgets::rename_field(ui, rename_id(target), value, rect, font, false); + let start = std::mem::take(&mut st.rename_start); + let (out, open) = widgets::rename_field(ui, rename_id(target), value, rect, font, start); if !open { st.rename = None; } @@ -271,7 +277,7 @@ fn sidebar(app: &mut FigApp, ui: &mut Ui, views: &[View], local: &Variables, st: Icon::Plus, tr("Create collection"), ) { - create_collection(app, ui, local, st); + create_collection(app, local, st); } egui::ScrollArea::vertical().id_salt("vars_collections").auto_shrink([false, false]).show(ui, |ui| { for view in views { @@ -332,11 +338,11 @@ fn collection_row(app: &mut FigApp, ui: &mut Ui, view: &View, c: &Collection, st } if view.editable() { if resp.double_clicked() { - start_rename(ui, st, target.clone()); + start_rename(st, target.clone()); } crate::right::context_popup(&resp).show(|ui| { if menu_item(ui, tr("Rename"), "") { - start_rename(ui, st, target.clone()); + start_rename(st, target.clone()); ui.close(); } if menu_item(ui, tr("Delete collection"), "") { @@ -421,11 +427,11 @@ fn group_rows(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, st: continue; } if resp.double_clicked() { - start_rename(ui, st, target.clone()); + start_rename(st, target.clone()); } crate::right::context_popup(&resp).show(|ui| { if menu_item(ui, tr("Rename group"), "") { - start_rename(ui, st, target.clone()); + start_rename(st, target.clone()); ui.close(); } if menu_item(ui, tr("Delete group"), "") { @@ -468,14 +474,14 @@ fn rename_group(app: &mut FigApp, members: &[&Variable], g: &str, new: &str, st: } } -fn create_collection(app: &mut FigApp, ui: &Ui, vars: &Variables, st: &mut Editor) { +fn create_collection(app: &mut FigApp, vars: &Variables, st: &mut Editor) { let name = unique("Collection", |n| vars.collections.iter().any(|c| c.name == n)); if let Some(id) = app.run("variable.createCollection", json!({"name": name, "modes": ["Mode 1"]})).and_then(|r| r["id"].as_str().map(str::to_string)) { st.library = None; st.collection = Some(id.clone()); - start_rename(ui, st, Rename::Collection(id)); + start_rename(st, Rename::Collection(id)); } } @@ -503,7 +509,7 @@ fn table(app: &mut FigApp, ui: &mut Ui, view: &View, st: &mut Editor) { ); ui.add_space(12.0); if widgets::button(ui, tr("Create collection"), true, true).clicked() { - create_collection(app, ui, view.vars, st); + create_collection(app, view.vars, st); } }); }); @@ -599,18 +605,18 @@ fn header_row(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, st: } ui.painter().with_clip_rect(cell).text(name_pos, Align2::LEFT_CENTER, &m.name, regular(11.0), t.text_secondary); if resp.double_clicked() { - start_rename(ui, st, target.clone()); + start_rename(st, target.clone()); } crate::right::context_popup(&resp).show(|ui| { if menu_item(ui, tr("Rename mode"), "") { - start_rename(ui, st, target.clone()); + start_rename(st, target.clone()); ui.close(); } if menu_item(ui, tr("Duplicate mode"), "") { let name = unique(&format!("{} copy", m.name), |n| coll.modes.iter().any(|x| x.name == n)); let made = app.run("variable.addMode", json!({"collection": coll.id, "name": name, "from": m.id})); if let Some(id) = made.and_then(|r| r["id"].as_str().map(str::to_string)) { - start_rename(ui, st, Rename::Mode(coll.id.clone(), id)); + start_rename(st, Rename::Mode(coll.id.clone(), id)); } ui.close(); } @@ -645,7 +651,7 @@ fn header_row(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, st: let name = unique("Mode", |n| coll.modes.iter().any(|m| m.name == n)); if let Some(id) = app.run("variable.addMode", json!({"collection": coll.id, "name": name})).and_then(|r| r["id"].as_str().map(str::to_string)) { - start_rename(ui, st, Rename::Mode(coll.id.clone(), id)); + start_rename(st, Rename::Mode(coll.id.clone(), id)); } } ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.border)); @@ -679,7 +685,7 @@ fn var_row(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, v: &Va } if editable { if !inherited && resp.double_clicked() && ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| p.x < name_r.max.x) { - start_rename(ui, st, target.clone()); + start_rename(st, target.clone()); } crate::right::context_popup(&resp).show(|ui| { if menu_item(ui, tr("Edit variable"), "") { @@ -687,7 +693,7 @@ fn var_row(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, v: &Va ui.close(); } if !inherited && menu_item(ui, tr("Rename"), "") { - start_rename(ui, st, target.clone()); + start_rename(st, target.clone()); ui.close(); } if !v.overrides.is_empty() && menu_item(ui, tr("Reset overrides"), "") { @@ -894,7 +900,7 @@ fn create_row(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, st: let params = json!({"collection": coll.id, "name": name, "type": kind.id()}); if let Some(id) = app.run("variable.create", params).and_then(|r| r["id"].as_str().map(str::to_string)) { st.query.clear(); - start_rename(ui, st, Rename::Variable(id)); + start_rename(st, Rename::Variable(id)); } ui.close(); } diff --git a/crates/ui-egui/src/variables_rename_tests.rs b/crates/ui-egui/src/variables_rename_tests.rs new file mode 100644 index 00000000..66a5e4e2 --- /dev/null +++ b/crates/ui-egui/src/variables_rename_tests.rs @@ -0,0 +1,82 @@ +//! Renames in the variables window, drawn headless with the screen reader tree on: creating a +//! collection opens its name for editing, and so does a double click on its row. On every frame +//! the focused node must be in the AccessKit tree; the platform adapter (UI Automation on Windows) +//! aborts the app when it isn't (#224). + +use egui::{Event, Key, Modifiers, PointerButton, Pos2}; +use figcraft_engine::Session; + +use crate::layers_drag_tests::Ui; +use crate::live_tests::{flat, text_center}; + +/// The variables window open on a document without collections, with AccessKit on. +fn variables_window() -> Ui { + let mut u = Ui::new(Session::new()); + u.app.ui.variables = Some(Default::default()); + u.ctx.enable_accesskit(); + for _ in 0..10 { + u.frame(vec![]); + } + u +} + +/// One frame with `events`, checking the focused node is in the screen reader tree. +fn checked_frame(u: &mut Ui, events: Vec, k: usize) { + let out = u.frame_output(events); + let tree = out.platform_output.accesskit_update.expect("an AccessKit update"); + assert!(tree.nodes.iter().any(|(i, _)| *i == tree.focus), "frame {k}: the focused node {:?} isn't in the tree", tree.focus); +} + +/// `clicks` clicks at `p` (two make a double click) and the frames that follow, each checked. +fn click_checking_focus(u: &mut Ui, p: Pos2, clicks: usize) { + for _ in 0..10 { + u.frame(vec![Event::PointerMoved(p)]); + } + let presses = [true, false].into_iter().cycle().take(clicks * 2).map(Some); + for (k, pressed) in presses.chain(std::iter::repeat_n(None, 20)).enumerate() { + let events = pressed.map(|pressed| Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + checked_frame(u, events.into_iter().collect(), k); + } +} + +/// Type `name` and press Enter (each frame checked). +fn type_name(u: &mut Ui, name: &str) { + checked_frame(u, vec![Event::Text(name.into())], 0); + for pressed in [true, false] { + checked_frame(u, vec![Event::Key { key: Key::Enter, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }], 1); + } + for k in 0..5 { + checked_frame(u, vec![], k + 2); + } +} + +fn collection_names(u: &Ui) -> Vec { + u.app.session.doc().unwrap().variables.collections.iter().map(|c| c.name.clone()).collect() +} + +#[test] +fn creating_a_collection_puts_its_name_in_edit_with_the_focus_in_the_screen_reader_tree() { + let mut u = variables_window(); + let shapes = flat(&u.frame_output(vec![]).shapes); + click_checking_focus(&mut u, text_center(&shapes, "Create collection"), 1); + assert_eq!(collection_names(&u), ["Collection"], "a click creates one collection"); + // The keyboard is in its name: typing renames it. + type_name(&mut u, "Brand"); + assert_eq!(collection_names(&u), ["Brand"]); + assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error); +} + +#[test] +fn double_clicking_a_collection_renames_it_with_the_focus_in_the_screen_reader_tree() { + let mut u = variables_window(); + u.app.session.execute("variable.createCollection", &serde_json::json!({"name": "Spacing"})).unwrap(); + for _ in 0..10 { + u.frame(vec![]); + } + let shapes = flat(&u.frame_output(vec![]).shapes); + click_checking_focus(&mut u, text_center(&shapes, "Spacing"), 2); + // The row's text was replaced by the field, whose text is selected: typing replaces it. + type_name(&mut u, "Space"); + assert_eq!(collection_names(&u), ["Space"]); + assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error); +} From 1c1b69959852d664fafb25a299f5c0c387926d14 Mon Sep 17 00:00:00 2001 From: tydude001 Date: Wed, 14 Oct 2026 13:35:50 -0500 Subject: [PATCH 09/21] packaging: RPM declares the glibc 2.35 floor, so RHEL 9 refuses it FigCraft's RPM declares no glibc floor, though the deb has `libc6 (>= 2.35)`. On Rocky Linux 9 (glibc 2.34) the RPM installs, and then `figcraft-cli` fails with ``/lib64/libm.so.6: version `GLIBC_2.35' not found``. Co-Authored-By: Claude Opus 5.5 --- packaging/linux/nfpm.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packaging/linux/nfpm.yaml b/packaging/linux/nfpm.yaml index 69667a2b..0adbfb04 100644 --- a/packaging/linux/nfpm.yaml +++ b/packaging/linux/nfpm.yaml @@ -51,7 +51,11 @@ overrides: rpm: # Soname provides rather than package names, so the same requirements resolve on Fedora/RHEL # (libxkbcommon-x11, libX11-xcb, ...) and openSUSE (libxkbcommon-x11-0, libX11-xcb1, ...). + # The glibc floor matches the deb's libc6 (>= 2.35). It names libm because the binaries' + # GLIBC_2.35 symbols (hypot, hypotf) are libm's: RHEL 9's glibc 2.34 provides + # libc.so.6(GLIBC_2.35) through backports, but not this, so it refuses the install. depends: + - libm.so.6(GLIBC_2.35)(64bit) - libxkbcommon.so.0()(64bit) - libxkbcommon-x11.so.0()(64bit) - libwayland-client.so.0()(64bit) From a3048770b6fbd6627933687f2e34aa78a45be778 Mon Sep 17 00:00:00 2001 From: Barzabel Date: Wed, 14 Oct 2026 21:37:55 +0300 Subject: [PATCH 10/21] M1: deeply nested layers don't run the layout out of stack (#210) --- crates/layout/src/depth_tests.rs | 95 ++++++++++++++++++++++++++++++++ crates/layout/src/lib.rs | 29 ++++++++++ 2 files changed, 124 insertions(+) create mode 100644 crates/layout/src/depth_tests.rs diff --git a/crates/layout/src/depth_tests.rs b/crates/layout/src/depth_tests.rs new file mode 100644 index 00000000..f8b597a3 --- /dev/null +++ b/crates/layout/src/depth_tests.rs @@ -0,0 +1,95 @@ +//! Deeply nested layers and a resize (#210): constraints, group scaling and auto layout fill +//! follow a resize down to [`MAX_NESTING`] levels and stop there, instead of running out of stack. +//! Each case runs on a 1 MB stack, the main thread's on Windows; thousands of levels overflowed it. + +use super::*; +use figcraft_doc::{AutoLayout, Constraints, Document, HConstraint, NodeKind, VConstraint}; + +/// Far deeper than the nesting the layout follows. +const DEEP: usize = 5000; + +fn on_small_stack(f: impl FnOnce() + Send + 'static) { + std::thread::Builder::new().stack_size(1 << 20).spawn(f).unwrap().join().unwrap(); +} + +fn width(d: &Document, id: NodeId) -> f64 { + d.node(id).unwrap().width +} + +/// A 100 × 100 frame holding `depth` frames, each inside the last and stretching with it; the +/// root and the chain from the top. +fn stretching_frames(depth: usize) -> (Document, NodeId, Vec) { + let mut d = Document::new("deep"); + let page = d.first_page().unwrap(); + let root = d.create(page, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap(); + let mut chain = Vec::new(); + let mut parent = root; + for _ in 0..depth { + let f = d.create(parent, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap(); + d.get_mut(f).unwrap().constraints = Constraints { horizontal: HConstraint::LeftRight, vertical: VConstraint::TopBottom }; + chain.push(f); + parent = f; + } + (d, root, chain) +} + +#[test] +fn deeply_nested_frames_follow_their_constraints_without_running_out_of_stack() { + on_small_stack(|| { + let (mut d, root, chain) = stretching_frames(DEEP); + set_size(&mut d, root, 200.0, 200.0); + apply_constraints(&mut d, root, 100.0, 100.0); + assert_eq!(width(&d, chain[0]), 200.0); + assert_eq!(width(&d, chain[MAX_NESTING - 2]), 200.0); + assert_eq!(width(&d, chain[DEEP - 1]), 100.0, "past the nesting the layout follows, layers stay"); + }); +} + +#[test] +fn a_shallow_chain_follows_all_the_way_down() { + let (mut d, root, chain) = stretching_frames(20); + set_size(&mut d, root, 200.0, 200.0); + apply_constraints(&mut d, root, 100.0, 100.0); + assert!(chain.iter().all(|f| width(&d, *f) == 200.0)); +} + +#[test] +fn deeply_nested_groups_scale_without_running_out_of_stack() { + on_small_stack(|| { + let mut d = Document::new("deep"); + let mut parent = d.first_page().unwrap(); + let root = d.create(parent, NodeKind::Group, "G", 0.0, 0.0, 100.0, 100.0).unwrap(); + parent = root; + let mut chain = Vec::new(); + for _ in 0..DEEP { + parent = d.create(parent, NodeKind::Group, "G", 0.0, 0.0, 100.0, 100.0).unwrap(); + chain.push(parent); + } + scale_group_children(&mut d, root, 2.0, 2.0); + assert_eq!(width(&d, chain[0]), 200.0); + }); +} + +#[test] +fn deeply_nested_fill_frames_lay_out_without_running_out_of_stack() { + on_small_stack(|| { + let mut d = Document::new("deep"); + let page = d.first_page().unwrap(); + let root = d.create(page, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap(); + d.get_mut(root).unwrap().layout = Some(AutoLayout { padding: [0.0; 4], ..AutoLayout::default() }); + let mut parent = root; + let mut chain = Vec::new(); + for _ in 0..DEEP { + let f = d.create(parent, NodeKind::Frame, "F", 0.0, 0.0, 10.0, 10.0).unwrap(); + let n = d.get_mut(f).unwrap(); + n.layout = Some(AutoLayout { padding: [0.0; 4], ..AutoLayout::default() }); + n.child_layout.sizing_h = Sizing::Fill; + chain.push(f); + parent = f; + } + d.get_mut(root).unwrap().width = 300.0; + layout_frame(&mut d, root, &NoMeasure); + assert_eq!(width(&d, chain[0]), 300.0); + assert_eq!(width(&d, chain[DEEP - 1]), 10.0, "past the nesting the layout follows, layers stay"); + }); +} diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index fd7ff00b..02e7bdaf 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -20,6 +20,30 @@ use figcraft_doc::{ }; use figcraft_geom::{Affine, Point, Rect}; +/// Most layout calls nested in one another (about one per level of layers): deeper layers don't +/// follow a resize, so a deeply nested document can't run the stack out (#210). +pub const MAX_NESTING: usize = 256; + +thread_local! { + static NESTING: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// One level of nested layout calls, released when dropped. +struct Nested; + +impl Nested { + /// Enter one more level; `None` when [`MAX_NESTING`] are in progress. + fn enter() -> Option { + NESTING.with(|n| (n.get() < MAX_NESTING).then(|| n.set(n.get() + 1)).map(|()| Self)) + } +} + +impl Drop for Nested { + fn drop(&mut self) { + NESTING.with(|n| n.set(n.get().saturating_sub(1))); + } +} + /// Measures text for a given width (auto-height text inside a Fill container). pub trait Measure { /// The height a text node needs at `width`. @@ -311,6 +335,7 @@ struct Item { /// Lay out one auto layout frame's children and size the frame if it hugs. pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) { + let Some(_nested) = Nested::enter() else { return }; let Some(f) = doc.node(frame) else { return }; let Some(al) = f.layout.clone() else { return }; let horizontal = matches!(al.mode, LayoutMode::Horizontal); @@ -893,6 +918,7 @@ fn layout_grid(doc: &mut Document, frame: NodeId, al: &AutoLayout, m: &dyn Measu /// (recursively for children frames that resize). In an auto layout frame only the children that /// ignore auto layout (absolute position) follow constraints; the layout places the others. pub fn apply_constraints(doc: &mut Document, id: NodeId, ow: f64, oh: f64) { + let Some(_nested) = Nested::enter() else { return }; let Some(f) = doc.node(id) else { return }; let absolute_only = f.layout.is_some(); let (nw, nh) = (f.width, f.height); @@ -977,6 +1003,7 @@ pub fn apply_constraints(doc: &mut Document, id: NodeId, ow: f64, oh: f64) { /// Each child keeps its rotation and flip and scales along its own axes ([`axis_factors`]), its /// centre following the group's scale. pub fn scale_group_children(doc: &mut Document, id: NodeId, kx: f64, ky: f64) { + let Some(_nested) = Nested::enter() else { return }; let Some(n) = doc.node(id) else { return }; for c in n.children.clone() { let Some(cn) = doc.node(c) else { continue }; @@ -1101,6 +1128,8 @@ mod behaviour_tests; #[cfg(test)] mod content_align_tests; #[cfg(test)] +mod depth_tests; +#[cfg(test)] mod nested_tests; #[cfg(test)] mod tests; From b8043cc2adf9e317ce862c047e82690f4aac845a Mon Sep 17 00:00:00 2001 From: Barzabel Date: Wed, 14 Oct 2026 22:23:51 +0300 Subject: [PATCH 11/21] M3: renaming a style folder no longer closes the app --- crates/ui-egui/src/lib.rs | 2 + .../ui-egui/src/style_folder_rename_tests.rs | 56 +++++++++++++++++++ crates/ui-egui/src/style_picker.rs | 7 ++- 3 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/src/style_folder_rename_tests.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 613f18f9..8f564ead 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -144,6 +144,8 @@ mod shortcut_keys_tests; #[cfg(test)] mod shortcuts_tests; #[cfg(test)] +mod style_folder_rename_tests; +#[cfg(test)] mod text_enter_tests; #[cfg(test)] mod titlebar_tests; diff --git a/crates/ui-egui/src/style_folder_rename_tests.rs b/crates/ui-egui/src/style_folder_rename_tests.rs new file mode 100644 index 00000000..624edfda --- /dev/null +++ b/crates/ui-egui/src/style_folder_rename_tests.rs @@ -0,0 +1,56 @@ +//! Renaming a folder of local styles, drawn headless with the screen reader tree on: Rename folder +//! in the folder's menu opens its name for editing. On every frame the focused node must be in the +//! AccessKit tree; the platform adapter (UI Automation on Windows) aborts the app when it isn't +//! (the same fault as #224). + +use egui::{Event, Key, Modifiers, PointerButton, Pos2}; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; +use crate::live_tests::{flat, text_center}; + +/// One frame with `events`, checking the focused node is in the screen reader tree. +fn checked_frame(u: &mut Ui, events: Vec, k: usize) -> Vec { + let out = u.frame_output(events); + let tree = out.platform_output.accesskit_update.expect("an AccessKit update"); + assert!(tree.nodes.iter().any(|(i, _)| *i == tree.focus), "frame {k}: the focused node {:?} isn't in the tree", tree.focus); + flat(&out.shapes) +} + +/// A click with `button` at `p` and the frames that follow, each checked. +fn click(u: &mut Ui, p: Pos2, button: PointerButton) -> Vec { + for _ in 0..5 { + u.frame(vec![Event::PointerMoved(p)]); + } + let mut shapes = Vec::new(); + for (k, pressed) in [Some(true), Some(false)].into_iter().chain(std::iter::repeat_n(None, 20)).enumerate() { + let events = pressed.map(|pressed| Event::PointerButton { pos: p, button, pressed, modifiers: Modifiers::NONE }); + shapes = checked_frame(u, events.into_iter().collect(), k); + } + shapes +} + +#[test] +fn renaming_a_style_folder_keeps_the_focus_in_the_screen_reader_tree() { + let mut s = Session::new(); + for name in ["Brand/Primary", "Brand/Accent"] { + s.execute("style.create", &json!({"type": "paint", "name": name, "value": [{"type": "solid", "color": "#0D99FF"}]})).unwrap(); + } + let mut u = Ui::new(s); + u.ctx.enable_accesskit(); + let shapes = checked_frame(&mut u, vec![], 0); + // The Design panel lists the local styles with nothing selected; the folder's menu renames it. + let menu = click(&mut u, text_center(&shapes, "Brand"), PointerButton::Secondary); + click(&mut u, text_center(&menu, "Rename folder"), PointerButton::Primary); + checked_frame(&mut u, vec![Event::Key { key: Key::A, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND }], 0); + checked_frame(&mut u, vec![Event::Text("Theme".into())], 1); + for pressed in [true, false] { + checked_frame(&mut u, vec![Event::Key { key: Key::Enter, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }], 2); + } + for k in 0..5 { + checked_frame(&mut u, vec![], k + 3); + } + let names: Vec = u.app.session.doc().unwrap().styles.iter().map(|s| s.name.clone()).collect(); + assert_eq!(names, ["Theme/Primary", "Theme/Accent"]); +} diff --git a/crates/ui-egui/src/style_picker.rs b/crates/ui-egui/src/style_picker.rs index 3a4e7b11..6f0cb2cd 100644 --- a/crates/ui-egui/src/style_picker.rs +++ b/crates/ui-egui/src/style_picker.rs @@ -1042,7 +1042,10 @@ pub fn local_styles(app: &mut FigApp, ui: &mut Ui, doc: &Document) { let (r, resp) = ui.allocate_exact_size(vec2(w, 24.0), Sense::click()); let text_r = Rect::from_min_max(pos2(r.min.x + widgets::PAD, r.min.y + 2.0), pos2(r.max.x - widgets::PAD, r.max.y - 2.0)); if renaming.as_deref() == Some(*f) { - let (out, open) = widgets::rename_field(ui, folder_rename_id().with(*f), f, text_r, regular(11.0), false); + // Focused as it is first drawn: a focus asked for before its field is drawn + // isn't in the screen reader tree (UI Automation aborts the app, #224). + let start = ui.data_mut(|d| d.remove_temp::(folder_rename_id().with("start"))).unwrap_or(false); + let (out, open) = widgets::rename_field(ui, folder_rename_id().with(*f), f, text_r, regular(11.0), start); if let Some(name) = out { run = Some(("style.renameFolder", json!({"folder": f, "name": name}))); } @@ -1055,7 +1058,7 @@ pub fn local_styles(app: &mut FigApp, ui: &mut Ui, doc: &Document) { crate::right::context_popup(&resp).show(|ui| { if crate::right::menu_item(ui, tr("Rename folder"), "") { ui.data_mut(|d| d.insert_temp(folder_rename_id(), f.to_string())); - ui.memory_mut(|m| m.request_focus(folder_rename_id().with(*f).with("te"))); + ui.data_mut(|d| d.insert_temp(folder_rename_id().with("start"), true)); ui.close(); } }); From 5e14632207c26301c1a1389edc6276e1ada094ab Mon Sep 17 00:00:00 2001 From: Barzabel Date: Wed, 14 Oct 2026 22:39:17 +0300 Subject: [PATCH 12/21] M1: cursor and double-click tests scan the system fonts themselves (#333) --- crates/engine/src/cursor_tests.rs | 3 +++ crates/engine/src/double_click_tests.rs | 3 +++ 2 files changed, 6 insertions(+) diff --git a/crates/engine/src/cursor_tests.rs b/crates/engine/src/cursor_tests.rs index 39a18ce4..0975f3fa 100644 --- a/crates/engine/src/cursor_tests.rs +++ b/crates/engine/src/cursor_tests.rs @@ -58,6 +58,9 @@ fn close(a: f64, b: f64) -> bool { /// A square (100,100)–(200,200), selected, and the text "Hello" at (400, 100), not selected. fn scene() -> (Session, NodeId, NodeId) { + // The text is measured with a font: without craft-fonts that is a system one, scanned here + // rather than by whichever test happens to run first (#333). + figcraft_text::scan_system_fonts(); let mut s = Session::new(); let r = id(&ex(&mut s, "shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100}))); let t = id(&ex(&mut s, "text.create", json!({"text": "Hello", "x": 400, "y": 100, "props": {"fontSize": 40}}))); diff --git a/crates/engine/src/double_click_tests.rs b/crates/engine/src/double_click_tests.rs index e51cb7da..2ffa0083 100644 --- a/crates/engine/src/double_click_tests.rs +++ b/crates/engine/src/double_click_tests.rs @@ -15,6 +15,9 @@ fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { /// A frame holding a group of a rectangle and a text layer at (60, 60); returns (group, text). fn scene(s: &mut Session) -> (NodeId, NodeId) { + // The text is measured with a font: without craft-fonts that is a system one, scanned here + // rather than by whichever test happens to run first (#333). + figcraft_text::scan_system_fonts(); let f = id_of(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 300}))["id"]).unwrap(); let r = id_of(&ex(s, "shape.rectangle", json!({"x": 40, "y": 40, "width": 200, "height": 60, "parent": f}))["id"]).unwrap(); let t = id_of(&ex(s, "text.create", json!({"text": "Hi there", "x": 60, "y": 60, "parent": f, "props": {"fontSize": 16}}))["id"]).unwrap(); From ca2f5dbceed0606a7f92317a3168ddb808d0815b Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:47:51 +0300 Subject: [PATCH 13/21] collab: refuse sequence numbers a host cannot advance The welcome's seq and processed.last_batch are the host's word and were used unchecked: accepted computed self.seq + 1 and queue bumped next_batch, so a host sending u64::MAX overflowed the client (panic in debug, wrap in release). Both counters now step with checked_add/saturating_add, and a sequence that cannot advance is refused as a protocol error (the established rejoin path). Test: hostile_sequence_numbers_do_not_overflow_the_client. --- crates/collab/src/client.rs | 9 +++++-- crates/collab/src/client_tests.rs | 43 +++++++++++++++++++++++++++++++ crates/collab/src/lib.rs | 2 ++ 3 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 crates/collab/src/client_tests.rs diff --git a/crates/collab/src/client.rs b/crates/collab/src/client.rs index a2c6e744..8f2a9841 100644 --- a/crates/collab/src/client.rs +++ b/crates/collab/src/client.rs @@ -307,7 +307,8 @@ impl Client { /// Queue `changes` as a new batch (already shown in the view). fn queue(&mut self, changes: Vec, kind: BatchKind, step: Option) { let batch_id = self.next_batch; - self.next_batch += 1; + // `next_batch` starts from the welcome's `last_batch`, the host's word: saturate. + self.next_batch = self.next_batch.saturating_add(1); self.pending.push_back(Pending { batch_id, base_seq: self.seq, changes, kind, step, rebased: false, sent: false }); } @@ -484,7 +485,11 @@ impl Client { /// A batch the server accepted (in order). Follow-ups it causes are queued. fn accepted(&mut self, acc: Accepted, recompute: &dyn Recompute) -> Result<(), Error> { - if acc.seq != self.seq + 1 { + // The host's sequence number is untrusted: at `u64::MAX` it can't step past it. + let Some(next) = self.seq.checked_add(1) else { + return Err(Error::Protocol(format!("sequence {} after {}", acc.seq, self.seq))); + }; + if acc.seq != next { return Err(Error::Protocol(format!("sequence {} after {}", acc.seq, self.seq))); } if acc.author != self.id { diff --git a/crates/collab/src/client_tests.rs b/crates/collab/src/client_tests.rs new file mode 100644 index 00000000..67e1e7a1 --- /dev/null +++ b/crates/collab/src/client_tests.rs @@ -0,0 +1,43 @@ +//! A client's handling of the host's messages: sequence numbers and batch ids come from the host, +//! so they are treated as hostile input (they must never overflow). + +use figcraft_doc::NodeKind; + +use crate::message::{Accepted, ClientMsg, Hello, ServerMsg}; +use crate::sim_tests::base; +use crate::{Client, Limits, NoRecompute, Outgoing, Server}; + +fn hello() -> Hello { + let ClientMsg::Hello(h) = Client::hello("f", "tok", "Ada", "#FF8800") else { panic!("not a hello") }; + h +} + +/// A welcome whose sequence numbers are what a lying host would send. +fn hostile_welcome(seq: u64, last_batch: u64) -> ServerMsg { + let mut s = Server::new(base(), "tok", Limits::default()); + let (_, out) = s.join(hello()).unwrap(); + let mut welcome = out + .into_iter() + .find_map(|o| match o { + Outgoing::To(_, m) => Some(m), + _ => None, + }) + .unwrap(); + let ServerMsg::Welcome { seq: seq0, processed, .. } = &mut welcome else { panic!("no welcome") }; + *seq0 = seq; + processed.last_batch = last_batch; + welcome +} + +#[test] +fn hostile_sequence_numbers_do_not_overflow_the_client() { + let mut c = Client::join(hostile_welcome(u64::MAX, u64::MAX)).unwrap(); + // This client's own first batch must not overflow its batch counter. + let mut d = c.document().clone(); + let page = d.first_page().unwrap(); + d.create(page, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap(); + assert!(c.edit(&d).unwrap().is_some(), "the edit is batched"); + // A batch the host claims follows `u64::MAX` can't exist: refuse it, don't overflow. + let acc = Accepted { seq: 0, author: c.id().wrapping_add(1), batch_id: 0, changes: Vec::new() }; + assert!(c.receive(ServerMsg::Changes(acc), &NoRecompute).is_err()); +} diff --git a/crates/collab/src/lib.rs b/crates/collab/src/lib.rs index 502d8a3e..2c693bc4 100644 --- a/crates/collab/src/lib.rs +++ b/crates/collab/src/lib.rs @@ -123,6 +123,8 @@ impl Recompute for NoRecompute { fn recompute(&self, _: &mut Document, _: &Document, _: &BTreeSet) {} } +#[cfg(test)] +mod client_tests; #[cfg(test)] mod codec_tests; #[cfg(test)] From 245837ff27ca0b5b3e5a7c012849918cba40851c Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:52:15 +0300 Subject: [PATCH 14/21] doc: name counters stop at u64::MAX instead of overflowing Document::next_name summed max + 1 over numbers parsed from existing layer names, and bump_name did the same for the duplicate-rename path: a document (or peer, or node.set) holding a layer named "Frame 18446744073709551615" overflowed both (panic in debug, wrap to a wrong name in release). next_name now collects the numbers it sees and, when the counter is spent, picks the lowest free number (mirrors new_style_id); bump_name only bumps when the number can step, else falls to its existing " 2" branch. Tests: next_name_never_overflows (doc) and duplicating_a_layer_named_past_the_counter_does_not_overflow (engine panel behaviour). --- crates/doc/src/lib.rs | 9 ++++++++- crates/doc/src/tests.rs | 10 ++++++++++ crates/engine/src/cmd/edit.rs | 3 ++- crates/engine/src/panels_behaviour_tests.rs | 11 +++++++++++ 4 files changed, 31 insertions(+), 2 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 3ae8bbe8..513800e3 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -903,15 +903,22 @@ impl Document { /// A unique name like "Frame 3" for a new node of a kind. pub fn next_name(&self, base: &str) -> String { let mut max = 0u64; + let mut taken = std::collections::BTreeSet::new(); for n in self.nodes() { if let Some(rest) = n.name.strip_prefix(base) && let Some(num) = rest.strip_prefix(' ') && let Ok(k) = num.parse::() { max = max.max(k); + taken.insert(k); } } - format!("{base} {}", max + 1) + let next = match max.checked_add(1) { + Some(next) => next, + // The counter is spent: the lowest free number instead of wrapping onto a name. + None => (1u64..).find(|k| !taken.contains(k)).unwrap_or(1), + }; + format!("{base} {next}") } pub fn style(&self, id: &str) -> Option<&Style> { diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index ef2f37b9..d54bb29b 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -335,6 +335,16 @@ fn next_name_increments() { assert_eq!(d.next_name("Ellipse"), "Ellipse 1"); } +#[test] +fn next_name_never_overflows() { + let mut d = Document::new("x"); + let p = page(&d); + d.create(p, NodeKind::Frame, "Frame 18446744073709551615", 0.0, 0.0, 1.0, 1.0).unwrap(); + assert_eq!(d.next_name("Frame"), "Frame 1", "the counter can't step past u64::MAX"); + d.create(p, NodeKind::Frame, "Frame 1", 0.0, 0.0, 1.0, 1.0).unwrap(); + assert_eq!(d.next_name("Frame"), "Frame 2"); +} + #[test] fn custom_easings() { use crate::proto::Easing; diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 57ba9fb0..4f5e91eb 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -446,8 +446,9 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { fn bump_name(n: &str) -> String { if let Some((base, num)) = n.rsplit_once(' ') && let Ok(k) = num.parse::() + && let Some(next) = k.checked_add(1) { - return format!("{base} {}", k + 1); + return format!("{base} {next}"); } format!("{n} 2") } diff --git a/crates/engine/src/panels_behaviour_tests.rs b/crates/engine/src/panels_behaviour_tests.rs index f2802461..b1097398 100644 --- a/crates/engine/src/panels_behaviour_tests.rs +++ b/crates/engine/src/panels_behaviour_tests.rs @@ -386,3 +386,14 @@ fn assets_panel_lists_files_and_drops_by_the_documented_rules() { assert_eq!(kids(&s, row).first(), Some(&into), "an asset dropped in an auto layout frame joins the flow where it is dropped"); assert_eq!(s.doc().unwrap().page_of(into), Some(page)); } + +#[test] +fn duplicating_a_layer_named_past_the_counter_does_not_overflow() { + let mut s = Session::new(); + ex(&mut s, "pref.renameDuplicates", json!({"on": true})); + let r = rect(&mut s, "r", 30.0, 40.0, None); + ex(&mut s, "node.set", json!({"id": r, "props": {"name": "Layer 18446744073709551615"}})); + let dup = id_of(&ex(&mut s, "edit.duplicate", json!({"ids": [r]}))["ids"][0]).unwrap(); + assert_eq!(name(&s, dup), "Layer 18446744073709551615 2"); + assert!(s.doc().unwrap().validate().is_empty(), "the document still checks out"); +} From 65e52aa7b99ad20ebe1bbeae84aeb654fc28bcd6 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:22:41 +0300 Subject: [PATCH 15/21] engine: long digit strings no longer overflow object.opacityKeys opacity_from_digits multiplied and added on every character of its input string before the one-or-two-digit check, so "9999999999" overflowed u32 on the way (panic in debug builds, wrap in release). The scan now steps n with checked_mul/checked_add and stops (None) at the first character the number cannot take, so a long digit string is refused like any other bad input. Test: opacity_digits (engine keymap) covers the ten-digit string and the command's BadParams refusal. --- crates/engine/src/cmd/edit.rs | 3 ++- crates/engine/src/keymap_tests.rs | 7 ++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 57ba9fb0..3068ffb9 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -803,7 +803,8 @@ pub fn opacity_from_digits(digits: &str) -> Option { let mut n = 0u32; let mut count = 0; for c in digits.chars() { - n = n * 10 + c.to_digit(10)?; + // Input-derived: a number that outgrows `u32` stops the scan (None); it never wraps. + n = n.checked_mul(10)?.checked_add(c.to_digit(10)?)?; count += 1; } match count { diff --git a/crates/engine/src/keymap_tests.rs b/crates/engine/src/keymap_tests.rs index 8f6f28c1..38775d3b 100644 --- a/crates/engine/src/keymap_tests.rs +++ b/crates/engine/src/keymap_tests.rs @@ -4,11 +4,11 @@ use serde_json::{Value, json}; -use crate::Session; use crate::cmd::edit::opacity_from_digits; use crate::keymap::{Context, GESTURES, Group, KEYS, Mods, Platform, display, lookup, matches, normalize, parse, primary}; use crate::params::id_of; use crate::tools::ToolId; +use crate::{EngineError, Session}; use figcraft_doc::NodeId; const PLATFORMS: [Platform; 2] = [Platform::Mac, Platform::Other]; @@ -186,6 +186,7 @@ fn opacity_digits() { assert_eq!(opacity_from_digits("00"), Some(0.0)); assert_eq!(opacity_from_digits(""), None); assert_eq!(opacity_from_digits("123"), None); + assert_eq!(opacity_from_digits("9999999999"), None, "ten digits must not overflow u32"); assert_eq!(opacity_from_digits("4x"), None); assert_eq!(opacity_from_digits("٣"), None, "ASCII digits only"); @@ -201,6 +202,10 @@ fn opacity_digits() { ex(&mut s, "edit.undo", json!({})); assert_eq!(op(&s, a), 0.3, "one undo step"); assert!(s.execute("object.opacityKeys", &json!({"digits": "123"})).is_err()); + assert!( + matches!(s.execute("object.opacityKeys", &json!({"digits": "9999999999"})), Err(EngineError::BadParams { .. })), + "an overflowing digit string is a parameter error, not a panic" + ); assert!(s.execute("object.opacityKeys", &json!({})).is_err()); ex(&mut s, "select.none", json!({})); assert!(s.execute("object.opacityKeys", &json!({"digits": "1"})).is_err(), "nothing selected"); From 3c03f370f9b482ed08fd5328a909b8c6a7c5e34c Mon Sep 17 00:00:00 2001 From: Rob Coenen <753704+rcoenen@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:07:05 -0400 Subject: [PATCH 16/21] M1: Cmd+R rename test sends Ctrl+R only where Ctrl is the command key On macOS egui never sends `ctrl` together with `command`; Ctrl comes alone there and Ctrl+R isn't a shortcut (rename is Cmd+R or F2), so case 1 failed on every macOS run. The keymap is right; the test now presses Ctrl+R only off macOS. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/layers_rename_tests.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/layers_rename_tests.rs b/crates/ui-egui/src/layers_rename_tests.rs index 652522d5..4f5c6af8 100644 --- a/crates/ui-egui/src/layers_rename_tests.rs +++ b/crates/ui-egui/src/layers_rename_tests.rs @@ -100,9 +100,12 @@ fn tab_on_the_last_row_commits_and_ends() { #[test] fn cmd_r_renames_the_selection_and_opens_its_parents() { - // Cmd+R on macOS; Ctrl+R elsewhere, where egui sets both `ctrl` and `command`. + // Cmd+R on macOS; Ctrl+R elsewhere, where egui sets both `ctrl` and `command`. On macOS Ctrl + // comes without `command` and Ctrl+R isn't a shortcut. + let cmd = Modifiers::MAC_CMD | Modifiers::COMMAND; let ctrl = Modifiers { ctrl: true, command: true, ..Default::default() }; - for (k, held) in [Modifiers::MAC_CMD | Modifiers::COMMAND, ctrl].into_iter().enumerate() { + let keys: &[Modifiers] = if cfg!(target_os = "macos") { &[cmd] } else { &[cmd, ctrl] }; + for (k, &held) in keys.iter().enumerate() { let (mut u, _, frame, child, _) = setup(); u.app.session.execute("select.set", &json!({"ids": [child]})).unwrap(); u.frame(vec![]); From c02e864873e5a0783947b7512a83f92e30bf1d61 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:22:29 +0000 Subject: [PATCH 17/21] Replace every occurrence in edit.findReplace when replacement contains find Found while testing crates/engine/src/cmd/edit.rs:975-981 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/edit.rs | 15 +++++++++++---- crates/engine/src/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 57ba9fb0..696f8dfe 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -970,13 +970,20 @@ fn find_replace(s: &mut Session, p: &Value) -> Result { } if let NodeKind::Text(t) = &mut n.kind { // Replace occurrence by occurrence so styles shift correctly. + // The search resumes after each inserted replacement, so a replacement that + // contains `find` neither loops forever nor stops the other occurrences. let mut guard = 0; - while let Some(pos) = t.characters.find(find.as_str()) { - let start = t.characters.get(..pos).map(|x| x.chars().count()).unwrap_or(0); - let len = find.chars().count(); + let mut cursor = 0; + let len = find.chars().count(); + let r_len = r.chars().count(); + loop { + let from = t.characters.char_indices().nth(cursor).map_or(t.characters.len(), |(b, _)| b); + let Some(pos) = t.characters.get(from..).and_then(|tail| tail.find(find.as_str())) else { break }; + let start = cursor + t.characters.get(from..from + pos).map_or(0, |x| x.chars().count()); t.replace_range(start, start + len, r); + cursor = start + r_len; guard += 1; - if guard > 10_000 || r.contains(find.as_str()) { + if guard > 10_000 { break; } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 40da2f1d..04e97a73 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -161,6 +161,28 @@ fn text_create_autosize_and_edit() { assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, "Hello"); } +#[test] +fn find_replace_replaces_every_occurrence_when_replacement_contains_find() { + for (text, find, replace, want) in [ + ("cat and cat", "cat", "cats", "cats and cats"), + ("cat and cat", "cat", "category", "category and category"), + ("banana", "a", "aa", "baanaanaa"), + ] { + let mut s = sess(); + let t = id(&s.execute("text.create", &json!({"text": text, "x": 0, "y": 0})).unwrap()); + s.execute("edit.findReplace", &json!({"find": find, "replace": replace})).unwrap(); + assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, want, "{text}: {find} -> {replace}"); + } +} + +#[test] +fn find_replace_rejects_empty_find() { + let mut s = sess(); + let t = id(&s.execute("text.create", &json!({"text": "cat", "x": 0, "y": 0})).unwrap()); + assert!(s.execute("edit.findReplace", &json!({"find": "", "replace": "x"})).is_err()); + assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, "cat"); +} + #[test] fn components_and_instances_sync() { let mut s = sess(); From 87b9a3a9ce79b73b89226a2263beab80fd078d89 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:08:36 +0000 Subject: [PATCH 18/21] Reject negative or fractional index in effect.set, export.update and export.remove Found while testing crates/engine/src/cmd/edit.rs:256 (effect.set), :305 (export.update), :317 (export.remove) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit.rs | 6 +++--- crates/engine/src/effects_tests.rs | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 57ba9fb0..cb194497 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -253,7 +253,7 @@ pub fn specs() -> Vec { ok() }), spec!("effect.set", "Set effect", "{ids?, index, effect {..}}", true, |s, p| { - let i = req_num("effect.set", p, "index")? as usize; + let i = req_index("effect.set", p, "index")?; let e: Effect = parse("effect.set", p, "effect")?.ok_or_else(|| bad("effect.set", "`effect` required"))?; for id in req_targets("effect.set", s, p)? { let n = s.doc_mut()?.get_mut(id)?; @@ -302,7 +302,7 @@ pub fn specs() -> Vec { true, |s, p| { let cmd = "export.update"; - let i = req_num(cmd, p, "index")? as usize; + let i = req_index(cmd, p, "index")?; super::file::export_setting(cmd, ExportSetting::default(), p)?; for id in req_targets(cmd, s, p)? { let n = s.doc_mut()?.get_mut(id)?; @@ -314,7 +314,7 @@ pub fn specs() -> Vec { } ), spec!("export.remove", "Remove export setting", "{ids?, index}", true, |s, p| { - let i = req_num("export.remove", p, "index")? as usize; + let i = req_index("export.remove", p, "index")?; for id in req_targets("export.remove", s, p)? { let n = s.doc_mut()?.get_mut(id)?; if i < n.exports.len() { diff --git a/crates/engine/src/effects_tests.rs b/crates/engine/src/effects_tests.rs index 5142c40d..be69fac2 100644 --- a/crates/engine/src/effects_tests.rs +++ b/crates/engine/src/effects_tests.rs @@ -85,3 +85,24 @@ fn inspect_and_css_show_the_new_effects() { let css = code_for(doc, r, CodeLang::Css); assert!(css.contains("backdrop-filter: blur(2px);"), "{css}"); } + +#[test] +fn negative_or_fractional_index_is_refused_not_item_zero() { + let mut s = Session::new(); + let r = rect(&mut s); + s.execute("effect.add", &json!({"ids": [r], "type": "layerBlur"})).unwrap(); + s.execute("export.add", &json!({"ids": [r]})).unwrap(); + s.execute("export.add", &json!({"ids": [r]})).unwrap(); + let before = s.doc().unwrap().node(r).unwrap().clone(); + assert_eq!(before.exports.len(), 2); + for index in [json!(-1), json!(-0.5), json!(1.5)] { + let set = json!({"ids": [r], "index": index, "effect": {"type": "dropShadow"}}); + assert!(s.execute("effect.set", &set).is_err(), "effect.set index {index}"); + let upd = json!({"ids": [r], "index": index, "format": "svg"}); + assert!(s.execute("export.update", &upd).is_err(), "export.update index {index}"); + assert!(s.execute("export.remove", &json!({"ids": [r], "index": index})).is_err(), "export.remove index {index}"); + let n = s.doc().unwrap().node(r).unwrap(); + assert_eq!(n.effects, before.effects, "index {index}"); + assert_eq!(n.exports, before.exports, "index {index}"); + } +} From 996852d15ca3397439304280363231649c12ba1e Mon Sep 17 00:00:00 2001 From: Rob Coenen <753704+rcoenen@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:55:43 -0400 Subject: [PATCH 19/21] M4: hanging punctuation test holds for fonts that kern with x offsets On macOS without craft-fonts the default face is Helvetica Neue, whose kerning comes out of the shaper partly as an x offset (e.g. on the "e" of "here."), so a glyph's x + advance is not its pen end. The layout was right (the full stop's pen sits exactly on the edge); the test assumed no offsets. Each hanging line is now compared with the same line set flush: every glyph must move by exactly the hanging mark's advance, and the flush right line must end on the edge. Co-Authored-By: Claude Opus 5.5 --- crates/text/src/tail_tests.rs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/crates/text/src/tail_tests.rs b/crates/text/src/tail_tests.rs index 9f771b81..890b254d 100644 --- a/crates/text/src/tail_tests.rs +++ b/crates/text/src/tail_tests.rs @@ -130,14 +130,23 @@ fn hanging_punctuation_outside_the_aligned_edge() { t.auto_resize = TextAutoResize::Height; let Some(plain) = laid(&t) else { return }; let open = plain.glyphs[0].advance; + // Kerning may draw a glyph off its pen position (an x offset, e.g. Helvetica Neue's "re"), so + // each hanging line is checked against the same line set flush: every glyph moves by exactly + // the hanging mark's advance, which puts the letters' pen edge on the box edge. + let moved = |a: &TextLayout, b: &TextLayout, by: f64| { + a.glyphs.len() == b.glyphs.len() && a.glyphs.iter().zip(&b.glyphs).all(|(a, b)| (a.x - b.x - by).abs() < EPS) + }; t.hanging_punctuation = true; let l = laid(&t).unwrap(); - assert!((l.glyphs[0].x + open).abs() < EPS && l.glyphs[1].x.abs() < EPS, "left: the quote hangs, Q starts the line"); + assert!(plain.glyphs[0].x.abs() < EPS && moved(&l, &plain, -open), "left: the quote hangs, Q starts the line"); t.align_h = TextAlignH::Right; + t.hanging_punctuation = false; + let flush = laid(&t).unwrap(); + t.hanging_punctuation = true; let l = laid(&t).unwrap(); - let last = l.glyphs.last().unwrap(); - let before = &l.glyphs[l.glyphs.len() - 2]; - assert!((before.x + before.advance - 400.0).abs() < EPS && last.x >= 400.0 - EPS, "right: the full stop hangs past the edge"); + let stop = l.glyphs.last().unwrap().advance; + let edge = flush.lines[0].x + flush.lines[0].width; + assert!((edge - 400.0).abs() < EPS && moved(&l, &flush, stop), "right: the full stop hangs past the edge"); t.align_h = TextAlignH::Center; let (a, b) = (laid(&t).unwrap(), { t.hanging_punctuation = false; From 7f2f8ee09af8be0f65cc85d7b4096659d122768d Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:02:06 +0200 Subject: [PATCH 20/21] Refuse negative or fractional indexes in page.select and doc.switch Found while testing crates/engine/src/cmd/page.rs:50 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/file.rs | 2 +- crates/engine/src/cmd/page.rs | 2 +- crates/engine/src/panels_behaviour_tests.rs | 17 +++++++++++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 781ed6b0..15086d4c 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -219,7 +219,7 @@ pub fn specs() -> Vec { } ), spec!("doc.switch", "Switch document", "{index}", false, |s, p| { - let i = req_num("doc.switch", p, "index")? as usize; + let i = req_index("doc.switch", p, "index")?; if i >= s.docs.len() { return Err(bad("doc.switch", "no such document")); } diff --git a/crates/engine/src/cmd/page.rs b/crates/engine/src/cmd/page.rs index b2d46988..dc760d77 100644 --- a/crates/engine/src/cmd/page.rs +++ b/crates/engine/src/cmd/page.rs @@ -47,7 +47,7 @@ pub fn specs() -> Vec { let id = match id(p, "id") { Some(i) => i, None => { - let k = req_num("page.select", p, "index")? as usize; + let k = req_index("page.select", p, "index")?; *doc.pages.get(k).ok_or_else(|| bad("page.select", "no such page"))? } }; diff --git a/crates/engine/src/panels_behaviour_tests.rs b/crates/engine/src/panels_behaviour_tests.rs index f2802461..a5a15a1f 100644 --- a/crates/engine/src/panels_behaviour_tests.rs +++ b/crates/engine/src/panels_behaviour_tests.rs @@ -337,6 +337,23 @@ fn pages_by_the_documented_rules() { assert_ne!(s.page().unwrap(), div, "deleting the open page opens a page, never a divider"); } +#[test] +fn page_select_and_doc_switch_refuse_negative_or_fractional_indexes() { + let mut s = Session::new(); + let p2 = id(&ex(&mut s, "page.add", json!({}))); + for bad_index in [json!(-1), json!(0.5)] { + assert!(s.execute("page.select", &json!({"index": bad_index})).is_err(), "page.select index {bad_index} must fail"); + assert_eq!(s.page().unwrap(), p2, "page.select index {bad_index} leaves the open page alone"); + } + ex(&mut s, "file.new", json!({})); + let active = s.active; + assert_eq!(active, 1); + for bad_index in [json!(-1), json!(0.5)] { + assert!(s.execute("doc.switch", &json!({"index": bad_index})).is_err(), "doc.switch index {bad_index} must fail"); + assert_eq!(s.active, active, "doc.switch index {bad_index} leaves the active tab alone"); + } +} + #[test] fn assets_panel_lists_files_and_drops_by_the_documented_rules() { let mut s = Session::new(); From 471694dc483b20fb8fb68716e9b1fb57110c94d6 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 18:00:02 +0700 Subject: [PATCH 21/21] #297: recovery retry tests fail the copy with a folder in its place, so they also run on Windows --- apps/figcraft/src/recovery_tests.rs | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/apps/figcraft/src/recovery_tests.rs b/apps/figcraft/src/recovery_tests.rs index 18d18f6a..bada7d98 100644 --- a/apps/figcraft/src/recovery_tests.rs +++ b/apps/figcraft/src/recovery_tests.rs @@ -69,17 +69,24 @@ fn copy_count(root: &std::path::Path) -> usize { std::fs::read_dir(dir).unwrap().flatten().filter(|f| f.path().extension().is_some_and(|e| e == "figcraft")).count() } +/// A folder at the path of `s`'s first document's copy in the run folder under `root`. +fn blocked_copy(root: &std::path::Path, s: &Session) -> std::path::PathBuf { + let dir = std::fs::read_dir(root).unwrap().flatten().next().unwrap().path(); + let blocker = dir.join(format!("{}.figcraft", s.docs[0].uid)); + std::fs::create_dir(&blocker).unwrap(); + blocker +} + #[test] fn a_copy_that_failed_is_tried_again_without_another_edit() { let root = root("retry"); let s = edited(); let mut r = Recovery::start(&root).unwrap(); - let dir = std::fs::read_dir(&root).unwrap().flatten().next().unwrap().path(); - let moved = root.join("moved"); - // The run folder is gone while the copy is written: it fails. - std::fs::rename(&dir, &moved).unwrap(); + // A folder where the copy goes makes its write fail on every platform (renaming the run folder + // away fails on Windows, where the run's lock file keeps it open). + let blocker = blocked_copy(&root, &s); r.write(&s, true); - std::fs::rename(&moved, &dir).unwrap(); + std::fs::remove_dir(&blocker).unwrap(); // Nothing was edited since: the same revision still has to be copied. r.write(&s, true); assert_eq!(copy_count(&root), 1, "the failed revision was copied again"); @@ -92,13 +99,11 @@ fn a_background_copy_that_failed_is_tried_again_without_another_edit() { let root = root("retry-bg"); let s = edited(); let mut r = Recovery::start(&root).unwrap(); - let dir = std::fs::read_dir(&root).unwrap().flatten().next().unwrap().path(); - let moved = root.join("moved"); - std::fs::rename(&dir, &moved).unwrap(); + let blocker = blocked_copy(&root, &s); r.write(&s, false); - // Let the failing write end before the folder comes back. + // Let the failing write end before the way is clear again. std::thread::sleep(std::time::Duration::from_millis(300)); - std::fs::rename(&moved, &dir).unwrap(); + std::fs::remove_dir(&blocker).unwrap(); for _ in 0..50 { r.write(&s, false); if copy_count(&root) == 1 {