diff --git a/ROADMAP.md b/ROADMAP.md index 83adfee..d2e723f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -159,7 +159,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent). | Styles & variables | 5 | 72% | binding effects, line height and letter spacing, variable groups in the editor sidebar | 3–5 | | Prototyping | 6 | 70% | scrolling inside device frames, prototype preview pane in the editor | 3–5 | | Import/export | 6 | 50% | PDF (in progress), .fig import, export panel polish, REST import of prototypes/variables | 18–32 | -| Dev mode / inspect | 2 | 55% | asset export list in Inspect, redlines in the Inspect tab, code for whole frames as components | 4–6 | +| Dev mode / inspect | 2 | 65% | asset export list in Inspect, code for whole frames as components | 3–4 | | Comments, versions, misc | 3 | 45% | comment polish, plugins (WASM) | 9–15 | Weighted feature depth ≈ **58%** (self-graded; the table's arithmetic gives ~59%). diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index b318720..051ea99 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -81,6 +81,7 @@ fn registry() -> &'static Registry { list.extend(crate::vectoredit::specs()); list.extend(crate::shapebuilder::specs()); list.extend(crate::codegen::specs()); + list.extend(crate::devinspect::specs()); let mut index = HashMap::new(); for (i, s) in list.iter().enumerate() { if index.insert(s.id, i).is_some() { diff --git a/crates/engine/src/devinspect.rs b/crates/engine/src/devinspect.rs new file mode 100644 index 0000000..ea2c82d --- /dev/null +++ b/crates/engine/src/devinspect.rs @@ -0,0 +1,277 @@ +//! Dev-mode inspection: a layer's properties grouped for handoff (layout, auto layout, +//! typography, fills, strokes, effects), each with the style or variable behind it when there is +//! one. The Inspect tab shows these, and `inspect.properties` returns them. + +use figcraft_color::{Color, GradientKind, PaintKind}; +use figcraft_doc::*; +use serde::Serialize; +use serde_json::{Value, json}; + +use crate::cmd::CommandSpec; +use crate::params::*; +use crate::spec; + +/// One group of properties ("Layout", "Typography"…). +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct Section { + pub title: &'static str, + pub rows: Vec, +} + +/// One property: its label and value as shown, an optional colour swatch (hex with alpha) and +/// the style or variable name it comes from. +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct Prop { + pub label: String, + pub value: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub color: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub token: Option, +} + +pub fn specs() -> Vec { + vec![spec!( + "inspect.properties", + "Inspect properties", + "{id?} → {sections: [{title, rows: [{label, value, color?, token?}]}]} layout (relative to the parent), auto layout, typography, fills, strokes and effects, with style and variable names", + false, + |s, p| { + let id = match id(p, "id") { + Some(i) => i, + None => s.selection().first().copied().ok_or_else(|| crate::bad("inspect.properties", "select a layer or pass `id`"))?, + }; + let doc = s.doc()?; + doc.node(id).ok_or_else(|| crate::bad("inspect.properties", "no such layer"))?; + Ok(json!({"sections": serde_json::to_value(properties(doc, id)).unwrap_or(Value::Null)})) + } + )] +} + +/// A number as a designer reads it: at most two decimals, no trailing zeros. +fn num(v: f64) -> String { + if !v.is_finite() { + return "0".into(); + } + let s = format!("{:.2}", (v * 100.0).round() / 100.0); + let s = s.trim_end_matches('0').trim_end_matches('.'); + if s == "-0" { "0".into() } else { s.to_string() } +} + +/// `#RRGGBB` plus the opacity when it isn't 100%. +fn color_text(c: Color) -> String { + if c.a < 0.999 { format!("#{} · {}%", c.to_hex(), (c.a * 100.0).round()) } else { format!("#{}", c.to_hex()) } +} + +fn prop(label: impl Into, value: impl Into) -> Prop { + Prop { label: label.into(), value: value.into(), color: None, token: None } +} + +impl Prop { + fn token(mut self, t: Option) -> Self { + self.token = t; + self + } + fn color(mut self, c: Color) -> Self { + self.color = Some(c.to_css_hex()); + self + } +} + +/// The variable bound to `field` on `n`, by name. +fn variable(doc: &Document, n: &Node, field: &str) -> Option { + n.bound.get(field).and_then(|v| doc.variables.get(v)).map(|v| v.name.clone()) +} + +fn style_name(doc: &Document, id: Option<&StyleId>) -> Option { + id.and_then(|s| doc.style(s)).map(|s| s.name.clone()) +} + +/// The properties of `id`, grouped; empty when it doesn't exist. +pub fn properties(doc: &Document, id: NodeId) -> Vec
{ + let Some(n) = doc.node(id) else { return Vec::new() }; + let mut out = vec![layout(doc, id, n)]; + if let Some(l) = &n.layout { + out.push(auto_layout(doc, n, l)); + } + if let Some(t) = n.kind.text() { + out.push(typography(doc, n, t)); + } + let paint_rows = |paints: &[figcraft_color::Paint]| -> Vec { + paints + .iter() + .filter(|p| p.visible) + .map(|p| match &p.kind { + PaintKind::Solid { color } => { + let c = color.with_alpha(color.a * p.opacity); + prop("Solid", color_text(c)).color(c) + } + PaintKind::Gradient(g) => { + let kind = match g.kind { + GradientKind::Linear => "Linear", + GradientKind::Radial => "Radial", + GradientKind::Angular => "Angular", + GradientKind::Diamond => "Diamond", + }; + let stops: Vec = g.stops.iter().map(|s| format!("#{} {}%", s.color.to_hex(), (s.position * 100.0).round())).collect(); + let r = prop(kind, stops.join(", ")); + match g.stops.first() { + Some(s) => r.color(s.color), + None => r, + } + } + PaintKind::Image(_) => prop("Image", format!("{}%", (p.opacity * 100.0).round())), + }) + .collect() + }; + let fills = paint_rows(&n.fills); + if !fills.is_empty() { + let token = style_name(doc, n.styles.fill.as_ref()).or_else(|| variable(doc, n, "fill")); + out.push(Section { title: "Fills", rows: tag_first(fills, token) }); + } + let mut strokes = paint_rows(&n.strokes); + if !strokes.is_empty() { + let token = style_name(doc, n.styles.stroke.as_ref()).or_else(|| variable(doc, n, "stroke")); + strokes = tag_first(strokes, token); + let align = match n.stroke.align { + StrokeAlign::Inside => "inside", + StrokeAlign::Center => "center", + StrokeAlign::Outside => "outside", + }; + strokes.push(prop("Weight", format!("{} {align}", num(n.stroke.weight))).token(variable(doc, n, "strokeWeight"))); + out.push(Section { title: "Strokes", rows: strokes }); + } + let effects: Vec = n + .effects + .iter() + .filter(|e| e.visible) + .filter_map(|e| match &e.kind { + EffectKind::DropShadow(s) | EffectKind::InnerShadow(s) => { + let label = if matches!(e.kind, EffectKind::DropShadow(_)) { "Drop shadow" } else { "Inner shadow" }; + let v = format!("{} {} {} {} {}", num(s.offset.0), num(s.offset.1), num(s.radius), num(s.spread), color_text(s.color)); + Some(prop(label, v).color(s.color)) + } + EffectKind::LayerBlur { radius } => Some(prop("Layer blur", num(*radius))), + EffectKind::BackgroundBlur { radius } => Some(prop("Background blur", num(*radius))), + _ => None, + }) + .collect(); + if !effects.is_empty() { + out.push(Section { title: "Effects", rows: tag_first(effects, style_name(doc, n.styles.effect.as_ref())) }); + } + out +} + +/// A style or variable applies to the whole list: show it on the first row. +fn tag_first(mut rows: Vec, token: Option) -> Vec { + if let Some(first) = rows.first_mut() { + first.token = token; + } + rows +} + +fn layout(doc: &Document, id: NodeId, n: &Node) -> Section { + let mut rows = + vec![prop("Width", num(n.width)).token(variable(doc, n, "width")), prop("Height", num(n.height)).token(variable(doc, n, "height"))]; + // Position from the parent frame's top-left (from the page origin at the top level). + if let Some(b) = doc.world_bounds(id) { + let origin = n + .parent + .filter(|p| !doc.node(*p).is_some_and(|pn| pn.is_page())) + .and_then(|p| doc.world_bounds(p)) + .map_or((0.0, 0.0), |pb| (pb.x0, pb.y0)); + rows.push(prop("Top", num(b.y0 - origin.1))); + rows.push(prop("Left", num(b.x0 - origin.0))); + } + let rot = figcraft_geom::rotation_degrees(n.transform); + if rot.abs() > 0.01 { + rows.push(prop("Rotation", format!("{}°", num(rot)))); + } + if n.opacity < 0.999 { + rows.push(prop("Opacity", format!("{}%", (f64::from(n.opacity) * 100.0).round())).token(variable(doc, n, "opacity"))); + } + if n.kind.has_corner_radius() { + let r = match n.corners.uniform() { + Some(r) if r > 0.0 => Some(num(r)), + Some(_) => None, + None => Some(n.corners.radii.iter().map(|r| num(*r)).collect::>().join(" ")), + }; + if let Some(r) = r { + rows.push(prop("Radius", r).token(variable(doc, n, "cornerRadius"))); + } + } + Section { title: "Layout", rows } +} + +fn auto_layout(doc: &Document, n: &Node, l: &AutoLayout) -> Section { + let dir = match (l.mode, l.wrap) { + (LayoutMode::Horizontal, true) => "Horizontal, wrap", + (LayoutMode::Horizontal, false) => "Horizontal", + (LayoutMode::Vertical, _) => "Vertical", + (LayoutMode::Grid, _) => "Grid", + }; + let mut rows = vec![prop("Direction", dir)]; + if l.mode == LayoutMode::Grid { + rows.push(prop("Grid", format!("{} × {}", l.grid_columns, l.grid_rows))); + rows.push(prop("Gap", format!("{} {}", num(l.gap), num(l.cross_gap)))); + } else { + let gap = if l.main_align == MainAlign::SpaceBetween { "Auto (space between)".to_string() } else { num(l.gap) }; + rows.push(prop("Gap", gap).token(variable(doc, n, "gap"))); + if l.wrap { + rows.push(prop("Row gap", num(l.cross_gap)).token(variable(doc, n, "crossGap"))); + } + } + let [t, r, b, lft] = l.padding; + let padding = if t == r && r == b && b == lft { + num(t) + } else if t == b && r == lft { + format!("{} {}", num(t), num(r)) + } else { + format!("{} {} {} {}", num(t), num(r), num(b), num(lft)) + }; + let pad_token = variable(doc, n, "padding").or_else(|| variable(doc, n, "paddingHorizontal")).or_else(|| variable(doc, n, "paddingVertical")); + rows.push(prop("Padding", padding).token(pad_token)); + let main = match l.main_align { + MainAlign::Min => "start", + MainAlign::Center => "center", + MainAlign::Max => "end", + MainAlign::SpaceBetween => "space between", + }; + let cross = match l.cross_align { + CrossAlign::Min => "start", + CrossAlign::Center => "center", + CrossAlign::Max => "end", + CrossAlign::Baseline => "baseline", + }; + rows.push(prop("Align", format!("{main}, {cross}"))); + Section { title: "Auto layout", rows } +} + +fn typography(doc: &Document, n: &Node, t: &TextData) -> Section { + let s = &t.style; + let token = style_name(doc, n.styles.text.as_ref()); + let mut rows = vec![ + prop("Font", s.font_family.clone()).token(token), + prop("Style", s.font_style.clone()), + prop("Size", num(s.font_size)).token(variable(doc, n, "fontSize")), + prop("Line height", s.line_height.label()), + prop("Letter spacing", s.letter_spacing.label()), + ]; + if t.paragraph_spacing > 0.0 { + rows.push(prop("Paragraph spacing", num(t.paragraph_spacing))); + } + let align = match t.align_h { + TextAlignH::Left => "left", + TextAlignH::Center => "center", + TextAlignH::Right => "right", + TextAlignH::Justified => "justified", + }; + rows.push(prop("Align", align)); + if s.decoration != TextDecoration::None { + rows.push(prop("Decoration", if s.decoration == TextDecoration::Underline { "underline" } else { "strikethrough" })); + } + if variable(doc, n, "characters").is_some() { + rows.push(prop("Content", t.characters.chars().take(80).collect::()).token(variable(doc, n, "characters"))); + } + Section { title: "Typography", rows } +} diff --git a/crates/engine/src/devinspect_tests.rs b/crates/engine/src/devinspect_tests.rs new file mode 100644 index 0000000..b10a9c2 --- /dev/null +++ b/crates/engine/src/devinspect_tests.rs @@ -0,0 +1,84 @@ +//! Dev-mode inspection: properties grouped with their styles and variables, positions relative +//! to the parent, and redlines on hover without Alt. + +use serde_json::{Value, json}; + +use crate::Session; +use crate::devinspect::{Section, properties}; +use crate::params::id_of; +use crate::tools::{PointerEvent, PointerKind}; + +fn id(v: Value) -> figcraft_doc::NodeId { + id_of(&v["id"]).unwrap() +} + +fn row<'a>(sections: &'a [Section], title: &str, label: &str) -> &'a crate::devinspect::Prop { + sections.iter().find(|s| s.title == title).and_then(|s| s.rows.iter().find(|r| r.label == label)).unwrap_or_else(|| panic!("{title}/{label}")) +} + +#[test] +fn properties_group_layout_auto_layout_paints_and_tokens() { + let mut s = Session::new(); + let f = id(s.execute("frame.create", &json!({"x": 100, "y": 50, "width": 300, "height": 200})).unwrap()); + let r = id(s.execute("shape.rectangle", &json!({"x": 130, "y": 90, "width": 80, "height": 40, "parent": f})).unwrap()); + s.execute("node.set", &json!({"id": r, "props": {"fill": "#0D99FF80", "cornerRadius": 6, "opacity": 0.5}})).unwrap(); + s.execute("variable.createCollection", &json!({"name": "T"})).unwrap(); + s.execute("variable.create", &json!({"collection": "T", "name": "radius/sm", "type": "number", "value": 6})).unwrap(); + s.execute("variable.bind", &json!({"ids": [r], "field": "cornerRadius", "variable": "radius/sm"})).unwrap(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + s.execute("style.create", &json!({"type": "paint", "name": "Brand/Blue"})).unwrap(); + s.execute("style.apply", &json!({"ids": [r], "style": "Brand/Blue"})).unwrap(); + let doc = s.doc().unwrap(); + let p = properties(doc, r); + assert_eq!(row(&p, "Layout", "Width").value, "80"); + // Top/left from the parent frame's corner (the rectangle was placed in the frame's coordinates). + assert_eq!((row(&p, "Layout", "Top").value.as_str(), row(&p, "Layout", "Left").value.as_str()), ("90", "130")); + assert_eq!(row(&p, "Layout", "Opacity").value, "50%"); + let radius = row(&p, "Layout", "Radius"); + assert_eq!((radius.value.as_str(), radius.token.as_deref()), ("6", Some("radius/sm"))); + let fill = &p.iter().find(|s| s.title == "Fills").unwrap().rows[0]; + assert_eq!(fill.value, "#0D99FF · 50%"); + assert_eq!(fill.color.as_deref(), Some("#0D99FF80")); + assert_eq!(fill.token.as_deref(), Some("Brand/Blue")); + assert!(p.iter().all(|s| s.title != "Auto layout" && s.title != "Typography")); + + // Auto layout: direction, gap, padding shorthand, alignment. + s.execute("layout.add", &json!({"ids": [f]})).unwrap(); + s.execute("node.set", &json!({"id": f, "props": {"layoutMode": "vertical", "gap": 12, "padding": [8, 16, 8, 16]}})).unwrap(); + let p = properties(s.doc().unwrap(), f); + assert_eq!(row(&p, "Auto layout", "Direction").value, "Vertical"); + assert_eq!(row(&p, "Auto layout", "Gap").value, "12"); + assert_eq!(row(&p, "Auto layout", "Padding").value, "8 16"); + // At the top level, positions are from the page origin. + assert_eq!(row(&p, "Layout", "Left").value, "100"); + + // The command returns the same, for MCP. + let out = s.execute("inspect.properties", &json!({"id": r})).unwrap(); + assert_eq!(out["sections"][0]["title"], "Layout"); + assert!(s.execute("inspect.properties", &json!({"id": 999_999})).is_err()); +} + +#[test] +fn typography_section_for_text() { + let mut s = Session::new(); + let t = id(s.execute("text.create", &json!({"text": "Hello", "x": 0, "y": 0})).unwrap()); + s.execute("node.set", &json!({"id": t, "props": {"fontSize": 18, "textAlignHorizontal": "center"}})).unwrap(); + let p = properties(s.doc().unwrap(), t); + assert_eq!(row(&p, "Typography", "Size").value, "18"); + assert!(p.iter().any(|s| s.title == "Typography")); +} + +#[test] +fn redlines_measure_on_hover_without_alt() { + let mut s = Session::new(); + let a = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()); + s.execute("shape.rectangle", &json!({"x": 100, "y": 0, "width": 50, "height": 50})).unwrap(); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + let hover = |s: &mut Session| { + s.pointer(&PointerEvent::at(PointerKind::Move, 125.0, 25.0, 1.0)).unwrap(); + s.tool.overlay.guides.len() + }; + assert_eq!(hover(&mut s), 0, "plain hover doesn't measure"); + s.tool.redlines = true; + assert!(hover(&mut s) > 0, "Inspect measures on hover"); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e405322..81055b6 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -11,6 +11,7 @@ pub mod catalog; pub mod cmd; pub mod codegen; pub mod components; +pub mod devinspect; pub mod drop; pub mod guard; pub mod inspect; @@ -868,6 +869,8 @@ mod al_drag_tests; #[cfg(test)] mod autosave_tests; #[cfg(test)] +mod devinspect_tests; +#[cfg(test)] mod frame_tests; #[cfg(test)] mod mask_tests; diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 4929a67..a2ee4fe 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -334,6 +334,8 @@ pub struct ToolState { /// removes; Alt merges). pub shape_builder: Option>, pub builder_remove: bool, + /// Show distances from the selection on hover without holding Alt (Dev mode inspection). + pub redlines: bool, } impl ToolState { @@ -534,13 +536,13 @@ impl Session { } else if let Some(leaf) = doc.hit_test(page, ev.p(), ev.tol() * 2.0) { let t = select_target(&doc, leaf, &sel, ev.cmd); hover = Some(t); - if ev.alt && !sel.is_empty() && !sel.contains(&t) { + if (ev.alt || self.tool.redlines) && !sel.is_empty() && !sel.contains(&t) { // Alt: measure from the selection to the hovered layer. if let (Some(a), Some(b)) = (doc.bounds_of(&sel), doc.world_bounds(t)) { guides = crate::snap::measure(a, b); } } - } else if ev.alt && !sel.is_empty() { + } else if (ev.alt || self.tool.redlines) && !sel.is_empty() { // Alt over empty canvas inside a frame: distances to the parent. if let Some(a) = doc.bounds_of(&sel) && let Some(p) = sel.first().and_then(|i| doc.node(*i)).and_then(|n| n.parent).filter(|p| *p != page) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 40effca..9876027 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -376,6 +376,8 @@ impl FigApp { let t = theme::Tokens::get(&ctx); // Control requests run first so their results reflect this frame. control::drain(self, &ctx); + // The Inspect tab measures on hover without Alt. + self.session.tool.redlines = self.ui.right_tab == RightTab::Inspect; if self.ui.present.is_some() { present::show(self, ui); return; diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index 667a03a..b802040 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -1403,35 +1403,71 @@ fn export(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { } /// The Inspect tab: size and position, and code for the selected layer. +/// One group of inspected properties: label, value (with a swatch for colours) and the style or +/// variable it comes from. Clicking a row copies its value. +fn inspect_section(app: &mut FigApp, ui: &mut Ui, s: &figcraft_engine::devinspect::Section) { + let t = Tokens::get(ui.ctx()); + widgets::separator(ui); + let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::hover()); + ui.painter().text(pos2(hr.min.x + PAD, hr.center().y), Align2::LEFT_CENTER, s.title, semibold(11.0), t.text); + for p in &s.rows { + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click()); + let resp = resp.on_hover_text(format!("Copy {}", p.value)); + if resp.hovered() { + ui.painter().rect_filled(r.shrink2(vec2(8.0, 0.0)), CornerRadius::same(5), t.hover); + } + let painter = ui.painter(); + painter.text(pos2(r.min.x + PAD, r.center().y), Align2::LEFT_CENTER, &p.label, regular(11.0), t.text_secondary); + // The style or variable, right-aligned as a chip. + let mut right = r.max.x - PAD; + if let Some(token) = &p.token { + let galley = painter.layout_no_wrap(token.clone(), regular(10.0), t.text_secondary); + let chip = + Rect::from_min_max(pos2((right - galley.size().x - 12.0).max(r.min.x + 196.0), r.center().y - 9.0), pos2(right, r.center().y + 9.0)); + painter.rect_filled(chip, CornerRadius::same(4), t.field); + painter.with_clip_rect(chip.shrink2(vec2(6.0, 0.0))).galley( + pos2(chip.min.x + 6.0, chip.center().y - galley.size().y / 2.0), + galley, + t.text_secondary, + ); + right = chip.min.x - 6.0; + } + let mut x = r.min.x + 96.0; + if let Some(c) = p.color.as_deref().and_then(figcraft_color::Color::from_hex) { + let sw = Rect::from_min_size(pos2(x, r.center().y - 6.0), vec2(12.0, 12.0)); + widgets::paint_swatch(painter, sw, &Paint::solid(c)); + x += 18.0; + } + let value_r = Rect::from_min_max(pos2(x, r.min.y), pos2(right.max(x + 10.0), r.max.y)); + painter.with_clip_rect(value_r).text(pos2(x, r.center().y), Align2::LEFT_CENTER, &p.value, regular(11.0), t.text); + if resp.clicked() { + app.services.clipboard_set_text(&p.value); + app.toast(format!("Copied {}", p.label.to_lowercase())); + } + } + ui.add_space(4.0); +} + fn inspect(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let Ok(ds) = app.session.ds() else { return }; let doc = ds.doc.clone(); let sel = ds.selection.clone(); - widgets::section_header(ui, "Inspect", &[], false); let Some(first) = sel.first().copied() else { - row(ui, 32.0, |ui| widgets::label(ui, "Select a layer to see its code and measurements", t.text_secondary)); + widgets::section_header(ui, "Inspect", &[], false); + row(ui, 32.0, |ui| widgets::label(ui, "Select a layer to see its properties and code", t.text_secondary)); + row(ui, 32.0, |ui| widgets::label(ui, "Hover other layers to measure the distance", t.text_secondary)); return; }; - if let Some(b) = doc.world_bounds(first) { - row(ui, 32.0, |ui| { - widgets::label( - ui, - &format!( - "{} × {} at {}, {}", - crate::canvas::fmt(b.width()), - crate::canvas::fmt(b.height()), - crate::canvas::fmt(b.x0), - crate::canvas::fmt(b.y0) - ), - t.text, - ); - }); + let name = doc.node(first).map(|n| n.name.clone()).unwrap_or_default(); + widgets::section_header(ui, &name, &[], false); + for s in figcraft_engine::devinspect::properties(&doc, first) { + inspect_section(app, ui, &s); } - widgets::separator(ui); use figcraft_engine::codegen::{CodeLang, code_for}; let lang_id = Id::new("inspect_lang"); let lang = ui.data(|d| d.get_temp::(lang_id)).and_then(|i| CodeLang::ALL.get(i).copied()).unwrap_or_default(); + widgets::separator(ui); let copy = widgets::section_header(ui, "Code", &[(Icon::Export, "Copy")], false).is_some(); row(ui, 32.0, |ui| { let resp = widgets::dropdown(ui, lang.label(), 120.0, Some(Icon::Code)); diff --git a/docs/mcp.md b/docs/mcp.md index 6b0650f..a0463b4 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -95,7 +95,7 @@ Or in a project's `.mcp.json`: | Tool | What it does | |---|---| | `list_commands {filter?}` | Every command: id, label, menu, shortcut, undoable, params doc. | -| `run_command {command, params}` | Run any command (e.g. `align.left`, `layout.add`, `component.create`, `instance.place` (drop an instance at a page point into the frame or auto layout slot under it), `component.exposeInstance` / `instance.exposed` (nested instance properties on instances), `inspect.code` (CSS, SwiftUI or Jetpack Compose for a layer), `style.create`, `variable.bind`, `variable.update` (rename, describe, scope), `variable.set` with `{alias}`, `variable.renameMode` / `variable.removeMode`, `variable.deleteCollection`, `proto.interaction.add`). | +| `run_command {command, params}` | Run any command (e.g. `align.left`, `layout.add`, `component.create`, `instance.place` (drop an instance at a page point into the frame or auto layout slot under it), `component.exposeInstance` / `instance.exposed` (nested instance properties on instances), `inspect.code` (CSS, SwiftUI or Jetpack Compose for a layer), `inspect.properties` (layout, auto layout, typography, fills, strokes and effects with their style and variable names), `style.create`, `variable.bind`, `variable.update` (rename, describe, scope), `variable.set` with `{alias}`, `variable.renameMode` / `variable.removeMode`, `variable.deleteCollection`, `proto.interaction.add`). | | `batch {commands: [{command, params}], label?}` | Several commands as one undo step (`command.batch`); all-or-nothing. | | `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`), text…); returns the updated layers. |