mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 13:56:54 -06:00
Merge main into #1615
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,507 @@
|
||||
//! Compact inspection keeps the tree useful without building every layer property.
|
||||
|
||||
use figcraft_doc::{NodeId, NodeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::inspect::{InspectOptions, document, document_with_options, node_json, node_json_with_options};
|
||||
|
||||
fn run(s: &mut Session, command: &str, params: Value) -> Value {
|
||||
s.execute(command, ¶ms).unwrap_or_else(|e| panic!("{command}: {e}"))
|
||||
}
|
||||
|
||||
fn create(s: &mut Session, params: Value) -> NodeId {
|
||||
crate::params::id_of(&run(s, "node.create", params)["id"]).unwrap()
|
||||
}
|
||||
|
||||
fn tree() -> (Session, NodeId, NodeId) {
|
||||
let mut s = Session::new();
|
||||
let frame = create(&mut s, json!({"type": "frame", "name": "Card", "x": 100, "y": 200, "width": 300, "height": 200}));
|
||||
let text = create(&mut s, json!({"type": "text", "parent": frame, "name": "Title", "text": "Hello", "x": 10, "y": 20}));
|
||||
create(&mut s, json!({"type": "rectangle", "parent": frame, "name": "Hidden", "props": {"visible": false, "fill": "#123456"}}));
|
||||
(s, frame, text)
|
||||
}
|
||||
|
||||
fn node(s: &Session, id: NodeId, depth: usize, params: Value) -> Value {
|
||||
node_json_with_options(s.doc().unwrap(), id, depth, &InspectOptions::parse(¶ms).unwrap())
|
||||
}
|
||||
|
||||
fn keys(v: &Value, allowed: &[&str]) {
|
||||
for (key, value) in v.as_object().unwrap() {
|
||||
assert!(allowed.contains(&key.as_str()), "unexpected {key}: {v}");
|
||||
if key == "children" {
|
||||
for child in value.as_array().unwrap() {
|
||||
keys(child, allowed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn golden_tree() -> (Session, NodeId) {
|
||||
let mut s = Session::new();
|
||||
let frame = create(
|
||||
&mut s,
|
||||
json!({"type": "frame", "name": "Card", "x": 100, "y": 200, "width": 300, "height": 200,
|
||||
"props": {"layout": "horizontal", "padding": 8, "gap": 4, "fill": "#123456", "stroke": "#abcdef", "strokeWeight": 2,
|
||||
"effects": [{"type": "dropShadow", "offset": [2, 3], "radius": 4}], "cornerRadius": 12}}),
|
||||
);
|
||||
create(
|
||||
&mut s,
|
||||
json!({"type": "text", "parent": frame, "name": "Title", "text": "Hello", "width": 80, "height": 24,
|
||||
"props": {"fontFamily": "Synthetic", "fontSize": 18, "fontStyle": "Bold", "letterSpacing": 1, "lineHeight": 24,
|
||||
"textAlign": "center", "textDecoration": "underline", "fill": "#fedcba"}}),
|
||||
);
|
||||
create(
|
||||
&mut s,
|
||||
json!({"type": "rectangle", "parent": frame, "name": "Hidden", "width": 20, "height": 30,
|
||||
"props": {"visible": false, "locked": true, "fill": "#654321"}}),
|
||||
);
|
||||
let main = create(&mut s, json!({"type": "component", "name": "Button", "x": 400, "y": 200, "width": 80, "height": 40}));
|
||||
run(&mut s, "instance.create", json!({"component": main, "x": 500, "y": 200}));
|
||||
run(&mut s, "page.add", json!({"name": "Second"}));
|
||||
(s, frame)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_options_keep_the_main_tree_and_document_output() {
|
||||
let (s, frame) = golden_tree();
|
||||
// Captured with this builder on origin/main (43af145a12eacd1e87f5f5e6984c49b7da040288), plus the
|
||||
// document `id` that #1533 added. That id comes from a process-wide counter, so it is the
|
||||
// session's own.
|
||||
let expected = json!({
|
||||
"colorProfile": "srgb",
|
||||
"currentPage": 7,
|
||||
"dirty": true,
|
||||
"history": {
|
||||
"redo": 0,
|
||||
"undo": [
|
||||
"Add page",
|
||||
"Create instance",
|
||||
"Create layer",
|
||||
"Create layer",
|
||||
"Create layer",
|
||||
"Create layer"
|
||||
]
|
||||
},
|
||||
"id": s.ds().unwrap().uid,
|
||||
"images": 0,
|
||||
"kind": "design",
|
||||
"name": "Untitled",
|
||||
"pages": [
|
||||
{
|
||||
"background": "#F5F5F5",
|
||||
"children": [
|
||||
{
|
||||
"absoluteBounds": {
|
||||
"height": 200.0,
|
||||
"width": 300.0,
|
||||
"x": 100.0,
|
||||
"y": 200.0
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"absoluteBounds": {
|
||||
"height": 24.0,
|
||||
"width": 80.0,
|
||||
"x": 110.0,
|
||||
"y": 210.0
|
||||
},
|
||||
"characters": "Hello",
|
||||
"fills": [
|
||||
{
|
||||
"color": "#FEDCBA",
|
||||
"type": "solid"
|
||||
}
|
||||
],
|
||||
"fontFamily": "Synthetic",
|
||||
"fontSize": 18.0,
|
||||
"fontStyle": "Bold",
|
||||
"height": 24.0,
|
||||
"id": 3,
|
||||
"letterSpacing": "1",
|
||||
"lineHeight": "24",
|
||||
"name": "Title",
|
||||
"sizing": {
|
||||
"horizontal": "fixed",
|
||||
"vertical": "hug"
|
||||
},
|
||||
"textAlign": "center",
|
||||
"textAutoResize": "height",
|
||||
"textDecoration": "underline",
|
||||
"type": "TEXT",
|
||||
"width": 80.0,
|
||||
"x": 10.0,
|
||||
"y": 10.0
|
||||
},
|
||||
{
|
||||
"absoluteBounds": {
|
||||
"height": 30.0,
|
||||
"width": 20.0,
|
||||
"x": 100.0,
|
||||
"y": 200.0
|
||||
},
|
||||
"fills": [
|
||||
{
|
||||
"color": "#654321",
|
||||
"type": "solid"
|
||||
}
|
||||
],
|
||||
"height": 30.0,
|
||||
"id": 4,
|
||||
"locked": true,
|
||||
"name": "Hidden",
|
||||
"sizing": {
|
||||
"horizontal": "fixed",
|
||||
"vertical": "fixed"
|
||||
},
|
||||
"type": "RECTANGLE",
|
||||
"visible": false,
|
||||
"width": 20.0,
|
||||
"x": 0.0,
|
||||
"y": 0.0
|
||||
}
|
||||
],
|
||||
"clip": true,
|
||||
"cornerRadius": 12.0,
|
||||
"effects": [
|
||||
{
|
||||
"blend": "normal",
|
||||
"color": {
|
||||
"a": 0.25,
|
||||
"b": 0.0,
|
||||
"g": 0.0,
|
||||
"r": 0.0
|
||||
},
|
||||
"offset": [
|
||||
2.0,
|
||||
3.0
|
||||
],
|
||||
"radius": 4.0,
|
||||
"showBehind": false,
|
||||
"spread": 0.0,
|
||||
"type": "dropShadow",
|
||||
"visible": true
|
||||
}
|
||||
],
|
||||
"fills": [
|
||||
{
|
||||
"color": "#123456",
|
||||
"type": "solid"
|
||||
}
|
||||
],
|
||||
"height": 200.0,
|
||||
"id": 2,
|
||||
"layout": {
|
||||
"columnTracks": [],
|
||||
"contentAlign": "auto",
|
||||
"crossAlign": "min",
|
||||
"crossGap": 10.0,
|
||||
"gap": 4.0,
|
||||
"gridColumns": 2,
|
||||
"gridRows": 2,
|
||||
"mainAlign": "min",
|
||||
"mode": "horizontal",
|
||||
"padding": [
|
||||
8.0,
|
||||
8.0,
|
||||
8.0,
|
||||
8.0
|
||||
],
|
||||
"reverseZ": false,
|
||||
"rowTracks": [],
|
||||
"strokesIncluded": true,
|
||||
"wrap": false
|
||||
},
|
||||
"name": "Card",
|
||||
"sizing": {
|
||||
"horizontal": "fixed",
|
||||
"vertical": "fixed"
|
||||
},
|
||||
"strokeAlign": "inside",
|
||||
"strokeWeight": 2.0,
|
||||
"strokes": [
|
||||
{
|
||||
"color": "#ABCDEF",
|
||||
"type": "solid"
|
||||
}
|
||||
],
|
||||
"type": "FRAME",
|
||||
"width": 300.0,
|
||||
"x": 100.0,
|
||||
"y": 200.0
|
||||
},
|
||||
{
|
||||
"absoluteBounds": {
|
||||
"height": 40.0,
|
||||
"width": 80.0,
|
||||
"x": 400.0,
|
||||
"y": 200.0
|
||||
},
|
||||
"clip": true,
|
||||
"fills": [
|
||||
{
|
||||
"color": "#FFFFFF",
|
||||
"type": "solid"
|
||||
}
|
||||
],
|
||||
"height": 40.0,
|
||||
"id": 5,
|
||||
"name": "Button",
|
||||
"type": "COMPONENT",
|
||||
"width": 80.0,
|
||||
"x": 400.0,
|
||||
"y": 200.0
|
||||
},
|
||||
{
|
||||
"absoluteBounds": {
|
||||
"height": 40.0,
|
||||
"width": 80.0,
|
||||
"x": 500.0,
|
||||
"y": 200.0
|
||||
},
|
||||
"clip": true,
|
||||
"fills": [
|
||||
{
|
||||
"color": "#FFFFFF",
|
||||
"type": "solid"
|
||||
}
|
||||
],
|
||||
"height": 40.0,
|
||||
"id": 6,
|
||||
"mainComponent": 5,
|
||||
"mainComponentName": "Button",
|
||||
"name": "Button",
|
||||
"type": "INSTANCE",
|
||||
"width": 80.0,
|
||||
"x": 500.0,
|
||||
"y": 200.0
|
||||
}
|
||||
],
|
||||
"id": 1,
|
||||
"name": "Page 1",
|
||||
"type": "PAGE"
|
||||
},
|
||||
{
|
||||
"background": "#F5F5F5",
|
||||
"id": 7,
|
||||
"name": "Second",
|
||||
"type": "PAGE"
|
||||
}
|
||||
],
|
||||
"path": null,
|
||||
"selection": [],
|
||||
"styles": [],
|
||||
"textEdit": null,
|
||||
"variables": 0
|
||||
});
|
||||
let expected_node = &expected["pages"][0]["children"][0];
|
||||
assert_eq!(node_json(s.doc().unwrap(), frame, 4).to_string(), expected_node.to_string());
|
||||
assert_eq!(document(&s, 4, true).to_string(), expected.to_string());
|
||||
for params in [json!({}), json!({"outline": false})] {
|
||||
let options = InspectOptions::parse(¶ms).unwrap();
|
||||
assert_eq!(node_json_with_options(s.doc().unwrap(), frame, 4, &options).to_string(), expected_node.to_string());
|
||||
assert_eq!(document_with_options(&s, 4, true, &options).to_string(), expected.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fields_filter_recursively_including_pages_without_changing_metadata_or_history() {
|
||||
let (s, _, _) = tree();
|
||||
let before = document(&s, 4, true);
|
||||
let selection = s.selection();
|
||||
let history = s.undo_depth();
|
||||
let options = InspectOptions::parse(&json!({"fields": ["name", "characters", "name"], "outline": false})).unwrap();
|
||||
let compact = document_with_options(&s, 4, true, &options);
|
||||
let mut metadata = before.clone();
|
||||
metadata["pages"] = compact["pages"].clone();
|
||||
assert_eq!(compact, metadata);
|
||||
keys(&compact["pages"][0], &["id", "name", "characters", "children", "childCount"]);
|
||||
assert_eq!(compact["pages"][0]["children"][0]["children"][0]["characters"], "Hello");
|
||||
assert_eq!(s.selection(), selection);
|
||||
assert_eq!(s.undo_depth(), history);
|
||||
assert_eq!(document(&s, 4, true), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_field_list_keeps_ids_and_structure_at_depth_boundaries() {
|
||||
let (s, frame, _) = tree();
|
||||
let compact = node(&s, frame, 4, json!({"fields": []}));
|
||||
keys(&compact, &["id", "children", "childCount"]);
|
||||
assert_eq!(compact["children"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(node(&s, frame, 0, json!({"fields": []})), json!({"id": frame, "childCount": 2}));
|
||||
let options = InspectOptions::parse(&json!({"fields": []})).unwrap();
|
||||
let summary = document_with_options(&s, 0, false, &options);
|
||||
assert_eq!(summary["pages"][0], json!({"id": s.page().unwrap(), "childCount": 1}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn geometry_keys_are_independently_selectable() {
|
||||
let (s, frame, text) = tree();
|
||||
let fields = ["x", "y", "width", "height", "absoluteBounds"];
|
||||
let compact = node(&s, frame, 2, json!({"fields": fields}));
|
||||
keys(&compact, &["id", "children", "childCount", "x", "y", "width", "height", "absoluteBounds"]);
|
||||
assert_eq!((compact["x"].as_f64(), compact["y"].as_f64()), (Some(100.0), Some(200.0)));
|
||||
assert_eq!(compact["children"][0]["x"], 10.0);
|
||||
assert_eq!(compact["children"][0]["absoluteBounds"]["x"], 110.0);
|
||||
for key in fields {
|
||||
let one = node(&s, text, 0, json!({"fields": [key]}));
|
||||
assert_eq!(one.as_object().unwrap().len(), 2, "{one}");
|
||||
assert_eq!(one[key], compact["children"][0][key]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_truncates_only_after_120_unicode_scalars() {
|
||||
let (mut s, _, text) = tree();
|
||||
for unit in ["a", "界", "\u{1f980}"] {
|
||||
for len in [0, 119, 120, 121] {
|
||||
let content = unit.repeat(len);
|
||||
s.doc_mut().unwrap().get_mut(text).unwrap().kind.text_mut().unwrap().characters = content.clone();
|
||||
let outline = node(&s, text, 0, json!({"outline": true}));
|
||||
let want = if len > 120 { format!("{}...", unit.repeat(120)) } else { content.clone() };
|
||||
assert_eq!(outline["characters"], want);
|
||||
assert_eq!(node(&s, text, 0, json!({"fields": ["characters"]}))["characters"], content);
|
||||
keys(&outline, &["id", "name", "type", "characters", "mainComponentName", "visible", "children", "childCount"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_visible_makes_it_explicit_for_every_node_including_pages() {
|
||||
let (s, frame, _) = tree();
|
||||
for params in [json!({"fields": ["visible"]}), json!({"outline": true})] {
|
||||
let options = InspectOptions::parse(¶ms).unwrap();
|
||||
let tree = document_with_options(&s, 4, true, &options);
|
||||
assert_eq!(tree["pages"][0]["visible"], true);
|
||||
let card = &tree["pages"][0]["children"][0];
|
||||
assert_eq!(card["visible"], true);
|
||||
assert_eq!(card["children"][0]["visible"], true);
|
||||
assert_eq!(card["children"][1]["visible"], false);
|
||||
}
|
||||
let no_visible = node(&s, frame, 4, json!({"fields": ["name"]}));
|
||||
assert!(no_visible["children"][1].get("visible").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_names_are_selected_without_other_instance_properties_even_with_a_missing_main() {
|
||||
let mut s = Session::new();
|
||||
let main = create(&mut s, json!({"type": "component", "name": "Button"}));
|
||||
let instance = crate::params::id_of(&run(&mut s, "instance.create", json!({"component": main}))["id"]).unwrap();
|
||||
for params in [json!({"fields": ["mainComponentName"]}), json!({"outline": true})] {
|
||||
let compact = node(&s, instance, 0, params);
|
||||
assert_eq!(compact["mainComponentName"], "Button");
|
||||
assert!(compact.get("mainComponent").is_none());
|
||||
}
|
||||
if let NodeKind::Instance { component, .. } = &mut s.doc_mut().unwrap().get_mut(instance).unwrap().kind {
|
||||
*component = NodeId(u64::MAX);
|
||||
}
|
||||
let compact = node(&s, instance, 0, json!({"outline": true}));
|
||||
assert_eq!(compact.get("mainComponentName"), Some(&Value::Null));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_properties_and_other_page_summaries_respect_fields() {
|
||||
let (mut s, _, _) = tree();
|
||||
let first = s.page().unwrap();
|
||||
run(&mut s, "node.set", json!({"ids": [first], "props": {"pluginData": {"source": "synthetic"}}}));
|
||||
run(&mut s, "page.add", json!({"name": "Second"}));
|
||||
let options = InspectOptions::parse(&json!({"fields": ["pluginData", "background"]})).unwrap();
|
||||
let compact = document_with_options(&s, 4, false, &options);
|
||||
assert_eq!(compact["pages"].as_array().unwrap().len(), 2);
|
||||
let page = &compact["pages"][0];
|
||||
keys(page, &["id", "pluginData", "background", "childCount", "children"]);
|
||||
assert_eq!(page["pluginData"]["source"], "synthetic");
|
||||
assert_eq!(page["childCount"], 1);
|
||||
assert!(page["background"].is_string());
|
||||
assert!(page.get("name").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_catalogue_accepts_emitted_keys_and_structure_keys() {
|
||||
let (mut s, frame, text) = tree();
|
||||
run(&mut s, "node.set", json!({"ids": [frame], "props": {"layout": "grid", "fill": "#123456", "stroke": "#abcdef"}}));
|
||||
s.doc_mut().unwrap().get_mut(frame).unwrap().dev_note = "Ready for review".into();
|
||||
s.doc_mut().unwrap().get_mut(frame).unwrap().dev_changed = true;
|
||||
let main = create(&mut s, json!({"type": "component", "name": "Button"}));
|
||||
let instance = crate::params::id_of(&run(&mut s, "instance.create", json!({"component": main}))["id"]).unwrap();
|
||||
s.doc_mut().unwrap().get_mut(text).unwrap().kind.text_mut().unwrap().style.style_id = Some("synthetic-style".into());
|
||||
// Exercise each of the six property helpers, including their more expensive values.
|
||||
for (id, key, sibling) in [
|
||||
(frame, "absoluteBounds", "width"),
|
||||
(frame, "fills", "strokes"),
|
||||
(text, "gridCell", "sizing"),
|
||||
(frame, "devNote", "devChanged"),
|
||||
(text, "textStyleRanges", "fontFamily"),
|
||||
(instance, "mainComponentName", "mainComponent"),
|
||||
] {
|
||||
let full = node_json(s.doc().unwrap(), id, 0);
|
||||
assert!(full.get(key).is_some(), "fixture must emit {key}: {full}");
|
||||
assert!(full.get(sibling).is_some(), "fixture must emit {sibling}: {full}");
|
||||
let selected = node(&s, id, 0, json!({"fields": [key]}));
|
||||
assert_eq!(selected[key], full[key]);
|
||||
assert!(selected.get(sibling).is_none(), "unrequested {sibling}: {selected}");
|
||||
keys(&selected, &["id", "children", "childCount", key]);
|
||||
}
|
||||
let full = node_json(s.doc().unwrap(), s.page().unwrap(), 3);
|
||||
fn check(s: &Session, full: &Value) {
|
||||
let id = crate::params::id_of(&full["id"]).unwrap();
|
||||
for (key, value) in full.as_object().unwrap() {
|
||||
let selected = node(s, id, 2, json!({"fields": [key]}));
|
||||
if key == "children" {
|
||||
assert_eq!(selected[key].as_array().map(Vec::len), value.as_array().map(Vec::len));
|
||||
} else {
|
||||
assert_eq!(&selected[key], value, "{key}");
|
||||
}
|
||||
}
|
||||
for child in full["children"].as_array().into_iter().flatten() {
|
||||
check(s, child);
|
||||
}
|
||||
}
|
||||
check(&s, &full);
|
||||
let structural = node(&s, frame, 0, json!({"fields": ["id", "children", "childCount"]}));
|
||||
assert_eq!(structural, json!({"id": frame, "childCount": 2}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_options_are_errors_and_unknown_names_list_the_catalogue() {
|
||||
for params in [
|
||||
json!({"fields": null}),
|
||||
json!({"fields": "name"}),
|
||||
json!({"fields": {"name": true}}),
|
||||
json!({"fields": [false]}),
|
||||
json!({"fields": [null]}),
|
||||
json!({"fields": ["name", 1]}),
|
||||
json!({"outline": null}),
|
||||
json!({"outline": "true"}),
|
||||
json!({"outline": 1}),
|
||||
json!({"outline": []}),
|
||||
json!({"outline": true, "fields": []}),
|
||||
json!({"outline": true, "fields": ["name"]}),
|
||||
] {
|
||||
assert!(InspectOptions::parse(¶ms).is_err(), "{params}");
|
||||
}
|
||||
for key in ["Name", "absolute_bounds", "noSuchField", "truncated"] {
|
||||
let error = InspectOptions::parse(&json!({"fields": [key]})).unwrap_err();
|
||||
assert!(error.contains(key), "{error}");
|
||||
for valid in crate::inspect::NODE_KEYS {
|
||||
assert!(error.contains(valid), "missing {valid}: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_get_uses_options_but_keeps_its_existing_depth_and_selection() {
|
||||
let (mut s, frame, _) = tree();
|
||||
let selection = s.selection();
|
||||
let history = s.undo_depth();
|
||||
let default = node_json(s.doc().unwrap(), frame, 1);
|
||||
assert_eq!(run(&mut s, "node.get", json!({"id": frame})), default);
|
||||
let compact = run(&mut s, "node.get", json!({"id": frame, "fields": []}));
|
||||
assert!(compact["children"].is_array(), "the engine command still inspects to depth 1");
|
||||
let error = s.execute("node.get", &json!({"id": frame, "fields": [], "depth": 0})).unwrap_err();
|
||||
assert!(error.to_string().contains("unknown parameter `depth`"), "{error}");
|
||||
keys(&compact, &["id", "children", "childCount"]);
|
||||
assert!(s.execute("node.get", &json!({"id": frame, "outline": true, "fields": []})).is_err());
|
||||
assert_eq!(s.selection(), selection);
|
||||
assert_eq!(s.undo_depth(), history);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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}),
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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", ¶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();
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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, ¶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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user