From d16878588ff4f739cbe00587f71c4b9b58a7f55a Mon Sep 17 00:00:00 2001
From: BFlat
Date: Sat, 10 Oct 2026 14:55:33 +0700
Subject: [PATCH] M6: Dev Mode component code, annotations and ready for dev
(#80)
* M6: Dev Mode component code, annotations and ready for dev
- Component code: React (new), SwiftUI and Compose write components as reusable
units whose component properties are parameters (variant unions/enums,
boolean flags, text strings, instance swaps as views, slots as optional
content); variant sets share one definition (React: data-* rules for the
other variants); instances are calls passing only the values they change.
Escaped, unique identifiers. `inspect.componentCode` returns the parts.
- Annotations: notes with pinned properties on any layer, saved with the file
(left out when empty), undoable `annotation.add/edit/remove`, `annotation.list`
with live values; drawn as callouts on the canvas in the Inspect tab and
edited in its Annotations section. Instances keep their own annotations.
- Ready for dev: `inspect.readyForDev` lists frames and sections by handoff
status; the Inspect tab sets the status and lists them with a filter.
- Shared widgets: a multi-line field and the status dropdown are reused.
Co-Authored-By: Claude Opus 5.5
* M6: test that bound variables become CSS custom properties
Co-Authored-By: Claude Opus 5.5
* M6: regenerate the parity report
Co-Authored-By: Claude Opus 5.5
---------
Co-authored-by: Claude Opus 5.5
---
README.md | 2 +-
ROADMAP.md | 6 +-
crates/doc/src/node.rs | 16 +
crates/engine/src/annotations.rs | 277 ++++++++
crates/engine/src/annotations_tests.rs | 204 ++++++
crates/engine/src/cmd/mod.rs | 2 +
crates/engine/src/codegen.rs | 602 +++++++++++++----
crates/engine/src/component_codegen.rs | 665 +++++++++++++++++++
crates/engine/src/component_codegen_tests.rs | 263 ++++++++
crates/engine/src/components.rs | 4 +
crates/engine/src/devinspect.rs | 73 +-
crates/engine/src/inspect.rs | 3 +
crates/engine/src/lib.rs | 6 +
crates/ui-egui/src/canvas.rs | 1 +
crates/ui-egui/src/component_panel.rs | 38 +-
crates/ui-egui/src/dev_mode.rs | 301 +++++++++
crates/ui-egui/src/lib.rs | 4 +
crates/ui-egui/src/right.rs | 83 ++-
crates/ui-egui/src/section_panel.rs | 55 +-
crates/ui-egui/src/widgets.rs | 34 +
docs/control-protocol.md | 3 +-
docs/mcp.md | 38 +-
docs/parity.md | 2 +-
23 files changed, 2434 insertions(+), 248 deletions(-)
create mode 100644 crates/engine/src/annotations.rs
create mode 100644 crates/engine/src/annotations_tests.rs
create mode 100644 crates/engine/src/component_codegen.rs
create mode 100644 crates/engine/src/component_codegen_tests.rs
create mode 100644 crates/ui-egui/src/dev_mode.rs
diff --git a/README.md b/README.md
index 0936fda2d..cab31e161 100644
--- a/README.md
+++ b/README.md
@@ -102,7 +102,7 @@
Prototype and hand off
- Connections, triggers, actions and Smart Animate-style transitions, flows, a presentation view with device frames and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, CSS and SVG, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF.
+ Connections, triggers, actions and Smart Animate-style transitions, flows, a presentation view with device frames and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations, a ready-for-dev list and code (CSS, HTML, React, SwiftUI, Compose) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF.
diff --git a/ROADMAP.md b/ROADMAP.md
index 4623b0c40..535378358 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -240,7 +240,9 @@ dimension measured side by side.
bar (frameless on Windows/Linux with drawn window buttons, drag, double-click maximise and edge
resizing; macOS window buttons inset into it), icon rail, file header, pages and
layers tree (rename, visibility/lock, drag to reorder/reparent), Assets panel, floating bottom
- toolbar with flyouts and mode switcher, Design / Prototype / Inspect (CSS) panels, colour picker
+ toolbar with flyouts and mode switcher, Design / Prototype / Inspect panels (Dev Mode: properties with their styles and variables, annotations
+ with pinned properties drawn as callouts on the canvas, a ready-for-dev list, CSS/HTML/React/SwiftUI/Compose
+ code where components take their properties as parameters and instances are calls), colour picker
(HSB square, hue/alpha, hex, gradient stops, image fills), main menu generated from the catalog,
context menu, Actions palette (⌘K), keyboard shortcuts from the catalog, presentation player with
transitions, light and dark themes, home screen, About/Preferences/shortcuts/variables dialogs.
@@ -268,7 +270,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent).
| Styles & variables | 5 | 86% | grid and component property bindings, reordering paints and effects in the panel | 1–2 |
| Prototyping | 6 | 86% | building conditionals in the interaction editor, hotspot hints, inspect in the presenter | 3–5 |
| Import/export | 6 | 66% | .fig import (best effort), variable-mode lookups in prototype expressions, PDF print options (bleed, CMYK) | 10–20 |
-| Dev mode / inspect | 2 | 78% | component code with properties as parameters, visual QA | 2–3 |
+| Dev mode / inspect | 2 | 84% | changes since ready for dev (compare view), focus view on one frame, annotation categories, visual QA | 1–2 |
| Comments, versions, misc | 3 | 55% | plugins (WASM), multiplayer | 8–14 |
Weighted feature depth ≈ **77%** (self-graded, from the table).
diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs
index e262a07cf..da865b38b 100644
--- a/crates/doc/src/node.rs
+++ b/crates/doc/src/node.rs
@@ -193,6 +193,18 @@ impl DevStatus {
}
}
+/// A note for developers on a layer (a Dev Mode annotation): its text and the properties it pins,
+/// which Dev Mode shows with their current values.
+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Annotation {
+ #[serde(default, skip_serializing_if = "String::is_empty")]
+ pub label: String,
+ /// Pinned property ids ("width", "fill", "fontSize"…; `figcraft_engine::annotations` lists
+ /// them). Unknown ids are kept and ignored.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub properties: Vec,
+}
+
/// A ruler guide on a page or frame.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Guide {
@@ -417,6 +429,9 @@ pub struct Node {
/// Free-form plugin / agent data.
#[serde(default, skip_serializing_if = "is_default")]
pub plugin_data: BTreeMap,
+ /// Dev Mode annotations, in the order they were added.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub annotations: Vec,
}
fn yes() -> bool {
@@ -523,6 +538,7 @@ impl Node {
overrides: BTreeSet::new(),
prop_refs: BTreeMap::new(),
plugin_data: BTreeMap::new(),
+ annotations: Vec::new(),
}
}
diff --git a/crates/engine/src/annotations.rs b/crates/engine/src/annotations.rs
new file mode 100644
index 000000000..6f832cf4a
--- /dev/null
+++ b/crates/engine/src/annotations.rs
@@ -0,0 +1,277 @@
+//! Dev Mode annotations: notes designers attach to layers for developers, each with optional
+//! pinned properties (width, fill, font size…) that Dev Mode shows with their current values. They
+//! are stored on the layer (`Node::annotations`), saved with the file and undoable.
+
+use figcraft_doc::*;
+use serde_json::{Value, json};
+
+use crate::cmd::CommandSpec;
+use crate::devinspect::{Prop, Section, properties};
+use crate::params::*;
+use crate::{bad, spec};
+
+/// Most annotations on one layer.
+pub const MAX_PER_LAYER: usize = 64;
+/// Longest annotation text (chars).
+pub const MAX_LABEL: usize = 5_000;
+/// Most annotated layers listed or drawn for one page.
+pub const MAX_LISTED: usize = 2_000;
+
+/// A property an annotation can pin: its id, its label and where Dev Mode's property list shows it
+/// (a section and a row; `None` = the section's rows other pinnable properties don't claim).
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub struct Pinnable {
+ pub id: &'static str,
+ pub label: &'static str,
+ section: &'static str,
+ row: Option<&'static str>,
+}
+
+const fn pin(id: &'static str, label: &'static str, section: &'static str, row: Option<&'static str>) -> Pinnable {
+ Pinnable { id, label, section, row }
+}
+
+/// Every pinnable property, in the order Dev Mode lists them.
+pub const PINNABLE: &[Pinnable] = &[
+ pin("width", "Width", "Layout", Some("Width")),
+ pin("height", "Height", "Layout", Some("Height")),
+ pin("top", "Top", "Layout", Some("Top")),
+ pin("left", "Left", "Layout", Some("Left")),
+ pin("rotation", "Rotation", "Layout", Some("Rotation")),
+ pin("opacity", "Opacity", "Layout", Some("Opacity")),
+ pin("cornerRadius", "Corner radius", "Layout", Some("Radius")),
+ pin("direction", "Direction", "Auto layout", Some("Direction")),
+ pin("gap", "Gap", "Auto layout", Some("Gap")),
+ pin("padding", "Padding", "Auto layout", Some("Padding")),
+ pin("alignment", "Alignment", "Auto layout", Some("Align")),
+ pin("fontFamily", "Font", "Typography", Some("Font")),
+ pin("fontStyle", "Font style", "Typography", Some("Style")),
+ pin("fontSize", "Font size", "Typography", Some("Size")),
+ pin("lineHeight", "Line height", "Typography", Some("Line height")),
+ pin("letterSpacing", "Letter spacing", "Typography", Some("Letter spacing")),
+ pin("textAlign", "Text align", "Typography", Some("Align")),
+ pin("fill", "Fill", "Fills", None),
+ pin("stroke", "Stroke", "Strokes", None),
+ pin("strokeWeight", "Stroke weight", "Strokes", Some("Weight")),
+ pin("effects", "Effects", "Effects", None),
+];
+
+/// The pinnable property with id (or label) `key`, ignoring case.
+pub fn pinnable(key: &str) -> Option<&'static Pinnable> {
+ let key = key.trim();
+ PINNABLE.iter().find(|p| p.id.eq_ignore_ascii_case(key) || p.label.eq_ignore_ascii_case(key))
+}
+
+/// Whether `n` has anything to show for `p` (a layer without auto layout has no gap…).
+pub fn applies(sections: &[Section], p: &Pinnable) -> bool {
+ pinned_in(sections, p).is_some()
+}
+
+/// `p`'s value in a layer's inspected `sections`, labelled with `p`'s label (`None` when the
+/// layer doesn't have it).
+pub fn pinned_in(sections: &[Section], p: &Pinnable) -> Option {
+ let s = sections.iter().find(|s| s.title == p.section)?;
+ let rows: Vec<&Prop> = match p.row {
+ Some(label) => s.rows.iter().filter(|r| r.label == label).take(1).collect(),
+ // The section's own rows: not those another pinnable property shows (strokes' weight).
+ None => s.rows.iter().filter(|r| !PINNABLE.iter().any(|q| q.section == p.section && q.row == Some(r.label.as_str()))).collect(),
+ };
+ let first = rows.first()?;
+ let value = match p.row {
+ Some(_) => first.value.clone(),
+ None => {
+ rows.iter().map(|r| if r.label == "Solid" { r.value.clone() } else { format!("{} {}", r.label, r.value) }).collect::>().join(", ")
+ }
+ };
+ Some(Prop { label: p.label.to_string(), value, color: first.color.clone(), token: first.token.clone() })
+}
+
+/// The pinned properties of annotation `a` on layer `id` with their current values (`None` for
+/// those the layer doesn't have); unknown ids are skipped.
+pub fn pinned(doc: &Document, id: NodeId, a: &Annotation) -> Vec<(&'static Pinnable, Option)> {
+ if a.properties.is_empty() {
+ return Vec::new();
+ }
+ let sections = properties(doc, id);
+ a.properties.iter().filter_map(|k| pinnable(k)).map(|p| (p, pinned_in(§ions, p))).collect()
+}
+
+/// The annotated layers on `page`, in layer order (bounded).
+pub fn on_page(doc: &Document, page: NodeId) -> Vec {
+ let mut out = Vec::new();
+ let mut stack = vec![page];
+ let mut visited = 0usize;
+ while let Some(id) = stack.pop() {
+ visited += 1;
+ if out.len() >= MAX_LISTED || visited > 200_000 {
+ break;
+ }
+ let Some(n) = doc.node(id) else { continue };
+ if !n.annotations.is_empty() {
+ out.push(id);
+ }
+ stack.extend(n.children.iter().rev().copied());
+ }
+ out
+}
+
+/// An annotation as JSON: its layer, index, text and pinned properties with their values.
+pub fn annotation_json(doc: &Document, id: NodeId, index: usize, a: &Annotation) -> Value {
+ let name = doc.node(id).map(|n| n.name.clone()).unwrap_or_default();
+ let props: Vec = pinned(doc, id, a)
+ .into_iter()
+ .map(|(p, v)| {
+ let mut o = json!({"property": p.id, "label": p.label, "value": v.as_ref().map(|v| v.value.clone())});
+ if let Some(t) = v.as_ref().and_then(|v| v.token.clone()) {
+ o["token"] = json!(t);
+ }
+ if let Some(c) = v.and_then(|v| v.color) {
+ o["color"] = json!(c);
+ }
+ o
+ })
+ .collect();
+ json!({"id": id, "name": name, "index": index, "label": a.label, "properties": props})
+}
+
+/// `properties` from params: known ids, each once, in the given order.
+fn parse_properties(cmd: &str, p: &Value) -> crate::Result
\n", open("p")));
+ }
_ => {
- let kids = kids(doc, n);
- if kids.is_empty() || depth >= MAX_DEPTH {
- self.markup.push_str(&format!("{pad}\n"));
+ let kids = self.ctx.kids(n);
+ let slot = self.ctx.param(n, "slot").filter(|_| self.jsx);
+ let open = open("div");
+ if let Some(p) = slot.filter(|_| kids.is_empty()) {
+ self.markup.push_str(&format!("{open}{{{}}}\n", p.ident));
+ } else if kids.is_empty() || self.ctx.too_deep(depth) {
+ self.markup.push_str(&format!("{open}\n"));
} else {
- self.markup.push_str(&format!("{pad}
\n"));
+ self.markup.push_str(&format!("{open}\n"));
+ // A slot shows the instance's content, or else the main's.
+ let (p1, p2) = (self.pad(depth + 1), self.pad(depth + 2));
+ let inner = match slot {
+ Some(p) => {
+ self.markup.push_str(&format!("{p1}{{{} ?? (\n{p2}<>\n", p.ident));
+ depth + 3
+ }
+ None => depth + 1,
+ };
+ let mut seen: Vec<&str> = Vec::new();
// Bottom layer first: later elements paint on top, like the layer order.
for k in kids {
- self.node(doc, k, Some(n), depth + 1);
+ let Some(kn) = doc.node(k) else { continue };
+ let count = seen.iter().filter(|s| **s == kn.name).count();
+ seen.push(&kn.name);
+ self.node(k, Some(n), inner, &format!("{path}/{}#{count}", kn.name));
+ }
+ if slot.is_some() {
+ self.markup.push_str(&format!("{p2}>\n{p1})}}\n"));
}
self.markup.push_str(&format!("{pad}
\n"));
}
@@ -383,17 +609,46 @@ fn swift_weight(w: u16) -> &'static str {
}
}
-fn swift(doc: &Document, id: NodeId, depth: usize, out: &mut String) {
- let Some(n) = doc.node(id) else { return };
+/// SwiftUI for a layer and its subtree at `depth`; `extra` modifiers go last (a free child's
+/// offset). A layer a boolean parameter shows sits in an `if`.
+pub(crate) fn swift(ctx: &Ctx, id: NodeId, depth: usize, out: &mut String, extra: &[String]) {
+ let Some(n) = ctx.doc.node(id) else { return };
+ if id != ctx.root
+ && let Some(p) = ctx.param(n, "visible")
+ {
+ let pad = indent(depth);
+ out.push_str(&format!("{pad}if {} {{\n", p.ident));
+ swift_layer(ctx, n, depth + 1, out, extra);
+ out.push_str(&format!("{pad}}}\n"));
+ return;
+ }
+ swift_layer(ctx, n, depth, out, extra);
+}
+
+fn swift_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[String]) {
+ let doc = ctx.doc;
let pad = indent(depth);
- if depth > MAX_DEPTH {
+ if ctx.too_deep(depth) {
out.push_str(&format!("{pad}// {} …\n", n.name));
return;
}
+ // Nested instances are calls; an instance swap parameter is a view.
+ let call = match ctx.param(n, "mainComponent").filter(|_| n.id != ctx.root) {
+ Some(p) => Some(format!("{pad}{}\n", p.ident)),
+ None => ctx.call(n, CodeLang::SwiftUi, depth),
+ };
+ if let Some(call) = call {
+ out.push_str(&call);
+ for m in extra {
+ out.push_str(&format!("{pad} {m}\n"));
+ }
+ return;
+ }
let mut mods: Vec = Vec::new();
match &n.kind {
NodeKind::Text(t) => {
- out.push_str(&format!("{pad}Text({})\n", literal(&t.characters, false)));
+ let text = ctx.param(n, "characters").map_or_else(|| literal(&t.characters, false), |p| p.ident.clone());
+ out.push_str(&format!("{pad}Text({text})\n"));
mods.push(format!(
".font(.custom({}, size: {}).weight({}))",
literal(&t.style.font_family, false),
@@ -419,24 +674,37 @@ fn swift(doc: &Document, id: NodeId, depth: usize, out: &mut String) {
mods.push(format!(".frame(width: {}, height: {})", num(n.width), num(n.height)));
}
k if k.is_frame_like() || matches!(k, NodeKind::Group) => {
- let children = kids(doc, n);
- let (open, close) = match n.layout.as_ref().map(|l| (l.mode, l)) {
- Some((LayoutMode::Vertical, l)) => (format!("VStack(alignment: .leading, spacing: {}) {{", num(l.gap)), "}"),
- Some((_, l)) => (format!("HStack(alignment: .center, spacing: {}) {{", num(l.gap)), "}"),
- None => ("ZStack(alignment: .topLeading) {".to_string(), "}"),
+ let children = ctx.kids(n);
+ let open = match n.layout.as_ref().map(|l| (l.mode, l)) {
+ Some((LayoutMode::Vertical, l)) => format!("VStack(alignment: .leading, spacing: {}) {{", num(l.gap)),
+ Some((_, l)) => format!("HStack(alignment: .center, spacing: {}) {{", num(l.gap)),
+ None => "ZStack(alignment: .topLeading) {".to_string(),
};
out.push_str(&format!("{pad}{open}\n"));
- for c in &children {
- swift(doc, *c, depth + 1, out);
- // Free children sit at their offset inside a ZStack.
- if n.layout.is_none()
- && let Some(cn) = doc.node(*c)
- {
- let [_, _, _, _, x, y] = cn.transform.as_coeffs();
- out.push_str(&format!("{} .offset(x: {}, y: {})\n", indent(depth + 1), num(x), num(y)));
+ // A slot shows the instance's content, or else the main's.
+ let slot = ctx.param(n, "slot");
+ let inner = match slot {
+ Some(p) => {
+ out.push_str(&format!("{pad} if let {0} {{\n{pad} {0}\n{pad} }} else {{\n", p.ident));
+ depth + 2
}
+ None => depth + 1,
+ };
+ for c in &children {
+ // Free children sit at their offset inside a ZStack.
+ let offset: Vec = match doc.node(*c).filter(|_| n.layout.is_none()) {
+ Some(cn) => {
+ let [_, _, _, _, x, y] = cn.transform.as_coeffs();
+ vec![format!(".offset(x: {}, y: {})", num(x), num(y))]
+ }
+ None => Vec::new(),
+ };
+ swift(ctx, *c, inner, out, &offset);
}
- out.push_str(&format!("{pad}{close}\n"));
+ if slot.is_some() {
+ out.push_str(&format!("{pad} }}\n"));
+ }
+ out.push_str(&format!("{pad}}}\n"));
if let Some(l) = &n.layout {
let [t, r, b, lft] = l.padding;
if [t, r, b, lft].iter().any(|v| *v > 0.0) {
@@ -477,6 +745,7 @@ fn swift(doc: &Document, id: NodeId, depth: usize, out: &mut String) {
if n.opacity < 1.0 {
mods.push(format!(".opacity({})", num(f64::from(n.opacity))));
}
+ mods.extend(extra.iter().cloned());
for m in mods {
out.push_str(&format!("{pad} {m}\n"));
}
@@ -513,20 +782,45 @@ fn modifier(n: &Node, shape: &str, padding: bool) -> String {
m
}
-fn compose(doc: &Document, id: NodeId, depth: usize, out: &mut String) {
- let Some(n) = doc.node(id) else { return };
+/// Jetpack Compose for a layer and its subtree at `depth`. A layer a boolean parameter shows sits
+/// in an `if`.
+pub(crate) fn compose(ctx: &Ctx, id: NodeId, depth: usize, out: &mut String) {
+ let Some(n) = ctx.doc.node(id) else { return };
+ if id != ctx.root
+ && let Some(p) = ctx.param(n, "visible")
+ {
+ let pad = indent(depth);
+ out.push_str(&format!("{pad}if ({}) {{\n", p.ident));
+ compose_layer(ctx, n, depth + 1, out);
+ out.push_str(&format!("{pad}}}\n"));
+ return;
+ }
+ compose_layer(ctx, n, depth, out);
+}
+
+fn compose_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String) {
+ let doc = ctx.doc;
let pad = indent(depth);
- if depth > MAX_DEPTH {
+ if ctx.too_deep(depth) {
out.push_str(&format!("{pad}// {} …\n", n.name));
return;
}
+ // Nested instances are calls; an instance swap parameter is a composable.
+ let call = match ctx.param(n, "mainComponent").filter(|_| n.id != ctx.root) {
+ Some(p) => Some(format!("{pad}{}()\n", p.ident)),
+ None => ctx.call(n, CodeLang::Compose, depth),
+ };
+ if let Some(call) = call {
+ out.push_str(&call);
+ return;
+ }
let shape = if radius(n) > 0.0 { format!("RoundedCornerShape({}.dp)", num(radius(n))) } else { "RectangleShape".to_string() };
match &n.kind {
NodeKind::Text(t) => {
let color = first_fill(n).map(kotlin_color).unwrap_or_else(|| "Color.Unspecified".into());
+ let text = ctx.param(n, "characters").map_or_else(|| literal(&t.characters, true), |p| p.ident.clone());
out.push_str(&format!(
- "{pad}Text(\n{pad} text = {},\n{pad} fontSize = {}.sp,\n{pad} fontWeight = FontWeight.W{},\n{pad} color = {color},\n{pad})\n",
- literal(&t.characters, true),
+ "{pad}Text(\n{pad} text = {text},\n{pad} fontSize = {}.sp,\n{pad} fontWeight = FontWeight.W{},\n{pad} color = {color},\n{pad})\n",
num(t.style.font_size),
(t.style.weight().clamp(100, 900) / 100) * 100
));
@@ -534,9 +828,13 @@ fn compose(doc: &Document, id: NodeId, depth: usize, out: &mut String) {
NodeKind::Ellipse { .. } => out.push_str(&format!("{pad}Box(modifier = {})\n", modifier(n, "CircleShape", false))),
NodeKind::Rectangle => out.push_str(&format!("{pad}Box(modifier = {})\n", modifier(n, &shape, false))),
k if k.is_frame_like() || matches!(k, NodeKind::Group) => {
- let children = kids(doc, n);
+ let children = ctx.kids(n);
let open = match n.layout.as_ref().map(|l| (l.mode, l)) {
- Some((LayoutMode::Vertical, l)) => format!("Column(\n{pad} modifier = {},\n{pad} verticalArrangement = Arrangement.spacedBy({}.dp),\n{pad}) {{", modifier(n, &shape, true), num(l.gap)),
+ Some((LayoutMode::Vertical, l)) => format!(
+ "Column(\n{pad} modifier = {},\n{pad} verticalArrangement = Arrangement.spacedBy({}.dp),\n{pad}) {{",
+ modifier(n, &shape, true),
+ num(l.gap)
+ ),
Some((_, l)) => format!(
"Row(\n{pad} modifier = {},\n{pad} horizontalArrangement = Arrangement.spacedBy({}.dp),\n{pad} verticalAlignment = Alignment.CenterVertically,\n{pad}) {{",
modifier(n, &shape, true),
@@ -545,18 +843,30 @@ fn compose(doc: &Document, id: NodeId, depth: usize, out: &mut String) {
None => format!("Box(modifier = {}) {{", modifier(n, &shape, false)),
};
out.push_str(&format!("{pad}{open}\n"));
+ // A slot shows the instance's content, or else the main's.
+ let slot = ctx.param(n, "slot");
+ let base = match slot {
+ Some(p) => {
+ out.push_str(&format!("{pad} if ({0} != null) {{\n{pad} {0}()\n{pad} }} else {{\n", p.ident));
+ depth + 1
+ }
+ None => depth,
+ };
for c in &children {
if n.layout.is_none()
&& let Some(cn) = doc.node(*c)
{
let [_, _, _, _, x, y] = cn.transform.as_coeffs();
- out.push_str(&format!("{}Box(modifier = Modifier.offset({}.dp, {}.dp)) {{\n", indent(depth + 1), num(x), num(y)));
- compose(doc, *c, depth + 2, out);
- out.push_str(&format!("{}}}\n", indent(depth + 1)));
+ out.push_str(&format!("{}Box(modifier = Modifier.offset({}.dp, {}.dp)) {{\n", indent(base + 1), num(x), num(y)));
+ compose(ctx, *c, base + 2, out);
+ out.push_str(&format!("{}}}\n", indent(base + 1)));
} else {
- compose(doc, *c, depth + 1, out);
+ compose(ctx, *c, base + 1, out);
}
}
+ if slot.is_some() {
+ out.push_str(&format!("{pad} }}\n"));
+ }
out.push_str(&format!("{pad}}}\n"));
}
_ => out.push_str(&format!(
diff --git a/crates/engine/src/component_codegen.rs b/crates/engine/src/component_codegen.rs
new file mode 100644
index 000000000..fe684ce84
--- /dev/null
+++ b/crates/engine/src/component_codegen.rs
@@ -0,0 +1,665 @@
+//! Component code: a component (or component set) becomes a React, SwiftUI or Jetpack Compose
+//! component whose properties are its parameters (a variant property → a union or enum, boolean →
+//! a flag showing the layers bound to it, text → a string, instance swap → a view parameter, slot →
+//! optional content), and an instance becomes a call passing the values it sets. Variants of a set
+//! share one definition (the set's first variant); in React their differences become CSS rules on
+//! `data-*` attributes. Returned by `inspect.componentCode` and used by `inspect.code`.
+
+use figcraft_doc::*;
+use serde_json::{Value, json};
+
+use crate::cmd::CommandSpec;
+use crate::codegen::{CodeLang, Ctx, Markup, class_name, compose, indent, literal, swift};
+use crate::components::{prop_default_value, slot_edited, slots_of, variant_options, variant_props, variant_set};
+use crate::params::*;
+use crate::{bad, spec};
+
+/// What a component property becomes in code.
+#[derive(Clone, Debug, PartialEq)]
+pub enum ParamKind {
+ Boolean,
+ Text,
+ InstanceSwap,
+ /// A variant property with its values.
+ Variant(Vec),
+ Slot,
+}
+
+impl ParamKind {
+ pub fn id(&self) -> &'static str {
+ match self {
+ ParamKind::Boolean => "boolean",
+ ParamKind::Text => "text",
+ ParamKind::InstanceSwap => "instanceSwap",
+ ParamKind::Variant(_) => "variant",
+ ParamKind::Slot => "slot",
+ }
+ }
+}
+
+/// A component property as a parameter.
+#[derive(Clone, Debug, PartialEq)]
+pub struct Param {
+ /// The property's name.
+ pub prop: String,
+ /// Its name in code: camelCase, unique, never a keyword.
+ pub ident: String,
+ pub kind: ParamKind,
+ /// The default: a bool, a string, a component id (instance swap) or null (slot).
+ pub default: Value,
+ /// Variant properties: the enum's name in SwiftUI (PascalCase, unique, never a type the
+ /// generated code uses); Compose prefixes it with the component's name. Empty otherwise.
+ pub enum_name: String,
+}
+
+/// A component as code: the main whose layers are drawn (a set's first variant), its set, its
+/// name in code and its parameters (variant properties first).
+#[derive(Clone, Debug, PartialEq)]
+pub struct Component {
+ pub comp: NodeId,
+ pub set: Option,
+ pub type_name: String,
+ pub params: Vec,
+}
+
+pub fn specs() -> Vec {
+ vec![spec!(
+ "inspect.componentCode",
+ "Component code",
+ "{id? (component, set or instance; default the selection), language?: react|swiftui|compose (default react)} → {component, name, set, properties: [{name, param, type, default, value? (instances)}], definition, usage? (instances)}",
+ false,
+ |s, p| {
+ let cmd = "inspect.componentCode";
+ let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component or instance"))?;
+ let lang = match string(p, "language").map(|l| CodeLang::from_id(&l)) {
+ Some(Some(l @ (CodeLang::React | CodeLang::SwiftUi | CodeLang::Compose))) => l,
+ Some(_) => return Err(bad(cmd, "language: react|swiftui|compose")),
+ None => CodeLang::React,
+ };
+ let doc = s.doc()?;
+ let c = main_of(doc, id).and_then(|m| describe(doc, m)).ok_or_else(|| bad(cmd, "not a component, set or instance"))?;
+ let values = instance_values(doc, id, &c);
+ let properties: Vec = c
+ .params
+ .iter()
+ .map(|p| {
+ let mut v = json!({"name": p.prop, "param": p.ident, "type": p.kind.id(), "default": p.default});
+ if let Some(vals) = &values {
+ v["value"] = vals.iter().find(|(name, _)| *name == p.prop).map_or(Value::Null, |(_, x)| x.clone());
+ }
+ v
+ })
+ .collect();
+ let mut out = json!({
+ "component": c.comp, "name": c.type_name, "set": c.set, "properties": properties,
+ "definition": definition(doc, &c, lang), "language": lang.id(),
+ });
+ if values.is_some() {
+ out["usage"] = json!(usage(doc, id, lang, 0));
+ }
+ Ok(out)
+ }
+ )]
+}
+
+/// Code for any layer in React, SwiftUI or Compose: an instance's call and its component's
+/// definition, a component's (or set's) definition, or the layer tree (React wraps it in a
+/// function component).
+pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String {
+ let Some(n) = doc.node(id) else { return String::new() };
+ let comp = main_of(doc, id).and_then(|m| describe(doc, m));
+ match (&n.kind, comp) {
+ (NodeKind::Instance { .. }, Some(c)) => format!("{}\n{}", usage(doc, id, lang, 0).unwrap_or_default(), definition(doc, &c, lang)),
+ (NodeKind::Component { .. } | NodeKind::ComponentSet { .. }, Some(c)) => definition(doc, &c, lang),
+ _ => match lang {
+ CodeLang::React => react(doc, &Component { comp: id, set: None, type_name: component_type_name(&n.name), params: Vec::new() }),
+ CodeLang::SwiftUi => {
+ let mut out = String::new();
+ swift(&Ctx::new(doc, id), id, 0, &mut out, &[]);
+ out
+ }
+ _ => {
+ let mut out = String::new();
+ compose(&Ctx::new(doc, id), id, 0, &mut out);
+ out
+ }
+ },
+ }
+}
+
+fn is_component(doc: &Document, id: NodeId) -> bool {
+ doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. }))
+}
+
+/// The main component a layer's code is defined by: a component itself, an instance's main, and
+/// for anything in a set the set's first variant.
+pub fn main_of(doc: &Document, id: NodeId) -> Option {
+ let n = doc.node(id)?;
+ let main = match &n.kind {
+ NodeKind::Component { .. } => id,
+ NodeKind::ComponentSet { .. } => return n.children.iter().copied().find(|c| is_component(doc, *c)),
+ NodeKind::Instance { component, .. } => *component,
+ _ => return None,
+ };
+ if !is_component(doc, main) {
+ return None;
+ }
+ match variant_set(doc, main) {
+ Some(set) => doc.node(set)?.children.iter().copied().find(|c| is_component(doc, *c)),
+ None => Some(main),
+ }
+}
+
+/// `comp` (a component) as code.
+pub fn describe(doc: &Document, comp: NodeId) -> Option {
+ let n = doc.node(comp)?;
+ let NodeKind::Component { props, .. } = &n.kind else { return None };
+ let set = variant_set(doc, comp);
+ let type_name = component_type_name(&set.and_then(|s| doc.node(s)).unwrap_or(n).name);
+ let mut used: Vec = Vec::new();
+ let mut enums: Vec = vec![type_name.clone()];
+ let mut params: Vec = Vec::new();
+ let mut add = |prop: String, kind: ParamKind, default: Value| {
+ let enum_name = if matches!(kind, ParamKind::Variant(_)) { enum_ident(&prop, &mut enums) } else { String::new() };
+ params.push(Param { ident: ident(&prop, &mut used), prop, kind, default, enum_name });
+ };
+ let mut seen: Vec = Vec::new();
+ if let Some(set) = set {
+ let own = variant_props(&n.name);
+ for (k, vals) in variant_options(doc, set) {
+ let default = own.get(&k).or_else(|| vals.first()).cloned().unwrap_or_default();
+ seen.push(k.clone());
+ add(k, ParamKind::Variant(vals), Value::String(default));
+ }
+ }
+ for (k, d) in props {
+ if seen.contains(k) {
+ continue;
+ }
+ let kind = match d {
+ PropDef::Boolean { .. } => ParamKind::Boolean,
+ PropDef::Text { .. } => ParamKind::Text,
+ PropDef::InstanceSwap { .. } => ParamKind::InstanceSwap,
+ PropDef::Variant { options, .. } => ParamKind::Variant(options.clone()),
+ PropDef::Slot { .. } => ParamKind::Slot,
+ };
+ add(k.clone(), kind, prop_default_value(d));
+ }
+ Some(Component { comp, set, type_name, params })
+}
+
+// ------------------------------------------------------------------ names
+
+/// ASCII words of a name ("Show icon?" → ["Show", "icon"]).
+fn words(s: &str) -> Vec<&str> {
+ s.split(|c: char| !c.is_ascii_alphanumeric()).filter(|w| !w.is_empty()).take(12).collect()
+}
+
+fn capitalized(w: &str) -> String {
+ let mut c = w.chars();
+ c.next().map(|f| f.to_ascii_uppercase().to_string() + c.as_str()).unwrap_or_default()
+}
+
+/// A type name: "Button / Primary" → `ButtonPrimary`.
+pub fn type_name(name: &str) -> String {
+ let t: String = words(name).into_iter().map(capitalized).collect::().chars().take(60).collect();
+ match t.chars().next() {
+ None => "Component".into(),
+ Some(c) if c.is_ascii_digit() => format!("Component{t}"),
+ Some(_) => t,
+ }
+}
+
+/// Words no generated parameter may be called (keywords of TypeScript, Swift and Kotlin, and
+/// names the generated code uses).
+const RESERVED: &[&str] = &[
+ "as",
+ "body",
+ "break",
+ "case",
+ "catch",
+ "class",
+ "const",
+ "continue",
+ "default",
+ "defer",
+ "delete",
+ "do",
+ "else",
+ "enum",
+ "export",
+ "extends",
+ "extension",
+ "false",
+ "for",
+ "fun",
+ "func",
+ "function",
+ "guard",
+ "if",
+ "import",
+ "in",
+ "init",
+ "interface",
+ "is",
+ "key",
+ "let",
+ "modifier",
+ "new",
+ "nil",
+ "null",
+ "object",
+ "operator",
+ "package",
+ "private",
+ "protocol",
+ "public",
+ "ref",
+ "repeat",
+ "return",
+ "self",
+ "static",
+ "struct",
+ "super",
+ "switch",
+ "this",
+ "throw",
+ "true",
+ "try",
+ "typealias",
+ "typeof",
+ "val",
+ "var",
+ "void",
+ "when",
+ "where",
+ "while",
+ "with",
+ "yield",
+];
+
+/// Types the generated SwiftUI and Compose code names: a variant enum may not shadow them.
+const TYPES: &[&str] = &[
+ "Alignment",
+ "AnyView",
+ "Arrangement",
+ "Bool",
+ "Boolean",
+ "Box",
+ "CaseIterable",
+ "CircleShape",
+ "Color",
+ "Column",
+ "Composable",
+ "Edge",
+ "EdgeInsets",
+ "Ellipse",
+ "EmptyView",
+ "Font",
+ "FontWeight",
+ "Group",
+ "HStack",
+ "Image",
+ "Modifier",
+ "RectangleShape",
+ "RoundedCornerShape",
+ "RoundedRectangle",
+ "Row",
+ "Shape",
+ "String",
+ "Text",
+ "Unit",
+ "VStack",
+ "View",
+ "ZStack",
+];
+
+/// A component's name in code: [`type_name`], suffixed when it would shadow one of [`TYPES`]
+/// ("Text" → `TextComponent`).
+pub fn component_type_name(name: &str) -> String {
+ let t = type_name(name);
+ if TYPES.contains(&t.as_str()) { format!("{t}Component") } else { t }
+}
+
+/// A variant enum's name: "Size" → `Size`, unique in `used`, never one of [`TYPES`].
+fn enum_ident(prop: &str, used: &mut Vec) -> String {
+ let t = type_name(prop);
+ let base = if t == "Component" || TYPES.contains(&t.as_str()) { format!("{t}Variant") } else { t };
+ let out = (1..).map(|k| if k == 1 { base.clone() } else { format!("{base}{k}") }).find(|n| !used.contains(n)).unwrap_or(base);
+ used.push(out.clone());
+ out
+}
+
+/// A parameter name: "Show icon" → `showIcon`, unique in `used`.
+fn ident(name: &str, used: &mut Vec) -> String {
+ let mut base = String::new();
+ for (i, w) in words(name).into_iter().enumerate() {
+ base.push_str(&if i == 0 { w.to_ascii_lowercase() } else { capitalized(w) });
+ }
+ let mut base: String = base.chars().take(40).collect();
+ if base.is_empty() {
+ base = "prop".into();
+ } else if base.starts_with(|c: char| c.is_ascii_digit()) {
+ base = format!("p{base}");
+ }
+ if RESERVED.contains(&base.as_str()) {
+ base.push_str("Value");
+ }
+ let out = (1..).map(|k| if k == 1 { base.clone() } else { format!("{base}{k}") }).find(|n| !used.contains(n)).unwrap_or(base);
+ used.push(out.clone());
+ out
+}
+
+/// Enum case names for variant values, unique: Swift's lowerCamel (`.small`, `` .`default` ``),
+/// Kotlin's PascalCase (`Small`).
+fn cases(values: &[String], swift: bool) -> Vec {
+ let mut used = Vec::new();
+ values
+ .iter()
+ .map(|v| {
+ if swift {
+ let id = ident(v, &mut used);
+ // Keyword cases stay readable in backticks rather than suffixed.
+ match id.strip_suffix("Value").filter(|b| RESERVED.contains(b)) {
+ Some(b) => format!("`{b}`"),
+ None => id,
+ }
+ } else {
+ let t = type_name(v);
+ let t = if t == "Component" && !v.is_empty() && words(v).is_empty() { "Value".to_string() } else { t };
+ let out = (1..).map(|k| if k == 1 { t.clone() } else { format!("{t}{k}") }).find(|n| !used.contains(n)).unwrap_or(t);
+ used.push(out.clone());
+ out
+ }
+ })
+ .collect()
+}
+
+/// A JavaScript string literal.
+fn js_string(s: &str) -> String {
+ let s: String = s.chars().take(10_000).collect();
+ serde_json::to_string(&s).unwrap_or_else(|_| "\"\"".into())
+}
+
+/// The type name of a component (a variant's set) by id.
+fn component_type(doc: &Document, comp: NodeId) -> Option {
+ let n = doc.node(comp).filter(|n| matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. }))?;
+ Some(component_type_name(&variant_set(doc, comp).and_then(|s| doc.node(s)).unwrap_or(n).name))
+}
+
+/// A Swift enum or Kotlin enum class name for a variant parameter.
+fn enum_name(c: &Component, p: &Param, swift: bool) -> String {
+ if swift { p.enum_name.clone() } else { format!("{}{}", c.type_name, p.enum_name) }
+}
+
+/// `value` of a parameter as an expression in `lang`.
+fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value, lang: CodeLang) -> String {
+ match (&p.kind, lang) {
+ (ParamKind::Boolean, _) => value.as_bool().unwrap_or(false).to_string(),
+ (ParamKind::Text, CodeLang::React) => js_string(value.as_str().unwrap_or("")),
+ (ParamKind::Text, l) => literal(value.as_str().unwrap_or(""), l == CodeLang::Compose),
+ (ParamKind::Variant(opts), l) => {
+ let v = value.as_str().unwrap_or("");
+ if l == CodeLang::React {
+ return js_string(v);
+ }
+ let swift = l == CodeLang::SwiftUi;
+ let i = opts.iter().position(|o| o == v).unwrap_or(0);
+ let case = cases(opts, swift).get(i).cloned().unwrap_or_else(|| "unknown".into());
+ if swift { format!(".{case}") } else { format!("{}.{case}", enum_name(c, p, false)) }
+ }
+ (ParamKind::InstanceSwap, l) => {
+ let t = id_of(value).and_then(|i| component_type(doc, i));
+ match (t, l) {
+ (Some(t), CodeLang::React) => format!("<{t} />"),
+ (Some(t), CodeLang::SwiftUi) => format!("AnyView({t}())"),
+ (Some(t), _) => format!("{{ {t}() }}"),
+ (None, CodeLang::React) => "null".into(),
+ (None, CodeLang::SwiftUi) => "AnyView(EmptyView())".into(),
+ (None, _) => "{}".into(),
+ }
+ }
+ (ParamKind::Slot, _) => "null".into(),
+ }
+}
+
+// ------------------------------------------------------------------ instances
+
+/// The values an instance sets for `c`'s parameters, in order (`None` for other layers): variant
+/// values from the variant it shows, slots as whether they hold its own content.
+fn instance_values(doc: &Document, id: NodeId, c: &Component) -> Option> {
+ let NodeKind::Instance { component, props } = &doc.node(id)?.kind else { return None };
+ let variant = doc.node(*component).map(|m| variant_props(&m.name)).unwrap_or_default();
+ let slots = slots_of(doc, id);
+ Some(
+ c.params
+ .iter()
+ .map(|p| {
+ let v = match &p.kind {
+ ParamKind::Variant(_) if c.set.is_some() => variant.get(&p.prop).map(|v| json!(v)),
+ ParamKind::Slot => Some(json!(slots.iter().any(|(l, name)| *name == p.prop && slot_edited(doc, *l)))),
+ _ => props.get(&p.prop).cloned(),
+ };
+ (p.prop.clone(), v.unwrap_or_else(|| p.default.clone()))
+ })
+ .collect(),
+ )
+}
+
+/// The call for instance `id` at `depth`, passing the values that differ from the defaults and the
+/// content of slots it fills (`None` when it isn't an instance of an existing component).
+pub fn usage(doc: &Document, id: NodeId, lang: CodeLang, depth: usize) -> Option {
+ let c = main_of(doc, id).and_then(|m| describe(doc, m))?;
+ let values = instance_values(doc, id, &c)?;
+ let slots = slots_of(doc, id);
+ let pad = if lang == CodeLang::React { " ".repeat(depth) } else { indent(depth) };
+ // (parameter, expression, whether it's a plain string literal)
+ let mut args: Vec<(&str, String, bool)> = Vec::new();
+ for (p, (_, v)) in c.params.iter().zip(&values) {
+ if let ParamKind::Slot = p.kind {
+ if v.as_bool() == Some(true)
+ && let Some((layer, _)) = slots.iter().find(|(_, name)| *name == p.prop)
+ {
+ args.push((&p.ident, slot_content(doc, *layer, lang, depth), false));
+ }
+ continue;
+ }
+ if *v == p.default || (p.kind == ParamKind::InstanceSwap && id_of(v) == id_of(&p.default)) {
+ continue;
+ }
+ let expr = value_expr(doc, &c, p, v, lang);
+ let plain = matches!(p.kind, ParamKind::Text | ParamKind::Variant(_)) && !expr.contains('\\');
+ args.push((&p.ident, expr, plain));
+ }
+ let t = &c.type_name;
+ Some(match lang {
+ CodeLang::React => {
+ // Plain strings read best as attribute values.
+ let attrs: String =
+ args.iter().map(|(name, expr, plain)| if *plain { format!(" {name}={expr}") } else { format!(" {name}={{{expr}}}") }).collect();
+ format!("{pad}<{t}{attrs} />\n")
+ }
+ CodeLang::SwiftUi => format!("{pad}{t}({})\n", args.iter().map(|(n, e, _)| format!("{n}: {e}")).collect::>().join(", ")),
+ _ => format!("{pad}{t}({})\n", args.iter().map(|(n, e, _)| format!("{n} = {e}")).collect::>().join(", ")),
+ })
+}
+
+/// The content an instance put in one of its slots, as an expression spanning lines.
+fn slot_content(doc: &Document, slot: NodeId, lang: CodeLang, depth: usize) -> String {
+ let Some(n) = doc.node(slot) else { return String::new() };
+ let ctx = Ctx { doc, root: slot, comp: None, base: depth };
+ let kids = ctx.kids(n);
+ match lang {
+ CodeLang::React => {
+ let mut m = Markup::new(&ctx, true);
+ for k in kids {
+ m.node(k, Some(n), depth + 2, "");
+ }
+ let (p1, p2) = (" ".repeat(depth), " ".repeat(depth + 1));
+ format!("(\n{p2}<>\n{}{p2}>\n{p1})", m.markup)
+ }
+ CodeLang::SwiftUi => {
+ let mut out = String::new();
+ for k in kids {
+ swift(&ctx, k, depth + 2, &mut out, &[]);
+ }
+ format!("AnyView(Group {{\n{out}{}}})", indent(depth + 1))
+ }
+ _ => {
+ let mut out = String::new();
+ for k in kids {
+ compose(&ctx, k, depth + 2, &mut out);
+ }
+ format!("{{\n{out}{}}}", indent(depth + 1))
+ }
+ }
+}
+
+// ------------------------------------------------------------------ definitions
+
+/// `c`'s definition in `lang`.
+pub fn definition(doc: &Document, c: &Component, lang: CodeLang) -> String {
+ match lang {
+ CodeLang::SwiftUi => swift_definition(doc, c),
+ CodeLang::Compose => compose_definition(doc, c),
+ _ => react(doc, c),
+ }
+}
+
+/// `data-*` attribute names for variant parameters.
+fn data_attr(p: &Param) -> String {
+ let mut used = Vec::new();
+ format!("data-{}", class_name(&p.prop, &mut used))
+}
+
+/// A TSX function component with its props interface, and its stylesheet (variables as custom
+/// properties, other variants as rules on their `data-*` attributes).
+fn react(doc: &Document, c: &Component) -> String {
+ let ctx = Ctx { doc, root: c.comp, comp: Some(c), base: 2 };
+ let root_name = c.set.and_then(|s| doc.node(s)).or_else(|| doc.node(c.comp)).map(|n| n.name.clone()).unwrap_or_default();
+ let root_class = class_name(&root_name, &mut Vec::new());
+ let variants: Vec<&Param> = c.params.iter().filter(|p| matches!(p.kind, ParamKind::Variant(_))).collect();
+ let pass = |root: NodeId| {
+ let vctx = Ctx { doc, root, comp: Some(c), base: 2 };
+ let mut m = Markup::new(&vctx, true);
+ m.root_class = Some(root_class.clone());
+ m.node(root, None, 2, "");
+ m.rules
+ };
+ let mut m = Markup::new(&ctx, true);
+ m.root_class = Some(root_class.clone());
+ m.root_attrs = variants.iter().map(|p| format!(" {}={{{}}}", data_attr(p), p.ident)).collect();
+ m.node(c.comp, None, 2, "");
+ // Other variants: what differs from this one, layer by layer.
+ let mut variant_rules = String::new();
+ let others: Vec = c.set.and_then(|s| doc.node(s)).map(|s| s.children.clone()).unwrap_or_default();
+ for v in others.into_iter().filter(|v| *v != c.comp && is_component(doc, *v)).take(64) {
+ let Some(vn) = doc.node(v) else { continue };
+ let pairs = variant_props(&vn.name);
+ let attrs: String = variants
+ .iter()
+ .filter_map(|p| pairs.get(&p.prop).map(|val| format!("[{}=\"{}\"]", data_attr(p), val.replace(['"', '\\'], ""))))
+ .collect();
+ for (path, _, decls) in pass(v) {
+ let Some((_, class, base)) = m.rules.iter().find(|(p, _, _)| *p == path) else { continue };
+ let base_lines: Vec<&str> = base.lines().collect();
+ let diff: Vec<&str> = decls.lines().filter(|l| !base_lines.contains(l)).collect();
+ if diff.is_empty() {
+ continue;
+ }
+ let sel = if path.is_empty() { format!(".{root_class}{attrs}") } else { format!(".{root_class}{attrs} .{class}") };
+ variant_rules.push_str(&format!("{sel} {{\n{}}}\n", diff.iter().map(|l| format!(" {l}\n")).collect::()));
+ }
+ }
+ let t = &c.type_name;
+ let mut out = format!("import \"./{root_class}.css\";\n\n");
+ if c.params.is_empty() {
+ out.push_str(&format!("export function {t}() {{\n"));
+ } else {
+ out.push_str(&format!("export interface {t}Props {{\n"));
+ for p in &c.params {
+ let ty = match &p.kind {
+ ParamKind::Variant(opts) => opts.iter().map(|o| js_string(o)).collect::>().join(" | "),
+ ParamKind::Boolean => "boolean".into(),
+ ParamKind::Text => "string".into(),
+ ParamKind::InstanceSwap | ParamKind::Slot => "React.ReactNode".into(),
+ };
+ out.push_str(&format!(" {}?: {ty};\n", p.ident));
+ }
+ out.push_str("}\n\n");
+ let args: Vec = c
+ .params
+ .iter()
+ .map(|p| match p.kind {
+ ParamKind::Slot => p.ident.clone(),
+ _ => format!("{} = {}", p.ident, value_expr(doc, c, p, &p.default, CodeLang::React)),
+ })
+ .collect();
+ out.push_str(&format!("export function {t}({{\n{}}}: {t}Props) {{\n", args.iter().map(|a| format!(" {a},\n")).collect::()));
+ }
+ out.push_str(&format!(" return (\n{} );\n}}\n\n/* {root_class}.css */\n{}{}{variant_rules}", m.markup, m.tokens.root_rule(), m.styles));
+ out
+}
+
+/// A SwiftUI view struct: variant enums, stored properties with defaults, and the body.
+fn swift_definition(doc: &Document, c: &Component) -> String {
+ let ctx = Ctx { doc, root: c.comp, comp: Some(c), base: 2 };
+ let mut out = format!("struct {}: View {{\n", c.type_name);
+ for p in &c.params {
+ if let ParamKind::Variant(opts) = &p.kind {
+ out.push_str(&format!(" enum {}: String, CaseIterable {{\n", enum_name(c, p, true)));
+ for (o, case) in opts.iter().zip(cases(opts, true)) {
+ out.push_str(&format!(" case {case} = {}\n", literal(o, false)));
+ }
+ out.push_str(" }\n");
+ }
+ }
+ for p in &c.params {
+ let ty = match &p.kind {
+ ParamKind::Variant(_) => enum_name(c, p, true),
+ ParamKind::Boolean => "Bool".into(),
+ ParamKind::Text => "String".into(),
+ ParamKind::InstanceSwap => "AnyView".into(),
+ ParamKind::Slot => "AnyView?".into(),
+ };
+ let default = if p.kind == ParamKind::Slot { "nil".into() } else { value_expr(doc, c, p, &p.default, CodeLang::SwiftUi) };
+ out.push_str(&format!(" var {}: {ty} = {default}\n", p.ident));
+ }
+ if !c.params.is_empty() {
+ out.push('\n');
+ }
+ out.push_str(" var body: some View {\n");
+ swift(&ctx, c.comp, 2, &mut out, &[]);
+ out.push_str(" }\n}\n");
+ out
+}
+
+/// A `@Composable` function: variant enum classes, parameters with defaults, and the body.
+fn compose_definition(doc: &Document, c: &Component) -> String {
+ let ctx = Ctx { doc, root: c.comp, comp: Some(c), base: 1 };
+ let mut out = String::new();
+ for p in &c.params {
+ if let ParamKind::Variant(opts) = &p.kind {
+ out.push_str(&format!("enum class {} {{ {} }}\n\n", enum_name(c, p, false), cases(opts, false).join(", ")));
+ }
+ }
+ out.push_str(&format!("@Composable\nfun {}(", c.type_name));
+ if !c.params.is_empty() {
+ out.push('\n');
+ for p in &c.params {
+ let ty = match &p.kind {
+ ParamKind::Variant(_) => enum_name(c, p, false),
+ ParamKind::Boolean => "Boolean".into(),
+ ParamKind::Text => "String".into(),
+ ParamKind::InstanceSwap => "@Composable () -> Unit".into(),
+ ParamKind::Slot => "(@Composable () -> Unit)?".into(),
+ };
+ let default = if p.kind == ParamKind::Slot { "null".into() } else { value_expr(doc, c, p, &p.default, CodeLang::Compose) };
+ out.push_str(&format!(" {}: {ty} = {default},\n", p.ident));
+ }
+ }
+ out.push_str(") {\n");
+ compose(&ctx, c.comp, 1, &mut out);
+ out.push_str("}\n");
+ out
+}
diff --git a/crates/engine/src/component_codegen_tests.rs b/crates/engine/src/component_codegen_tests.rs
new file mode 100644
index 000000000..2457171d0
--- /dev/null
+++ b/crates/engine/src/component_codegen_tests.rs
@@ -0,0 +1,263 @@
+//! Component code: components become React / SwiftUI / Compose components whose properties are
+//! parameters, and instances become calls passing the values they set.
+
+use crate::Session;
+use crate::codegen::{CodeLang, code_for};
+use crate::component_codegen::{ParamKind, describe, main_of};
+use crate::components::tests::{ex, idv};
+use figcraft_doc::*;
+use serde_json::{Value, json};
+
+fn code(s: &mut Session, id: NodeId, lang: &str) -> String {
+ ex(s, "inspect.code", json!({"id": id, "language": lang}))["code"].as_str().unwrap_or_default().to_string()
+}
+
+fn component_code(s: &mut Session, id: NodeId, lang: &str) -> Value {
+ ex(s, "inspect.componentCode", json!({"id": id, "language": lang}))
+}
+
+/// A "Card" component with a "Title" text (text property), a "Dot" ellipse (boolean property
+/// "Show dot") and a "Bg" rectangle. Returns (card, title, dot).
+fn card(s: &mut Session) -> (NodeId, NodeId, NodeId) {
+ let f = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"})));
+ ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 200, "height": 100, "parent": f, "props": {"name": "Bg", "fill": "#eeeeee"}}));
+ let title = idv(&ex(s, "text.create", json!({"text": "Hello", "x": 10, "y": 10, "parent": f, "props": {"name": "Title"}})));
+ let dot = idv(&ex(s, "shape.ellipse", json!({"x": 170, "y": 10, "width": 20, "height": 20, "parent": f, "props": {"name": "Dot"}})));
+ let c = idv(&ex(s, "component.create", json!({"ids": [f]})));
+ ex(s, "component.addProperty", json!({"id": dot, "name": "Show dot", "type": "boolean", "default": true}));
+ ex(s, "component.addProperty", json!({"id": c, "name": "Title", "type": "text", "layer": title}));
+ (c, title, dot)
+}
+
+/// A "Button" set with State=Default|Hover variants (the hover background blue). Returns (set,
+/// default variant, hover variant).
+fn button_set(s: &mut Session) -> (NodeId, NodeId, NodeId) {
+ let mut variants = Vec::new();
+ for (k, (state, color)) in [("Default", "#ff0000"), ("Hover", "#0000ff")].iter().enumerate() {
+ let f = idv(&ex(s, "frame.create", json!({"x": k as f64 * 150.0, "y": 300, "width": 100, "height": 40, "name": format!("State={state}")})));
+ ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 40, "parent": f, "props": {"name": "Bg", "fill": color}}));
+ variants.push(idv(&ex(s, "component.create", json!({"ids": [f]}))));
+ }
+ let set = idv(&ex(s, "component.combineVariants", json!({"ids": variants.clone()})));
+ ex(s, "node.set", json!({"id": set, "props": {"name": "Button"}}));
+ (set, variants[0], variants[1])
+}
+
+#[test]
+fn components_take_their_properties_as_parameters() {
+ let mut s = Session::new();
+ let (c, _, _) = card(&mut s);
+ let react = code(&mut s, c, "react");
+ for want in [
+ "export interface CardProps {",
+ " showDot?: boolean;",
+ " title?: string;",
+ "export function Card({",
+ " showDot = true,",
+ " title = \"Hello\",",
+ "}: CardProps) {",
+ "{showDot && (",
+ ">{title}",
+ "/* card.css */",
+ "border-radius: 50%;",
+ ] {
+ assert!(react.contains(want), "React has {want:?}:\n{react}");
+ }
+ let swift = code(&mut s, c, "swiftui");
+ for want in ["struct Card: View {", " var showDot: Bool = true", " var title: String = \"Hello\"", "if showDot {", "Text(title)"] {
+ assert!(swift.contains(want), "SwiftUI has {want:?}:\n{swift}");
+ }
+ let kt = code(&mut s, c, "compose");
+ for want in ["@Composable\nfun Card(", " showDot: Boolean = true,", " title: String = \"Hello\",", "if (showDot) {", "text = title,"] {
+ assert!(kt.contains(want), "Compose has {want:?}:\n{kt}");
+ }
+ // The command describes the properties.
+ let v = component_code(&mut s, c, "react");
+ assert_eq!(v["name"], "Card");
+ assert_eq!(
+ v["properties"],
+ json!([
+ {"name": "Show dot", "param": "showDot", "type": "boolean", "default": true},
+ {"name": "Title", "param": "title", "type": "text", "default": "Hello"},
+ ])
+ );
+ assert_eq!(v["definition"].as_str(), Some(react.as_str()));
+ assert!(v.get("usage").is_none(), "a main has no call");
+ // Deterministic.
+ assert_eq!(code(&mut s, c, "react"), react);
+}
+
+#[test]
+fn instances_are_calls_passing_the_values_they_set() {
+ let mut s = Session::new();
+ let (c, _, _) = card(&mut s);
+ let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 300, "y": 0})));
+ let v = component_code(&mut s, i, "react");
+ assert_eq!(v["usage"], "\n", "defaults aren't passed");
+ ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Show dot": false, "Title": "Buy"}}));
+ let v = component_code(&mut s, i, "react");
+ assert_eq!(v["usage"], "\n");
+ assert_eq!(v["properties"][0]["value"], false);
+ assert_eq!(v["properties"][1]["value"], "Buy");
+ // inspect.code on an instance: the call, then the definition.
+ let react = code(&mut s, i, "react");
+ assert!(react.starts_with("\n"), "{react}");
+ assert!(react.contains("export function Card({"));
+ assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Card(showDot: false, title: \"Buy\")\n");
+ assert_eq!(component_code(&mut s, i, "compose")["usage"], "Card(showDot = false, title = \"Buy\")\n");
+ // Text that needs escaping is an expression in JSX and escaped per language.
+ ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Title": "Say \"hi\" $5\nnow"}}));
+ assert_eq!(component_code(&mut s, i, "react")["usage"], "\n");
+ assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Card(showDot: false, title: \"Say \\\"hi\\\" $5\\nnow\")\n");
+ assert_eq!(component_code(&mut s, i, "compose")["usage"], "Card(showDot = false, title = \"Say \\\"hi\\\" \\$5\\nnow\")\n");
+}
+
+#[test]
+fn nested_instances_in_a_layer_tree_are_calls() {
+ let mut s = Session::new();
+ let (c, _, _) = card(&mut s);
+ let screen = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 500, "width": 400, "height": 300, "name": "Home screen"})));
+ let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 20, "y": 30, "parent": screen})));
+ ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Title": "Inbox"}}));
+ let react = code(&mut s, screen, "react");
+ assert!(react.contains("export function HomeScreen() {"), "{react}");
+ assert!(react.contains(""), "{react}");
+ assert!(!react.contains("Hello"), "the instance's layers aren't spelled out: {react}");
+ assert!(code(&mut s, screen, "swiftui").contains("Card(title: \"Inbox\")"));
+ assert!(code(&mut s, screen, "compose").contains("Card(title = \"Inbox\")"));
+ // HTML and CSS stay plain markup.
+ assert!(code(&mut s, screen, "html").contains("Inbox"));
+}
+
+#[test]
+fn variant_sets_share_one_definition() {
+ let mut s = Session::new();
+ let (set, default, hover) = button_set(&mut s);
+ let doc = s.doc().unwrap();
+ assert_eq!(main_of(doc, set), Some(default));
+ assert_eq!(main_of(doc, hover), Some(default), "every variant is defined by the set's first");
+ let c = describe(doc, default).unwrap();
+ assert_eq!(c.type_name, "Button");
+ assert_eq!(c.params.len(), 1);
+ assert_eq!(c.params[0].ident, "state");
+ assert_eq!(c.params[0].kind, ParamKind::Variant(vec!["Default".into(), "Hover".into()]));
+ let react = code(&mut s, set, "react");
+ assert!(react.contains(" state?: \"Default\" | \"Hover\";"), "{react}");
+ assert!(react.contains(" state = \"Default\","), "{react}");
+ assert!(react.contains("data-state={state}"), "{react}");
+ // The hover variant's difference is a rule on its data attribute.
+ assert!(react.contains(".button[data-state=\"Hover\"] .bg {\n background: #0000FF;"), "{react}");
+ let swift = code(&mut s, set, "swiftui");
+ assert!(
+ swift.contains(" enum State: String, CaseIterable {\n case `default` = \"Default\"\n case hover = \"Hover\""),
+ "{swift}"
+ );
+ assert!(swift.contains(" var state: State = .`default`"), "{swift}");
+ let kt = code(&mut s, set, "compose");
+ assert!(kt.contains("enum class ButtonState { Default, Hover }"), "{kt}");
+ assert!(kt.contains(" state: ButtonState = ButtonState.Default,"), "{kt}");
+ // An instance showing the hover variant passes it.
+ let i = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 600})));
+ ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"State": "Hover"}}));
+ assert_eq!(component_code(&mut s, i, "react")["usage"], "\n");
+ assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Button(state: .hover)\n");
+ assert_eq!(component_code(&mut s, i, "compose")["usage"], "Button(state = ButtonState.Hover)\n");
+}
+
+#[test]
+fn instance_swap_properties_take_views() {
+ let mut s = Session::new();
+ let icon = |s: &mut Session, name: &str, x: f64| {
+ let f = idv(&ex(s, "frame.create", json!({"x": x, "y": 400, "width": 24, "height": 24, "name": name})));
+ ex(s, "shape.star", json!({"x": 0, "y": 0, "width": 24, "height": 24, "parent": f}));
+ idv(&ex(s, "component.create", json!({"ids": [f]})))
+ };
+ let (heart, star) = (icon(&mut s, "Heart icon", 0.0), icon(&mut s, "Star icon", 50.0));
+ let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 120, "height": 40, "name": "Chip"})));
+ let nested = idv(&ex(&mut s, "instance.create", json!({"component": heart, "x": 8, "y": 8, "parent": f})));
+ let chip = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
+ ex(&mut s, "component.addProperty", json!({"id": chip, "name": "Icon", "type": "instanceSwap", "default": heart, "layer": nested}));
+ let react = code(&mut s, chip, "react");
+ assert!(react.contains(" icon?: React.ReactNode;") && react.contains(" icon = ,"), "{react}");
+ assert!(react.contains("{icon}"), "the nested instance is the parameter: {react}");
+ let swift = code(&mut s, chip, "swiftui");
+ assert!(swift.contains(" var icon: AnyView = AnyView(HeartIcon())"), "{swift}");
+ let kt = code(&mut s, chip, "compose");
+ assert!(kt.contains(" icon: @Composable () -> Unit = { HeartIcon() },") && kt.contains("icon()"), "{kt}");
+ let i = idv(&ex(&mut s, "instance.create", json!({"component": chip, "x": 0, "y": 100})));
+ ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Icon": star}}));
+ assert_eq!(component_code(&mut s, i, "react")["usage"], "} />\n");
+ assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Chip(icon: AnyView(StarIcon()))\n");
+ assert_eq!(component_code(&mut s, i, "compose")["usage"], "Chip(icon = { StarIcon() })\n");
+}
+
+#[test]
+fn slots_take_optional_content() {
+ let mut s = Session::new();
+ let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 200, "name": "Panel"})));
+ let content = idv(&ex(&mut s, "frame.create", json!({"x": 10, "y": 50, "width": 280, "height": 140, "parent": f, "name": "Content"})));
+ ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 40, "parent": content, "props": {"name": "Placeholder"}}));
+ let panel = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
+ ex(&mut s, "component.makeSlot", json!({"id": content}));
+ let react = code(&mut s, panel, "react");
+ assert!(react.contains(" content?: React.ReactNode;") && react.contains("{content ?? ("), "{react}");
+ assert!(code(&mut s, panel, "swiftui").contains("var content: AnyView? = nil"));
+ assert!(code(&mut s, panel, "compose").contains("content: (@Composable () -> Unit)? = null,"));
+ // An instance filling the slot passes its content.
+ let g = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 400, "width": 40, "height": 20, "name": "Badge"})));
+ let badge = idv(&ex(&mut s, "component.create", json!({"ids": [g]})));
+ let i = idv(&ex(&mut s, "instance.create", json!({"component": panel, "x": 400, "y": 0})));
+ assert_eq!(component_code(&mut s, i, "react")["usage"], "\n", "the main's content isn't passed");
+ ex(&mut s, "instance.slotInsert", json!({"id": i, "slot": "Content", "component": badge, "replace": true}));
+ let usage = component_code(&mut s, i, "react")["usage"].as_str().unwrap_or_default().to_string();
+ assert!(usage.starts_with(""), "{usage}");
+}
+
+#[test]
+fn identifiers_are_escaped_and_unique() {
+ let mut s = Session::new();
+ let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100, "name": "Text"})));
+ let c = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
+ for (name, ty, default) in [
+ ("default", "boolean", json!(false)),
+ ("Show icon?", "boolean", json!(true)),
+ ("Label", "text", json!("a")),
+ ("label", "text", json!("b")),
+ ("2 columns", "boolean", json!(false)),
+ ("Color", "variant", json!("Red")),
+ ] {
+ ex(&mut s, "component.addProperty", json!({"id": c, "name": name, "type": ty, "default": default}));
+ }
+ let doc = s.doc().unwrap();
+ let comp = describe(doc, c).unwrap();
+ assert_eq!(comp.type_name, "TextComponent", "never shadows SwiftUI's Text");
+ let idents: Vec<(&str, &str)> = comp.params.iter().map(|p| (p.prop.as_str(), p.ident.as_str())).collect();
+ for want in [("default", "defaultValue"), ("Show icon?", "showIcon"), ("2 columns", "p2Columns"), ("Color", "color")] {
+ assert!(idents.contains(&want), "{want:?} in {idents:?}");
+ }
+ let labels: Vec<&str> = comp.params.iter().filter(|p| p.prop.eq_ignore_ascii_case("label")).map(|p| p.ident.as_str()).collect();
+ assert_eq!(labels.len(), 2);
+ assert_ne!(labels[0], labels[1], "unique: {labels:?}");
+ let color = comp.params.iter().find(|p| p.prop == "Color").map(|p| p.enum_name.clone());
+ assert_eq!(color.as_deref(), Some("ColorVariant"), "never shadows SwiftUI's Color");
+ let swift = code_for(doc, c, CodeLang::SwiftUi);
+ assert!(swift.contains("struct TextComponent: View {") && swift.contains("enum ColorVariant: String, CaseIterable {"), "{swift}");
+ // Errors.
+ let r = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})));
+ assert!(s.execute("inspect.componentCode", &json!({"id": r})).is_err(), "not a component");
+ assert!(s.execute("inspect.componentCode", &json!({"id": c, "language": "html"})).is_err());
+ assert!(s.execute("inspect.componentCode", &json!({"id": 999_999})).is_err());
+}
+
+#[test]
+fn bound_variables_become_custom_properties() {
+ let mut s = Session::new();
+ let (c, _, _) = card(&mut s);
+ ex(&mut s, "variable.createCollection", json!({"name": "Tokens"}));
+ ex(&mut s, "variable.create", json!({"collection": "Tokens", "name": "Colors/Brand 500", "type": "color", "value": "#3366FF"}));
+ ex(&mut s, "variable.bind", json!({"ids": [c], "field": "fill", "variable": "Colors/Brand 500"}));
+ let css = code(&mut s, c, "css");
+ assert!(css.contains("background: var(--colors-brand-500, #3366FF);"), "{css}");
+ let react = code(&mut s, c, "react");
+ assert!(react.contains(":root {\n --colors-brand-500: #3366FF;\n}"), "{react}");
+}
diff --git a/crates/engine/src/components.rs b/crates/engine/src/components.rs
index 23ee18102..773738660 100644
--- a/crates/engine/src/components.rs
+++ b/crates/engine/src/components.rs
@@ -263,6 +263,7 @@ pub fn create_instance_at(doc: &mut Document, component: NodeId, parent: NodeId,
root.overrides.clear();
root.transform = Affine::translate((x, y));
root.prop_refs.clear();
+ root.annotations.clear();
doc.insert(root, parent, index)?;
sync_instance(doc, id);
Ok(id)
@@ -560,6 +561,8 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize) {
n.source = Some(*s);
n.overrides.clear();
n.prop_refs.clear();
+ // Annotations belong to the layer they were added to, not to its copies.
+ n.annotations = olds.get(&nid).map(|o| o.annotations.clone()).unwrap_or_default();
if let Some(old) = olds.get(&nid) {
carry_overrides(old, &mut n, Carry::Values);
}
@@ -624,6 +627,7 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize) {
fresh.exports = root.exports.clone();
fresh.prop_refs = root.prop_refs.clone();
fresh.plugin_data = root.plugin_data.clone();
+ fresh.annotations = root.annotations.clone();
fresh.locked = root.locked;
fresh.visible = root.visible;
fresh.var_modes = root.var_modes.clone();
diff --git a/crates/engine/src/devinspect.rs b/crates/engine/src/devinspect.rs
index 5895568d3..d5a76cfce 100644
--- a/crates/engine/src/devinspect.rs
+++ b/crates/engine/src/devinspect.rs
@@ -63,8 +63,67 @@ pub fn assets(doc: &Document, ids: &[NodeId]) -> Vec {
out
}
+/// A frame or section with a handoff status, for Dev Mode's list of what's ready.
+#[derive(Clone, Debug, PartialEq, Serialize)]
+pub struct DevItem {
+ pub id: NodeId,
+ pub name: String,
+ pub page: NodeId,
+ pub status: DevStatus,
+}
+
+/// Most items listed.
+const MAX_DEV_ITEMS: usize = 500;
+
+/// The layers with handoff status `status` (`None`: any status) on `page` (`None`: every page),
+/// in page and layer order (bounded).
+pub fn dev_items(doc: &Document, status: Option, page: Option) -> Vec {
+ let mut out = Vec::new();
+ let mut visited = 0usize;
+ for pg in doc.pages.iter().copied().filter(|pg| page.is_none_or(|p| p == *pg)) {
+ let mut stack = vec![pg];
+ while let Some(id) = stack.pop() {
+ visited += 1;
+ if out.len() >= MAX_DEV_ITEMS || visited > 200_000 {
+ return out;
+ }
+ let Some(n) = doc.node(id) else { continue };
+ if !n.dev_status.is_none() && status.is_none_or(|s| s == n.dev_status) {
+ out.push(DevItem { id, name: n.name.clone(), page: pg, status: n.dev_status });
+ }
+ stack.extend(n.children.iter().rev().copied());
+ }
+ }
+ out
+}
+
pub fn specs() -> Vec {
vec![
+ spec!(
+ "inspect.readyForDev",
+ "Ready for dev",
+ "{status?: readyForDev|completed|any (default readyForDev), page? (page id; default every page)} → {items: [{id, name, page, status}]} frames and sections by handoff status (set it with `node.set {devStatus}`)",
+ false,
+ |s, p| {
+ let cmd = "inspect.readyForDev";
+ let status = match string(p, "status").as_deref() {
+ None => Some(DevStatus::ReadyForDev),
+ Some(v) if v.eq_ignore_ascii_case("any") || v.eq_ignore_ascii_case("all") => None,
+ Some(v) => Some(
+ DevStatus::ALL
+ .into_iter()
+ .find(|d| !d.is_none() && d.id().eq_ignore_ascii_case(v))
+ .ok_or_else(|| crate::bad(cmd, "status: readyForDev|completed|any"))?,
+ ),
+ };
+ let doc = s.doc()?;
+ let page = match get(p, "page") {
+ Some(v) => Some(id_of(v).filter(|pg| doc.pages.contains(pg)).ok_or_else(|| crate::bad(cmd, "page: a page id"))?),
+ None => None,
+ };
+ Ok(json!({"items": serde_json::to_value(dev_items(doc, status, page)).unwrap_or(Value::Null)}))
+ }
+ ),
spec!(
"inspect.assets",
"Inspect assets",
@@ -123,9 +182,21 @@ impl Prop {
}
}
+/// The variable bound to property `field` of `n` ("fill" and "stroke" also find bindings stored
+/// under their list forms, like "fills/0/color").
+pub fn bound_variable<'d>(doc: &'d Document, n: &Node, field: &str) -> Option<&'d Variable> {
+ let forms: &[&str] = match field {
+ "fill" => &["fill", "fills", "fills/0", "fills/0/color"],
+ "stroke" => &["stroke", "strokes/0/color"],
+ _ => &[],
+ };
+ let found = if forms.is_empty() { n.bound.get(field) } else { forms.iter().find_map(|k| n.bound.get(*k)) };
+ found.and_then(|v| doc.variables.get(v))
+}
+
/// 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())
+ bound_variable(doc, n, field).map(|v| v.name.clone())
}
fn style_name(doc: &Document, id: Option<&StyleId>) -> Option {
diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs
index 95f9378b0..8d81ac9fb 100644
--- a/crates/engine/src/inspect.rs
+++ b/crates/engine/src/inspect.rs
@@ -127,6 +127,9 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
if !n.exports.is_empty() {
o.insert("exports".into(), serde_json::to_value(&n.exports).unwrap_or(Value::Null));
}
+ if !n.annotations.is_empty() {
+ o.insert("annotations".into(), serde_json::to_value(&n.annotations).unwrap_or(Value::Null));
+ }
if !n.interactions.is_empty() {
o.insert("interactions".into(), serde_json::to_value(&n.interactions).unwrap_or(Value::Null));
}
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index dafc17b1e..5a81700ba 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -8,9 +8,11 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod al_keys;
+pub mod annotations;
pub mod catalog;
pub mod cmd;
pub mod codegen;
+pub mod component_codegen;
pub mod components;
pub mod devinspect;
pub mod distance;
@@ -942,12 +944,16 @@ mod al_keys_tests;
#[cfg(test)]
mod al_nesting_tests;
#[cfg(test)]
+mod annotations_tests;
+#[cfg(test)]
mod autosave_tests;
#[cfg(test)]
mod binding_lists_tests;
#[cfg(test)]
mod comments_polish_tests;
#[cfg(test)]
+mod component_codegen_tests;
+#[cfg(test)]
mod devinspect_tests;
#[cfg(test)]
mod effects_tests;
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
index 563b43120..4f7862ae8 100644
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -175,6 +175,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
painter.image(tex.id(), Rect::from_min_size(tl, vec2(w, h)), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
overlays(app, &painter, &t);
+ crate::dev_mode::draw(app, &painter);
crate::handles::draw(app, &painter);
crate::comments::draw(app, &painter);
crate::comments::popovers(app, &ctx);
diff --git a/crates/ui-egui/src/component_panel.rs b/crates/ui-egui/src/component_panel.rs
index 0f847eb72..0d200bd46 100644
--- a/crates/ui-egui/src/component_panel.rs
+++ b/crates/ui-egui/src/component_panel.rs
@@ -56,44 +56,10 @@ pub fn instance_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[No
/// A component's description: a multi-line field, saved when it loses focus.
fn description_ui(app: &mut FigApp, ui: &mut Ui, id: NodeId, description: &str) {
- let t = Tokens::get(ui.ctx());
widgets::separator(ui);
widgets::sublabels(ui, "Description", None);
- let buf_id = Id::new(("comp_desc", id));
- // The draft lives only while the field has focus; otherwise it shows the stored text.
- let focused = ui.memory(|m| m.has_focus(buf_id.with("te")));
- let mut text: String = if focused { ui.data(|d| d.get_temp(buf_id)).unwrap_or_else(|| description.to_string()) } else { description.to_string() };
- let mut done = None;
- ui.horizontal(|ui| {
- ui.add_space(crate::widgets::PAD);
- let w = ui.available_width() - crate::widgets::PAD;
- let r = egui::Frame::NONE.fill(t.field).corner_radius(egui::CornerRadius::same(5)).inner_margin(egui::Margin::same(6)).show(ui, |ui| {
- ui.add(
- egui::TextEdit::multiline(&mut text)
- .id(buf_id.with("te"))
- .frame(egui::Frame::NONE)
- .font(crate::theme::regular(11.0))
- .hint_text("Describe how and when to use this component")
- .desired_rows(3)
- .desired_width(w - 12.0),
- )
- });
- if r.inner.lost_focus() {
- done = Some(text.clone());
- }
- });
- match done {
- Some(new) => {
- ui.data_mut(|d| d.remove::(buf_id));
- if new != description {
- app.run("node.set", json!({"id": id, "props": {"description": new.chars().take(10_000).collect::()}}));
- }
- }
- None => {
- if ui.memory(|m| m.has_focus(buf_id.with("te"))) {
- ui.data_mut(|d| d.insert_temp(buf_id, text));
- }
- }
+ if let Some(new) = widgets::multiline_field(ui, Id::new(("comp_desc", id)), description, "Describe how and when to use this component", 3) {
+ app.run("node.set", json!({"id": id, "props": {"description": new.chars().take(10_000).collect::()}}));
}
ui.add_space(8.0);
}
diff --git a/crates/ui-egui/src/dev_mode.rs b/crates/ui-egui/src/dev_mode.rs
new file mode 100644
index 000000000..8121b2830
--- /dev/null
+++ b/crates/ui-egui/src/dev_mode.rs
@@ -0,0 +1,301 @@
+//! Dev Mode extras in the Inspect tab: annotations (notes with pinned properties) drawn as
+//! callouts on the canvas and edited in the panel, the handoff status of the selected frames, and
+//! the list of frames and sections that are ready for dev.
+
+use egui::{Align2, CornerRadius, Id, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
+use figcraft_doc::{Annotation, DevStatus, Document, NodeId};
+use figcraft_engine::annotations::{self, PINNABLE, Pinnable};
+use figcraft_engine::codegen::{CodeLang, code_for};
+use figcraft_engine::devinspect::{self, DevItem, Prop};
+use serde_json::json;
+
+use crate::icons::{self, Icon};
+use crate::right::{menu_check, prop_row, row};
+use crate::theme::{ACCENT, Tokens, regular, semibold};
+use crate::widgets::{self, PAD};
+use crate::{FigApp, RightTab};
+
+/// Width of an annotation callout on the canvas.
+const CALLOUT_W: f32 = 220.0;
+/// Gap between a layer and its callouts.
+const CALLOUT_GAP: f32 = 24.0;
+
+/// The status filters of the ready-for-dev list.
+const FILTERS: [(Option, &str); 3] =
+ [(Some(DevStatus::ReadyForDev), "Ready for dev"), (Some(DevStatus::Completed), "Completed"), (None, "Any status")];
+
+/// Dev Mode UI state: the filter of the ready list, and what the canvas, the list and the code
+/// show, recomputed only when the document (its revision), the page or the choice change.
+#[derive(Default)]
+pub struct DevUi {
+ pub filter: usize,
+ annotated: Vec,
+ annotated_key: Option<(u64, u64, NodeId)>,
+ ready: Vec,
+ ready_key: Option<(u64, u64, usize)>,
+ code: Option<((u64, u64, NodeId, CodeLang), String)>,
+}
+
+/// Whether Dev Mode extras show: the Inspect tab is open and nothing is presented.
+fn active(app: &FigApp) -> bool {
+ app.ui.right_tab == RightTab::Inspect && app.ui.present.is_none()
+}
+
+// ------------------------------------------------------------------ canvas
+
+/// The annotations on the current page as callouts beside their layers (Inspect tab only).
+pub fn draw(app: &mut FigApp, painter: &egui::Painter) {
+ if !active(app) {
+ return;
+ }
+ let Ok(ds) = app.session.ds() else { return };
+ let key = (ds.uid, ds.revision, ds.page);
+ if app.ui.dev.annotated_key != Some(key) {
+ app.ui.dev.annotated = annotations::on_page(&ds.doc, ds.page);
+ app.ui.dev.annotated_key = Some(key);
+ }
+ let t = Tokens::get(painter.ctx());
+ let clip = painter.clip_rect();
+ // Callouts already drawn: a later one that would overlap moves down below them.
+ let mut placed: Vec = Vec::new();
+ for id in &app.ui.dev.annotated {
+ let Some(n) = ds.doc.node(*id).filter(|n| n.visible) else { continue };
+ let Some(b) = ds.doc.world_bounds(*id) else { continue };
+ let r = Rect::from_two_pos(app.to_screen(b.x0, b.y0), app.to_screen(b.x1, b.y1));
+ let x = r.max.x + CALLOUT_GAP;
+ if x > clip.max.x || x + CALLOUT_W < clip.min.x || r.min.y > clip.max.y {
+ continue;
+ }
+ let selected = ds.selection.contains(id);
+ let anchor = pos2(r.max.x, r.min.y);
+ let mut y = r.min.y;
+ for (k, a) in n.annotations.iter().enumerate() {
+ if y > clip.max.y {
+ break;
+ }
+ let pinned = annotations::pinned(&ds.doc, *id, a);
+ let card = Callout::new(painter, &t, a, &pinned);
+ let mut rect = Rect::from_min_size(pos2(x, y), vec2(CALLOUT_W, card.height));
+ while let Some(below) = placed.iter().filter(|p| p.intersects(rect.expand(3.0))).map(|p| p.max.y).reduce(f32::max) {
+ rect = rect.translate(vec2(0.0, below + 6.0 - rect.min.y));
+ }
+ if k == 0 {
+ painter.line_segment([anchor, pos2(rect.min.x, rect.min.y + 14.0)], Stroke::new(1.0, ACCENT));
+ painter.circle_filled(anchor, 3.5, ACCENT);
+ }
+ card.paint(painter, &t, rect, &pinned, selected);
+ placed.push(rect);
+ y = rect.max.y + 6.0;
+ }
+ }
+}
+
+/// One annotation as a card: its note (up to six lines) and its pinned properties with their
+/// values.
+struct Callout {
+ note: Option>,
+ height: f32,
+}
+
+impl Callout {
+ fn new(painter: &egui::Painter, t: &Tokens, a: &Annotation, pinned: &[(&Pinnable, Option)]) -> Self {
+ let note = (!a.label.trim().is_empty()).then(|| {
+ let mut job = egui::text::LayoutJob::simple(a.label.clone(), regular(11.0), t.text, CALLOUT_W - 22.0);
+ job.wrap.max_rows = 6;
+ painter.layout_job(job)
+ });
+ let note_h = note.as_ref().map_or(0.0, |g| g.size().y);
+ let gap = if note.is_some() && !pinned.is_empty() { 6.0 } else { 0.0 };
+ Callout { note, height: note_h + gap + pinned.len() as f32 * 18.0 + 16.0 }
+ }
+
+ fn paint(self, painter: &egui::Painter, t: &Tokens, rect: Rect, pinned: &[(&Pinnable, Option)], selected: bool) {
+ painter.add(egui::epaint::Shadow { offset: [0, 2], blur: 8, spread: 0, color: t.shadow }.as_shape(rect, CornerRadius::same(6)));
+ painter.rect_filled(rect, CornerRadius::same(6), t.panel);
+ painter.rect_stroke(rect, CornerRadius::same(6), Stroke::new(1.0, if selected { ACCENT } else { t.border_strong }), StrokeKind::Inside);
+ painter.rect_filled(Rect::from_min_size(rect.min + vec2(0.0, 6.0), vec2(3.0, rect.height() - 12.0)), CornerRadius::same(2), ACCENT);
+ let x = rect.min.x + 12.0;
+ let mut y = rect.min.y + 8.0;
+ if let Some(g) = self.note {
+ y += g.size().y + if pinned.is_empty() { 0.0 } else { 6.0 };
+ painter.galley(pos2(x, rect.min.y + 8.0), g, t.text);
+ }
+ for (p, v) in pinned {
+ let mid = y + 9.0;
+ painter.text(pos2(x, mid), Align2::LEFT_CENTER, p.label, regular(10.0), t.text_secondary);
+ let mut vx = x + 84.0;
+ if let Some(c) = v.as_ref().and_then(|v| v.color.as_deref()).and_then(figcraft_color::Color::from_hex) {
+ widgets::paint_swatch(painter, Rect::from_center_size(pos2(vx + 5.0, mid), vec2(10.0, 10.0)), &figcraft_color::Paint::solid(c));
+ vx += 15.0;
+ }
+ // The style or variable reads better than its value.
+ let value = v.as_ref().map(|v| v.token.clone().unwrap_or_else(|| v.value.clone())).unwrap_or_else(|| "—".into());
+ let value_r = Rect::from_min_max(pos2(vx, y), pos2(rect.max.x - 8.0, y + 18.0));
+ painter.with_clip_rect(value_r).text(pos2(vx, mid), Align2::LEFT_CENTER, value, regular(10.0), t.text);
+ y += 18.0;
+ }
+ }
+}
+
+// ------------------------------------------------------------------ Inspect panel
+
+/// Layer `id`'s code in `lang`, generated again only when the document or the choice change (a
+/// component set's code compares every variant).
+pub fn code(app: &mut FigApp, id: NodeId, lang: CodeLang) -> String {
+ let Ok(ds) = app.session.ds() else { return String::new() };
+ let key = (ds.uid, ds.revision, id, lang);
+ match &app.ui.dev.code {
+ Some((k, c)) if *k == key => c.clone(),
+ _ => {
+ let c = code_for(&ds.doc, id, lang);
+ app.ui.dev.code = Some((key, c.clone()));
+ c
+ }
+ }
+}
+
+/// The handoff status of the selected frames or sections (nothing for other layers).
+pub fn status(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
+ if sel.is_empty() || !sel.iter().all(|i| crate::section_panel::has_status(doc, *i)) {
+ return;
+ }
+ widgets::separator(ui);
+ widgets::sublabels(ui, "Status", None);
+ let w = ui.available_width() - PAD * 2.0;
+ row(ui, 32.0, |ui| crate::section_panel::status_dropdown(app, ui, doc, sel, w));
+ ui.add_space(4.0);
+}
+
+/// The selected layer's annotations: each note editable in place with its pinned properties (click
+/// to copy), a menu to pin more, and a field to add one.
+pub fn annotations_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId) {
+ let t = Tokens::get(ui.ctx());
+ let Some(n) = doc.node(id) else { return };
+ let sections = devinspect::properties(doc, id);
+ widgets::separator(ui);
+ widgets::section_header(ui, "Annotations", &[], n.annotations.is_empty());
+ for (k, a) in n.annotations.iter().enumerate() {
+ let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::hover());
+ ui.painter().text(pos2(r.min.x + PAD, r.center().y), Align2::LEFT_CENTER, format!("Note {}", k + 1), semibold(10.0), t.text_secondary);
+ let del = Rect::from_center_size(pos2(r.max.x - PAD - 4.0, r.center().y), vec2(22.0, 22.0));
+ let resp = ui.interact(del, Id::new(("annot_del", id, k)), Sense::click()).on_hover_text("Remove annotation");
+ if resp.hovered() {
+ ui.painter().rect_filled(del, CornerRadius::same(5), t.hover);
+ }
+ icons::paint(ui.painter(), del.shrink(5.0), Icon::Minus, t.icon);
+ if resp.clicked() {
+ app.run("annotation.remove", json!({"id": id, "index": k}));
+ }
+ if let Some(label) = widgets::multiline_field(ui, Id::new(("annot_note", id, k)), &a.label, "Add a note for developers", 2) {
+ if label.trim().is_empty() && a.properties.is_empty() {
+ app.run("annotation.remove", json!({"id": id, "index": k}));
+ } else {
+ app.run("annotation.edit", json!({"id": id, "index": k, "label": label}));
+ }
+ }
+ ui.add_space(2.0);
+ for (p, v) in annotations::pinned(doc, id, a) {
+ prop_row(app, ui, &v.unwrap_or_else(|| Prop { label: p.label.to_string(), value: "—".into(), color: None, token: None }));
+ }
+ if let Some(p) = pin_menu(ui, Id::new(("annot_pin", id, k)), §ions, &a.properties) {
+ let mut props = a.properties.clone();
+ match props.iter().position(|q| q == p.id) {
+ Some(i) => {
+ props.remove(i);
+ }
+ None => props.push(p.id.to_string()),
+ }
+ if props.is_empty() && a.label.trim().is_empty() {
+ app.run("annotation.remove", json!({"id": id, "index": k}));
+ } else {
+ app.run("annotation.edit", json!({"id": id, "index": k, "properties": props}));
+ }
+ }
+ ui.add_space(6.0);
+ }
+ // A new annotation starts as a note; properties are pinned to it afterwards.
+ if n.annotations.len() < annotations::MAX_PER_LAYER
+ && let Some(label) = widgets::multiline_field(ui, Id::new(("annot_new", id)), "", "Add an annotation for developers", 1)
+ && !label.trim().is_empty()
+ {
+ app.run("annotation.add", json!({"id": id, "label": label}));
+ }
+ ui.add_space(4.0);
+}
+
+/// "Pin a property": a menu of the properties the layer has (those already `pinned` checked);
+/// returns the one picked.
+fn pin_menu(ui: &mut Ui, id: Id, sections: &[devinspect::Section], pinned: &[String]) -> Option<&'static Pinnable> {
+ let mut out = None;
+ let w = ui.available_width() - PAD * 2.0;
+ row(ui, 30.0, |ui| {
+ let resp = ui.push_id(id, |ui| widgets::dropdown(ui, "Pin a property", w, Some(Icon::Plus))).inner;
+ egui::Popup::menu(&resp).show(|ui| {
+ for p in PINNABLE {
+ let on = pinned.iter().any(|q| q == p.id);
+ if (on || annotations::applies(sections, p)) && menu_check(ui, on, p.label, "") {
+ out = Some(p);
+ ui.close();
+ }
+ }
+ });
+ });
+ out
+}
+
+/// The frames and sections with a handoff status, filtered by status, across every page: click one
+/// to go to it.
+pub fn ready_list(app: &mut FigApp, ui: &mut Ui) {
+ let t = Tokens::get(ui.ctx());
+ let Ok(ds) = app.session.ds() else { return };
+ let filter = app.ui.dev.filter.min(FILTERS.len() - 1);
+ let key = (ds.uid, ds.revision, filter);
+ if app.ui.dev.ready_key != Some(key) {
+ app.ui.dev.ready = devinspect::dev_items(&ds.doc, FILTERS.get(filter).and_then(|f| f.0), None);
+ app.ui.dev.ready_key = Some(key);
+ }
+ let doc = ds.doc.clone();
+ widgets::separator(ui);
+ widgets::section_header(ui, "Ready for dev", &[], app.ui.dev.ready.is_empty());
+ let w = ui.available_width() - PAD * 2.0;
+ row(ui, 32.0, |ui| {
+ let resp = widgets::dropdown(ui, FILTERS.get(filter).map_or("", |f| f.1), w, Some(Icon::Code));
+ egui::Popup::menu(&resp).show(|ui| {
+ for (i, (_, label)) in FILTERS.iter().enumerate() {
+ if menu_check(ui, i == filter, label, "") {
+ app.ui.dev.filter = i;
+ ui.close();
+ }
+ }
+ });
+ });
+ if app.ui.dev.ready.is_empty() {
+ row(ui, 28.0, |ui| widgets::label(ui, "Nothing here yet. Set a frame's status to list it.", t.text_secondary));
+ return;
+ }
+ let mut go = None;
+ for item in &app.ui.dev.ready {
+ let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 28.0), Sense::click());
+ let resp = resp.on_hover_text(format!("Go to {}", item.name));
+ if resp.hovered() {
+ ui.painter().rect_filled(r.shrink2(vec2(8.0, 0.0)), CornerRadius::same(5), t.hover);
+ }
+ let dot = if item.status == DevStatus::ReadyForDev { t.success } else { t.text_tertiary };
+ ui.painter().circle_filled(pos2(r.min.x + PAD + 4.0, r.center().y), 4.0, dot);
+ let page = doc.node(item.page).map(|p| p.name.clone()).unwrap_or_default();
+ let page_w = ui.painter().layout_no_wrap(page.clone(), regular(10.0), t.text_secondary).size().x.min(90.0);
+ let page_r = Rect::from_min_max(pos2(r.max.x - PAD - page_w, r.min.y), pos2(r.max.x - PAD, r.max.y));
+ ui.painter().with_clip_rect(page_r).text(page_r.right_center(), Align2::RIGHT_CENTER, page, regular(10.0), t.text_secondary);
+ let name_r = Rect::from_min_max(pos2(r.min.x + PAD + 14.0, r.min.y), pos2(page_r.min.x - 8.0, r.max.y));
+ let name = widgets::elide(ui, &item.name, name_r.width());
+ ui.painter().text(name_r.left_center(), Align2::LEFT_CENTER, name, regular(11.0), t.text);
+ if resp.clicked() {
+ go = Some(item.id);
+ }
+ }
+ if let Some(id) = go {
+ app.run("select.set", json!({"ids": [id]}));
+ app.run("view.zoomSelection", json!({}));
+ }
+ ui.add_space(4.0);
+}
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index bcf4faf13..0b744b78c 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -13,6 +13,7 @@ pub mod comments;
pub mod component_panel;
pub mod control;
pub mod credits;
+pub mod dev_mode;
pub mod dialogs;
pub mod export_panel;
pub mod font_preview;
@@ -203,6 +204,8 @@ pub struct UiState {
pub left_collapsed: bool,
pub comments: comments::CommentUi,
pub versions: versions::VersionUi,
+ /// Dev Mode: annotations on the canvas and the ready-for-dev list.
+ pub dev: dev_mode::DevUi,
/// What FigCraft last put on the system clipboard: when the clipboard still holds it, a paste
/// uses our copied layers; anything else there (an image, SVG, text) came from another app.
@@ -266,6 +269,7 @@ impl Default for UiState {
left_collapsed: false,
comments: comments::CommentUi::default(),
versions: versions::VersionUi::default(),
+ dev: dev_mode::DevUi::default(),
last_copy: None,
next_autosave: 0.0,
diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs
index 809c88d56..18960dab9 100644
--- a/crates/ui-egui/src/right.rs
+++ b/crates/ui-egui/src/right.rs
@@ -1537,7 +1537,6 @@ fn grids(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) {
@@ -1546,43 +1545,50 @@ fn inspect_section(app: &mut FigApp, ui: &mut Ui, s: &figcraft_engine::devinspec
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()));
- }
+ prop_row(app, ui, p);
}
ui.add_space(4.0);
}
+/// An inspected property row: label, value (with a swatch for colours) and the style or variable
+/// it comes from as a chip. Clicking it copies the value.
+pub(crate) fn prop_row(app: &mut FigApp, ui: &mut Ui, p: &figcraft_engine::devinspect::Prop) {
+ let t = Tokens::get(ui.ctx());
+ 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()));
+ }
+}
+
/// The selection's layers with export settings: click one to export it, or export them all.
fn inspect_assets(app: &mut FigApp, ui: &mut Ui, assets: &[figcraft_engine::devinspect::Asset]) {
let t = Tokens::get(ui.ctx());
@@ -1620,6 +1626,8 @@ fn inspect_assets(app: &mut FigApp, ui: &mut Ui, assets: &[figcraft_engine::devi
ui.add_space(4.0);
}
+/// The Inspect tab (Dev Mode): the handoff status, annotations, properties, assets and code of
+/// the selected layer, or the list of what's ready for dev.
fn inspect(app: &mut FigApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let Ok(ds) = app.session.ds() else { return };
@@ -1629,15 +1637,18 @@ fn inspect(app: &mut FigApp, ui: &mut Ui) {
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));
+ crate::dev_mode::ready_list(app, ui);
return;
};
let name = doc.node(first).map(|n| n.name.clone()).unwrap_or_default();
widgets::section_header(ui, &name, &[], false);
+ crate::dev_mode::status(app, ui, &doc, &sel);
+ crate::dev_mode::annotations_section(app, ui, &doc, first);
for s in figcraft_engine::devinspect::properties(&doc, first) {
inspect_section(app, ui, &s);
}
inspect_assets(app, ui, &figcraft_engine::devinspect::assets(&doc, &sel));
- use figcraft_engine::codegen::{CodeLang, code_for};
+ use figcraft_engine::codegen::CodeLang;
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);
@@ -1653,7 +1664,7 @@ fn inspect(app: &mut FigApp, ui: &mut Ui) {
}
});
});
- let code = code_for(&doc, first, lang);
+ let code = crate::dev_mode::code(app, first, lang);
egui::Frame::NONE
.fill(t.field)
.corner_radius(CornerRadius::same(6))
diff --git a/crates/ui-egui/src/section_panel.rs b/crates/ui-egui/src/section_panel.rs
index 1f88f0af5..6bd6315e6 100644
--- a/crates/ui-egui/src/section_panel.rs
+++ b/crates/ui-egui/src/section_panel.rs
@@ -22,17 +22,42 @@ const SECTION_COLORS: [(&str, &str); 8] = [
("Violet", "#EBE3FF"),
];
-pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
- let is_section = |i: &NodeId| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Section { .. }));
- let top_frame = |i: &NodeId| {
- doc.node(*i).is_some_and(|n| {
+fn is_section(doc: &Document, id: NodeId) -> bool {
+ doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Section { .. }))
+}
+
+/// Whether `id` can carry a handoff status: a section, or a frame at the top of a page or section.
+pub fn has_status(doc: &Document, id: NodeId) -> bool {
+ is_section(doc, id)
+ || doc.node(id).is_some_and(|n| {
n.kind.is_frame_like()
&& !matches!(n.kind, NodeKind::Instance { .. })
&& n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.is_page() || matches!(p.kind, NodeKind::Section { .. }))
})
+}
+
+/// The handoff status of `ids` as a dropdown `width` wide ("Changed" when edited since marked ready,
+/// "Mixed" when they differ).
+pub fn status_dropdown(app: &mut FigApp, ui: &mut Ui, doc: &Document, ids: &[NodeId], width: f32) {
+ let label = match common(doc, ids, |n| (n.dev_status, n.dev_changed)) {
+ Some((DevStatus::ReadyForDev, true)) => "Changed",
+ Some((d, _)) => d.label(),
+ None => "Mixed",
};
- let sections = !sel.is_empty() && sel.iter().all(is_section);
- if sel.is_empty() || !sel.iter().all(|i| is_section(i) || top_frame(i)) {
+ let resp = widgets::dropdown(ui, label, width, Some(Icon::Code));
+ egui::Popup::menu(&resp).show(|ui| {
+ for d in DevStatus::ALL {
+ if menu_item(ui, d.label(), "") {
+ app.set_props(ids, json!({"devStatus": d.id()}));
+ ui.close();
+ }
+ }
+ });
+}
+
+pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
+ let sections = !sel.is_empty() && sel.iter().all(|i| is_section(doc, *i));
+ if sel.is_empty() || !sel.iter().all(|i| has_status(doc, *i)) {
return;
}
let t = crate::theme::Tokens::get(ui.ctx());
@@ -64,23 +89,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
}
widgets::sublabels(ui, "Status", None);
let changed = sel.iter().any(|i| doc.node(*i).is_some_and(|n| n.dev_status == DevStatus::ReadyForDev && n.dev_changed));
- row(ui, 32.0, |ui| {
- let st = common(doc, sel, |n| (n.dev_status, n.dev_changed));
- let label = match st {
- Some((DevStatus::ReadyForDev, true)) => "Changed",
- Some((d, _)) => d.label(),
- None => "Mixed",
- };
- let resp = widgets::dropdown(ui, label, col * 2.0 + 8.0, Some(Icon::Code));
- egui::Popup::menu(&resp).show(|ui| {
- for d in DevStatus::ALL {
- if menu_item(ui, d.label(), "") {
- app.set_props(&ids, json!({"devStatus": d.id()}));
- ui.close();
- }
- }
- });
- });
+ row(ui, 32.0, |ui| status_dropdown(app, ui, doc, &ids, col * 2.0 + 8.0));
if changed {
// Changed since marked ready: say why to resolve it (back to Ready for dev).
row(ui, 32.0, |ui| {
diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs
index c621a3a80..36bf2c361 100644
--- a/crates/ui-egui/src/widgets.rs
+++ b/crates/ui-egui/src/widgets.rs
@@ -302,6 +302,40 @@ pub fn text_field(ui: &mut Ui, id: Id, value: &str, width: f32, placeholder: &st
text_field_in(ui, id, value, rect, placeholder)
}
+/// A multi-line text field across the panel (descriptions, notes) that commits when it loses
+/// focus: returns the new text then, when it changed. The draft lives only while it has focus.
+pub fn multiline_field(ui: &mut Ui, id: Id, value: &str, hint: &str, rows: usize) -> Option {
+ let t = Tokens::get(ui.ctx());
+ let te = id.with("te");
+ let focused = ui.memory(|m| m.has_focus(te));
+ let mut text: String = if focused { ui.data(|d| d.get_temp(id)).unwrap_or_else(|| value.to_string()) } else { value.to_string() };
+ let mut done = false;
+ ui.horizontal(|ui| {
+ ui.add_space(PAD);
+ let w = ui.available_width() - PAD;
+ let r = egui::Frame::NONE.fill(t.field).corner_radius(CornerRadius::same(5)).inner_margin(egui::Margin::same(6)).show(ui, |ui| {
+ ui.add(
+ egui::TextEdit::multiline(&mut text)
+ .id(te)
+ .frame(egui::Frame::NONE)
+ .font(regular(11.0))
+ .hint_text(hint)
+ .desired_rows(rows)
+ .desired_width(w - 12.0),
+ )
+ });
+ done = r.inner.lost_focus();
+ });
+ if done {
+ ui.data_mut(|d| d.remove::(id));
+ return (text != value).then_some(text);
+ }
+ if ui.memory(|m| m.has_focus(te)) {
+ ui.data_mut(|d| d.insert_temp(id, text));
+ }
+ None
+}
+
/// An editable field with a menu of `options` behind the chevron at its right (export sizes…).
/// Returns the typed text (on Enter / focus loss) or the option picked.
pub fn combo_field(ui: &mut Ui, id: Id, value: &str, width: f32, options: &[String]) -> Option {
diff --git a/docs/control-protocol.md b/docs/control-protocol.md
index e7818e386..e801734f8 100644
--- a/docs/control-protocol.md
+++ b/docs/control-protocol.md
@@ -56,7 +56,8 @@ absoluteBounds, visible, locked, opacity, blend, fills, strokes, strokeWeight, e
layout (including grid `columnTracks` / `rowTracks`), sizing, `minWidth` / `maxWidth` /
`minHeight` / `maxHeight`, `absolute`, constraints (children of plain frames and absolute
children), `gridCell` (a grid auto layout child's column, row and spans), clip, text properties,
-component/instance links, overrides, `slot` (a slot layer's property name), boundVariables, interactions, exports. The auto layout props
+component/instance links, overrides, `slot` (a slot layer's property name), boundVariables, interactions, exports,
+`devStatus` (handoff status), `annotations` (Dev Mode notes: `[{label, properties}]`). The auto layout props
that `node.set` accepts are listed in [mcp.md](mcp.md#conventions-agents-must-know).
### `ui.inspect {}`
diff --git a/docs/mcp.md b/docs/mcp.md
index 515d909a1..522c23543 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -126,7 +126,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), `component.makeSlot` / `instance.slotInsert` / `instance.resetSlot` (slots, below), `inspect.code` (CSS, HTML + CSS for a whole frame, SwiftUI or Jetpack Compose for a layer), `inspect.properties` (layout, auto layout, typography, fills, strokes and effects with their style and variable names), `inspect.assets` (layers under the selection with export settings), `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), `component.makeSlot` / `instance.slotInsert` / `instance.resetSlot` (slots, below), `inspect.code` (CSS, HTML + CSS for a whole frame, React, SwiftUI or Jetpack Compose for a layer; components take their properties as parameters and instances are calls, see Dev Mode below), `inspect.properties` (layout, auto layout, typography, fills, strokes and effects with their style and variable names), `inspect.assets` (layers under the selection with export settings), `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`; marking again clears "Changed"), a section's `sectionContentsHidden`, text…); returns the updated layers. |
@@ -383,6 +383,42 @@ closing or saving a version ends the preview first, and `version.list` marks the
has unsaved changes since its last version; the app runs it every 30 minutes for each open document,
and a file keeps the newest 20 autosaves (user versions are never dropped for them). `file.versionHistory` returns the same list and opens the panel in the app.
+### Dev Mode
+
+- **Component code:** `inspect.code {id, language: react|swiftui|compose}` on a component, a
+ component set or a variant writes the component as a reusable unit: React (TSX) with a props
+ interface, SwiftUI with a `View` struct, Compose with a `@Composable` function. Its component
+ properties are the parameters, with their defaults: variant properties become string unions
+ (React) or enums, booleans become flags around the layers they show, text properties strings,
+ instance swaps views (`React.ReactNode`, `AnyView`, a composable lambda) and slots optional
+ content. Every variant of a set shares one definition; in React the other variants' differences
+ become CSS rules on `data-*` attributes. On an instance (or a layer tree containing instances)
+ each instance is a call passing only the values it changes, e.g. ``, `Button(state: .hover, label: "Buy")`, `Button(state = ButtonState.Hover, label =
+ "Buy")`. Names are escaped and unique (keywords get a suffix, `default` → `defaultValue`; names
+ that would shadow framework types get one too, `Text` → `TextComponent`). `inspect.componentCode
+ {id?, language?}` returns the parts: `{component, name, set, properties: [{name, param, type,
+ default, value? (instances)}], definition, usage? (instances)}`.
+- **Annotations:** notes for developers on any layer, each with optional pinned properties that
+ Dev Mode shows with their current values (and the style or variable behind them).
+ `annotation.add {id?, label?, properties?}` (a note, pinned properties or both; returns `{id,
+ index}`), `annotation.edit {id?, index, label?, properties?}` (`properties` replaces the list),
+ `annotation.remove {id?, index?}` (no index: all of the layer's), all undoable, and
+ `annotation.list {ids? | id? | page? ("all")}` → `{annotations: [{id, name, index, label,
+ properties: [{property, label, value, token?, color?}]}]}` (`value` is null when the layer doesn't
+ have that property). Pinnable ids: `width`, `height`, `top`, `left`, `rotation`, `opacity`,
+ `cornerRadius`, `direction`, `gap`, `padding`, `alignment`, `fontFamily`, `fontStyle`,
+ `fontSize`, `lineHeight`, `letterSpacing`, `textAlign`, `fill`, `stroke`, `strokeWeight`,
+ `effects`. A layer holds at most 64 annotations; notes are capped at 5,000 characters. They are
+ saved with the file (`get_node` lists them as `annotations`); instances don't inherit their
+ main's annotations and keep their own across main edits. In the app the Inspect tab draws them
+ as callouts beside their layers and edits them in its Annotations section.
+- **Ready for dev:** frames at the top of a page or section, and sections, carry a handoff status
+ (`node.set {devStatus: none|readyForDev|completed}`). `inspect.readyForDev {status?:
+ readyForDev|completed|any, page?}` lists them across pages (or on one page) in page and layer
+ order: `{items: [{id, name, page, status}]}`. The Inspect tab sets the status of the selected
+ frames and, with nothing selected, lists them with a status filter (click one to go to it).
+
### Ruler guides
`guide.add {vertical, offset, frame? | page?}` (returns `{index}`), `guide.move {index, offset, …}`,
diff --git a/docs/parity.md b/docs/parity.md
index ef3f3ee38..a25c8b041 100644
--- a/docs/parity.md
+++ b/docs/parity.md
@@ -1,6 +1,6 @@
# Parity report
-Generated by `cargo xtask parity`: FigCraft commands registered for the incumbent's main menu and toolbar (411 commands in the engine).
+Generated by `cargo xtask parity`: FigCraft commands registered for the incumbent's main menu and toolbar (420 commands in the engine).
**197 / 202 menu items (97.5%)**