Merge main into #1615

This commit is contained in:
bflatastic committed 2026-10-30 14:34:35 +07:00
commit 83c6947e23
16 files changed
+1224 -159

No files matched your search

+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
@@ -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 Line truncated
> **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 sesLine truncated
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 |