diff --git a/crates/engine/src/automation/inspect.rs b/crates/engine/src/automation/inspect.rs index 586dbb94a..5be87a213 100644 --- a/crates/engine/src/automation/inspect.rs +++ b/crates/engine/src/automation/inspect.rs @@ -5,32 +5,188 @@ use serde_json::{Value, json}; use crate::Session; +/// Top-level node keys accepted by `fields`, including the structure kept in every tree. +pub const NODE_KEYS: &[&str] = &[ + "absolute", + "absoluteBounds", + "annotations", + "arc", + "background", + "blend", + "board", + "booleanOperation", + "boundVariables", + "brush", + "characters", + "childCount", + "children", + "clip", + "codeLinks", + "componentProperties", + "componentPropertyDefinitions", + "constraints", + "cornerRadius", + "cornerSmoothing", + "dashes", + "description", + "devChanged", + "devNote", + "devResources", + "devStatus", + "effects", + "exports", + "fills", + "flows", + "fontFamily", + "fontSize", + "fontStyle", + "fontVariations", + "gridCell", + "grids", + "hangingList", + "hangingPunctuation", + "height", + "id", + "innerRadius", + "interactions", + "isMask", + "layout", + "leadingTrim", + "letterSpacing", + "lineHeight", + "lineIndentations", + "lineTypes", + "listSpacing", + "listStartOffsets", + "listStyle", + "locked", + "mainComponent", + "mainComponentName", + "maskType", + "maxHeight", + "maxLines", + "maxWidth", + "measurements", + "minHeight", + "minWidth", + "miterAngle", + "name", + "opacity", + "operandOperation", + "overrides", + "pluginData", + "pointCount", + "rotation", + "sectionContentsHidden", + "segments", + "sizing", + "slot", + "strokeAlign", + "strokeJoin", + "strokeSides", + "strokeWeight", + "strokes", + "styledRuns", + "styles", + "textAlign", + "textAutoResize", + "textDecoration", + "textDecorationColor", + "textDecorationOffset", + "textDecorationSkipInk", + "textDecorationStyle", + "textDecorationThickness", + "textPath", + "textStyleRanges", + "type", + "vertices", + "visible", + "width", + "widthProfile", + "x", + "y", +]; + +/// Which layer properties to build. Missing options keep the full, sparse inspection output. +#[derive(Clone, Debug, Default)] +pub struct InspectOptions { + fields: Option>, + outline: bool, +} + +impl InspectOptions { + /// Parse the same options for engine commands, control methods and MCP tools. + pub fn parse(p: &Value) -> Result { + let outline = match p.get("outline") { + None => false, + Some(Value::Bool(v)) => *v, + Some(_) => return Err("`outline` must be a boolean".into()), + }; + if outline && p.get("fields").is_some() { + return Err("`outline: true` cannot be combined with `fields`".into()); + } + let fields = match p.get("fields") { + None if outline => Some(["id", "name", "type", "characters", "mainComponentName", "visible"].into_iter().collect()), + None => None, + Some(Value::Array(values)) => { + let mut fields = std::collections::BTreeSet::new(); + for value in values { + let key = value.as_str().ok_or("`fields` must be an array of strings")?; + let key = NODE_KEYS + .iter() + .copied() + .find(|k| *k == key) + .ok_or_else(|| format!("unknown field `{key}`; valid keys: {}", NODE_KEYS.join(", ")))?; + fields.insert(key); + } + Some(fields) + } + Some(_) => return Err("`fields` must be an array of strings".into()), + }; + Ok(Self { fields, outline }) + } + + fn includes(&self, key: &str) -> bool { + self.fields.as_ref().is_none_or(|fields| fields.contains(key)) + } + + fn explicit(&self, key: &str) -> bool { + self.fields.as_ref().is_some_and(|fields| fields.contains(key)) + } +} + /// A node and (to `depth`) its children, with the properties a designer would check. pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value { + node_json_with_options(doc, id, depth, &InspectOptions::default()) +} + +/// A node's selected properties, applied at every level of its subtree. +pub fn node_json_with_options(doc: &Document, id: NodeId, depth: usize, options: &InspectOptions) -> Value { let Some(n) = doc.node(id) else { return Value::Null }; - let mut v = json!({ - "id": n.id, - "name": n.name, - "type": n.kind.type_name(), - }); + let mut v = json!({"id": n.id}); let Some(o) = v.as_object_mut() else { return v }; + insert_prop(o, options, "name", || json!(n.name)); + insert_prop(o, options, "type", || json!(n.kind.type_name())); + if options.explicit("visible") { + insert_prop(o, options, "visible", || json!(n.visible)); + } if !n.is_page() { - base_props(doc, n, id, o); - paint_props(n, o); - layout_props(doc, n, o); - dev_props(doc, n, o); + base_props(doc, n, id, o, options); + paint_props(n, o, options); + layout_props(doc, n, o, options); + dev_props(doc, n, o, options); } // Layer data written with `node.set {props: {pluginData}}` (pages too). if !n.plugin_data.is_empty() { - o.insert("pluginData".into(), json!(n.plugin_data)); + insert_prop(o, options, "pluginData", || json!(n.plugin_data)); } match &n.kind { - NodeKind::Text(t) => text_props(t, o), - _ => kind_props(doc, n, o), + NodeKind::Text(t) => text_props(t, o, options), + _ => kind_props(doc, n, o, options), } if !n.children.is_empty() { if depth > 0 { - o.insert("children".into(), Value::Array(n.children.iter().map(|c| node_json(doc, *c, depth - 1)).collect())); + o.insert("children".into(), Value::Array(n.children.iter().map(|c| node_json_with_options(doc, *c, depth - 1, options)).collect())); } else { o.insert("childCount".into(), json!(n.children.len())); } @@ -38,272 +194,301 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value { v } -fn base_props(doc: &Document, n: &Node, id: NodeId, o: &mut serde_json::Map) { - let c = doc.frame_relative(id).as_coeffs(); - o.insert("x".into(), json!(round(c[4]))); - o.insert("y".into(), json!(round(c[5]))); - o.insert("width".into(), json!(round(n.width))); - o.insert("height".into(), json!(round(n.height))); - let rot = figcraft_geom::rotation_degrees(n.transform); - if rot.abs() > 1e-6 { - o.insert("rotation".into(), json!(round(rot))); - } - if let Some(b) = doc.world_bounds(id) { - o.insert("absoluteBounds".into(), json!({"x": round(b.x0), "y": round(b.y0), "width": round(b.width()), "height": round(b.height())})); - } - if !n.visible { - o.insert("visible".into(), json!(false)); - } - if n.locked { - o.insert("locked".into(), json!(true)); - } - if n.opacity < 1.0 { - o.insert("opacity".into(), json!(n.opacity)); - } - if !matches!(n.blend, figcraft_color::BlendMode::Normal | figcraft_color::BlendMode::PassThrough) { - o.insert("blend".into(), json!(n.blend)); +/// Build a property only when it was requested (or no field list was given). +fn insert_prop(o: &mut serde_json::Map, options: &InspectOptions, key: &str, value: impl FnOnce() -> Value) { + if options.includes(key) { + o.insert(key.into(), value()); } } -fn paint_props(n: &Node, o: &mut serde_json::Map) { +fn base_props(doc: &Document, n: &Node, id: NodeId, o: &mut serde_json::Map, options: &InspectOptions) { + if options.includes("x") || options.includes("y") { + let c = doc.frame_relative(id).as_coeffs(); + insert_prop(o, options, "x", || json!(round(c[4]))); + insert_prop(o, options, "y", || json!(round(c[5]))); + } + insert_prop(o, options, "width", || json!(round(n.width))); + insert_prop(o, options, "height", || json!(round(n.height))); + if options.includes("rotation") { + let rot = figcraft_geom::rotation_degrees(n.transform); + if rot.abs() > 1e-6 { + insert_prop(o, options, "rotation", || json!(round(rot))); + } + } + if let Some(b) = options.includes("absoluteBounds").then(|| doc.world_bounds(id)).flatten() { + insert_prop( + o, + options, + "absoluteBounds", + || json!({"x": round(b.x0), "y": round(b.y0), "width": round(b.width()), "height": round(b.height())}), + ); + } + if !n.visible { + insert_prop(o, options, "visible", || json!(false)); + } + if n.locked { + insert_prop(o, options, "locked", || json!(true)); + } + if n.opacity < 1.0 { + insert_prop(o, options, "opacity", || json!(n.opacity)); + } + if !matches!(n.blend, figcraft_color::BlendMode::Normal | figcraft_color::BlendMode::PassThrough) { + insert_prop(o, options, "blend", || json!(n.blend)); + } +} + +fn paint_props(n: &Node, o: &mut serde_json::Map, options: &InspectOptions) { if !n.fills.is_empty() { - o.insert("fills".into(), paints(&n.fills)); + insert_prop(o, options, "fills", || paints(&n.fills)); } if !n.strokes.is_empty() { - o.insert("strokes".into(), paints(&n.strokes)); - o.insert("strokeWeight".into(), json!(n.stroke.weight)); - o.insert("strokeAlign".into(), json!(n.stroke.align)); + insert_prop(o, options, "strokes", || paints(&n.strokes)); + insert_prop(o, options, "strokeWeight", || json!(n.stroke.weight)); + insert_prop(o, options, "strokeAlign", || json!(n.stroke.align)); if n.stroke.sides.is_some() { - o.insert("strokeSides".into(), json!(n.stroke.sides)); + insert_prop(o, options, "strokeSides", || json!(n.stroke.sides)); } if !n.stroke.dashes.is_empty() { - o.insert("dashes".into(), json!(n.stroke.dashes)); + insert_prop(o, options, "dashes", || json!(n.stroke.dashes)); } if n.stroke.join != StrokeJoin::Miter { - o.insert("strokeJoin".into(), json!(n.stroke.join)); + insert_prop(o, options, "strokeJoin", || json!(n.stroke.join)); } else if n.stroke.miter_limit != 4.0 { - o.insert("miterAngle".into(), json!((n.stroke.miter_angle() * 100.0).round() / 100.0)); + insert_prop(o, options, "miterAngle", || json!((n.stroke.miter_angle() * 100.0).round() / 100.0)); } if let Some(p) = n.stroke.profile() { - let points = crate::cmd::props::width_points_json(Some(p)); - o.insert("widthProfile".into(), json!({"preset": p.preset().map(|x| x.id()), "points": points})); + insert_prop(o, options, "widthProfile", || { + let points = crate::cmd::props::width_points_json(Some(p)); + json!({"preset": p.preset().map(|x| x.id()), "points": points}) + }); } if let Some(b) = &n.stroke.brush { - o.insert("brush".into(), camel(serde_json::to_value(b).unwrap_or(Value::Null))); + insert_prop(o, options, "brush", || camel(serde_json::to_value(b).unwrap_or(Value::Null))); } } if !n.effects.is_empty() { - o.insert("effects".into(), camel(serde_json::to_value(&n.effects).unwrap_or(Value::Null))); + insert_prop(o, options, "effects", || camel(serde_json::to_value(&n.effects).unwrap_or(Value::Null))); } if n.corners.radii.iter().any(|r| *r > 0.0) { - o.insert( - "cornerRadius".into(), - match n.corners.uniform() { - Some(r) => json!(r), - None => json!(n.corners.radii), - }, - ); + insert_prop(o, options, "cornerRadius", || match n.corners.uniform() { + Some(r) => json!(r), + None => json!(n.corners.radii), + }); if n.corners.smoothing > 0.0 { - o.insert("cornerSmoothing".into(), json!(n.corners.smoothing)); + insert_prop(o, options, "cornerSmoothing", || json!(n.corners.smoothing)); } } } -fn layout_props(doc: &Document, n: &Node, o: &mut serde_json::Map) { +fn layout_props(doc: &Document, n: &Node, o: &mut serde_json::Map, options: &InspectOptions) { if let Some(l) = &n.layout { - o.insert("layout".into(), camel(serde_json::to_value(l).unwrap_or(Value::Null))); + insert_prop(o, options, "layout", || camel(serde_json::to_value(l).unwrap_or(Value::Null))); } let parent_al = n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some()); if n.layout.is_some() || parent_al || matches!(n.kind, NodeKind::Text(_)) { - o.insert("sizing".into(), json!({"horizontal": n.child_layout.sizing_h, "vertical": n.child_layout.sizing_v})); + insert_prop(o, options, "sizing", || json!({"horizontal": n.child_layout.sizing_h, "vertical": n.child_layout.sizing_v})); } let cl = &n.child_layout; if cl.absolute { - o.insert("absolute".into(), json!(true)); + insert_prop(o, options, "absolute", || json!(true)); } for (k, v) in [("minWidth", cl.min_w), ("maxWidth", cl.max_w), ("minHeight", cl.min_h), ("maxHeight", cl.max_h)] { if let Some(v) = v { - o.insert(k.into(), json!(v)); + insert_prop(o, options, k, || json!(v)); } } - if let Some(cell) = parent_al + if let Some(cell) = (parent_al && options.includes("gridCell")) .then(|| n.parent.and_then(|p| figcraft_layout::grid_geometry(doc, p))) .flatten() .and_then(|g| g.cells.into_iter().find(|c| c.id == n.id)) { - o.insert( - "gridCell".into(), + insert_prop(o, options, "gridCell", || { json!({"column": cell.column, "row": cell.row, "columnSpan": cell.column_span, "rowSpan": cell.row_span, - "anchored": cl.grid_column.is_some() || cl.grid_row.is_some(), "alignH": cl.grid_align_h, "alignV": cl.grid_align_v}), - ); + "anchored": cl.grid_column.is_some() || cl.grid_row.is_some(), "alignH": cl.grid_align_h, "alignV": cl.grid_align_v}) + }); } if (!parent_al || cl.absolute) && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.kind.is_frame_like()) { - o.insert("constraints".into(), serde_json::to_value(n.constraints).unwrap_or(Value::Null)); + insert_prop(o, options, "constraints", || serde_json::to_value(n.constraints).unwrap_or(Value::Null)); } if n.kind.is_frame_like() { - o.insert("clip".into(), json!(n.clip)); + insert_prop(o, options, "clip", || json!(n.clip)); } } -fn dev_props(doc: &Document, n: &Node, o: &mut serde_json::Map) { +fn dev_props(doc: &Document, n: &Node, o: &mut serde_json::Map, options: &InspectOptions) { if !n.dev_status.is_none() { - o.insert("devStatus".into(), json!(n.dev_status.id())); + insert_prop(o, options, "devStatus", || json!(n.dev_status.id())); } if n.dev_changed { - o.insert("devChanged".into(), json!(true)); + insert_prop(o, options, "devChanged", || json!(true)); } if !n.dev_note.is_empty() { - o.insert("devNote".into(), json!(n.dev_note)); + insert_prop(o, options, "devNote", || json!(n.dev_note)); } if let NodeKind::Section { contents_hidden } = n.kind { - o.insert("sectionContentsHidden".into(), json!(contents_hidden)); + insert_prop(o, options, "sectionContentsHidden", || json!(contents_hidden)); } if n.is_mask { - o.insert("isMask".into(), json!(true)); - o.insert("maskType".into(), json!(n.mask_type.id())); + insert_prop(o, options, "isMask", || json!(true)); + insert_prop(o, options, "maskType", || json!(n.mask_type.id())); } if !n.grids.is_empty() { - o.insert("grids".into(), camel(serde_json::to_value(&n.grids).unwrap_or(Value::Null))); + insert_prop(o, options, "grids", || camel(serde_json::to_value(&n.grids).unwrap_or(Value::Null))); } if !n.exports.is_empty() { - o.insert("exports".into(), serde_json::to_value(&n.exports).unwrap_or(Value::Null)); + insert_prop(o, options, "exports", || serde_json::to_value(&n.exports).unwrap_or(Value::Null)); } if let Some(b) = &n.board { - o.insert("board".into(), serde_json::to_value(b).unwrap_or(Value::Null)); + insert_prop(o, options, "board", || serde_json::to_value(b).unwrap_or(Value::Null)); } if !n.annotations.is_empty() { - o.insert("annotations".into(), serde_json::to_value(&n.annotations).unwrap_or(Value::Null)); + insert_prop(o, options, "annotations", || serde_json::to_value(&n.annotations).unwrap_or(Value::Null)); } if !n.code_links.is_empty() { - o.insert("codeLinks".into(), camel(serde_json::to_value(&n.code_links).unwrap_or(Value::Null))); + insert_prop(o, options, "codeLinks", || camel(serde_json::to_value(&n.code_links).unwrap_or(Value::Null))); } if !n.measurements.is_empty() { - o.insert("measurements".into(), serde_json::to_value(&n.measurements).unwrap_or(Value::Null)); + insert_prop(o, options, "measurements", || serde_json::to_value(&n.measurements).unwrap_or(Value::Null)); } - let dev = crate::dev_resources::list_json(doc, n.id); - if dev.as_array().is_some_and(|a| !a.is_empty()) { - o.insert("devResources".into(), dev); + if options.includes("devResources") { + let dev = crate::dev_resources::list_json(doc, n.id); + if dev.as_array().is_some_and(|a| !a.is_empty()) { + insert_prop(o, options, "devResources", || dev); + } } if !n.interactions.is_empty() { - o.insert("interactions".into(), serde_json::to_value(&n.interactions).unwrap_or(Value::Null)); + insert_prop(o, options, "interactions", || serde_json::to_value(&n.interactions).unwrap_or(Value::Null)); } if !n.styles.is_empty() { - o.insert("styles".into(), serde_json::to_value(&n.styles).unwrap_or(Value::Null)); + insert_prop(o, options, "styles", || serde_json::to_value(&n.styles).unwrap_or(Value::Null)); } if !n.bound.is_empty() { - o.insert("boundVariables".into(), json!(n.bound)); + insert_prop(o, options, "boundVariables", || json!(n.bound)); } if !n.overrides.is_empty() { - o.insert("overrides".into(), json!(n.overrides)); + insert_prop(o, options, "overrides", || json!(n.overrides)); } - if let Some(slot) = crate::components::slot_name(doc, n.id) { - o.insert("slot".into(), json!(slot)); + if let Some(slot) = options.includes("slot").then(|| crate::components::slot_name(doc, n.id)).flatten() { + insert_prop(o, options, "slot", || json!(slot)); } // An operand combining by an operation of its own. - if let Some(op) = n.bool_op.filter(|_| crate::booleans::group_of(doc, n.id).is_some()) { - o.insert("operandOperation".into(), json!(op)); + if let Some(op) = n.bool_op.filter(|_| options.includes("operandOperation") && crate::booleans::group_of(doc, n.id).is_some()) { + insert_prop(o, options, "operandOperation", || json!(op)); } } -fn text_props(t: &TextData, o: &mut serde_json::Map) { - o.insert("characters".into(), json!(t.characters)); - o.insert("fontFamily".into(), json!(t.style.font_family)); - o.insert("fontStyle".into(), json!(t.style.font_style)); - o.insert("fontSize".into(), json!(t.style.font_size)); +fn text_props(t: &TextData, o: &mut serde_json::Map, options: &InspectOptions) { + insert_prop(o, options, "characters", || { + if options.outline { + let mut chars = t.characters.chars(); + let mut text: String = chars.by_ref().take(120).collect(); + if chars.next().is_some() { + text.push_str("..."); + } + json!(text) + } else { + json!(t.characters) + } + }); + insert_prop(o, options, "fontFamily", || json!(t.style.font_family)); + insert_prop(o, options, "fontStyle", || json!(t.style.font_style)); + insert_prop(o, options, "fontSize", || json!(t.style.font_size)); if !t.style.variations.is_empty() { - o.insert("fontVariations".into(), json!(t.style.variations)); + insert_prop(o, options, "fontVariations", || json!(t.style.variations)); } - o.insert("lineHeight".into(), json!(t.style.line_height.label())); - o.insert("letterSpacing".into(), json!(t.style.letter_spacing.label())); - o.insert("textAlign".into(), json!(t.align_h)); - o.insert("textAutoResize".into(), json!(t.auto_resize)); + insert_prop(o, options, "lineHeight", || json!(t.style.line_height.label())); + insert_prop(o, options, "letterSpacing", || json!(t.style.letter_spacing.label())); + insert_prop(o, options, "textAlign", || json!(t.align_h)); + insert_prop(o, options, "textAutoResize", || json!(t.auto_resize)); if t.max_lines > 0 { - o.insert("maxLines".into(), json!(t.max_lines)); + insert_prop(o, options, "maxLines", || json!(t.max_lines)); } if let Some(tp) = &t.path { - o.insert("textPath".into(), json!(tp)); + insert_prop(o, options, "textPath", || json!(tp)); } if t.style.decoration != TextDecoration::None { - o.insert("textDecoration".into(), json!(t.style.decoration)); + insert_prop(o, options, "textDecoration", || json!(t.style.decoration)); } if let Some(l) = t.style.decoration_look.as_deref() { - o.insert("textDecorationStyle".into(), json!(l.style)); - o.insert("textDecorationThickness".into(), json!(l.thickness.label())); - o.insert("textDecorationOffset".into(), json!(l.offset.label())); - o.insert("textDecorationSkipInk".into(), json!(l.skip_ink)); - o.insert("textDecorationColor".into(), l.color.map_or(json!("auto"), |c| json!(format!("#{}", c.to_hex())))); + insert_prop(o, options, "textDecorationStyle", || json!(l.style)); + insert_prop(o, options, "textDecorationThickness", || json!(l.thickness.label())); + insert_prop(o, options, "textDecorationOffset", || json!(l.offset.label())); + insert_prop(o, options, "textDecorationSkipInk", || json!(l.skip_ink)); + insert_prop(o, options, "textDecorationColor", || l.color.map_or(json!("auto"), |c| json!(format!("#{}", c.to_hex())))); } // Lists: one style for the layer, or each paragraph's type, nesting level and start. if t.paragraphs.is_empty() { if t.list != figcraft_doc::ListStyle::None { - o.insert("listStyle".into(), json!(t.list)); + insert_prop(o, options, "listStyle", || json!(t.list)); } } else { - o.insert("lineTypes".into(), t.paragraphs.iter().map(|p| json!(p.list)).collect()); - o.insert("lineIndentations".into(), t.paragraphs.iter().map(|p| json!(p.indent)).collect()); + insert_prop(o, options, "lineTypes", || t.paragraphs.iter().map(|p| json!(p.list)).collect()); + insert_prop(o, options, "lineIndentations", || t.paragraphs.iter().map(|p| json!(p.indent)).collect()); if t.paragraphs.iter().any(|p| p.start_offset > 0) { - o.insert("listStartOffsets".into(), t.paragraphs.iter().map(|p| json!(p.start_offset)).collect()); + insert_prop(o, options, "listStartOffsets", || t.paragraphs.iter().map(|p| json!(p.start_offset)).collect()); } } if t.list_spacing != 0.0 { - o.insert("listSpacing".into(), json!(t.list_spacing)); + insert_prop(o, options, "listSpacing", || json!(t.list_spacing)); } for (k, on) in [("hangingPunctuation", t.hanging_punctuation), ("hangingList", t.hanging_list)] { if on { - o.insert(k.into(), json!(true)); + insert_prop(o, options, k, || json!(true)); } } if t.leading_trim != figcraft_doc::LeadingTrim::None { - o.insert("leadingTrim".into(), json!(t.leading_trim)); + insert_prop(o, options, "leadingTrim", || json!(t.leading_trim)); } if !t.runs.is_empty() { - o.insert("styledRuns".into(), serde_json::to_value(&t.runs).unwrap_or(Value::Null)); + insert_prop(o, options, "styledRuns", || serde_json::to_value(&t.runs).unwrap_or(Value::Null)); } // Chars linked to text styles: [start, end, style id] (the layer as a whole: `styles.text`). - let linked: Vec = t.spans().into_iter().filter_map(|(a, b, s)| s.style_id.as_ref().map(|id| json!([a, b, id]))).collect(); - if !linked.is_empty() { - o.insert("textStyleRanges".into(), Value::Array(linked)); + if options.includes("textStyleRanges") { + let linked: Vec = t.spans().into_iter().filter_map(|(a, b, s)| s.style_id.as_ref().map(|id| json!([a, b, id]))).collect(); + if !linked.is_empty() { + insert_prop(o, options, "textStyleRanges", || Value::Array(linked)); + } } } -fn kind_props(doc: &Document, n: &Node, o: &mut serde_json::Map) { +fn kind_props(doc: &Document, n: &Node, o: &mut serde_json::Map, options: &InspectOptions) { match &n.kind { NodeKind::Ellipse { start, sweep, inner } if (*sweep).abs() < std::f64::consts::TAU - 1e-9 || *inner > 0.0 => { - o.insert("arc".into(), json!({"start": start.to_degrees(), "sweep": sweep.to_degrees(), "inner": inner})); + insert_prop(o, options, "arc", || json!({"start": start.to_degrees(), "sweep": sweep.to_degrees(), "inner": inner})); } NodeKind::Polygon { count } => { - o.insert("pointCount".into(), json!(count)); + insert_prop(o, options, "pointCount", || json!(count)); } NodeKind::Star { count, inner_ratio } => { - o.insert("pointCount".into(), json!(count)); - o.insert("innerRadius".into(), json!(inner_ratio)); + insert_prop(o, options, "pointCount", || json!(count)); + insert_prop(o, options, "innerRadius", || json!(inner_ratio)); } NodeKind::BooleanOp { op } => { - o.insert("booleanOperation".into(), json!(op)); + insert_prop(o, options, "booleanOperation", || json!(op)); } NodeKind::Instance { component, props } => { - o.insert("mainComponent".into(), json!(component)); - o.insert("mainComponentName".into(), json!(doc.node(*component).map(|c| c.name.clone()))); + insert_prop(o, options, "mainComponent", || json!(component)); + insert_prop(o, options, "mainComponentName", || json!(doc.node(*component).map(|c| c.name.clone()))); if !props.is_empty() { - o.insert("componentProperties".into(), json!(props)); + insert_prop(o, options, "componentProperties", || json!(props)); } } NodeKind::Component { description, props, .. } => { if !description.is_empty() { - o.insert("description".into(), json!(description)); + insert_prop(o, options, "description", || json!(description)); } if !props.is_empty() { - o.insert("componentPropertyDefinitions".into(), serde_json::to_value(props).unwrap_or(Value::Null)); + insert_prop(o, options, "componentPropertyDefinitions", || serde_json::to_value(props).unwrap_or(Value::Null)); } } NodeKind::Vector { network } => { - o.insert("vertices".into(), json!(network.vertices.len())); - o.insert("segments".into(), json!(network.segments.len())); + insert_prop(o, options, "vertices", || json!(network.vertices.len())); + insert_prop(o, options, "segments", || json!(network.segments.len())); } NodeKind::Page { background, flows, .. } => { - o.insert("background".into(), json!(background.to_css_hex())); + insert_prop(o, options, "background", || json!(background.to_css_hex())); if !flows.is_empty() { - o.insert("flows".into(), serde_json::to_value(flows).unwrap_or(Value::Null)); + insert_prop(o, options, "flows", || serde_json::to_value(flows).unwrap_or(Value::Null)); } } _ => {} @@ -339,10 +524,20 @@ fn paints(p: &[figcraft_color::Paint]) -> Value { /// The whole document (current page in depth, other pages summarised). pub fn document(s: &Session, depth: usize, all_pages: bool) -> Value { + document_with_options(s, depth, all_pages, &InspectOptions::default()) +} + +/// The document metadata and a tree built with only the selected layer properties. +pub fn document_with_options(s: &Session, depth: usize, all_pages: bool, options: &InspectOptions) -> Value { let Ok(ds) = s.ds() else { return json!({"documents": 0}) }; let doc = &ds.doc; let pages: Vec = - doc.pages.iter().map(|p| if all_pages || *p == ds.page { node_json(doc, *p, depth) } else { node_json(doc, *p, 0) }).collect(); + doc.pages + .iter() + .map(|p| { + if all_pages || *p == ds.page { node_json_with_options(doc, *p, depth, options) } else { node_json_with_options(doc, *p, 0, options) } + }) + .collect(); json!({ "id": ds.uid, "name": doc.name, diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 3c4fa1256..e4d818439 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -305,9 +305,10 @@ pub fn specs() -> Vec { ok() }), spec!("node.set", "Set properties", super::props::KEYS, true, set), - spec!("node.get", "Get layer", "{id}", false, |s, p| { + spec!("node.get", "Get layer", "{id, fields?: [node keys], outline?: bool (not with fields)}", false, |s, p| { let i = req_id("node.get", p, "id")?; - Ok(crate::inspect::node_json(s.doc()?, i, 1)) + let options = crate::inspect::InspectOptions::parse(p).map_err(|e| bad("node.get", e))?; + Ok(crate::inspect::node_json_with_options(s.doc()?, i, 1, &options)) }), spec!( "node.find", diff --git a/crates/engine/src/inspect_fields_tests.rs b/crates/engine/src/inspect_fields_tests.rs new file mode 100644 index 000000000..2083a5108 --- /dev/null +++ b/crates/engine/src/inspect_fields_tests.rs @@ -0,0 +1,507 @@ +//! Compact inspection keeps the tree useful without building every layer property. + +use figcraft_doc::{NodeId, NodeKind}; +use serde_json::{Value, json}; + +use crate::Session; +use crate::inspect::{InspectOptions, document, document_with_options, node_json, node_json_with_options}; + +fn run(s: &mut Session, command: &str, params: Value) -> Value { + s.execute(command, ¶ms).unwrap_or_else(|e| panic!("{command}: {e}")) +} + +fn create(s: &mut Session, params: Value) -> NodeId { + crate::params::id_of(&run(s, "node.create", params)["id"]).unwrap() +} + +fn tree() -> (Session, NodeId, NodeId) { + let mut s = Session::new(); + let frame = create(&mut s, json!({"type": "frame", "name": "Card", "x": 100, "y": 200, "width": 300, "height": 200})); + let text = create(&mut s, json!({"type": "text", "parent": frame, "name": "Title", "text": "Hello", "x": 10, "y": 20})); + create(&mut s, json!({"type": "rectangle", "parent": frame, "name": "Hidden", "props": {"visible": false, "fill": "#123456"}})); + (s, frame, text) +} + +fn node(s: &Session, id: NodeId, depth: usize, params: Value) -> Value { + node_json_with_options(s.doc().unwrap(), id, depth, &InspectOptions::parse(¶ms).unwrap()) +} + +fn keys(v: &Value, allowed: &[&str]) { + for (key, value) in v.as_object().unwrap() { + assert!(allowed.contains(&key.as_str()), "unexpected {key}: {v}"); + if key == "children" { + for child in value.as_array().unwrap() { + keys(child, allowed); + } + } + } +} + +fn golden_tree() -> (Session, NodeId) { + let mut s = Session::new(); + let frame = create( + &mut s, + json!({"type": "frame", "name": "Card", "x": 100, "y": 200, "width": 300, "height": 200, + "props": {"layout": "horizontal", "padding": 8, "gap": 4, "fill": "#123456", "stroke": "#abcdef", "strokeWeight": 2, + "effects": [{"type": "dropShadow", "offset": [2, 3], "radius": 4}], "cornerRadius": 12}}), + ); + create( + &mut s, + json!({"type": "text", "parent": frame, "name": "Title", "text": "Hello", "width": 80, "height": 24, + "props": {"fontFamily": "Synthetic", "fontSize": 18, "fontStyle": "Bold", "letterSpacing": 1, "lineHeight": 24, + "textAlign": "center", "textDecoration": "underline", "fill": "#fedcba"}}), + ); + create( + &mut s, + json!({"type": "rectangle", "parent": frame, "name": "Hidden", "width": 20, "height": 30, + "props": {"visible": false, "locked": true, "fill": "#654321"}}), + ); + let main = create(&mut s, json!({"type": "component", "name": "Button", "x": 400, "y": 200, "width": 80, "height": 40})); + run(&mut s, "instance.create", json!({"component": main, "x": 500, "y": 200})); + run(&mut s, "page.add", json!({"name": "Second"})); + (s, frame) +} + +#[test] +fn empty_options_keep_the_main_tree_and_document_output() { + let (s, frame) = golden_tree(); + // Captured with this builder on origin/main (43af145a12eacd1e87f5f5e6984c49b7da040288), plus the + // document `id` that #1533 added. That id comes from a process-wide counter, so it is the + // session's own. + let expected = json!({ + "colorProfile": "srgb", + "currentPage": 7, + "dirty": true, + "history": { + "redo": 0, + "undo": [ + "Add page", + "Create instance", + "Create layer", + "Create layer", + "Create layer", + "Create layer" + ] + }, + "id": s.ds().unwrap().uid, + "images": 0, + "kind": "design", + "name": "Untitled", + "pages": [ + { + "background": "#F5F5F5", + "children": [ + { + "absoluteBounds": { + "height": 200.0, + "width": 300.0, + "x": 100.0, + "y": 200.0 + }, + "children": [ + { + "absoluteBounds": { + "height": 24.0, + "width": 80.0, + "x": 110.0, + "y": 210.0 + }, + "characters": "Hello", + "fills": [ + { + "color": "#FEDCBA", + "type": "solid" + } + ], + "fontFamily": "Synthetic", + "fontSize": 18.0, + "fontStyle": "Bold", + "height": 24.0, + "id": 3, + "letterSpacing": "1", + "lineHeight": "24", + "name": "Title", + "sizing": { + "horizontal": "fixed", + "vertical": "hug" + }, + "textAlign": "center", + "textAutoResize": "height", + "textDecoration": "underline", + "type": "TEXT", + "width": 80.0, + "x": 10.0, + "y": 10.0 + }, + { + "absoluteBounds": { + "height": 30.0, + "width": 20.0, + "x": 100.0, + "y": 200.0 + }, + "fills": [ + { + "color": "#654321", + "type": "solid" + } + ], + "height": 30.0, + "id": 4, + "locked": true, + "name": "Hidden", + "sizing": { + "horizontal": "fixed", + "vertical": "fixed" + }, + "type": "RECTANGLE", + "visible": false, + "width": 20.0, + "x": 0.0, + "y": 0.0 + } + ], + "clip": true, + "cornerRadius": 12.0, + "effects": [ + { + "blend": "normal", + "color": { + "a": 0.25, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + "offset": [ + 2.0, + 3.0 + ], + "radius": 4.0, + "showBehind": false, + "spread": 0.0, + "type": "dropShadow", + "visible": true + } + ], + "fills": [ + { + "color": "#123456", + "type": "solid" + } + ], + "height": 200.0, + "id": 2, + "layout": { + "columnTracks": [], + "contentAlign": "auto", + "crossAlign": "min", + "crossGap": 10.0, + "gap": 4.0, + "gridColumns": 2, + "gridRows": 2, + "mainAlign": "min", + "mode": "horizontal", + "padding": [ + 8.0, + 8.0, + 8.0, + 8.0 + ], + "reverseZ": false, + "rowTracks": [], + "strokesIncluded": true, + "wrap": false + }, + "name": "Card", + "sizing": { + "horizontal": "fixed", + "vertical": "fixed" + }, + "strokeAlign": "inside", + "strokeWeight": 2.0, + "strokes": [ + { + "color": "#ABCDEF", + "type": "solid" + } + ], + "type": "FRAME", + "width": 300.0, + "x": 100.0, + "y": 200.0 + }, + { + "absoluteBounds": { + "height": 40.0, + "width": 80.0, + "x": 400.0, + "y": 200.0 + }, + "clip": true, + "fills": [ + { + "color": "#FFFFFF", + "type": "solid" + } + ], + "height": 40.0, + "id": 5, + "name": "Button", + "type": "COMPONENT", + "width": 80.0, + "x": 400.0, + "y": 200.0 + }, + { + "absoluteBounds": { + "height": 40.0, + "width": 80.0, + "x": 500.0, + "y": 200.0 + }, + "clip": true, + "fills": [ + { + "color": "#FFFFFF", + "type": "solid" + } + ], + "height": 40.0, + "id": 6, + "mainComponent": 5, + "mainComponentName": "Button", + "name": "Button", + "type": "INSTANCE", + "width": 80.0, + "x": 500.0, + "y": 200.0 + } + ], + "id": 1, + "name": "Page 1", + "type": "PAGE" + }, + { + "background": "#F5F5F5", + "id": 7, + "name": "Second", + "type": "PAGE" + } + ], + "path": null, + "selection": [], + "styles": [], + "textEdit": null, + "variables": 0 + }); + let expected_node = &expected["pages"][0]["children"][0]; + assert_eq!(node_json(s.doc().unwrap(), frame, 4).to_string(), expected_node.to_string()); + assert_eq!(document(&s, 4, true).to_string(), expected.to_string()); + for params in [json!({}), json!({"outline": false})] { + let options = InspectOptions::parse(¶ms).unwrap(); + assert_eq!(node_json_with_options(s.doc().unwrap(), frame, 4, &options).to_string(), expected_node.to_string()); + assert_eq!(document_with_options(&s, 4, true, &options).to_string(), expected.to_string()); + } +} + +#[test] +fn fields_filter_recursively_including_pages_without_changing_metadata_or_history() { + let (s, _, _) = tree(); + let before = document(&s, 4, true); + let selection = s.selection(); + let history = s.undo_depth(); + let options = InspectOptions::parse(&json!({"fields": ["name", "characters", "name"], "outline": false})).unwrap(); + let compact = document_with_options(&s, 4, true, &options); + let mut metadata = before.clone(); + metadata["pages"] = compact["pages"].clone(); + assert_eq!(compact, metadata); + keys(&compact["pages"][0], &["id", "name", "characters", "children", "childCount"]); + assert_eq!(compact["pages"][0]["children"][0]["children"][0]["characters"], "Hello"); + assert_eq!(s.selection(), selection); + assert_eq!(s.undo_depth(), history); + assert_eq!(document(&s, 4, true), before); +} + +#[test] +fn an_empty_field_list_keeps_ids_and_structure_at_depth_boundaries() { + let (s, frame, _) = tree(); + let compact = node(&s, frame, 4, json!({"fields": []})); + keys(&compact, &["id", "children", "childCount"]); + assert_eq!(compact["children"].as_array().unwrap().len(), 2); + assert_eq!(node(&s, frame, 0, json!({"fields": []})), json!({"id": frame, "childCount": 2})); + let options = InspectOptions::parse(&json!({"fields": []})).unwrap(); + let summary = document_with_options(&s, 0, false, &options); + assert_eq!(summary["pages"][0], json!({"id": s.page().unwrap(), "childCount": 1})); +} + +#[test] +fn geometry_keys_are_independently_selectable() { + let (s, frame, text) = tree(); + let fields = ["x", "y", "width", "height", "absoluteBounds"]; + let compact = node(&s, frame, 2, json!({"fields": fields})); + keys(&compact, &["id", "children", "childCount", "x", "y", "width", "height", "absoluteBounds"]); + assert_eq!((compact["x"].as_f64(), compact["y"].as_f64()), (Some(100.0), Some(200.0))); + assert_eq!(compact["children"][0]["x"], 10.0); + assert_eq!(compact["children"][0]["absoluteBounds"]["x"], 110.0); + for key in fields { + let one = node(&s, text, 0, json!({"fields": [key]})); + assert_eq!(one.as_object().unwrap().len(), 2, "{one}"); + assert_eq!(one[key], compact["children"][0][key]); + } +} + +#[test] +fn outline_truncates_only_after_120_unicode_scalars() { + let (mut s, _, text) = tree(); + for unit in ["a", "界", "\u{1f980}"] { + for len in [0, 119, 120, 121] { + let content = unit.repeat(len); + s.doc_mut().unwrap().get_mut(text).unwrap().kind.text_mut().unwrap().characters = content.clone(); + let outline = node(&s, text, 0, json!({"outline": true})); + let want = if len > 120 { format!("{}...", unit.repeat(120)) } else { content.clone() }; + assert_eq!(outline["characters"], want); + assert_eq!(node(&s, text, 0, json!({"fields": ["characters"]}))["characters"], content); + keys(&outline, &["id", "name", "type", "characters", "mainComponentName", "visible", "children", "childCount"]); + } + } +} + +#[test] +fn selecting_visible_makes_it_explicit_for_every_node_including_pages() { + let (s, frame, _) = tree(); + for params in [json!({"fields": ["visible"]}), json!({"outline": true})] { + let options = InspectOptions::parse(¶ms).unwrap(); + let tree = document_with_options(&s, 4, true, &options); + assert_eq!(tree["pages"][0]["visible"], true); + let card = &tree["pages"][0]["children"][0]; + assert_eq!(card["visible"], true); + assert_eq!(card["children"][0]["visible"], true); + assert_eq!(card["children"][1]["visible"], false); + } + let no_visible = node(&s, frame, 4, json!({"fields": ["name"]})); + assert!(no_visible["children"][1].get("visible").is_none()); +} + +#[test] +fn component_names_are_selected_without_other_instance_properties_even_with_a_missing_main() { + let mut s = Session::new(); + let main = create(&mut s, json!({"type": "component", "name": "Button"})); + let instance = crate::params::id_of(&run(&mut s, "instance.create", json!({"component": main}))["id"]).unwrap(); + for params in [json!({"fields": ["mainComponentName"]}), json!({"outline": true})] { + let compact = node(&s, instance, 0, params); + assert_eq!(compact["mainComponentName"], "Button"); + assert!(compact.get("mainComponent").is_none()); + } + if let NodeKind::Instance { component, .. } = &mut s.doc_mut().unwrap().get_mut(instance).unwrap().kind { + *component = NodeId(u64::MAX); + } + let compact = node(&s, instance, 0, json!({"outline": true})); + assert_eq!(compact.get("mainComponentName"), Some(&Value::Null)); +} + +#[test] +fn page_properties_and_other_page_summaries_respect_fields() { + let (mut s, _, _) = tree(); + let first = s.page().unwrap(); + run(&mut s, "node.set", json!({"ids": [first], "props": {"pluginData": {"source": "synthetic"}}})); + run(&mut s, "page.add", json!({"name": "Second"})); + let options = InspectOptions::parse(&json!({"fields": ["pluginData", "background"]})).unwrap(); + let compact = document_with_options(&s, 4, false, &options); + assert_eq!(compact["pages"].as_array().unwrap().len(), 2); + let page = &compact["pages"][0]; + keys(page, &["id", "pluginData", "background", "childCount", "children"]); + assert_eq!(page["pluginData"]["source"], "synthetic"); + assert_eq!(page["childCount"], 1); + assert!(page["background"].is_string()); + assert!(page.get("name").is_none()); +} + +#[test] +fn the_catalogue_accepts_emitted_keys_and_structure_keys() { + let (mut s, frame, text) = tree(); + run(&mut s, "node.set", json!({"ids": [frame], "props": {"layout": "grid", "fill": "#123456", "stroke": "#abcdef"}})); + s.doc_mut().unwrap().get_mut(frame).unwrap().dev_note = "Ready for review".into(); + s.doc_mut().unwrap().get_mut(frame).unwrap().dev_changed = true; + let main = create(&mut s, json!({"type": "component", "name": "Button"})); + let instance = crate::params::id_of(&run(&mut s, "instance.create", json!({"component": main}))["id"]).unwrap(); + s.doc_mut().unwrap().get_mut(text).unwrap().kind.text_mut().unwrap().style.style_id = Some("synthetic-style".into()); + // Exercise each of the six property helpers, including their more expensive values. + for (id, key, sibling) in [ + (frame, "absoluteBounds", "width"), + (frame, "fills", "strokes"), + (text, "gridCell", "sizing"), + (frame, "devNote", "devChanged"), + (text, "textStyleRanges", "fontFamily"), + (instance, "mainComponentName", "mainComponent"), + ] { + let full = node_json(s.doc().unwrap(), id, 0); + assert!(full.get(key).is_some(), "fixture must emit {key}: {full}"); + assert!(full.get(sibling).is_some(), "fixture must emit {sibling}: {full}"); + let selected = node(&s, id, 0, json!({"fields": [key]})); + assert_eq!(selected[key], full[key]); + assert!(selected.get(sibling).is_none(), "unrequested {sibling}: {selected}"); + keys(&selected, &["id", "children", "childCount", key]); + } + let full = node_json(s.doc().unwrap(), s.page().unwrap(), 3); + fn check(s: &Session, full: &Value) { + let id = crate::params::id_of(&full["id"]).unwrap(); + for (key, value) in full.as_object().unwrap() { + let selected = node(s, id, 2, json!({"fields": [key]})); + if key == "children" { + assert_eq!(selected[key].as_array().map(Vec::len), value.as_array().map(Vec::len)); + } else { + assert_eq!(&selected[key], value, "{key}"); + } + } + for child in full["children"].as_array().into_iter().flatten() { + check(s, child); + } + } + check(&s, &full); + let structural = node(&s, frame, 0, json!({"fields": ["id", "children", "childCount"]})); + assert_eq!(structural, json!({"id": frame, "childCount": 2})); +} + +#[test] +fn invalid_options_are_errors_and_unknown_names_list_the_catalogue() { + for params in [ + json!({"fields": null}), + json!({"fields": "name"}), + json!({"fields": {"name": true}}), + json!({"fields": [false]}), + json!({"fields": [null]}), + json!({"fields": ["name", 1]}), + json!({"outline": null}), + json!({"outline": "true"}), + json!({"outline": 1}), + json!({"outline": []}), + json!({"outline": true, "fields": []}), + json!({"outline": true, "fields": ["name"]}), + ] { + assert!(InspectOptions::parse(¶ms).is_err(), "{params}"); + } + for key in ["Name", "absolute_bounds", "noSuchField", "truncated"] { + let error = InspectOptions::parse(&json!({"fields": [key]})).unwrap_err(); + assert!(error.contains(key), "{error}"); + for valid in crate::inspect::NODE_KEYS { + assert!(error.contains(valid), "missing {valid}: {error}"); + } + } +} + +#[test] +fn node_get_uses_options_but_keeps_its_existing_depth_and_selection() { + let (mut s, frame, _) = tree(); + let selection = s.selection(); + let history = s.undo_depth(); + let default = node_json(s.doc().unwrap(), frame, 1); + assert_eq!(run(&mut s, "node.get", json!({"id": frame})), default); + let compact = run(&mut s, "node.get", json!({"id": frame, "fields": []})); + assert!(compact["children"].is_array(), "the engine command still inspects to depth 1"); + let error = s.execute("node.get", &json!({"id": frame, "fields": [], "depth": 0})).unwrap_err(); + assert!(error.to_string().contains("unknown parameter `depth`"), "{error}"); + keys(&compact, &["id", "children", "childCount"]); + assert!(s.execute("node.get", &json!({"id": frame, "outline": true, "fields": []})).is_err()); + assert_eq!(s.selection(), selection); + assert_eq!(s.undo_depth(), history); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index cedc61be6..e5b924fcd 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -349,6 +349,8 @@ mod import_journal_tests; #[cfg(test)] mod inspect_behaviour_tests; #[cfg(test)] +mod inspect_fields_tests; +#[cfg(test)] mod instance_modes_tests; #[cfg(test)] mod instance_resizing_tests; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e566f6613..e983d6064 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -459,6 +459,12 @@ fn every_command_survives_junk_params() { // would write to it.) json!({"ids": [2, 3], "type": "vector", "path": [5], "width": -1e308, "props": {"path": "M0 0 L1e308 0 L-1e308 1e308", "height": "NaN", "x": 1e308}}), json!({"id": 2, "type": "vector", "path": {"d": 1}, "props": {"path": "M NaN 0 Z", "width": 1e-308}}), + // Compact inspection: fields and outline with malformed types and unknown keys. + json!({"id": 2, "fields": "name", "outline": true}), + json!({"id": 2, "fields": ["name", 5]}), + json!({"id": 2, "fields": ["Name", "noSuchField"]}), + json!({"id": 2, "fields": null, "outline": []}), + json!({"id": 2, "outline": "yes"}), // Layer search roots, filters and limits as junk. json!({"root": {"id": 2}, "name": [1], "type": "nope", "text": true, "component": [], "limit": -1}), json!({"root": 999999, "limit": 1e308}), diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 3ac5a57cf..449bbd353 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -252,7 +252,8 @@ impl Headless { )), "document.inspect" => { let depth = p.get("depth").and_then(Value::as_u64).unwrap_or(8).min(64) as usize; - Ok(figcraft_engine::inspect::document(&self.session, depth, b(&p, "allPages"))) + let options = figcraft_engine::inspect::InspectOptions::parse(&p)?; + Ok(figcraft_engine::inspect::document_with_options(&self.session, depth, b(&p, "allPages"), &options)) } "node.get" => { let id = p.get("id").and_then(figcraft_engine::params::id_of).ok_or("`id` required (a node id)")?; @@ -261,7 +262,8 @@ impl Headless { if !doc.contains(id) { return Err(format!("no such node {id}")); } - Ok(figcraft_engine::inspect::node_json(doc, id, depth)) + let options = figcraft_engine::inspect::InspectOptions::parse(&p)?; + Ok(figcraft_engine::inspect::node_json_with_options(doc, id, depth, &options)) } "ui.inspect" => Ok(self.inspect_ui()), "ui.pointer" => self.pointer(&p), diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index e82c30c8e..2cf139915 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -64,6 +64,15 @@ fn id_schema(desc: &str) -> Value { fn ids_schema(desc: &str) -> Value { json!({"type": "array", "description": desc, "items": {"anyOf": [{"type": "integer"}, {"type": "string"}]}}) } +fn fields_schema() -> Value { + json!({"type": "array", "items": {"type": "string", "enum": figcraft_engine::inspect::NODE_KEYS}, + "description": "Exact, case-sensitive top-level node keys to include at every level, pages too (e.g. name, type, x, fills, characters, pluginData). Omit for all properties; [] keeps only ids and structure. id, children, childCount and the transport's truncated marker are always kept. Selecting visible emits an explicit boolean for every node. Unknown keys are an error."}) +} +fn outline_schema() -> Value { + boolean( + "Compact tree: id, name, type, characters (first 120 characters, then ... only if longer), mainComponentName and explicit visible booleans, plus structure. Cannot combine true with fields; false permits fields.", + ) +} fn obj(props: Value, required: &[&str]) -> Value { let mut o = json!({"type": "object", "properties": props}); if !required.is_empty() { @@ -213,6 +222,8 @@ fn tools() -> Vec { obj( json!({ "depth": int("Tree depth for the current page (default 4)"), + "fields": fields_schema(), + "outline": outline_schema(), "allPages": boolean("Expand every page, not only the current one"), "maxBytes": int( "Largest response to send, in bytes of the whole JSON-RPC message (default 8388608, which fits the usual 10 MiB client buffer); 1024 of it is kept for the envelope and the request id" @@ -230,6 +241,8 @@ fn tools() -> Vec { json!({ "id": id_schema("Layer id"), "depth": int("Child depth (default 1)"), + "fields": fields_schema(), + "outline": outline_schema(), "offset": int("First child to report (default 0), for reading a node with more children than fit in one message"), "maxBytes": int( "Largest response to send, in bytes of the whole JSON-RPC message (default 8388608, which fits the usual 10 MiB client buffer); 1024 of it is kept for the envelope and the request id" @@ -751,19 +764,31 @@ fn wire_size(value: &Value) -> usize { /// The document at `depth`, and what its text block would take on the wire. The tree is sent as a /// text block, so it is measured pretty-printed *and* escaped -- escaping adds about a tenth. -fn inspect_at(b: &mut dyn Backend, depth: u64, all: bool) -> Result<(Value, usize), String> { - let value = b.call("document.inspect", json!({"depth": depth, "allPages": all}))?; +fn inspect_at(b: &mut dyn Backend, depth: u64, all: bool, options: &Value) -> Result<(Value, usize), String> { + let mut params = options.clone(); + params["depth"] = json!(depth); + params["allPages"] = json!(all); + let value = b.call("document.inspect", params)?; let wire = wire_size(&value); Ok((value, wire)) } /// One layer's subtree at `depth`, and what its text block would take on the wire. -fn node_at(b: &mut dyn Backend, id: u64, depth: u64) -> Result<(Value, usize), String> { - let value = b.call("node.get", json!({"id": id, "depth": depth}))?; +fn node_at(b: &mut dyn Backend, id: u64, depth: u64, options: &Value) -> Result<(Value, usize), String> { + let mut params = options.clone(); + params["id"] = json!(id); + params["depth"] = json!(depth); + let value = b.call("node.get", params)?; let wire = wire_size(&value); Ok((value, wire)) } +fn inspection_options(a: &Map) -> Result { + let options = Value::Object(a.iter().filter(|(k, _)| matches!(k.as_str(), "fields" | "outline")).map(|(k, v)| (k.clone(), v.clone())).collect()); + figcraft_engine::inspect::InspectOptions::parse(&options)?; + Ok(options) +} + fn inspect_document(b: &mut dyn Backend, a: &Map) -> Result { let depth = n_arg(a, "depth")?.unwrap_or(4.0).clamp(0.0, 64.0) as u64; let all = match a.get("allPages") { @@ -771,9 +796,10 @@ fn inspect_document(b: &mut dyn Backend, a: &Map) -> Result *x, Some(_) => return Err("`allPages` must be a boolean".into()), }; + let options = inspection_options(a)?; let (max_bytes, budget) = budget(a)?; - let (value, wire) = inspect_at(b, depth, all)?; + let (value, wire) = inspect_at(b, depth, all, &options)?; if wire <= budget { return Ok(ToolResult::json(&value)); } @@ -791,7 +817,7 @@ fn inspect_document(b: &mut dyn Backend, a: &Map) -> Result = None; while lo <= hi { let mid = lo + (hi - lo) / 2; - let (v, w) = inspect_at(b, mid, all)?; + let (v, w) = inspect_at(b, mid, all, &options)?; if w + MARKER_RESERVE <= budget { best = Some((mid, v, w)); if mid == hi { @@ -840,9 +866,10 @@ fn get_node(b: &mut dyn Backend, a: &Map) -> Result) -> Result(created["content"][0]["text"].as_str().unwrap()).unwrap()["id"].clone(); + let r = call(&mut s, "get_node", json!({"id": id, "outline": true})); + assert_eq!(r["isError"], false, "{r}"); + let outline: Value = serde_json::from_str(r["content"][0]["text"].as_str().unwrap()).unwrap(); + assert_eq!(outline["characters"], format!("{}...", "界".repeat(120))); + assert_eq!(outline["visible"], true); + assert!(outline.get("fontSize").is_none()); + let r = call(&mut s, "inspect_document", json!({"fields": [], "outline": false})); + assert_eq!(r["isError"], false, "{r}"); + let compact: Value = serde_json::from_str(r["content"][0]["text"].as_str().unwrap()).unwrap(); + assert_eq!(compact["pages"][0]["children"][0], json!({"id": id})); + assert!(compact["pages"][0].get("name").is_none()); +} + #[test] fn an_attached_agent_comments_under_its_own_name() { let mut s = Server::new(Box::new(Attached::port(fake_app()).unwrap())); diff --git a/crates/mcp/tests/it/inspect_fields.rs b/crates/mcp/tests/it/inspect_fields.rs new file mode 100644 index 000000000..19119aeb8 --- /dev/null +++ b/crates/mcp/tests/it/inspect_fields.rs @@ -0,0 +1,207 @@ +//! Compact trees are built by the backend, including every size-budget retry. + +use crate::common::Client; +use figcraft_mcp::{Backend, Headless, call_tool}; +use serde_json::{Value, json}; + +fn tree(mut create: impl FnMut(Value) -> Value) -> Value { + let root = create(json!({"type": "frame", "name": "Product list", "width": 1200, "height": 2400})); + for i in 0..24 { + let card = create(json!({"type": "frame", "parent": root, "name": format!("Product card {i}"), "width": 280, "height": 160, + "props": {"fill": "#F0F0F0", "cornerRadius": 12, "pluginData": {"source": "synthetic".repeat(40)}}})); + for j in 0..6 { + create(json!({"type": "text", "parent": card, "name": format!("Description {j}"), "text": "Synthetic product description. ".repeat(8), + "x": 12, "y": j * 20, "width": 240})); + } + } + root +} + +fn compact_keys(node: &Value, outline: bool) { + let allowed: &[&str] = if outline { + &["id", "name", "type", "characters", "mainComponentName", "visible", "children", "childCount", "truncated"] + } else { + &["id", "name", "children", "childCount", "truncated"] + }; + for key in node.as_object().unwrap().keys() { + assert!(allowed.contains(&key.as_str()), "unexpected {key}: {node}"); + } + for child in node["children"].as_array().into_iter().flatten() { + compact_keys(child, outline); + } +} + +#[test] +fn tool_schemas_describe_compact_inspection_options() { + let mut c = Client::new(); + let tools = c.rpc("tools/list", json!({})); + for name in ["inspect_document", "get_node"] { + let tool = tools["result"]["tools"].as_array().unwrap().iter().find(|t| t["name"] == name).unwrap(); + let props = &tool["inputSchema"]["properties"]; + assert_eq!(props["fields"]["type"], "array"); + assert_eq!(props["fields"]["items"]["type"], "string"); + assert!(props["fields"]["items"]["enum"].as_array().unwrap().contains(&json!("absoluteBounds"))); + assert_eq!(props["outline"]["type"], "boolean"); + assert!(props["fields"]["description"].as_str().unwrap().contains("case-sensitive")); + assert!(props["outline"]["description"].as_str().unwrap().contains("120")); + } +} + +#[test] +fn a_realistic_nested_tree_is_much_smaller_with_fields_or_outline() { + let mut c = Client::new(); + let root = tree(|params| c.create(params)); + for (tool, mut args) in [("inspect_document", json!({"depth": 6})), ("get_node", json!({"id": root, "depth": 6}))] { + let full = c.ok(tool, args.clone()); + let full_bytes = full.to_string().len(); + args["outline"] = json!(true); + let outline = c.ok(tool, args.clone()); + assert!(outline.to_string().len() * 2 < full_bytes, "{tool}: outline {} vs full {full_bytes}", outline.to_string().len()); + args.as_object_mut().unwrap().remove("outline"); + args["fields"] = json!([]); + let ids = c.ok(tool, args); + assert!(ids.to_string().len() * 5 < full_bytes, "{tool}: ids {} vs full {full_bytes}", ids.to_string().len()); + if tool == "inspect_document" { + assert_eq!(outline["history"], full["history"]); + assert_eq!(ids["selection"], full["selection"]); + compact_keys(&outline["pages"][0], true); + } else { + compact_keys(&outline, true); + } + } +} + +struct Recording { + inner: Headless, + calls: Vec<(String, Value)>, +} + +impl Backend for Recording { + fn call(&mut self, method: &str, params: Value) -> Result { + self.calls.push((method.into(), params.clone())); + self.inner.call(method, params) + } + fn has_ui(&self) -> bool { + false + } + fn describe(&self) -> String { + "recording headless".into() + } +} + +#[test] +fn fields_and_outline_survive_every_depth_retry() { + let mut backend = Recording { inner: Headless::new(), calls: Vec::new() }; + let root = tree(|params| { + let result = call_tool(&mut backend, "create_node", ¶ms); + assert!(!result.is_error, "{}", result.first_text()); + serde_json::from_str::(result.first_text()).unwrap()["id"].clone() + }); + for options in [json!({"fields": ["name"], "outline": false}), json!({"outline": true})] { + for (tool, method) in [("inspect_document", "document.inspect"), ("get_node", "node.get")] { + backend.calls.clear(); + let mut args = options.clone(); + if tool == "get_node" { + args["id"] = root.clone(); + } + args["depth"] = json!(6); + args["maxBytes"] = json!(6000); + let result = call_tool(&mut backend, tool, &args); + assert!(!result.is_error, "{}", result.first_text()); + let value: Value = serde_json::from_str(result.first_text()).unwrap(); + assert!(value["truncated"].is_object(), "{tool}: {value}"); + assert!(backend.calls.len() > 1, "must exercise retries: {tool}"); + for (called, params) in &backend.calls { + assert_eq!(called, method); + for key in ["fields", "outline"] { + assert_eq!(params.get(key), options.get(key), "{tool}: {params}"); + } + } + let outline = options["outline"] == true; + if tool == "inspect_document" { + compact_keys(&value["pages"][0], outline); + } else { + compact_keys(&value, outline); + } + } + } +} + +#[test] +fn compact_children_keep_window_markers_and_page_in_order() { + let mut c = Client::new(); + let root = tree(|params| c.create(params)); + let full = c.ok("get_node", json!({"id": root, "depth": 1, "fields": []})); + let expected = full["children"].as_array().unwrap(); + let wire = serde_json::to_string(&serde_json::to_string_pretty(&full).unwrap()).unwrap().len(); + let limit = wire + 1024 - 1; + assert!(limit > 2048, "room for both the envelope and the marker"); + let mut seen = Vec::new(); + let mut offset = Some(0); + let mut pages = 0; + while let Some(at) = offset { + let page = c.ok("get_node", json!({"id": root, "depth": 1, "fields": [], "offset": at, "maxBytes": limit})); + assert!(page["truncated"]["children"].is_object(), "{page}"); + let wire = serde_json::to_string(&serde_json::to_string_pretty(&page).unwrap()).unwrap().len(); + assert!(wire + 1024 <= limit, "page at {at} exceeds maxBytes: {wire} + envelope > {limit}"); + assert_eq!(page["truncated"]["children"]["from"], at); + seen.extend(page["children"].as_array().unwrap().iter().cloned()); + offset = page["truncated"]["children"]["nextOffset"].as_u64(); + pages += 1; + assert!(pages <= expected.len()); + } + assert!(pages > 1, "the fixture must require a child window"); + assert_eq!(&seen, expected); +} + +fn json_example(section: &str) -> &str { + section.split("```json\n").nth(1).unwrap().split("```").next().unwrap() +} + +#[test] +fn compact_examples_run_verbatim_from_the_docs() { + let mut c = Client::new(); + for _ in 0..2 { + c.create(json!({"type": "rectangle"})); + } + let docs = include_str!("../../../../docs/mcp.md"); + let section = docs.split("### Compact layer trees\n").nth(1).unwrap(); + for line in json_example(section).lines() { + let reply: Value = serde_json::from_str(&c.server.handle_line(line).unwrap()).unwrap(); + assert_eq!(reply["result"]["isError"], false, "{reply}"); + } + let mut h = Headless::new(); + for _ in 0..2 { + h.call("engine.execute", json!({"command": "node.create", "params": {"type": "rectangle"}})).unwrap(); + } + let docs = include_str!("../../../../docs/control-protocol.md"); + let section = docs.split("### `node.get ").nth(1).unwrap(); + for line in json_example(section).lines() { + let request: Value = serde_json::from_str(line).unwrap(); + h.call(request["method"].as_str().unwrap(), request["params"].clone()).unwrap(); + } +} + +#[test] +fn invalid_compact_options_fail_in_tools_and_headless_control() { + let mut c = Client::new(); + let id = c.create(json!({"type": "rectangle"})); + let mut h = Headless::new(); + let created = h.call("engine.execute", json!({"command": "node.create", "params": {"type": "rectangle"}})).unwrap(); + assert_eq!(created["id"], id); + for mut args in [ + json!({"fields": [1]}), + json!({"fields": null}), + json!({"fields": ["Name"]}), + json!({"outline": "yes"}), + json!({"outline": true, "fields": []}), + ] { + args["id"] = id.clone(); + for tool in ["inspect_document", "get_node"] { + assert!(!c.err(tool, args.clone()).is_empty()); + } + for method in ["document.inspect", "node.get"] { + assert!(h.call(method, args.clone()).is_err(), "{method}: {args}"); + } + } +} diff --git a/crates/mcp/tests/it/main.rs b/crates/mcp/tests/it/main.rs index 5233cd7fe..862f46064 100644 --- a/crates/mcp/tests/it/main.rs +++ b/crates/mcp/tests/it/main.rs @@ -17,6 +17,8 @@ mod export_xml; #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod garbage; #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod inspect_fields; +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod inspect_response_limit; #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod overlay_anchor; diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index c92787a0d..f2697f583 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -1398,12 +1398,16 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul )), "document.inspect" => { let depth = p.get("depth").and_then(Value::as_u64).unwrap_or(8).min(64) as usize; - in_document(app, p, |s| Ok(figcraft_engine::inspect::document(s, depth, b(p, "allPages")))) + let options = figcraft_engine::inspect::InspectOptions::parse(p)?; + in_document(app, p, |s| Ok(figcraft_engine::inspect::document_with_options(s, depth, b(p, "allPages"), &options))) } "node.get" => { let id = p.get("id").and_then(figcraft_engine::params::id_of).ok_or("`id` required")?; let depth = p.get("depth").and_then(Value::as_u64).unwrap_or(1) as usize; - in_document(app, p, |s| s.doc().map(|d| figcraft_engine::inspect::node_json(d, id, depth)).map_err(|e| e.to_string())) + let options = figcraft_engine::inspect::InspectOptions::parse(p)?; + in_document(app, p, |s| { + s.doc().map(|d| figcraft_engine::inspect::node_json_with_options(d, id, depth, &options)).map_err(|e| e.to_string()) + }) } "window.new" => { let index = p.get("index").and_then(Value::as_u64).map(|i| i as usize); diff --git a/crates/ui-egui/src/inspect_fields_tests.rs b/crates/ui-egui/src/inspect_fields_tests.rs new file mode 100644 index 000000000..102e232b3 --- /dev/null +++ b/crates/ui-egui/src/inspect_fields_tests.rs @@ -0,0 +1,48 @@ +//! Attached control replies build compact layer trees before they leave the app. + +use figcraft_engine::Session; +use serde_json::{Value, json}; + +use crate::layers_drag_tests::Ui; + +fn call(u: &mut Ui, method: &str, params: Value) -> Result { + crate::control::handle(&mut u.app, &u.ctx.clone(), method, ¶ms) +} + +#[test] +fn control_inspection_filters_nodes_and_pages_before_replying() { + let mut u = Ui::new(Session::new()); + let frame = call(&mut u, "engine.execute", json!({"command": "node.create", "params": {"type": "frame", "name": "Card"}})).unwrap()["id"].clone(); + let text = + call(&mut u, "engine.execute", json!({"command": "node.create", "params": {"type": "text", "parent": frame, "text": "界".repeat(121)}})) + .unwrap()["id"] + .clone(); + let default = call(&mut u, "document.inspect", json!({"depth": 3})).unwrap(); + let selection = u.app.session.selection(); + let history = u.app.session.undo_depth(); + let compact = call(&mut u, "document.inspect", json!({"depth": 3, "fields": []})).unwrap(); + let page = &compact["pages"][0]; + assert!(page.get("name").is_none()); + assert_eq!(page["children"][0]["children"][0], json!({"id": text})); + let shallow = call(&mut u, "node.get", json!({"id": frame, "depth": 0, "fields": []})).unwrap(); + assert_eq!(shallow, json!({"id": frame, "childCount": 1})); + let outline = call(&mut u, "node.get", json!({"id": frame, "depth": 1, "outline": true})).unwrap(); + assert_eq!(outline["children"][0]["characters"], format!("{}...", "界".repeat(120))); + assert_eq!(outline["visible"], true); + assert!(outline.get("fills").is_none()); + assert_eq!(u.app.session.selection(), selection); + assert_eq!(u.app.session.undo_depth(), history); + assert_eq!(call(&mut u, "document.inspect", json!({"depth": 3})).unwrap(), default); +} + +#[test] +fn control_inspection_rejects_invalid_options_consistently() { + let mut u = Ui::new(Session::new()); + for method in ["document.inspect", "node.get"] { + for mut params in [json!({"fields": ["Name"]}), json!({"fields": [2]}), json!({"outline": "true"}), json!({"outline": true, "fields": []})] { + params["id"] = json!(u.app.session.page().unwrap()); + let error = call(&mut u, method, params).unwrap_err(); + assert!(error.contains("fields") || error.contains("field") || error.contains("outline"), "{error}"); + } + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 33c83164b..3aa8baa10 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -122,6 +122,8 @@ mod ime_tests; #[cfg(test)] mod import_report_tests; #[cfg(test)] +mod inspect_fields_tests; +#[cfg(test)] mod layers_behaviour_tests; #[cfg(test)] mod layers_drag_tests; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 6c2d85018..efd5e2f88 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -163,14 +163,15 @@ against the shared log, not by the UI thread, so it does not freeze the app, use need a drawn frame: it works while the window is minimised. Other requests on the same connection wait until the poll finishes (at most 25 s); use a separate connection to keep editing meanwhile. -### `document.inspect {depth?, allPages?}` +### `document.inspect {depth?, allPages?, fields?, outline?}` The active document as JSON: name, path, dirty, currentPage, selection, pages (the current page's layer tree to `depth`, default 8; others summarised unless `allPages`), styles, variable count, images, the colour profile (`colorProfile`: `srgb` or `displayP3`), the text being edited and the -undo history. +undo history. `fields` and `outline` select layer properties at every level, pages included, without +changing document metadata (see below). -### `node.get {id, depth?}` +### `node.get {id, depth?, fields?, outline?}` One layer (children to `depth`, default 1). Fields: id, name, type, x, y, width, height, rotation, absoluteBounds, visible, locked, opacity, blend, fills, strokes, strokeWeight, effects, cornerRadius, @@ -187,6 +188,25 @@ starting from the layer: `[{to} | {side}]`), `pluginData` (layer data written wi only when there is some). The auto layout props that `node.set` accepts are listed in [mcp.md](mcp.md#conventions-agents-must-know). +Both inspection methods accept `fields: [string]`: exact, case-sensitive top-level node keys such +as `name`, `type`, `x`, `fills`, `characters` or `pluginData`, applied recursively to pages too. +`[]` keeps only ids and structure. `id`, `children` and depth-boundary `childCount` are always kept. +An unknown key is an error that lists the valid keys; a `fields` that is not an array of strings, +or an `outline` that is not a boolean, is an error too. + +`outline: true` selects `id`, `name`, `type`, `characters`, `mainComponentName` and `visible`, plus +structure. `characters` is cut after 120 characters (Unicode scalars), with ASCII `...` appended +only when longer. When `visible` is selected (by either option), every node has an explicit +boolean; without options the output stays sparse. `outline: true` with `fields` is an error; +`outline: false` with `fields` is allowed. Document metadata is unchanged. Through +`engine.execute`, the engine command `node.get` takes the same options but no `depth`; it always +returns one level of children. + +```json +{"id":5,"method":"document.inspect","params":{"depth":6,"outline":true}} +{"id":6,"method":"node.get","params":{"id":3,"depth":2,"fields":["name","width","height"]}} +``` + ### `ui.inspect {}` UI state: panels and tabs (`panels`: the `left` and `right` panels' widths as asked for and whether the left one is closed, `leftCollapsed`), the cursor the canvas shows (`cursor`: a CSS cursor name such as `default`, `crosshair`, `ew-resize`, `grab`, `copy`, `not-allowed`, `zoom-in`, or one FigCraft draws: `rotate `, `pen`, `pen-close`, `pen-add`, `pen-bend`, `eyedropper`, `comment`; `null` while the pointer is elsewhere or over something with a cursor of its own, a ruler guide or a gradient handle; the engine's own hint is `tool.overlay.cursor`), current tool (`tool`: its id, overlay hints and, for a selected diff --git a/docs/mcp.md b/docs/mcp.md index a25ae8fad..44c68c5d9 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -353,8 +353,8 @@ a later step can use `{"$step": 0, "field": "ids/0"}` to target the first match. | `batch {commands: [{command, params}], label?}` | Several commands as one undo step (`command.batch`), which keeps one copy of the document however many steps there are (an `edit.undo` step undoes the batch's edits so far together); all-or-nothing for open documents and their document state. Failed inner commands are removed from the journal; preferences, clipboard, tool state and UI requests are not rolled back. A step can use what an earlier step returned: name it with `"as": "card"` and `{"$ref": "card"}` anywhere in a later step's params becomes the `id` in its result (`{"$step": n}` does the same by zero-based position); `"field"` takes another key or a slash path such as `ids/0` (see Step references below). | | `create_node {type, x, y, width, height, parent, index, name, text, path, props}` | Create a frame, rectangle, ellipse, polygon, star, line, arrow, text, vector, section, component or slice; returns `{id, node}`. | | `set_properties {ids, props}` | `node.set` on layers (geometry, fills, strokes, effects, corner radius, auto layout, sizing, constraints, masks (`isMask`, `maskType: alpha\|vector\|luminance`), handoff status (`devStatus: none\|readyForDev\|completed`; marking again clears "Changed"), a section's `sectionContentsHidden`, text…); returns the updated layers. | -| `inspect_document {depth, allPages}` | The document as JSON (its `id`, layer tree, styles, selection, history). The tools that act on a document also take `document` (see Naming the document below). | -| `get_node {id, depth}` | One layer as JSON; `pluginData` (`{key: value}`, values as strings) when the layer or page has layer data written with `set_properties {props: {pluginData}}`. | +| `inspect_document {depth, allPages, fields?, outline?}` | The document as JSON (its `id`, layer tree, styles, selection, history); `fields` selects layer properties, `outline: true` gives a compact tree (below). The tools that act on a document also take `document` (see Naming the document below). | +| `get_node {id, depth, fields?, outline?}` | One layer as JSON; takes the same `fields` and `outline` options; `pluginData` (`{key: value}`, values as strings) when the layer or page has layer data written with `set_properties {props: {pluginData}}`. | | `select {ids}` | Replace the selection (`[]` deselects). | | `export {ids, format, scale \| size, path?, bleed?, cropMarks?, bleedMarks?, cmyk?, contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?, vdCompat?}` | png, jpg, svg, webp, pdf, xml (an Android Vector Drawable: `svgIdAttribute` writes `android:name`, `vdCompat` is `bestEffort` (default: approximated with a warning), `exact` (left out with a warning) or `ignore` (no warnings: overlapping layer opacity dropped, even-odd fills kept as `fillType="evenOdd"`, diamond gradients left out), and the result lists `warnings` for what was changed or left out), or `fig`: the whole file as `.fig`, experimental (see Exporting `.fig` files below); `size` is `2x`, `512w` or `512h`. The image covers what the layers paint (shadows towards their offset, strokes, unclipped children, a mask group's mask; hidden layers take no room; a slice is its area with everything under it, or only the frame or group it sits in while `contentsOnly` is on); `contentsOnly: false` also draws every other layer showing in that area, `includeBoundingBox` exports the layers' own boxes; SVG: `svgOutlineText: false` keeps ``, `svgIdAttribute` writes unique ids from layer names, `svgSimplifyStroke: false` writes every stroke as an outline. The same options go in export settings (`export.add` / `export.update`). PDF: frames one page each (sized in points), anything else together on one page; print options: `bleed` (points or a length like `"3mm"`, `"0.125in"`), crop and bleed marks, CMYK colour (see Exports below), also taken by `file.export`, `file.exportLayers`, `file.exportFramesPdf` and stored per export setting by `export.add` / `export.update`; `run_command` `file.exportFramesPdf {page?, path?}` writes every top-level frame of a page. Without `path`, images come back as MCP image content, SVG as text, XML as text followed by `{format, bytes, warnings}`. Layers' own export settings: `run_command` with `export.add` / `export.update {index, size?, suffix?, format?}` / `export.remove` and `file.exportLayers {ids?, dir?, assets?}` (every layer once per setting, named `.`, a slash in a layer name making a folder; `assets: true` exports the Inspect tab's assets instead: icons as SVG, images as PNG). | | `screenshot {ids?, scale?, maxSize?, window?}` | PNG of the page (with background) or of layers, scaled to fit `maxSize` (default 1600). `window: true` captures the app window in front when attached, a window id (see Windows below) that window. | @@ -366,6 +366,28 @@ a later step can use `{"$step": 0, "field": "ids/0"}` to target the first match. | `inspect_ui {}` | Tool, view and status (and panels, timings and the app's `windows` when attached). | | `wait_for_changes {after?, timeoutMs?, topics?, limit?}` | Wait for new comment events in the attached app; returns pending events at once, else waits up to `timeoutMs` (default 20 s, max 25 s). Pass the returned `cursor` back to keep watching. Headless returns immediately. | +### Compact layer trees + +`inspect_document` and `get_node` accept `fields`, an array of exact, case-sensitive top-level node +keys, applied to every layer and page. For example, `fields: ["name", "type", "width", "height"]` +leaves out paints and text properties; `fields: []` keeps only ids and structure. `id`, `children`, +depth-boundary `childCount` and the transport's `truncated` marker are always kept (`truncated` is +not a selectable field). An unknown key is an error that lists the valid keys; a `fields` that is +not an array of strings, or an `outline` that is not a boolean, is an error too. Document metadata +stays unchanged. + +`outline: true` selects `id`, `name`, `type`, `characters`, `mainComponentName` and `visible`, plus +structure. `characters` is cut after 120 characters (Unicode scalars), with ASCII `...` appended +only when longer. Selecting `visible`, explicitly or through outline, reports a boolean even for +visible layers and pages; +without these options the output stays sparse. `outline: true` cannot be combined with `fields`; +`outline: false` can. Both options keep the existing depth and response-size limits. + +```json +{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"inspect_document","arguments":{"depth":6,"outline":true}}} +{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_node","arguments":{"id":3,"depth":2,"fields":["name","width","height"]}}} +``` + ### Starter templates An agent can start from a template instead of an empty page. `run_command {command: "template.list"}` diff --git a/docs/roadmap.md b/docs/roadmap.md index 7d38496e3..d569deccf 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-30 · **Change:** minor (a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; 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-30 · **Change:** minor (a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); compact layer trees with fields and outline (M0.9, part of #996); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; 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 @@ -49,6 +49,7 @@ blockers. |---|---|---|---| | M0 Foundation: workspace, model, renderer, engine, UI shell, app, control channel | done (2026-10-09) | — | — | | M0.9 CLI + MCP, web build, release packaging, PDF export | done (v0.2.0 released 2026-10-10 for every platform; a development example generates CLI completions and man pages, part of #1396) | Package installation of the generated files is still outstanding | — | +| M0.9 CLI + MCP, web build, release packaging, PDF export | done (v0.2.0 released 2026-10-10 for every platform). Added since: compact inspection with `fields` and `outline` (part of #996) | — | — | | M1 Core editing completeness (tools, panels, menus, clipboard, files) | ~77% | interaction pass (Open recent, the outline mode variants and keyboard box selection landed 2026-10-11; repeat commands preserve the underlying edit and undo steps, #181; the Layout panel's constrain-proportions link scales the other side, #220) | 6–12 + pass | | M2 Layout depth | ~80% | grid `fr` rows, real-file QA | 6–12 | | M3 Components & styles depth | ~78% (extended collections, variables import/export, gradient stop and variant bindings landed 2026-10-11; stored references keep exact variable and collection ids) | real libraries, component playground | 7–14 | @@ -149,6 +150,7 @@ blockers. | 2026-10-27 | minor | Alt during a drag (M1, #1385): Alt (Option) turns a move into a copy whenever it goes down, before the drag or during it, and letting go of it during the drag moves the original again; the undo step is named for what the drag did, and only a copy snaps to the layers it came from. Regression test in the duplicate spacing tests. Numbers unchanged | | 2026-10-27 | minor | Variables in props (M3, #1000): `node.create` and `node.set` take `{$var: variable id or name}` for `fill` and `stroke`, including unambiguous `Collection/name` lookups; bindings apply after literal values and style links and clear the paint's style link, with the same type checks, alpha and modes as `variable.bind`, in one undo step. Regression tests cover lookups, ordering, batch references, rollback and undo/redo. Numbers unchanged | | 2026-10-27 | minor | Finding layers without selecting them (M0.9, part of #996): `node.find` returns ids and a total with AND filters for name, canonical type, text and main component name, under a root or on the current page; ids are limited, the count is not. The selection, page and undo history stay unchanged. Available through `run_command` and batch step references. Regression tests in `crates/engine/src/node_find_tests.rs` and `crates/mcp/tests/it/agent_tasks.rs`. Numbers unchanged | +| 2026-10-27 | minor | Compact layer trees (M0.9, part of #996): `fields` selects node properties recursively and `outline` keeps names, types, short text, main component names and visibility, plus ids and structure, through the engine, control channel and MCP. Default output and response-size limits unchanged. Numbers unchanged | | 2026-10-26 | minor | Dragging a selected unfilled shape (M1, #1370): a press inside a selected stroke-only shape over a filled layer stacked below it moved that layer; the drag-from-inside fallback of #910 now treats any layer painted below the selection like the frame or canvas behind it, so the selection drags, while a layer painted above it still takes the press. Regression test in the selection behaviour suite. Numbers unchanged | | 2026-10-26 | minor | Text truncation (M4, #1375, #1376, #1377): the last line shown takes as much of the next word as fits before the ellipsis ("Lorem consec…", not "Lorem…"); an explicit height, `transform.resize`, a resize handle or fixed sizing on a truncated text keeps it truncating instead of turning it fixed size; Max lines on auto-height text hugs up to that many lines and ends in an ellipsis (`text.maxLines`, `node.set {maxLines}`, Type settings, `.fig` export and import), and `node.get` / `get_node` report `maxLines`. Regression tests in the text and engine behaviour suites. Numbers unchanged | | 2026-10-24 | minor | Installed weights of an embedded font family (M7, #1246): release builds embed some families with only a few styles (Poppins Regular, Italic and Bold), and the first font scan dropped every installed face of such a family, so an installed Poppins SemiBold or Medium was never offered (a rescan did offer them). The first scan now keeps the embedded faces, which still win for the styles they cover, and offers the installed styles they lack beside them. Regression test in the font database tests. Numbers unchanged |