From 57c1133a345377dbf809c741b7ba9230c2ca7b98 Mon Sep 17 00:00:00 2001 From: Mert Dede Date: Tue, 27 Oct 2026 07:57:54 +0300 Subject: [PATCH] M0.9: find layer ids without changing the selection (#996) node.find {root?, name?, type?, text?, component?, limit?} returns {ids, total} for the strict descendants of root (or the current page) that match every filter given: name, text and main component name as case-insensitive substrings, type as an exact type name. It changes neither the selection, the page nor the undo history. Part of #996; fields/outline are left for later. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Fable 5.1 Co-Authored-By: GPT-6.1 Sol Co-Authored-By: GPT-6 Astra --- crates/engine/src/cmd/edit.rs | 75 +++++++++++ crates/engine/src/lib.rs | 2 + crates/engine/src/node_find_tests.rs | 186 +++++++++++++++++++++++++++ crates/engine/src/tests.rs | 3 + crates/mcp/tests/it/agent_tasks.rs | 26 ++++ docs/mcp.md | 11 ++ docs/roadmap.md | 3 +- 7 files changed, 305 insertions(+), 1 deletion(-) create mode 100644 crates/engine/src/node_find_tests.rs diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 7ec63c69..151b5820 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -26,6 +26,74 @@ use select::{ select_parent, select_same, select_sibling, toggle, }; +/// The layer types `node.find` takes, as `NodeKind::type_name` spells them. +const LAYER_TYPES: [&str; 16] = [ + "PAGE", + "FRAME", + "GROUP", + "SECTION", + "RECTANGLE", + "ELLIPSE", + "POLYGON", + "STAR", + "LINE", + "VECTOR", + "TEXT", + "BOOLEAN_OPERATION", + "COMPONENT", + "COMPONENT_SET", + "INSTANCE", + "SLICE", +]; + +fn node_find(s: &mut Session, p: &Value) -> Result { + let cmd = "node.find"; + let filter = |key| -> Result> { + get(p, key).map(|v| v.as_str().map(str::to_lowercase).ok_or_else(|| bad(cmd, format!("`{key}` must be a string")))).transpose() + }; + let name = filter("name")?; + let text = filter("text")?; + let component = filter("component")?; + let kind = filter("type")?; + if let Some(kind) = &kind + && !LAYER_TYPES.iter().any(|t| t.eq_ignore_ascii_case(kind)) + { + return Err(bad(cmd, format!("unknown type; use one of {}", LAYER_TYPES.join(", ")))); + } + let limit = match get(p, "limit") { + None => 100, + Some(v) => v.as_u64().filter(|n| *n <= 10000).ok_or_else(|| bad(cmd, "`limit` must be an integer from 0 to 10000"))? as usize, + }; + let root = opt_id(cmd, p, "root")?.map_or_else(|| s.page(), Ok)?; + let doc = s.doc()?; + // Keep one child iterator per ancestor, not a materialised subtree or all pending siblings. + let mut stack = vec![doc.get(root)?.children.iter()]; + let mut ids = Vec::new(); + let mut total = 0usize; + while let Some(children) = stack.last_mut() { + let Some(&id) = children.next() else { + stack.pop(); + continue; + }; + let n = doc.get(id)?; + stack.push(n.children.iter()); + if name.as_ref().is_some_and(|q| !n.name.to_lowercase().contains(q)) + || kind.as_ref().is_some_and(|q| !n.kind.type_name().eq_ignore_ascii_case(q)) + || text.as_ref().is_some_and(|q| !matches!(&n.kind, NodeKind::Text(t) if t.characters.to_lowercase().contains(q))) + || component.as_ref().is_some_and(|q| { + !matches!(&n.kind, NodeKind::Instance { component, .. } if doc.node(*component).is_some_and(|c| c.name.to_lowercase().contains(q))) + }) + { + continue; + } + total += 1; + if ids.len() < limit { + ids.push(id); + } + } + Ok(json!({"ids": ids, "total": total})) +} + pub fn specs() -> Vec { vec![ spec!("edit.undo", "Undo", "{}", false, |s, _| Ok(json!({"undone": s.undo()?}))), @@ -232,6 +300,13 @@ pub fn specs() -> Vec { let i = req_id("node.get", p, "id")?; Ok(crate::inspect::node_json(s.doc()?, i, 1)) }), + spec!( + "node.find", + "Find layers", + "{root? (strict descendants; default current page), name?, type? (canonical layer type), text?, component? (main component name), limit? (0-10000; default 100)}: every filter must match; name, text and component are case-insensitive substrings, type is exact (any case); returns {ids, total} and does not select", + false, + node_find + ), spec!( "node.reparent", "Move layer into", diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 60f240cb..e45bea15 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -342,6 +342,8 @@ mod menu_gaps_tests; #[cfg(test)] mod nested_override_tests; #[cfg(test)] +mod node_find_tests; +#[cfg(test)] mod node_set_refusals_tests; #[cfg(test)] mod online_tests; diff --git a/crates/engine/src/node_find_tests.rs b/crates/engine/src/node_find_tests.rs new file mode 100644 index 00000000..09bb7e63 --- /dev/null +++ b/crates/engine/src/node_find_tests.rs @@ -0,0 +1,186 @@ +//! Finding layers without changing the selection, page or edit history (#996). + +use figcraft_doc::*; +use serde_json::{Value, json}; +use std::sync::Arc; + +use crate::Session; +use crate::components::tests::{ex, idv}; + +fn add(s: &mut Session, parent: NodeId, kind: NodeKind, name: &str) -> NodeId { + s.doc_mut().unwrap().create(parent, kind, name, 0.0, 0.0, 100.0, 100.0).unwrap() +} + +fn found(s: &mut Session, params: Value, ids: &[NodeId]) { + assert_eq!(ex(s, "node.find", params), json!({"ids": ids, "total": ids.len()})); +} + +#[test] +fn node_find_filters_are_literal_case_insensitive_and_anded() { + let mut s = Session::new(); + let page = s.page().unwrap(); + let frame = add(&mut s, page, NodeKind::Frame, "CAFÉ [Card]"); + let title = add(&mut s, frame, NodeKind::Text(TextData::new("HÉLLO 世界")), "Café Title"); + let other = add(&mut s, frame, NodeKind::Text(TextData::new("Other")), "Title"); + let rect = add(&mut s, page, NodeKind::Rectangle, "HÉLLO 世界"); + found(&mut s, json!({"name": "CAFÉ"}), &[frame, title]); + found(&mut s, json!({"name": "[cArD]"}), &[frame]); + found(&mut s, json!({"type": "tExT"}), &[title, other]); + found(&mut s, json!({"text": "héLLo 世"}), &[title]); + found(&mut s, json!({"name": "title", "type": "TEXT", "text": "世界"}), &[title]); + found(&mut s, json!({"name": "café", "type": "RECTANGLE"}), &[]); + found(&mut s, json!({"text": "héllo", "name": "HÉLLO"}), &[]); + found(&mut s, json!({"text": ""}), &[title, other]); + found(&mut s, json!({"name": ""}), &[frame, title, other, rect]); + found(&mut s, json!({"name": ".*"}), &[]); +} + +#[test] +fn node_find_scope_is_preorder_strict_descendants_in_stored_order() { + let mut s = Session::new(); + let page = s.page().unwrap(); + let root = add(&mut s, page, NodeKind::Frame, "Root"); + let first = add(&mut s, root, NodeKind::Frame, "First"); + let nested = add(&mut s, first, NodeKind::Rectangle, "Nested"); + let second = add(&mut s, root, NodeKind::Rectangle, "Second"); + let outside = add(&mut s, page, NodeKind::Rectangle, "Outside"); + s.doc_mut().unwrap().get_mut(first).unwrap().visible = false; + s.doc_mut().unwrap().get_mut(second).unwrap().locked = true; + found(&mut s, json!({}), &[root, first, nested, second, outside]); + found(&mut s, json!({"root": root}), &[first, nested, second]); + found(&mut s, json!({"root": root, "name": "root"}), &[]); + found(&mut s, json!({"root": nested}), &[]); + found(&mut s, json!({"root": page}), &[root, first, nested, second, outside]); + s.doc_mut().unwrap().reorder(second, 0).unwrap(); + found(&mut s, json!({"root": root}), &[second, first, nested]); +} + +#[test] +fn node_find_other_page_requires_root_and_does_not_switch_pages() { + let mut s = Session::new(); + let page = s.page().unwrap(); + let here = add(&mut s, page, NodeKind::Frame, "Here"); + let other_page = s.doc_mut().unwrap().add_page("Other"); + let root = add(&mut s, other_page, NodeKind::Frame, "There"); + let child = add(&mut s, root, NodeKind::Rectangle, "Child"); + found(&mut s, json!({}), &[here]); + found(&mut s, json!({"root": other_page}), &[root, child]); + found(&mut s, json!({"root": root}), &[child]); + assert_eq!(s.page().unwrap(), page); +} + +#[test] +fn node_find_instances_match_main_name_and_visit_actual_children_only() { + let mut s = Session::new(); + let page = s.page().unwrap(); + let main = add(&mut s, page, NodeKind::Component { description: String::new(), props: Default::default() }, "CAFÉ [Button]"); + let original = add(&mut s, main, NodeKind::Text(TextData::new("Original")), "Original"); + let inst = idv(&ex(&mut s, "instance.create", json!({"component": main}))); + let child = s.doc().unwrap().get(inst).unwrap().children[0]; + ex(&mut s, "node.set", json!({"ids": [inst], "props": {"name": "Copy"}})); + ex(&mut s, "text.set", json!({"id": child, "text": "Changed"})); + let broken = add(&mut s, page, NodeKind::Instance { component: NodeId(u64::MAX), props: Default::default() }, "Missing"); + found(&mut s, json!({"component": "café [bUt"}), &[inst]); + found(&mut s, json!({"component": "", "type": "instance", "name": "copy"}), &[inst]); + found(&mut s, json!({"component": "copy"}), &[]); + found(&mut s, json!({"component": "button", "text": "Changed"}), &[]); + found(&mut s, json!({"root": inst}), &[child]); + found(&mut s, json!({"root": inst, "text": "original"}), &[]); + found(&mut s, json!({"root": inst, "text": "changed"}), &[child]); + found(&mut s, json!({}), &[main, original, inst, child, broken]); +} + +#[test] +fn node_find_accepts_every_canonical_type() { + let mut s = Session::new(); + let page = s.page().unwrap(); + let kinds = [ + NodeKind::Frame, + NodeKind::Group, + NodeKind::Section { contents_hidden: false }, + NodeKind::Rectangle, + NodeKind::Ellipse { start: 0.0, sweep: 1.0, inner: 0.0 }, + NodeKind::Polygon { count: 3 }, + NodeKind::Star { count: 5, inner_ratio: 0.5 }, + NodeKind::Line, + NodeKind::Vector { network: Default::default() }, + NodeKind::Text(TextData::new("Text")), + NodeKind::BooleanOp { op: BoolOp::Union }, + NodeKind::Component { description: String::new(), props: Default::default() }, + NodeKind::ComponentSet { description: String::new() }, + NodeKind::Instance { component: NodeId(u64::MAX), props: Default::default() }, + NodeKind::Slice, + ]; + for kind in kinds { + let name = kind.type_name(); + let id = add(&mut s, page, kind, name); + assert_eq!(ex(&mut s, "node.get", json!({"id": id}))["type"], name); + found(&mut s, json!({"type": name.to_ascii_lowercase()}), &[id]); + } + found(&mut s, json!({"type": "PAGE"}), &[]); +} + +#[test] +fn node_find_limit_caps_ids_not_total_and_zero_counts_only() { + let mut s = Session::new(); + let page = s.page().unwrap(); + let ids: Vec<_> = (0..105).map(|_| add(&mut s, page, NodeKind::Rectangle, "Match")).collect(); + assert_eq!(ex(&mut s, "node.find", json!({})), json!({"ids": &ids[..100], "total": 105})); + assert_eq!(ex(&mut s, "node.find", json!({"limit": 2})), json!({"ids": &ids[..2], "total": 105})); + assert_eq!(ex(&mut s, "node.find", json!({"limit": 0})), json!({"ids": [], "total": 105})); + found(&mut s, json!({"limit": 10000}), &ids); + found(&mut s, json!({"name": "missing", "limit": 0}), &[]); +} + +#[test] +fn node_find_rejects_bad_root_type_filters_and_limits() { + let mut s = Session::new(); + for params in [ + json!({"root": 999999}), + json!({"root": "nope"}), + json!({"root": {"id": 1}}), + json!({"type": "BOOLEANOP"}), + json!({"type": "FRAME "}), + json!({"type": ""}), + json!({"type": 3}), + json!({"name": []}), + json!({"text": true}), + json!({"component": {}}), + json!({"limit": -1}), + json!({"limit": 10001}), + json!({"limit": 1.5}), + json!({"limit": 1.0}), + json!({"limit": "2"}), + json!({"limit": true}), + json!({"limit": []}), + json!({"limit": 1e308}), + ] { + assert!(s.execute("node.find", ¶ms).is_err(), "{params}"); + } + let error = s.execute("node.find", &json!({"type": "x".repeat(10000)})).unwrap_err().to_string(); + assert!(error.contains("unknown type"), "{error}"); + assert!(error.len() < 300, "unknown types are not echoed whole"); +} + +#[test] +fn node_find_leaves_document_selection_page_and_history_untouched() { + let mut s = Session::new(); + let layer = idv(&ex(&mut s, "shape.rectangle", json!({"width": 10, "height": 10}))); + ex(&mut s, "node.set", json!({"ids": [layer], "props": {"name": "Previous edit"}})); + ex(&mut s, "node.set", json!({"ids": [layer], "props": {"name": "Undone edit"}})); + ex(&mut s, "edit.undo", json!({})); + ex(&mut s, "select.set", json!({"ids": [layer]})); + let ds = s.ds().unwrap(); + let (doc, selection, page, undo, redo) = (ds.doc.clone(), ds.selection.clone(), ds.page, ds.history.undo.len(), ds.history.redo.len()); + assert!(undo > 0 && redo > 0); + found(&mut s, json!({"name": "previous"}), &[layer]); + found(&mut s, json!({"name": "missing"}), &[]); + ex(&mut s, "node.find", json!({"limit": 0})); + assert!(s.execute("node.find", &json!({"root": 999999})).is_err()); + let ds = s.ds().unwrap(); + assert!(Arc::ptr_eq(&ds.doc, &doc)); + assert_eq!((&ds.selection, ds.page, ds.history.undo.len(), ds.history.redo.len()), (&selection, page, undo, redo)); + assert_eq!(ex(&mut s, "edit.undo", json!({}))["undone"], true); + assert_ne!(s.doc().unwrap().get(layer).unwrap().name, "Previous edit"); + assert_eq!(s.ds().unwrap().history.undo.len(), undo - 1); +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 811724f2..966e976a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -454,6 +454,9 @@ fn every_command_survives_junk_params() { // would write to it.) json!({"ids": [2, 3], "type": "vector", "path": [5], "width": -1e308, "props": {"path": "M0 0 L1e308 0 L-1e308 1e308", "height": "NaN", "x": 1e308}}), json!({"id": 2, "type": "vector", "path": {"d": 1}, "props": {"path": "M NaN 0 Z", "width": 1e-308}}), + // Layer search roots, filters and limits as junk. + json!({"root": {"id": 2}, "name": [1], "type": "nope", "text": true, "component": [], "limit": -1}), + json!({"root": 999999, "limit": 1e308}), // Batch step names and reference paths as junk. json!({"commands": [{"command": "select.none", "as": "a"}, {"command": "select.none", "as": [1]}, {"command": "select.none", "as": "a"}]}), json!({"commands": [{"command": "select.all", "as": "\u{0}"}, {"command": "node.set", "params": {"ids": [ diff --git a/crates/mcp/tests/it/agent_tasks.rs b/crates/mcp/tests/it/agent_tasks.rs index ae576e12..d087b25e 100644 --- a/crates/mcp/tests/it/agent_tasks.rs +++ b/crates/mcp/tests/it/agent_tasks.rs @@ -307,6 +307,32 @@ fn task_vector_edit_keys() { assert!(c.call("run_command", json!({"command": "vector.edit.state", "params": {}}))["isError"] == true, "Enter left edit mode"); } +#[test] +fn task_node_find_without_selecting_then_use_ids_in_batch() { + let mut c = Client::new(); + let card = c.create(json!({"type": "frame", "name": "Card"})); + let title = c.create(json!({"type": "text", "parent": card, "name": "Title", "text": "Hello"})); + c.ok("select", json!({"ids": [card]})); + let before = c.doc(); + assert_eq!(c.run("node.find", json!({"root": card, "type": "text", "text": "HELLO"})), json!({"ids": [title], "total": 1})); + let after = c.doc(); + for key in ["selection", "currentPage", "history"] { + assert_eq!(after[key], before[key], "{key}"); + } + let out = c.ok( + "batch", + json!({"commands": [ + {"command": "node.find", "params": {"root": card, "name": "title"}}, + {"command": "node.set", "params": {"ids": [{"$step": 0, "field": "ids/0"}], "props": {"name": "Found title"}}} + ]}), + ); + assert_eq!(out[0], json!({"ids": [title], "total": 1})); + assert_eq!(c.node(&title)["name"], "Found title"); + assert_eq!(c.doc()["selection"], before["selection"]); + c.run("edit.undo", json!({})); + assert_eq!(c.node(&title)["name"], "Title"); +} + /// A whole card in one `batch` call: later steps reach the frames earlier steps created through /// `{"$step": n}`, so the tree is built in one round-trip and one undo step. #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 05dfd1e6..9a63f2a2 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -303,6 +303,17 @@ Or in a project's `.mcp.json`: - Programmatic calls never open dialogs. - Verify your work: `inspect_document` / `get_node` for numbers, `screenshot` for pixels. +To find layers without changing the selection, use `run_command` with `node.find`, for example +`{"command": "node.find", "params": {"root": 12, "type": "TEXT", "text": "hello", "limit": 20}}`. +It searches strict descendants of `root`, or the current page without one, in stored child order +(depth-first, parent before children), including hidden and locked layers and instance children. +`name`, `text` (text layers only) and `component` (instances by their main component's name) are +case-insensitive literal substrings; `type` is a canonical layer type, case-insensitive. All +filters must match. The result is `{"ids": [...], "total": N}`: at most `limit` ids (default 100, +0 to 10000), with every match counted in `total`; `limit: 0` counts only. A root on another page +works without switching pages. Unknown roots or types and invalid limits are errors. In a batch, +a later step can use `{"$step": 0, "field": "ids/0"}` to target the first match. + ## Tools | Tool | What it does | diff --git a/docs/roadmap.md b/docs/roadmap.md index 14d734ba..23461637 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (finding layers without selecting them (M0.9, part of #996); Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-27 | minor | Finding layers without selecting them (M0.9, part of #996): `node.find` returns ids and a total with AND filters for name, canonical type, text and main component name, under a root or on the current page; ids are limited, the count is not. The selection, page and undo history stay unchanged. Available through `run_command` and batch step references. Regression tests in `crates/engine/src/node_find_tests.rs` and `crates/mcp/tests/it/agent_tasks.rs`. Numbers unchanged | | 2026-10-26 | minor | Dragging a selected unfilled shape (M1, #1370): a press inside a selected stroke-only shape over a filled layer stacked below it moved that layer; the drag-from-inside fallback of #910 now treats any layer painted below the selection like the frame or canvas behind it, so the selection drags, while a layer painted above it still takes the press. Regression test in the selection behaviour suite. Numbers unchanged | | 2026-10-26 | minor | Text truncation (M4, #1375, #1376, #1377): the last line shown takes as much of the next word as fits before the ellipsis ("Lorem consec…", not "Lorem…"); an explicit height, `transform.resize`, a resize handle or fixed sizing on a truncated text keeps it truncating instead of turning it fixed size; Max lines on auto-height text hugs up to that many lines and ends in an ellipsis (`text.maxLines`, `node.set {maxLines}`, Type settings, `.fig` export and import), and `node.get` / `get_node` report `maxLines`. Regression tests in the text and engine behaviour suites. Numbers unchanged | | 2026-10-24 | minor | Installed weights of an embedded font family (M7, #1246): release builds embed some families with only a few styles (Poppins Regular, Italic and Bold), and the first font scan dropped every installed face of such a family, so an installed Poppins SemiBold or Medium was never offered (a rescan did offer them). The first scan now keeps the embedded faces, which still win for the styles they cover, and offers the installed styles they lack beside them. Regression test in the font database tests. Numbers unchanged |