M6: Inspect tab: properties with styles and variables, redlines on hover (#41)

The Inspect tab lists the selected layer's properties for handoff:
layout (size, position from the parent, rotation, opacity, radius), auto
layout (direction, gap, padding, alignment), typography, fills, strokes
and effects, each with the style or variable it comes from. Clicking a
value copies it. With the Inspect tab open, hovering measures distances
from the selection without holding Alt.

New command inspect.properties returns the same groups for agents.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BFlat
2026-10-10 06:07:48 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 476cdd95f1
commit d53cd60691
9 changed files with 426 additions and 21 deletions
+1
View File
@@ -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() {
+277
View File
@@ -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<Prop>,
}
/// 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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub token: Option<String>,
}
pub fn specs() -> Vec<CommandSpec> {
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<String>, value: impl Into<String>) -> Prop {
Prop { label: label.into(), value: value.into(), color: None, token: None }
}
impl Prop {
fn token(mut self, t: Option<String>) -> 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<String> {
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<String> {
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<Section> {
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<Prop> {
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<String> = 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<Prop> = 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<Prop>, token: Option<String>) -> Vec<Prop> {
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::<Vec<_>>().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::<String>()).token(variable(doc, n, "characters")));
}
Section { title: "Typography", rows }
}
+84
View File
@@ -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");
}
+3
View File
@@ -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;
+4 -2
View File
@@ -334,6 +334,8 @@ pub struct ToolState {
/// removes; Alt merges).
pub shape_builder: Option<std::sync::Arc<crate::shapebuilder::Arrangement>>,
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)