mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-01 06:16:46 -06:00
M0.9: inspect layer trees with selected fields or a compact outline (#996)
node.get, the control channel's inspect methods and the MCP get_node / inspect_document tools take fields: [node keys], applied at every level, or outline: true (id, name, type, characters cut to 120 characters, mainComponentName, visible). Ids and structure are always kept, properties that aren't selected aren't computed, and the default output is unchanged. Part of #996. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Co-Authored-By: GPT-6.1 Sol <noreply@openai.com> Co-Authored-By: GPT-6 Astra <noreply@openai.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
GPT-6.1 Sol
parent
43af145a12
commit
d86a7ad9d9
@@ -5,280 +5,461 @@ 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 c = doc.frame_relative(id).as_coeffs();
|
||||
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 };
|
||||
// Keep unselected properties lazy, including paint, text and layout JSON.
|
||||
macro_rules! put {
|
||||
($key:expr, $value:expr $(,)?) => {
|
||||
if options.includes($key) {
|
||||
o.insert($key.into(), $value);
|
||||
}
|
||||
};
|
||||
}
|
||||
put!("name", json!(n.name));
|
||||
put!("type", json!(n.kind.type_name()));
|
||||
if options.explicit("visible") || (!n.is_page() && !n.visible) {
|
||||
put!("visible", json!(n.visible));
|
||||
}
|
||||
if !n.is_page() {
|
||||
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 options.includes("x") || options.includes("y") {
|
||||
let c = doc.frame_relative(id).as_coeffs();
|
||||
put!("x", json!(round(c[4])));
|
||||
put!("y", json!(round(c[5])));
|
||||
}
|
||||
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())}));
|
||||
put!("width", json!(round(n.width)));
|
||||
put!("height", json!(round(n.height)));
|
||||
if options.includes("rotation") {
|
||||
let rot = figcraft_geom::rotation_degrees(n.transform);
|
||||
if rot.abs() > 1e-6 {
|
||||
put!("rotation", json!(round(rot)));
|
||||
}
|
||||
}
|
||||
if !n.visible {
|
||||
o.insert("visible".into(), json!(false));
|
||||
if let Some(b) = options.includes("absoluteBounds").then(|| doc.world_bounds(id)).flatten() {
|
||||
put!("absoluteBounds", json!({"x": round(b.x0), "y": round(b.y0), "width": round(b.width()), "height": round(b.height())}));
|
||||
}
|
||||
if n.locked {
|
||||
o.insert("locked".into(), json!(true));
|
||||
put!("locked", json!(true));
|
||||
}
|
||||
if n.opacity < 1.0 {
|
||||
o.insert("opacity".into(), json!(n.opacity));
|
||||
put!("opacity", json!(n.opacity));
|
||||
}
|
||||
if !matches!(n.blend, figcraft_color::BlendMode::Normal | figcraft_color::BlendMode::PassThrough) {
|
||||
o.insert("blend".into(), json!(n.blend));
|
||||
put!("blend", json!(n.blend));
|
||||
}
|
||||
if !n.fills.is_empty() {
|
||||
o.insert("fills".into(), paints(&n.fills));
|
||||
put!("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));
|
||||
put!("strokes", paints(&n.strokes));
|
||||
put!("strokeWeight", json!(n.stroke.weight));
|
||||
put!("strokeAlign", json!(n.stroke.align));
|
||||
if n.stroke.sides.is_some() {
|
||||
o.insert("strokeSides".into(), json!(n.stroke.sides));
|
||||
put!("strokeSides", json!(n.stroke.sides));
|
||||
}
|
||||
if !n.stroke.dashes.is_empty() {
|
||||
o.insert("dashes".into(), json!(n.stroke.dashes));
|
||||
put!("dashes", json!(n.stroke.dashes));
|
||||
}
|
||||
if n.stroke.join != StrokeJoin::Miter {
|
||||
o.insert("strokeJoin".into(), json!(n.stroke.join));
|
||||
put!("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));
|
||||
put!("miterAngle", json!((n.stroke.miter_angle() * 100.0).round() / 100.0));
|
||||
}
|
||||
if let Some(p) = n.stroke.profile() {
|
||||
if let Some(p) = n.stroke.profile().filter(|_| options.includes("widthProfile")) {
|
||||
let points = crate::cmd::props::width_points_json(Some(p));
|
||||
o.insert("widthProfile".into(), json!({"preset": p.preset().map(|x| x.id()), "points": points}));
|
||||
put!("widthProfile", 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)));
|
||||
put!("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)));
|
||||
put!("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(),
|
||||
put!(
|
||||
"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));
|
||||
put!("cornerSmoothing", json!(n.corners.smoothing));
|
||||
}
|
||||
}
|
||||
if let Some(l) = &n.layout {
|
||||
o.insert("layout".into(), camel(serde_json::to_value(l).unwrap_or(Value::Null)));
|
||||
put!("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}));
|
||||
put!("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));
|
||||
put!("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));
|
||||
put!(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(),
|
||||
put!(
|
||||
"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}),
|
||||
);
|
||||
}
|
||||
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));
|
||||
put!("constraints", serde_json::to_value(n.constraints).unwrap_or(Value::Null));
|
||||
}
|
||||
if n.kind.is_frame_like() {
|
||||
o.insert("clip".into(), json!(n.clip));
|
||||
put!("clip", json!(n.clip));
|
||||
}
|
||||
if !n.dev_status.is_none() {
|
||||
o.insert("devStatus".into(), json!(n.dev_status.id()));
|
||||
put!("devStatus", json!(n.dev_status.id()));
|
||||
}
|
||||
if n.dev_changed {
|
||||
o.insert("devChanged".into(), json!(true));
|
||||
put!("devChanged", json!(true));
|
||||
}
|
||||
if !n.dev_note.is_empty() {
|
||||
o.insert("devNote".into(), json!(n.dev_note));
|
||||
put!("devNote", json!(n.dev_note));
|
||||
}
|
||||
if let NodeKind::Section { contents_hidden } = n.kind {
|
||||
o.insert("sectionContentsHidden".into(), json!(contents_hidden));
|
||||
put!("sectionContentsHidden", json!(contents_hidden));
|
||||
}
|
||||
if n.is_mask {
|
||||
o.insert("isMask".into(), json!(true));
|
||||
o.insert("maskType".into(), json!(n.mask_type.id()));
|
||||
put!("isMask", json!(true));
|
||||
put!("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)));
|
||||
put!("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));
|
||||
put!("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));
|
||||
put!("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));
|
||||
put!("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)));
|
||||
put!("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));
|
||||
put!("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()) {
|
||||
put!("devResources", dev);
|
||||
}
|
||||
}
|
||||
if !n.interactions.is_empty() {
|
||||
o.insert("interactions".into(), serde_json::to_value(&n.interactions).unwrap_or(Value::Null));
|
||||
put!("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));
|
||||
put!("styles", serde_json::to_value(&n.styles).unwrap_or(Value::Null));
|
||||
}
|
||||
if !n.bound.is_empty() {
|
||||
o.insert("boundVariables".into(), json!(n.bound));
|
||||
put!("boundVariables", json!(n.bound));
|
||||
}
|
||||
if !n.overrides.is_empty() {
|
||||
o.insert("overrides".into(), json!(n.overrides));
|
||||
put!("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() {
|
||||
put!("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()) {
|
||||
put!("operandOperation", json!(op));
|
||||
}
|
||||
}
|
||||
// Layer data written with `node.set {props: {pluginData}}` (pages too).
|
||||
if !n.plugin_data.is_empty() {
|
||||
o.insert("pluginData".into(), json!(n.plugin_data));
|
||||
put!("pluginData", json!(n.plugin_data));
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::Text(t) => {
|
||||
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));
|
||||
put!(
|
||||
"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)
|
||||
}
|
||||
);
|
||||
put!("fontFamily", json!(t.style.font_family));
|
||||
put!("fontStyle", json!(t.style.font_style));
|
||||
put!("fontSize", json!(t.style.font_size));
|
||||
if !t.style.variations.is_empty() {
|
||||
o.insert("fontVariations".into(), json!(t.style.variations));
|
||||
put!("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));
|
||||
put!("lineHeight", json!(t.style.line_height.label()));
|
||||
put!("letterSpacing", json!(t.style.letter_spacing.label()));
|
||||
put!("textAlign", json!(t.align_h));
|
||||
put!("textAutoResize", json!(t.auto_resize));
|
||||
if t.max_lines > 0 {
|
||||
o.insert("maxLines".into(), json!(t.max_lines));
|
||||
put!("maxLines", json!(t.max_lines));
|
||||
}
|
||||
if let Some(tp) = &t.path {
|
||||
o.insert("textPath".into(), json!(tp));
|
||||
put!("textPath", json!(tp));
|
||||
}
|
||||
if t.style.decoration != TextDecoration::None {
|
||||
o.insert("textDecoration".into(), json!(t.style.decoration));
|
||||
put!("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()))));
|
||||
put!("textDecorationStyle", json!(l.style));
|
||||
put!("textDecorationThickness", json!(l.thickness.label()));
|
||||
put!("textDecorationOffset", json!(l.offset.label()));
|
||||
put!("textDecorationSkipInk", json!(l.skip_ink));
|
||||
put!("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));
|
||||
put!("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());
|
||||
put!("lineTypes", t.paragraphs.iter().map(|p| json!(p.list)).collect());
|
||||
put!("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());
|
||||
put!("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));
|
||||
put!("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));
|
||||
put!(k, json!(true));
|
||||
}
|
||||
}
|
||||
if t.leading_trim != figcraft_doc::LeadingTrim::None {
|
||||
o.insert("leadingTrim".into(), json!(t.leading_trim));
|
||||
put!("leadingTrim", json!(t.leading_trim));
|
||||
}
|
||||
if !t.runs.is_empty() {
|
||||
o.insert("styledRuns".into(), serde_json::to_value(&t.runs).unwrap_or(Value::Null));
|
||||
put!("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() {
|
||||
put!("textStyleRanges", Value::Array(linked));
|
||||
}
|
||||
}
|
||||
}
|
||||
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}));
|
||||
put!("arc", json!({"start": start.to_degrees(), "sweep": sweep.to_degrees(), "inner": inner}));
|
||||
}
|
||||
NodeKind::Polygon { count } => {
|
||||
o.insert("pointCount".into(), json!(count));
|
||||
put!("pointCount", json!(count));
|
||||
}
|
||||
NodeKind::Star { count, inner_ratio } => {
|
||||
o.insert("pointCount".into(), json!(count));
|
||||
o.insert("innerRadius".into(), json!(inner_ratio));
|
||||
put!("pointCount", json!(count));
|
||||
put!("innerRadius", json!(inner_ratio));
|
||||
}
|
||||
NodeKind::BooleanOp { op } => {
|
||||
o.insert("booleanOperation".into(), json!(op));
|
||||
put!("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())));
|
||||
put!("mainComponent", json!(component));
|
||||
put!("mainComponentName", json!(doc.node(*component).map(|c| c.name.clone())));
|
||||
if !props.is_empty() {
|
||||
o.insert("componentProperties".into(), json!(props));
|
||||
put!("componentProperties", json!(props));
|
||||
}
|
||||
}
|
||||
NodeKind::Component { description, props } => {
|
||||
if !description.is_empty() {
|
||||
o.insert("description".into(), json!(description));
|
||||
put!("description", json!(description));
|
||||
}
|
||||
if !props.is_empty() {
|
||||
o.insert("componentPropertyDefinitions".into(), serde_json::to_value(props).unwrap_or(Value::Null));
|
||||
put!("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()));
|
||||
put!("vertices", json!(network.vertices.len()));
|
||||
put!("segments", json!(network.segments.len()));
|
||||
}
|
||||
NodeKind::Page { background, flows, .. } => {
|
||||
o.insert("background".into(), json!(background.to_css_hex()));
|
||||
put!("background", json!(background.to_css_hex()));
|
||||
if !flows.is_empty() {
|
||||
o.insert("flows".into(), serde_json::to_value(flows).unwrap_or(Value::Null));
|
||||
put!("flows", serde_json::to_value(flows).unwrap_or(Value::Null));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
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()));
|
||||
}
|
||||
@@ -315,10 +496,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!({
|
||||
"name": doc.name,
|
||||
"kind": doc.kind.id(),
|
||||
|
||||
@@ -228,9 +228,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.reparent",
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
//! Compact inspection keeps the tree useful without building every layer property.
|
||||
|
||||
use figcraft_doc::{NodeId, NodeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::inspect::{InspectOptions, document, document_with_options, node_json, node_json_with_options};
|
||||
|
||||
fn run(s: &mut Session, command: &str, params: Value) -> Value {
|
||||
s.execute(command, ¶ms).unwrap_or_else(|e| panic!("{command}: {e}"))
|
||||
}
|
||||
|
||||
fn create(s: &mut Session, params: Value) -> NodeId {
|
||||
crate::params::id_of(&run(s, "node.create", params)["id"]).unwrap()
|
||||
}
|
||||
|
||||
fn tree() -> (Session, NodeId, NodeId) {
|
||||
let mut s = Session::new();
|
||||
let frame = create(&mut s, json!({"type": "frame", "name": "Card", "x": 100, "y": 200, "width": 300, "height": 200}));
|
||||
let text = create(&mut s, json!({"type": "text", "parent": frame, "name": "Title", "text": "Hello", "x": 10, "y": 20}));
|
||||
create(&mut s, json!({"type": "rectangle", "parent": frame, "name": "Hidden", "props": {"visible": false, "fill": "#123456"}}));
|
||||
(s, frame, text)
|
||||
}
|
||||
|
||||
fn node(s: &Session, id: NodeId, depth: usize, params: Value) -> Value {
|
||||
node_json_with_options(s.doc().unwrap(), id, depth, &InspectOptions::parse(¶ms).unwrap())
|
||||
}
|
||||
|
||||
fn keys(v: &Value, allowed: &[&str]) {
|
||||
for (key, value) in v.as_object().unwrap() {
|
||||
assert!(allowed.contains(&key.as_str()), "unexpected {key}: {v}");
|
||||
if key == "children" {
|
||||
for child in value.as_array().unwrap() {
|
||||
keys(child, allowed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn golden_tree() -> (Session, NodeId) {
|
||||
let mut s = Session::new();
|
||||
let frame = create(
|
||||
&mut s,
|
||||
json!({"type": "frame", "name": "Card", "x": 100, "y": 200, "width": 300, "height": 200,
|
||||
"props": {"layout": "horizontal", "padding": 8, "gap": 4, "fill": "#123456", "stroke": "#abcdef", "strokeWeight": 2,
|
||||
"effects": [{"type": "dropShadow", "offset": [2, 3], "radius": 4}], "cornerRadius": 12}}),
|
||||
);
|
||||
create(
|
||||
&mut s,
|
||||
json!({"type": "text", "parent": frame, "name": "Title", "text": "Hello", "width": 80, "height": 24,
|
||||
"props": {"fontFamily": "Synthetic", "fontSize": 18, "fontStyle": "Bold", "letterSpacing": 1, "lineHeight": 24,
|
||||
"textAlign": "center", "textDecoration": "underline", "fill": "#fedcba"}}),
|
||||
);
|
||||
create(
|
||||
&mut s,
|
||||
json!({"type": "rectangle", "parent": frame, "name": "Hidden", "width": 20, "height": 30,
|
||||
"props": {"visible": false, "locked": true, "fill": "#654321"}}),
|
||||
);
|
||||
let main = create(&mut s, json!({"type": "component", "name": "Button", "x": 400, "y": 200, "width": 80, "height": 40}));
|
||||
run(&mut s, "instance.create", json!({"component": main, "x": 500, "y": 200}));
|
||||
run(&mut s, "page.add", json!({"name": "Second"}));
|
||||
(s, frame)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_options_keep_the_main_tree_and_document_output() {
|
||||
let (s, frame) = golden_tree();
|
||||
// Captured with this builder on origin/main (43af145a12eacd1e87f5f5e6984c49b7da040288).
|
||||
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"
|
||||
]
|
||||
},
|
||||
"images": 0,
|
||||
"kind": "design",
|
||||
"name": "Untitled",
|
||||
"pages": [
|
||||
{
|
||||
"background": "#F5F5F5",
|
||||
"children": [
|
||||
{
|
||||
"absoluteBounds": {
|
||||
"height": 200.0,
|
||||
"width": 300.0,
|
||||
"x": 100.0,
|
||||
"y": 200.0
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"absoluteBounds": {
|
||||
"height": 24.0,
|
||||
"width": 80.0,
|
||||
"x": 110.0,
|
||||
"y": 210.0
|
||||
},
|
||||
"characters": "Hello",
|
||||
"fills": [
|
||||
{
|
||||
"color": "#FEDCBA",
|
||||
"type": "solid"
|
||||
}
|
||||
],
|
||||
"fontFamily": "Synthetic",
|
||||
"fontSize": 18.0,
|
||||
"fontStyle": "Bold",
|
||||
"height": 24.0,
|
||||
"id": 3,
|
||||
"letterSpacing": "1",
|
||||
"lineHeight": "24",
|
||||
"name": "Title",
|
||||
"sizing": {
|
||||
"horizontal": "fixed",
|
||||
"vertical": "hug"
|
||||
},
|
||||
"textAlign": "center",
|
||||
"textAutoResize": "height",
|
||||
"textDecoration": "underline",
|
||||
"type": "TEXT",
|
||||
"width": 80.0,
|
||||
"x": 10.0,
|
||||
"y": 10.0
|
||||
},
|
||||
{
|
||||
"absoluteBounds": {
|
||||
"height": 30.0,
|
||||
"width": 20.0,
|
||||
"x": 100.0,
|
||||
"y": 200.0
|
||||
},
|
||||
"fills": [
|
||||
{
|
||||
"color": "#654321",
|
||||
"type": "solid"
|
||||
}
|
||||
],
|
||||
"height": 30.0,
|
||||
"id": 4,
|
||||
"locked": true,
|
||||
"name": "Hidden",
|
||||
"sizing": {
|
||||
"horizontal": "fixed",
|
||||
"vertical": "fixed"
|
||||
},
|
||||
"type": "RECTANGLE",
|
||||
"visible": false,
|
||||
"width": 20.0,
|
||||
"x": 0.0,
|
||||
"y": 0.0
|
||||
}
|
||||
],
|
||||
"clip": true,
|
||||
"cornerRadius": 12.0,
|
||||
"effects": [
|
||||
{
|
||||
"blend": "normal",
|
||||
"color": {
|
||||
"a": 0.25,
|
||||
"b": 0.0,
|
||||
"g": 0.0,
|
||||
"r": 0.0
|
||||
},
|
||||
"offset": [
|
||||
2.0,
|
||||
3.0
|
||||
],
|
||||
"radius": 4.0,
|
||||
"showBehind": false,
|
||||
"spread": 0.0,
|
||||
"type": "dropShadow",
|
||||
"visible": true
|
||||
}
|
||||
],
|
||||
"fills": [
|
||||
{
|
||||
"color": "#123456",
|
||||
"type": "solid"
|
||||
}
|
||||
],
|
||||
"height": 200.0,
|
||||
"id": 2,
|
||||
"layout": {
|
||||
"columnTracks": [],
|
||||
"contentAlign": "auto",
|
||||
"crossAlign": "min",
|
||||
"crossGap": 10.0,
|
||||
"gap": 4.0,
|
||||
"gridColumns": 2,
|
||||
"gridRows": 2,
|
||||
"mainAlign": "min",
|
||||
"mode": "horizontal",
|
||||
"padding": [
|
||||
8.0,
|
||||
8.0,
|
||||
8.0,
|
||||
8.0
|
||||
],
|
||||
"reverseZ": false,
|
||||
"rowTracks": [],
|
||||
"strokesIncluded": true,
|
||||
"wrap": false
|
||||
},
|
||||
"name": "Card",
|
||||
"sizing": {
|
||||
"horizontal": "fixed",
|
||||
"vertical": "fixed"
|
||||
},
|
||||
"strokeAlign": "inside",
|
||||
"strokeWeight": 2.0,
|
||||
"strokes": [
|
||||
{
|
||||
"color": "#ABCDEF",
|
||||
"type": "solid"
|
||||
}
|
||||
],
|
||||
"type": "FRAME",
|
||||
"width": 300.0,
|
||||
"x": 100.0,
|
||||
"y": 200.0
|
||||
},
|
||||
{
|
||||
"absoluteBounds": {
|
||||
"height": 40.0,
|
||||
"width": 80.0,
|
||||
"x": 400.0,
|
||||
"y": 200.0
|
||||
},
|
||||
"clip": true,
|
||||
"fills": [
|
||||
{
|
||||
"color": "#FFFFFF",
|
||||
"type": "solid"
|
||||
}
|
||||
],
|
||||
"height": 40.0,
|
||||
"id": 5,
|
||||
"name": "Button",
|
||||
"type": "COMPONENT",
|
||||
"width": 80.0,
|
||||
"x": 400.0,
|
||||
"y": 200.0
|
||||
},
|
||||
{
|
||||
"absoluteBounds": {
|
||||
"height": 40.0,
|
||||
"width": 80.0,
|
||||
"x": 500.0,
|
||||
"y": 200.0
|
||||
},
|
||||
"clip": true,
|
||||
"fills": [
|
||||
{
|
||||
"color": "#FFFFFF",
|
||||
"type": "solid"
|
||||
}
|
||||
],
|
||||
"height": 40.0,
|
||||
"id": 6,
|
||||
"mainComponent": 5,
|
||||
"mainComponentName": "Button",
|
||||
"name": "Button",
|
||||
"type": "INSTANCE",
|
||||
"width": 80.0,
|
||||
"x": 500.0,
|
||||
"y": 200.0
|
||||
}
|
||||
],
|
||||
"id": 1,
|
||||
"name": "Page 1",
|
||||
"type": "PAGE"
|
||||
},
|
||||
{
|
||||
"background": "#F5F5F5",
|
||||
"id": 7,
|
||||
"name": "Second",
|
||||
"type": "PAGE"
|
||||
}
|
||||
],
|
||||
"path": null,
|
||||
"selection": [],
|
||||
"styles": [],
|
||||
"textEdit": null,
|
||||
"variables": 0
|
||||
});
|
||||
let expected_node = &expected["pages"][0]["children"][0];
|
||||
assert_eq!(node_json(s.doc().unwrap(), frame, 4).to_string(), expected_node.to_string());
|
||||
assert_eq!(document(&s, 4, true).to_string(), expected.to_string());
|
||||
for params in [json!({}), json!({"outline": false})] {
|
||||
let options = InspectOptions::parse(¶ms).unwrap();
|
||||
assert_eq!(node_json_with_options(s.doc().unwrap(), frame, 4, &options).to_string(), expected_node.to_string());
|
||||
assert_eq!(document_with_options(&s, 4, true, &options).to_string(), expected.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fields_filter_recursively_including_pages_without_changing_metadata_or_history() {
|
||||
let (s, _, _) = tree();
|
||||
let before = document(&s, 4, true);
|
||||
let selection = s.selection();
|
||||
let history = s.undo_depth();
|
||||
let options = InspectOptions::parse(&json!({"fields": ["name", "characters", "name"], "outline": false})).unwrap();
|
||||
let compact = document_with_options(&s, 4, true, &options);
|
||||
let mut metadata = before.clone();
|
||||
metadata["pages"] = compact["pages"].clone();
|
||||
assert_eq!(compact, metadata);
|
||||
keys(&compact["pages"][0], &["id", "name", "characters", "children", "childCount"]);
|
||||
assert_eq!(compact["pages"][0]["children"][0]["children"][0]["characters"], "Hello");
|
||||
assert_eq!(s.selection(), selection);
|
||||
assert_eq!(s.undo_depth(), history);
|
||||
assert_eq!(document(&s, 4, true), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_field_list_keeps_ids_and_structure_at_depth_boundaries() {
|
||||
let (s, frame, _) = tree();
|
||||
let compact = node(&s, frame, 4, json!({"fields": []}));
|
||||
keys(&compact, &["id", "children", "childCount"]);
|
||||
assert_eq!(compact["children"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(node(&s, frame, 0, json!({"fields": []})), json!({"id": frame, "childCount": 2}));
|
||||
let options = InspectOptions::parse(&json!({"fields": []})).unwrap();
|
||||
let summary = document_with_options(&s, 0, false, &options);
|
||||
assert_eq!(summary["pages"][0], json!({"id": s.page().unwrap(), "childCount": 1}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn geometry_keys_are_independently_selectable() {
|
||||
let (s, frame, text) = tree();
|
||||
let fields = ["x", "y", "width", "height", "absoluteBounds"];
|
||||
let compact = node(&s, frame, 2, json!({"fields": fields}));
|
||||
keys(&compact, &["id", "children", "childCount", "x", "y", "width", "height", "absoluteBounds"]);
|
||||
assert_eq!((compact["x"].as_f64(), compact["y"].as_f64()), (Some(100.0), Some(200.0)));
|
||||
assert_eq!(compact["children"][0]["x"], 10.0);
|
||||
assert_eq!(compact["children"][0]["absoluteBounds"]["x"], 110.0);
|
||||
for key in fields {
|
||||
let one = node(&s, text, 0, json!({"fields": [key]}));
|
||||
assert_eq!(one.as_object().unwrap().len(), 2, "{one}");
|
||||
assert_eq!(one[key], compact["children"][0][key]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_truncates_only_after_120_unicode_scalars() {
|
||||
let (mut s, _, text) = tree();
|
||||
for unit in ["a", "界", "\u{1f980}"] {
|
||||
for len in [0, 119, 120, 121] {
|
||||
let content = unit.repeat(len);
|
||||
s.doc_mut().unwrap().get_mut(text).unwrap().kind.text_mut().unwrap().characters = content.clone();
|
||||
let outline = node(&s, text, 0, json!({"outline": true}));
|
||||
let want = if len > 120 { format!("{}...", unit.repeat(120)) } else { content.clone() };
|
||||
assert_eq!(outline["characters"], want);
|
||||
assert_eq!(node(&s, text, 0, json!({"fields": ["characters"]}))["characters"], content);
|
||||
keys(&outline, &["id", "name", "type", "characters", "mainComponentName", "visible", "children", "childCount"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_visible_makes_it_explicit_for_every_node_including_pages() {
|
||||
let (s, frame, _) = tree();
|
||||
for params in [json!({"fields": ["visible"]}), json!({"outline": true})] {
|
||||
let options = InspectOptions::parse(¶ms).unwrap();
|
||||
let tree = document_with_options(&s, 4, true, &options);
|
||||
assert_eq!(tree["pages"][0]["visible"], true);
|
||||
let card = &tree["pages"][0]["children"][0];
|
||||
assert_eq!(card["visible"], true);
|
||||
assert_eq!(card["children"][0]["visible"], true);
|
||||
assert_eq!(card["children"][1]["visible"], false);
|
||||
}
|
||||
let no_visible = node(&s, frame, 4, json!({"fields": ["name"]}));
|
||||
assert!(no_visible["children"][1].get("visible").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_names_are_selected_without_other_instance_properties_even_with_a_missing_main() {
|
||||
let mut s = Session::new();
|
||||
let main = create(&mut s, json!({"type": "component", "name": "Button"}));
|
||||
let instance = crate::params::id_of(&run(&mut s, "instance.create", json!({"component": main}))["id"]).unwrap();
|
||||
for params in [json!({"fields": ["mainComponentName"]}), json!({"outline": true})] {
|
||||
let compact = node(&s, instance, 0, params);
|
||||
assert_eq!(compact["mainComponentName"], "Button");
|
||||
assert!(compact.get("mainComponent").is_none());
|
||||
}
|
||||
if let NodeKind::Instance { component, .. } = &mut s.doc_mut().unwrap().get_mut(instance).unwrap().kind {
|
||||
*component = NodeId(u64::MAX);
|
||||
}
|
||||
let compact = node(&s, instance, 0, json!({"outline": true}));
|
||||
assert_eq!(compact.get("mainComponentName"), Some(&Value::Null));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_properties_and_other_page_summaries_respect_fields() {
|
||||
let (mut s, _, _) = tree();
|
||||
let first = s.page().unwrap();
|
||||
run(&mut s, "node.set", json!({"ids": [first], "props": {"pluginData": {"source": "synthetic"}}}));
|
||||
run(&mut s, "page.add", json!({"name": "Second"}));
|
||||
let options = InspectOptions::parse(&json!({"fields": ["pluginData", "background"]})).unwrap();
|
||||
let compact = document_with_options(&s, 4, false, &options);
|
||||
assert_eq!(compact["pages"].as_array().unwrap().len(), 2);
|
||||
let page = &compact["pages"][0];
|
||||
keys(page, &["id", "pluginData", "background", "childCount", "children"]);
|
||||
assert_eq!(page["pluginData"]["source"], "synthetic");
|
||||
assert_eq!(page["childCount"], 1);
|
||||
assert!(page["background"].is_string());
|
||||
assert!(page.get("name").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_catalogue_accepts_emitted_keys_and_structure_keys() {
|
||||
let (s, frame, _) = tree();
|
||||
let full = node_json(s.doc().unwrap(), frame, 2);
|
||||
fn check(s: &Session, full: &Value) {
|
||||
let id = crate::params::id_of(&full["id"]).unwrap();
|
||||
for (key, value) in full.as_object().unwrap() {
|
||||
let selected = node(s, id, 2, json!({"fields": [key]}));
|
||||
if key == "children" {
|
||||
assert_eq!(selected[key].as_array().map(Vec::len), value.as_array().map(Vec::len));
|
||||
} else {
|
||||
assert_eq!(&selected[key], value, "{key}");
|
||||
}
|
||||
}
|
||||
for child in full["children"].as_array().into_iter().flatten() {
|
||||
check(s, child);
|
||||
}
|
||||
}
|
||||
check(&s, &full);
|
||||
let structural = node(&s, frame, 0, json!({"fields": ["id", "children", "childCount"]}));
|
||||
assert_eq!(structural, json!({"id": frame, "childCount": 2}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_options_are_errors_and_unknown_names_list_the_catalogue() {
|
||||
for params in [
|
||||
json!({"fields": null}),
|
||||
json!({"fields": "name"}),
|
||||
json!({"fields": {"name": true}}),
|
||||
json!({"fields": [false]}),
|
||||
json!({"fields": [null]}),
|
||||
json!({"fields": ["name", 1]}),
|
||||
json!({"outline": null}),
|
||||
json!({"outline": "true"}),
|
||||
json!({"outline": 1}),
|
||||
json!({"outline": []}),
|
||||
json!({"outline": true, "fields": []}),
|
||||
json!({"outline": true, "fields": ["name"]}),
|
||||
] {
|
||||
assert!(InspectOptions::parse(¶ms).is_err(), "{params}");
|
||||
}
|
||||
for key in ["Name", "absolute_bounds", "noSuchField", "truncated"] {
|
||||
let error = InspectOptions::parse(&json!({"fields": [key]})).unwrap_err();
|
||||
assert!(error.contains(key), "{error}");
|
||||
for valid in crate::inspect::NODE_KEYS {
|
||||
assert!(error.contains(valid), "missing {valid}: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_get_uses_options_but_keeps_its_existing_depth_and_selection() {
|
||||
let (mut s, frame, _) = tree();
|
||||
let selection = s.selection();
|
||||
let history = s.undo_depth();
|
||||
let default = node_json(s.doc().unwrap(), frame, 1);
|
||||
assert_eq!(run(&mut s, "node.get", json!({"id": frame})), default);
|
||||
let compact = run(&mut s, "node.get", json!({"id": frame, "fields": [], "depth": 0}));
|
||||
assert!(compact["children"].is_array(), "the engine command still inspects to depth 1");
|
||||
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);
|
||||
}
|
||||
@@ -304,6 +304,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;
|
||||
|
||||
@@ -454,6 +454,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"}),
|
||||
// Batch step names and reference paths as junk.
|
||||
json!({"commands": [{"command": "select.none", "as": "a"}, {"command": "select.none", "as": [1]}, {"command": "select.none", "as": "a"}]}),
|
||||
json!({"commands": [{"command": "select.all", "as": "\u{0}"}, {"command": "node.set", "params": {"ids": [
|
||||
|
||||
@@ -250,7 +250,8 @@ impl Backend for 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)")?;
|
||||
@@ -259,7 +260,8 @@ impl Backend for 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
@@ -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() {
|
||||
@@ -177,6 +186,8 @@ pub fn tool_definitions() -> 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"
|
||||
@@ -194,6 +205,8 @@ pub fn tool_definitions() -> 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"
|
||||
@@ -625,19 +638,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") {
|
||||
@@ -645,9 +670,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));
|
||||
}
|
||||
@@ -665,7 +691,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 {
|
||||
@@ -714,9 +740,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}"));
|
||||
}
|
||||
@@ -732,7 +759,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 {
|
||||
|
||||
@@ -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 attached_to_nothing_fails_cleanly() {
|
||||
let port = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
//! 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", ¶ms);
|
||||
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();
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,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;
|
||||
|
||||
@@ -950,12 +950,14 @@ 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;
|
||||
Ok(figcraft_engine::inspect::document(&app.session, depth, b(p, "allPages")))
|
||||
let options = figcraft_engine::inspect::InspectOptions::parse(p)?;
|
||||
Ok(figcraft_engine::inspect::document_with_options(&app.session, 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;
|
||||
app.session.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)?;
|
||||
app.session.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, ¶ms)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_inspection_filters_nodes_and_pages_before_replying() {
|
||||
let mut u = Ui::new(Session::new());
|
||||
let frame = call(&mut u, "engine.execute", json!({"command": "node.create", "params": {"type": "frame", "name": "Card"}})).unwrap()["id"].clone();
|
||||
let text =
|
||||
call(&mut u, "engine.execute", json!({"command": "node.create", "params": {"type": "text", "parent": frame, "text": "界".repeat(121)}}))
|
||||
.unwrap()["id"]
|
||||
.clone();
|
||||
let default = call(&mut u, "document.inspect", json!({"depth": 3})).unwrap();
|
||||
let selection = u.app.session.selection();
|
||||
let history = u.app.session.undo_depth();
|
||||
let compact = call(&mut u, "document.inspect", json!({"depth": 3, "fields": []})).unwrap();
|
||||
let page = &compact["pages"][0];
|
||||
assert!(page.get("name").is_none());
|
||||
assert_eq!(page["children"][0]["children"][0], json!({"id": text}));
|
||||
let shallow = call(&mut u, "node.get", json!({"id": frame, "depth": 0, "fields": []})).unwrap();
|
||||
assert_eq!(shallow, json!({"id": frame, "childCount": 1}));
|
||||
let outline = call(&mut u, "node.get", json!({"id": frame, "depth": 1, "outline": true})).unwrap();
|
||||
assert_eq!(outline["children"][0]["characters"], format!("{}...", "界".repeat(120)));
|
||||
assert_eq!(outline["visible"], true);
|
||||
assert!(outline.get("fills").is_none());
|
||||
assert_eq!(u.app.session.selection(), selection);
|
||||
assert_eq!(u.app.session.undo_depth(), history);
|
||||
assert_eq!(call(&mut u, "document.inspect", json!({"depth": 3})).unwrap(), default);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_inspection_rejects_invalid_options_consistently() {
|
||||
let mut u = Ui::new(Session::new());
|
||||
for method in ["document.inspect", "node.get"] {
|
||||
for mut params in [json!({"fields": ["Name"]}), json!({"fields": [2]}), json!({"outline": "true"}), json!({"outline": true, "fields": []})] {
|
||||
params["id"] = json!(u.app.session.page().unwrap());
|
||||
let error = call(&mut u, method, params).unwrap_err();
|
||||
assert!(error.contains("fields") || error.contains("field") || error.contains("outline"), "{error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,6 +96,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;
|
||||
|
||||
@@ -123,14 +123,15 @@ it and duplicate it:
|
||||
|
||||
Every command: `[{id, label, params, menu, shortcut, undoable}]`.
|
||||
|
||||
### `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,
|
||||
@@ -147,6 +148,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.
|
||||
Unknown keys are errors listing the valid keys; wrong types are
|
||||
errors too.
|
||||
|
||||
`outline: true` selects `id`, `name`, `type`, `characters`, `mainComponentName` and `visible`, plus
|
||||
structure. `characters` stops at 120 characters, 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. The engine command
|
||||
`node.get` (not this control method) 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
|
||||
|
||||
+22
-2
@@ -312,8 +312,8 @@ Or in a project's `.mcp.json`:
|
||||
| `batch {commands: [{command, params}], label?}` | Several commands as one undo step (`command.batch`); 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 (layer tree, styles, selection, history). |
|
||||
| `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 (layer tree, styles, selection, history); `fields` selects layer properties, `outline: true` gives a compact tree (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. |
|
||||
@@ -324,6 +324,26 @@ Or in a project's `.mcp.json`:
|
||||
| `type_text {text, id?}` | Type into the text being edited, or replace the contents of text layer `id`. |
|
||||
| `inspect_ui {}` | Tool, view and status (and panels, timings and the app's `windows` when attached). |
|
||||
|
||||
### 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). Unknown keys or wrong types
|
||||
are errors; an unknown key's error lists the valid keys. Document metadata stays unchanged.
|
||||
|
||||
`outline: true` selects `id`, `name`, `type`, `characters`, `mainComponentName` and `visible`, plus
|
||||
structure. Text is cut after 120 characters, with ASCII `...` 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"}`
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
# FigCraft roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (compact layer trees with fields and outline (#996, part 1); Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and
|
||||
[`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours
|
||||
@@ -75,6 +75,7 @@ blockers.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-27 | minor | 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 |
|
||||
|
||||
Reference in New Issue
Block a user