Merge main into #1615

This commit is contained in:
bflatastic
2026-10-30 14:34:35 +07:00
16 changed files with 1224 additions and 159 deletions
+334 -139
View File
@@ -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<std::collections::BTreeSet<&'static str>>,
outline: bool,
}
impl InspectOptions {
/// Parse the same options for engine commands, control methods and MCP tools.
pub fn parse(p: &Value) -> Result<Self, String> {
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<String, Value>) {
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<String, Value>, 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<String, Value>) {
fn base_props(doc: &Document, n: &Node, id: NodeId, o: &mut serde_json::Map<String, Value>, 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<String, Value>, 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<String, Value>) {
fn layout_props(doc: &Document, n: &Node, o: &mut serde_json::Map<String, Value>, 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<String, Value>) {
fn dev_props(doc: &Document, n: &Node, o: &mut serde_json::Map<String, Value>, 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<String, Value>) {
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<String, Value>, 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<Value> = 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<Value> = 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<String, Value>) {
fn kind_props(doc: &Document, n: &Node, o: &mut serde_json::Map<String, Value>, 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<Value> =
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,
+3 -2
View File
@@ -305,9 +305,10 @@ pub fn specs() -> Vec<CommandSpec> {
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",
+507
View File
@@ -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, &params).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(&params).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(&params).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(&params).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(&params).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);
}
+2
View File
@@ -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;
+6
View File
@@ -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}),
+4 -2
View File
@@ -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),
+35 -8
View File
@@ -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<Value> {
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<Value> {
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<String, Value>) -> Result<Value, String> {
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<String, Value>) -> Result<ToolResult, String> {
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<String, Value>) -> Result<ToolR
Some(Value::Bool(x)) => *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<String, Value>) -> Result<ToolR
let mut best: Option<(u64, Value, usize)> = None;
while lo <= hi {
let mid = lo + (hi - lo) / 2;
let (v, w) = inspect_at(b, mid, all)?;
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<String, Value>) -> Result<ToolResult, S
let id = id_arg(a.get("id").ok_or("`id` is required")?)?;
let depth = n_arg(a, "depth")?.unwrap_or(1.0).clamp(0.0, 64.0) as u64;
let offset = n_arg(a, "offset")?.unwrap_or(0.0).max(0.0).min(u32::MAX as f64) as usize;
let options = inspection_options(a)?;
let (max_bytes, budget) = budget(a)?;
let (value, wire) = node_at(b, id, depth)?;
let (value, wire) = node_at(b, id, depth, &options)?;
if value.is_null() {
return Err(format!("no such layer {id}"));
}
@@ -858,7 +885,7 @@ fn get_node(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolResult, S
let (mut lo, mut hi) = (1u64, depth - 1);
while lo <= hi {
let mid = lo + (hi - lo) / 2;
let (v, w) = node_at(b, id, mid)?;
let (v, w) = node_at(b, id, mid, &options)?;
if w + MARKER_RESERVE <= budget {
(used, value, used_wire) = (mid, v, w);
if mid == hi {
+18
View File
@@ -64,6 +64,24 @@ fn attached_backend_forwards_everything() {
assert_eq!(err["isError"], true);
}
#[test]
fn attached_inspection_forwards_compact_options_to_the_app() {
let mut s = Server::new(Box::new(Attached::port(fake_app()).unwrap()));
let created = call(&mut s, "create_node", json!({"type": "text", "text": "界".repeat(121), "name": "Title"}));
let id = serde_json::from_str::<Value>(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()));
+207
View File
@@ -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<Value, String> {
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", &params);
assert!(!result.is_error, "{}", result.first_text());
serde_json::from_str::<Value>(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}");
}
}
}
+2
View File
@@ -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;
+6 -2
View File
@@ -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);
@@ -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<Value, String> {
crate::control::handle(&mut u.app, &u.ctx.clone(), method, &params)
}
#[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}");
}
}
}
+2
View File
@@ -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;
+23 -3
View File
@@ -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 <degrees>`, `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
+24 -2
View File
@@ -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 `<text>`, `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 `<layer><suffix>.<format>`, 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"}`
+3 -1
View File
File diff suppressed because one or more lines are too long