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 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
Co-Authored-By: GPT-6 Astra <noreply@openai.com>
This commit is contained in:
Mert Dede
2026-10-27 07:57:54 +03:00
co-authored by Claude Opus 5.5 GPT-6.1 Sol
parent 43af145a12
commit 57c1133a34
7 changed files with 305 additions and 1 deletions
+75
View File
@@ -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<Value> {
let cmd = "node.find";
let filter = |key| -> Result<Option<String>> {
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<CommandSpec> {
vec![
spec!("edit.undo", "Undo", "{}", false, |s, _| Ok(json!({"undone": s.undo()?}))),
@@ -232,6 +300,13 @@ pub fn specs() -> Vec<CommandSpec> {
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",
+2
View File
@@ -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;
+186
View File
@@ -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", &params).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);
}
+3
View File
@@ -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": [
+26
View File
@@ -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]
+11
View File
@@ -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 |
+2 -1
View File
@@ -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 |