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>> {
+ let Some(v) = get(p, "properties") else { return Ok(None) };
+ let Value::Array(list) = v else { return Err(bad(cmd, "`properties` must be a list of property ids")) };
+ let mut out: Vec = Vec::new();
+ for item in list.iter().take(PINNABLE.len() * 4) {
+ let k = item.as_str().unwrap_or_default();
+ let found = pinnable(k).ok_or_else(|| {
+ let ids: Vec<&str> = PINNABLE.iter().map(|p| p.id).collect();
+ bad(cmd, format!("unknown property {item}; one of: {}", ids.join(", ")))
+ })?;
+ if !out.iter().any(|o| o == found.id) {
+ out.push(found.id.to_string());
+ }
+ }
+ Ok(Some(out))
+}
+
+/// `label` from params, trimmed of trailing whitespace and capped.
+fn parse_label(cmd: &str, p: &Value) -> crate::Result> {
+ match get(p, "label").or_else(|| get(p, "text")) {
+ None => Ok(None),
+ Some(Value::String(s)) => Ok(Some(s.trim_end().chars().take(MAX_LABEL).collect())),
+ Some(_) => Err(bad(cmd, "`label` must be a string")),
+ }
+}
+
+fn check(cmd: &str, a: &Annotation) -> crate::Result<()> {
+ if a.label.trim().is_empty() && a.properties.is_empty() {
+ return Err(bad(cmd, "an annotation needs a note or a pinned property"));
+ }
+ Ok(())
+}
+
+/// The layer an annotation command acts on: `id`, else the first selected layer.
+fn layer(cmd: &str, s: &crate::Session, p: &Value) -> crate::Result {
+ let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a layer or pass `id`"))?;
+ let n = s.doc()?.node(id).ok_or_else(|| bad(cmd, "no such layer"))?;
+ if n.is_page() {
+ return Err(bad(cmd, "pages can't be annotated"));
+ }
+ Ok(id)
+}
+
+/// The annotation `index` on `id` must exist.
+fn existing(cmd: &str, s: &crate::Session, id: NodeId, p: &Value) -> crate::Result {
+ let index = req_index(cmd, p, "index")?;
+ let count = s.doc()?.node(id).map_or(0, |n| n.annotations.len());
+ if index >= count {
+ return Err(bad(cmd, format!("no annotation {index} (the layer has {count})")));
+ }
+ Ok(index)
+}
+
+pub fn specs() -> Vec {
+ vec![
+ spec!(
+ "annotation.add",
+ "Add annotation",
+ "{id? (default the first selected layer), label? (the note), properties?: [width|height|top|left|rotation|opacity|cornerRadius|direction|gap|padding|alignment|fontFamily|fontStyle|fontSize|lineHeight|letterSpacing|textAlign|fill|stroke|strokeWeight|effects]} → {id, index} (a note, pinned properties or both)",
+ true,
+ |s, p| {
+ let cmd = "annotation.add";
+ let id = layer(cmd, s, p)?;
+ let a = Annotation { label: parse_label(cmd, p)?.unwrap_or_default(), properties: parse_properties(cmd, p)?.unwrap_or_default() };
+ check(cmd, &a)?;
+ let n = s.doc_mut()?.get_mut(id)?;
+ if n.annotations.len() >= MAX_PER_LAYER {
+ return Err(bad(cmd, format!("a layer has at most {MAX_PER_LAYER} annotations")));
+ }
+ n.annotations.push(a);
+ Ok(json!({"id": id, "index": n.annotations.len() - 1}))
+ }
+ ),
+ spec!("annotation.edit", "Edit annotation", "{id?, index, label?, properties?: [..] (replaces the pinned properties)}", true, |s, p| {
+ let cmd = "annotation.edit";
+ let id = layer(cmd, s, p)?;
+ let index = existing(cmd, s, id, p)?;
+ let (label, props) = (parse_label(cmd, p)?, parse_properties(cmd, p)?);
+ if label.is_none() && props.is_none() {
+ return Err(bad(cmd, "pass `label` and/or `properties`"));
+ }
+ let n = s.doc_mut()?.get_mut(id)?;
+ let a = n.annotations.get_mut(index).ok_or_else(|| bad(cmd, "no such annotation"))?;
+ if let Some(l) = label {
+ a.label = l;
+ }
+ if let Some(pr) = props {
+ a.properties = pr;
+ }
+ check(cmd, a)?;
+ Ok(json!({"id": id, "index": index}))
+ }),
+ spec!("annotation.remove", "Remove annotation", "{id?, index? (omit: every annotation on the layer)}", true, |s, p| {
+ let cmd = "annotation.remove";
+ let id = layer(cmd, s, p)?;
+ let index = match get(p, "index") {
+ Some(_) => Some(existing(cmd, s, id, p)?),
+ None => None,
+ };
+ let n = s.doc_mut()?.get_mut(id)?;
+ if n.annotations.is_empty() {
+ return Err(bad(cmd, "the layer has no annotations"));
+ }
+ match index {
+ Some(i) if i < n.annotations.len() => {
+ n.annotations.remove(i);
+ }
+ Some(_) => return Err(bad(cmd, "no such annotation")),
+ None => n.annotations.clear(),
+ }
+ Ok(json!({"id": id, "remaining": n.annotations.len()}))
+ }),
+ spec!(
+ "annotation.list",
+ "List annotations",
+ "{ids? | id? (those layers), page? (page id, or \"all\"; default the current page)} → {annotations: [{id, name, index, label, properties: [{property, label, value (null when the layer has none), token?, color?}]}]}",
+ false,
+ |s, p| {
+ let doc = s.doc()?;
+ let layers: Vec = if get(p, "ids").is_some() || get(p, "id").is_some() {
+ targets(s, p).into_iter().filter(|i| doc.contains(*i)).collect()
+ } else if string(p, "page").is_some_and(|v| v.eq_ignore_ascii_case("all")) {
+ doc.pages.iter().flat_map(|pg| on_page(doc, *pg)).take(MAX_LISTED).collect()
+ } else {
+ let page = id(p, "page").unwrap_or(s.page()?);
+ if !doc.pages.contains(&page) {
+ return Err(bad("annotation.list", format!("{page} is not a page")));
+ }
+ on_page(doc, page)
+ };
+ let list: Vec = layers
+ .iter()
+ .filter_map(|i| doc.node(*i).map(|n| (i, n)))
+ .flat_map(|(i, n)| n.annotations.iter().enumerate().map(move |(k, a)| annotation_json(doc, *i, k, a)))
+ .collect();
+ Ok(json!({"annotations": list}))
+ }
+ ),
+ ]
+}
diff --git a/crates/engine/src/annotations_tests.rs b/crates/engine/src/annotations_tests.rs
new file mode 100644
index 000000000..14fb78c89
--- /dev/null
+++ b/crates/engine/src/annotations_tests.rs
@@ -0,0 +1,204 @@
+//! Dev Mode annotations (notes with pinned properties on layers) and the ready-for-dev list.
+
+use crate::components::tests::{ex, idv, node};
+use crate::{EngineError, Session};
+use figcraft_doc::*;
+use serde_json::json;
+
+fn rect(s: &mut Session) -> NodeId {
+ idv(&ex(s, "shape.rectangle", json!({"x": 10, "y": 20, "width": 120, "height": 40, "props": {"name": "Button", "fill": "#3366FF"}})))
+}
+
+#[test]
+fn annotations_are_added_edited_and_removed_with_undo() {
+ let mut s = Session::new();
+ let r = rect(&mut s);
+ let out = ex(&mut s, "annotation.add", json!({"id": r, "label": "Use the brand button", "properties": ["width", "Fill", "fill"]}));
+ assert_eq!(out, json!({"id": r.0, "index": 0}));
+ let a = &node(&s, r).annotations;
+ assert_eq!(a.len(), 1);
+ assert_eq!(a[0].label, "Use the brand button");
+ assert_eq!(a[0].properties, vec!["width".to_string(), "fill".to_string()], "canonical ids, each once");
+ // The selection is the default layer; a second annotation with only a pinned property.
+ ex(&mut s, "select.set", json!({"ids": [r]}));
+ assert_eq!(ex(&mut s, "annotation.add", json!({"properties": ["height"]}))["index"], 1);
+ ex(&mut s, "annotation.edit", json!({"id": r, "index": 1, "label": "Tap target"}));
+ assert_eq!(node(&s, r).annotations[1], Annotation { label: "Tap target".into(), properties: vec!["height".into()] });
+ ex(&mut s, "annotation.edit", json!({"id": r, "index": 1, "properties": []}));
+ assert!(node(&s, r).annotations[1].properties.is_empty());
+ // Each change is one undo step.
+ ex(&mut s, "edit.undo", json!({}));
+ assert_eq!(node(&s, r).annotations[1].properties, vec!["height".to_string()]);
+ ex(&mut s, "annotation.remove", json!({"id": r, "index": 0}));
+ assert_eq!(node(&s, r).annotations.len(), 1);
+ assert_eq!(node(&s, r).annotations[0].label, "Tap target");
+ ex(&mut s, "edit.undo", json!({}));
+ assert_eq!(node(&s, r).annotations.len(), 2);
+ ex(&mut s, "annotation.remove", json!({"id": r}));
+ assert!(node(&s, r).annotations.is_empty(), "no index: every annotation");
+ // Refused: empty, unknown properties, bad indexes, pages; the document is unchanged.
+ let before = s.doc().unwrap().clone();
+ for (cmd, p) in [
+ ("annotation.add", json!({"id": r})),
+ ("annotation.add", json!({"id": r, "label": " "})),
+ ("annotation.add", json!({"id": r, "label": "x", "properties": ["colour"]})),
+ ("annotation.add", json!({"id": r, "label": "x", "properties": "width"})),
+ ("annotation.add", json!({"id": r, "label": 5})),
+ ("annotation.add", json!({"id": s.page().unwrap(), "label": "page"})),
+ ("annotation.edit", json!({"id": r, "index": 0, "label": "x"})),
+ ("annotation.remove", json!({"id": r})),
+ ("annotation.remove", json!({"id": 999_999})),
+ ] {
+ assert!(s.execute(cmd, &p).is_err(), "{cmd} {p} refused");
+ }
+ assert_eq!(*s.doc().unwrap(), before);
+ ex(&mut s, "annotation.add", json!({"id": r, "label": "x"}));
+ assert!(s.execute("annotation.edit", &json!({"id": r, "index": 0, "label": ""})).is_err(), "a note or a property stays");
+ assert!(s.execute("annotation.edit", &json!({"id": r, "index": 0})).is_err(), "nothing to change");
+ assert!(s.execute("annotation.edit", &json!({"id": r, "index": -1, "label": "y"})).is_err());
+ assert!(s.execute("annotation.edit", &json!({"id": r, "index": 1, "label": "y"})).is_err());
+ // Capped per layer.
+ for k in 1..crate::annotations::MAX_PER_LAYER {
+ ex(&mut s, "annotation.add", json!({"id": r, "label": format!("n{k}")}));
+ }
+ assert!(s.execute("annotation.add", &json!({"id": r, "label": "one too many"})).is_err());
+}
+
+#[test]
+fn pinned_properties_show_their_current_values() {
+ let mut s = Session::new();
+ let r = rect(&mut s);
+ ex(&mut s, "variable.createCollection", json!({"name": "Tokens"}));
+ ex(&mut s, "variable.create", json!({"collection": "Tokens", "name": "Brand/Primary", "type": "color", "value": "#3366FF"}));
+ ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "Brand/Primary"}));
+ ex(&mut s, "annotation.add", json!({"id": r, "label": "Primary action", "properties": ["width", "fill", "gap", "fontSize"]}));
+ let list = ex(&mut s, "annotation.list", json!({}))["annotations"].clone();
+ assert_eq!(list.as_array().map(Vec::len), Some(1));
+ let a = &list[0];
+ assert_eq!(a["id"], r.0);
+ assert_eq!(a["name"], "Button");
+ assert_eq!(a["label"], "Primary action");
+ let props = a["properties"].as_array().cloned().unwrap_or_default();
+ assert_eq!(props[0], json!({"property": "width", "label": "Width", "value": "120"}));
+ assert_eq!(props[1]["value"], "#3366FF");
+ assert_eq!(props[1]["token"], "Brand/Primary", "the variable behind the fill");
+ assert_eq!(props[1]["color"], "#3366FF");
+ assert_eq!(props[2]["value"], json!(null), "no auto layout: no gap");
+ assert_eq!(props[3]["value"], json!(null));
+ // Values follow edits.
+ ex(&mut s, "node.set", json!({"id": r, "props": {"width": 200}}));
+ let list = ex(&mut s, "annotation.list", json!({"id": r}))["annotations"].clone();
+ assert_eq!(list[0]["properties"][0]["value"], "200");
+ // Text layers: typography.
+ let t = idv(&ex(&mut s, "text.create", json!({"text": "Hi", "x": 0, "y": 100})));
+ ex(&mut s, "node.set", json!({"id": t, "props": {"fontSize": 24}}));
+ ex(&mut s, "annotation.add", json!({"id": t, "properties": ["fontSize", "textAlign"]}));
+ let list = ex(&mut s, "annotation.list", json!({"id": t}))["annotations"].clone();
+ assert_eq!(list[0]["properties"][0]["value"], "24");
+ assert_eq!(list[0]["properties"][1]["value"], "left");
+ // Every annotation on the page, in layer order; other pages, "all".
+ assert_eq!(ex(&mut s, "annotation.list", json!({}))["annotations"].as_array().map(Vec::len), Some(2));
+ ex(&mut s, "page.add", json!({"name": "Other"}));
+ assert_eq!(ex(&mut s, "annotation.list", json!({}))["annotations"].as_array().map(Vec::len), Some(0));
+ assert_eq!(ex(&mut s, "annotation.list", json!({"page": "all"}))["annotations"].as_array().map(Vec::len), Some(2));
+ assert!(s.execute("annotation.list", &json!({"page": r})).is_err(), "not a page");
+}
+
+#[test]
+fn annotations_are_saved_and_old_files_open() {
+ let mut s = Session::new();
+ let r = rect(&mut s);
+ ex(&mut s, "annotation.add", json!({"id": r, "label": "Note", "properties": ["height"]}));
+ let doc = s.doc().unwrap().clone();
+ let json = serde_json::to_string(&doc).unwrap();
+ assert!(json.contains("\"annotations\":[{\"label\":\"Note\",\"properties\":[\"height\"]}]"), "{json}");
+ let back: Document = serde_json::from_str(&json).unwrap();
+ assert_eq!(back.node(r).unwrap().annotations, doc.node(r).unwrap().annotations);
+ // Layers without annotations leave the field out; files without it read as none.
+ let plain = serde_json::to_value(Node::new(NodeId(7), NodeKind::Rectangle, "R", 1.0, 1.0)).unwrap();
+ assert!(plain.get("annotations").is_none());
+ let n: Node = serde_json::from_value(plain).unwrap();
+ assert!(n.annotations.is_empty());
+ // Unknown pinned ids from newer files are kept and skipped.
+ let mut n2 = n.clone();
+ n2.annotations.push(Annotation { label: String::new(), properties: vec!["future".into(), "width".into()] });
+ let a = n2.annotations[0].clone();
+ let pinned = crate::annotations::pinned(&doc, r, &a);
+ assert_eq!(pinned.len(), 1);
+ assert_eq!(pinned[0].0.id, "width");
+ // node.get shows them.
+ assert_eq!(ex(&mut s, "node.get", json!({"id": r}))["annotations"], json!([{"label": "Note", "properties": ["height"]}]));
+}
+
+#[test]
+fn instances_keep_their_own_annotations() {
+ let mut s = Session::new();
+ let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100, "name": "Card"})));
+ let inner = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50, "parent": f})));
+ let c = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
+ ex(&mut s, "annotation.add", json!({"id": c, "label": "On the main"}));
+ ex(&mut s, "annotation.add", json!({"id": inner, "label": "Inner on the main"}));
+ let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 200, "y": 0})));
+ assert!(node(&s, i).annotations.is_empty(), "the main's annotations stay on the main");
+ let copy = crate::components::tests::copy_of(&s, i, inner);
+ assert!(node(&s, copy).annotations.is_empty());
+ ex(&mut s, "annotation.add", json!({"id": i, "label": "On the instance"}));
+ ex(&mut s, "annotation.add", json!({"id": copy, "label": "Inner on the instance"}));
+ // A main edit rebuilds the instance: its own annotations stay.
+ ex(&mut s, "node.set", json!({"id": inner, "props": {"fill": "#00ff00"}}));
+ assert_eq!(node(&s, i).annotations[0].label, "On the instance");
+ let copy = crate::components::tests::copy_of(&s, i, inner);
+ assert_eq!(node(&s, copy).annotations[0].label, "Inner on the instance");
+ assert_eq!(node(&s, c).annotations[0].label, "On the main");
+}
+
+#[test]
+fn ready_for_dev_lists_frames_by_status() {
+ let mut s = Session::new();
+ let a = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100, "name": "Login"})));
+ let b = idv(&ex(&mut s, "frame.create", json!({"x": 200, "y": 0, "width": 100, "height": 100, "name": "Signup"})));
+ let sec = idv(&ex(&mut s, "node.create", json!({"type": "section", "x": 1000, "y": 0, "width": 400, "height": 300})));
+ ex(&mut s, "node.set", json!({"id": sec, "props": {"name": "Onboarding"}}));
+ ex(&mut s, "node.set", json!({"ids": [a, sec], "props": {"devStatus": "readyForDev"}}));
+ ex(&mut s, "node.set", json!({"id": b, "props": {"devStatus": "completed"}}));
+ let page = s.page().unwrap();
+ let pg2 = idv(&ex(&mut s, "page.add", json!({"name": "Settings"})));
+ ex(&mut s, "page.select", json!({"id": pg2}));
+ let c = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100, "name": "Profile"})));
+ ex(&mut s, "node.set", json!({"id": c, "props": {"devStatus": "readyForDev"}}));
+ let names = |v: &serde_json::Value| -> Vec {
+ v["items"].as_array().map(|a| a.iter().filter_map(|i| i["name"].as_str().map(String::from)).collect()).unwrap_or_default()
+ };
+ let ready = ex(&mut s, "inspect.readyForDev", json!({}));
+ assert_eq!(names(&ready), vec!["Login", "Onboarding", "Profile"], "every page, page and layer order");
+ assert_eq!(ready["items"][0], json!({"id": a.0, "name": "Login", "page": page.0, "status": "readyForDev"}));
+ assert_eq!(names(&ex(&mut s, "inspect.readyForDev", json!({"status": "completed"}))), vec!["Signup"]);
+ assert_eq!(names(&ex(&mut s, "inspect.readyForDev", json!({"status": "any", "page": page}))), vec!["Login", "Signup", "Onboarding"]);
+ assert!(s.execute("inspect.readyForDev", &json!({"status": "none"})).is_err());
+ assert!(s.execute("inspect.readyForDev", &json!({"page": a})).is_err(), "not a page");
+ let doc = s.doc().unwrap();
+ assert_eq!(crate::devinspect::dev_items(doc, Some(DevStatus::ReadyForDev), Some(pg2)).len(), 1);
+}
+
+#[test]
+fn annotation_commands_survive_junk() {
+ let junk = [
+ json!({}),
+ json!(null),
+ json!({"id": "x", "index": "NaN", "label": [1], "properties": {"a": 1}}),
+ json!({"index": 1e300, "properties": [null, 5, "width", {"x": 1}]}),
+ json!({"ids": [-1, "0:0"], "page": {"x": 1}, "status": 7}),
+ json!({"index": -0.5, "label": "\u{0}\u{2028}".repeat(4000)}),
+ ];
+ for cmd in ["annotation.add", "annotation.edit", "annotation.remove", "annotation.list", "inspect.readyForDev", "inspect.componentCode"] {
+ for j in &junk {
+ let mut s = Session::new();
+ let r = rect(&mut s);
+ ex(&mut s, "annotation.add", json!({"id": r, "label": "n", "properties": ["width"]}));
+ ex(&mut s, "select.set", json!({"ids": [r]}));
+ if let Err(EngineError::Internal { cmd, msg }) = s.execute(cmd, j) {
+ panic!("{cmd} panicked on {j}: {msg}");
+ }
+ }
+ }
+}
diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs
index 318cfe1bd..a79e604ec 100644
--- a/crates/engine/src/cmd/mod.rs
+++ b/crates/engine/src/cmd/mod.rs
@@ -86,6 +86,8 @@ fn registry() -> &'static Registry {
list.extend(crate::shapebuilder::specs());
list.extend(crate::codegen::specs());
list.extend(crate::devinspect::specs());
+ list.extend(crate::component_codegen::specs());
+ list.extend(crate::annotations::specs());
list.extend(crate::distance::specs());
let mut index = HashMap::new();
for (i, s) in list.iter().enumerate() {
diff --git a/crates/engine/src/codegen.rs b/crates/engine/src/codegen.rs
index 9feea5350..86b2fed2b 100644
--- a/crates/engine/src/codegen.rs
+++ b/crates/engine/src/codegen.rs
@@ -1,12 +1,16 @@
//! Code for a layer, as shown by the Inspect panel and returned by `inspect.code`: CSS for the
-//! layer itself, and SwiftUI or Jetpack Compose for its subtree (stacks for auto layout, absolute
-//! offsets otherwise). The output is a starting point, not a pixel-exact translation.
+//! layer itself, HTML or React for its subtree, and SwiftUI or Jetpack Compose (stacks for auto
+//! layout, absolute offsets otherwise). Components become components whose properties are
+//! parameters and instances become calls passing their values (see [`crate::component_codegen`]);
+//! variables bound to a layer become CSS custom properties. The output is a starting point, not a
+//! pixel-exact translation.
use figcraft_color::{Color, PaintKind};
use figcraft_doc::*;
use serde_json::json;
use crate::cmd::CommandSpec;
+use crate::component_codegen::{Component, Param};
use crate::params::*;
use crate::{bad, spec};
@@ -14,12 +18,12 @@ pub fn specs() -> Vec {
vec![spec!(
"inspect.code",
"Code for layer",
- "{id?, language?: css|html|swiftui|compose (default css; html is the whole layer tree)} → {code}",
+ "{id?, language?: css|html|react|swiftui|compose (default css; html and react are the whole layer tree; components take their properties as parameters, instances pass their values)} → {code, language}",
false,
|s, p| {
let id = targets(s, p).first().copied().ok_or_else(|| bad("inspect.code", "select a layer"))?;
let lang = match string(p, "language") {
- Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad("inspect.code", "language: css|swiftui|compose"))?,
+ Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad("inspect.code", "language: css|html|react|swiftui|compose"))?,
None => CodeLang::Css,
};
let doc = s.doc()?;
@@ -38,16 +42,19 @@ pub enum CodeLang {
Css,
/// The whole layer tree as nested HTML elements with a stylesheet.
Html,
+ /// A React function component (TSX) with its stylesheet.
+ React,
SwiftUi,
Compose,
}
impl CodeLang {
- pub const ALL: [CodeLang; 4] = [CodeLang::Css, CodeLang::Html, CodeLang::SwiftUi, CodeLang::Compose];
+ pub const ALL: [CodeLang; 5] = [CodeLang::Css, CodeLang::Html, CodeLang::React, CodeLang::SwiftUi, CodeLang::Compose];
pub fn id(self) -> &'static str {
match self {
CodeLang::Css => "css",
CodeLang::Html => "html",
+ CodeLang::React => "react",
CodeLang::SwiftUi => "swiftui",
CodeLang::Compose => "compose",
}
@@ -56,11 +63,15 @@ impl CodeLang {
match self {
CodeLang::Css => "CSS",
CodeLang::Html => "HTML + CSS",
+ CodeLang::React => "React",
CodeLang::SwiftUi => "SwiftUI",
CodeLang::Compose => "Compose",
}
}
pub fn from_id(s: &str) -> Option {
+ if s.eq_ignore_ascii_case("tsx") || s.eq_ignore_ascii_case("jsx") {
+ return Some(CodeLang::React);
+ }
CodeLang::ALL.into_iter().find(|l| l.id().eq_ignore_ascii_case(s) || l.label().eq_ignore_ascii_case(s))
}
}
@@ -70,7 +81,7 @@ const MAX_DEPTH: usize = 12;
const MAX_CHILDREN: usize = 200;
/// `v` without trailing zeros (2 decimals).
-fn num(v: f64) -> String {
+pub(crate) fn num(v: f64) -> String {
if !v.is_finite() {
return "0".into();
}
@@ -82,16 +93,53 @@ pub fn code_for(doc: &Document, id: NodeId, lang: CodeLang) -> String {
match lang {
CodeLang::Css => css(doc, id),
CodeLang::Html => html(doc, id),
- CodeLang::SwiftUi => {
- let mut out = String::new();
- swift(doc, id, 0, &mut out);
- out
- }
- CodeLang::Compose => {
- let mut out = String::new();
- compose(doc, id, 0, &mut out);
- out
+ CodeLang::React | CodeLang::SwiftUi | CodeLang::Compose => crate::component_codegen::code(doc, id, lang),
+ }
+}
+
+/// What a tree walk generates for: the document, the layer the code is for (instances under it
+/// become calls), and the component whose properties are parameters.
+pub(crate) struct Ctx<'a> {
+ pub doc: &'a Document,
+ pub root: NodeId,
+ pub comp: Option<&'a Component>,
+ /// Indentation the walk starts at (the depth cap counts from it).
+ pub base: usize,
+}
+
+impl<'a> Ctx<'a> {
+ pub fn new(doc: &'a Document, root: NodeId) -> Self {
+ Ctx { doc, root, comp: None, base: 0 }
+ }
+
+ /// The component parameter controlling `field` ("visible", "characters", "mainComponent",
+ /// "slot") of `n`.
+ pub fn param(&self, n: &Node, field: &str) -> Option<&'a Param> {
+ let name = n.prop_refs.get(field)?;
+ self.comp?.params.iter().find(|p| &p.prop == name)
+ }
+
+ /// Visible children in paint order (bottom first), capped; in component code, hidden layers a
+ /// boolean parameter shows are included.
+ pub fn kids(&self, n: &Node) -> Vec {
+ n.children
+ .iter()
+ .copied()
+ .filter(|c| self.doc.node(*c).is_some_and(|k| k.visible || self.param(k, "visible").is_some()))
+ .take(MAX_CHILDREN)
+ .collect()
+ }
+
+ fn too_deep(&self, depth: usize) -> bool {
+ depth > self.base + MAX_DEPTH
+ }
+
+ /// A call for an instance below the root (`None` for other layers, or a missing main).
+ fn call(&self, n: &Node, lang: CodeLang, depth: usize) -> Option {
+ if n.id == self.root || !matches!(n.kind, NodeKind::Instance { .. }) {
+ return None;
}
+ crate::component_codegen::usage(self.doc, n.id, lang, depth)
}
}
@@ -122,7 +170,7 @@ fn radius(n: &Node) -> f64 {
}
/// Text content as a string literal (escaped for Swift; Kotlin also escapes `$`).
-fn literal(s: &str, kotlin: bool) -> String {
+pub(crate) fn literal(s: &str, kotlin: bool) -> String {
let mut out = String::from("\"");
for c in s.chars().take(10_000) {
match c {
@@ -140,74 +188,138 @@ fn literal(s: &str, kotlin: bool) -> String {
out
}
-/// Visible children in paint order (bottom first), capped.
-fn kids(doc: &Document, n: &Node) -> Vec {
- n.children.iter().copied().filter(|c| doc.node(*c).is_some_and(|k| k.visible)).take(MAX_CHILDREN).collect()
-}
-
-fn indent(depth: usize) -> String {
+pub(crate) fn indent(depth: usize) -> String {
" ".repeat(depth)
}
// ------------------------------------------------------------------ CSS
-fn css(doc: &Document, id: NodeId) -> String {
- let Some(n) = doc.node(id) else { return String::new() };
- format!("/* {} */\n{}", n.name.replace("*/", "* /"), css_decls(n))
+/// The CSS custom properties generated code uses: name → fallback value, in first-use order.
+#[derive(Default)]
+pub(crate) struct Tokens(pub Vec<(String, String)>);
+
+impl Tokens {
+ /// `var(--name, value)`, remembering the variable.
+ fn var(&mut self, variable: &str, value: String) -> String {
+ let name = css_var_name(variable);
+ let out = format!("var({name}, {value})");
+ if !self.0.iter().any(|(n, _)| *n == name) {
+ self.0.push((name, value));
+ }
+ out
+ }
+
+ /// A `:root` rule declaring the variables (empty without any).
+ pub fn root_rule(&self) -> String {
+ if self.0.is_empty() {
+ return String::new();
+ }
+ let decls: String = self.0.iter().map(|(n, v)| format!(" {n}: {v};\n")).collect();
+ format!(":root {{\n{decls}}}\n")
+ }
}
-/// The CSS declarations for `n`'s own look and size, one per line.
-fn css_decls(n: &Node) -> String {
- let mut out = format!("width: {}px;\nheight: {}px;\n", num(n.width), num(n.height));
- if let Some(l) = &n.layout {
- out.push_str("display: flex;\n");
- out.push_str(&format!("flex-direction: {};\n", if l.mode == LayoutMode::Vertical { "column" } else { "row" }));
- if l.wrap {
- out.push_str("flex-wrap: wrap;\n");
+/// A variable's CSS custom property name: "Colors/Brand 500" → `--colors-brand-500`.
+pub fn css_var_name(variable: &str) -> String {
+ let mut out = String::from("--");
+ for c in variable.chars().flat_map(char::to_lowercase).take(80) {
+ if c.is_alphanumeric() || c == '_' {
+ out.push(c);
+ } else if !out.ends_with('-') {
+ out.push('-');
}
- out.push_str(&format!("gap: {}px;\n", num(l.gap)));
- let [pt, pr, pb, pl] = l.padding;
- out.push_str(&format!("padding: {}px {}px {}px {}px;\n", num(pt), num(pr), num(pb), num(pl)));
- out.push_str(&format!(
- "justify-content: {};\nalign-items: {};\n",
- match l.main_align {
- MainAlign::Min => "flex-start",
- MainAlign::Center => "center",
- MainAlign::Max => "flex-end",
- MainAlign::SpaceBetween => "space-between",
- },
- match l.cross_align {
- CrossAlign::Min => "flex-start",
- CrossAlign::Center => "center",
- CrossAlign::Max => "flex-end",
- CrossAlign::Baseline => "baseline",
- }
- ));
}
- if let Some(r) = n.corners.uniform().filter(|r| *r > 0.0) {
- out.push_str(&format!("border-radius: {}px;\n", num(r)));
+ let trimmed = out.trim_end_matches('-');
+ if trimmed.len() <= 2 { "--variable".into() } else { trimmed.to_string() }
+}
+
+/// `value`, or a reference to the variable bound to the first of `fields` that has one.
+fn tok(doc: &Document, n: &Node, fields: &[&str], value: String, t: &mut Tokens) -> String {
+ match fields.iter().find_map(|f| crate::devinspect::bound_variable(doc, n, f)) {
+ Some(v) => t.var(&v.name, value),
+ None => value,
+ }
+}
+
+fn css(doc: &Document, id: NodeId) -> String {
+ let Some(n) = doc.node(id) else { return String::new() };
+ let mut t = Tokens::default();
+ let decls = css_decls(doc, n, &mut t, (false, false));
+ format!("/* {} */\n{decls}", n.name.replace("*/", "* /"))
+}
+
+/// The CSS declarations for `n`'s own look and size, one per line; `hug` leaves out the width
+/// and/or height (they follow the content). Bound variables become `var(--name, value)`.
+pub(crate) fn css_decls(doc: &Document, n: &Node, t: &mut Tokens, hug: (bool, bool)) -> String {
+ let px = |v: f64| format!("{}px", num(v));
+ let mut out = String::new();
+ let mut put = |key: &str, value: String| out.push_str(&format!("{key}: {value};\n"));
+ if !hug.0 {
+ put("width", tok(doc, n, &["width"], px(n.width), t));
+ }
+ if !hug.1 {
+ put("height", tok(doc, n, &["height"], px(n.height), t));
+ }
+ if let Some(l) = &n.layout {
+ put("display", "flex".into());
+ put("flex-direction", if l.mode == LayoutMode::Vertical { "column" } else { "row" }.into());
+ if l.wrap {
+ put("flex-wrap", "wrap".into());
+ }
+ put("gap", tok(doc, n, &["gap"], px(l.gap), t));
+ let [pt, pr, pb, pl] = l.padding;
+ let sides = [
+ tok(doc, n, &["paddingTop", "paddingVertical", "padding"], px(pt), t),
+ tok(doc, n, &["paddingRight", "paddingHorizontal", "padding"], px(pr), t),
+ tok(doc, n, &["paddingBottom", "paddingVertical", "padding"], px(pb), t),
+ tok(doc, n, &["paddingLeft", "paddingHorizontal", "padding"], px(pl), t),
+ ];
+ put("padding", sides.join(" "));
+ let justify = match l.main_align {
+ MainAlign::Min => "flex-start",
+ MainAlign::Center => "center",
+ MainAlign::Max => "flex-end",
+ MainAlign::SpaceBetween => "space-between",
+ };
+ put("justify-content", justify.into());
+ let align = match l.cross_align {
+ CrossAlign::Min => "flex-start",
+ CrossAlign::Center => "center",
+ CrossAlign::Max => "flex-end",
+ CrossAlign::Baseline => "baseline",
+ };
+ put("align-items", align.into());
+ }
+ if matches!(n.kind, NodeKind::Ellipse { .. }) {
+ put("border-radius", "50%".into());
+ } else if let Some(r) = n.corners.uniform().filter(|r| *r > 0.0) {
+ put("border-radius", tok(doc, n, &["cornerRadius"], px(r), t));
} else if n.corners.radii.iter().any(|r| *r > 0.0) {
- let [a, b, c, d] = n.corners.radii;
- out.push_str(&format!("border-radius: {}px {}px {}px {}px;\n", num(a), num(b), num(c), num(d)));
+ let fields = ["topLeftRadius", "topRightRadius", "bottomRightRadius", "bottomLeftRadius"];
+ let corners: Vec = n.corners.radii.iter().zip(fields).map(|(r, f)| tok(doc, n, &[f, "cornerRadius"], px(*r), t)).collect();
+ put("border-radius", corners.join(" "));
}
if n.opacity < 1.0 {
- out.push_str(&format!("opacity: {};\n", num(f64::from(n.opacity))));
+ put("opacity", tok(doc, n, &["opacity"], num(f64::from(n.opacity)), t));
}
- for p in n.fills.iter().filter(|p| p.visible) {
+ for (i, p) in n.fills.iter().enumerate().filter(|(_, p)| p.visible) {
match &p.kind {
PaintKind::Solid { color } => {
let key = if matches!(n.kind, NodeKind::Text(_)) { "color" } else { "background" };
- out.push_str(&format!("{key}: {};\n", color.with_alpha(color.a * p.opacity).to_css()));
+ let c = color.with_alpha(color.a * p.opacity).to_css();
+ // A variable binds the first paint's colour.
+ put(key, if i == 0 { tok(doc, n, &["fill"], c, t) } else { c });
}
PaintKind::Gradient(g) => {
let stops: Vec = g.sorted_stops().iter().map(|s| format!("{} {}%", s.color.to_css(), (s.position * 100.0).round())).collect();
- out.push_str(&format!("background: linear-gradient({});\n", stops.join(", ")));
+ put("background", format!("linear-gradient({})", stops.join(", ")));
}
- PaintKind::Image(_) => out.push_str("background: url(image.png) center / cover;\n"),
+ PaintKind::Image(_) => put("background", "url(image.png) center / cover".into()),
}
}
if let Some(c) = stroke_color(n) {
- out.push_str(&format!("border: {}px solid {};\n", num(n.stroke.weight), c.to_css()));
+ let weight = tok(doc, n, &["strokeWeight"], px(n.stroke.weight), t);
+ put("border", format!("{weight} solid {}", tok(doc, n, &["stroke"], c.to_css(), t)));
}
let shadows: Vec = n
.effects
@@ -224,45 +336,43 @@ fn css_decls(n: &Node) -> String {
})
.collect();
if !shadows.is_empty() {
- out.push_str(&format!("box-shadow: {};\n", shadows.join(", ")));
+ put("box-shadow", shadows.join(", "));
}
for e in n.effects.iter().filter(|e| e.visible) {
match e.kind {
- EffectKind::LayerBlur { radius } => out.push_str(&format!("filter: blur({}px);\n", num(radius / 2.0))),
- EffectKind::BackgroundBlur { radius } => out.push_str(&format!("backdrop-filter: blur({}px);\n", num(radius / 2.0))),
+ EffectKind::LayerBlur { radius } => put("filter", format!("blur({}px)", num(radius / 2.0))),
+ EffectKind::BackgroundBlur { radius } => put("backdrop-filter", format!("blur({}px)", num(radius / 2.0))),
// CSS has no refraction: the frost is the nearest.
- EffectKind::Glass(ref g) => out.push_str(&format!("backdrop-filter: blur({}px);\n", num(g.sanitized().frost / 2.0))),
+ EffectKind::Glass(ref g) => put("backdrop-filter", format!("blur({}px)", num(g.sanitized().frost / 2.0))),
_ => {}
}
}
- if let NodeKind::Text(t) = &n.kind {
- out.push_str(&format!(
- "font-family: \"{}\";\nfont-size: {}px;\nfont-weight: {};\n",
- t.style.font_family,
- num(t.style.font_size),
- t.style.weight()
- ));
- if t.style.italic() {
- out.push_str("font-style: italic;\n");
+ if let NodeKind::Text(tx) = &n.kind {
+ let s = &tx.style;
+ put("font-family", tok(doc, n, &["fontFamily"], format!("\"{}\"", s.font_family.replace('"', "'")), t));
+ put("font-size", tok(doc, n, &["fontSize"], px(s.font_size), t));
+ put("font-weight", s.weight().to_string());
+ if s.italic() {
+ put("font-style", "italic".into());
}
- match t.style.line_height {
- LineHeight::Pixels(v) => out.push_str(&format!("line-height: {}px;\n", num(v))),
- LineHeight::Percent(p) => out.push_str(&format!("line-height: {}%;\n", num(p))),
+ match s.line_height {
+ LineHeight::Pixels(v) => put("line-height", tok(doc, n, &["lineHeight"], px(v), t)),
+ LineHeight::Percent(p) => put("line-height", format!("{}%", num(p))),
LineHeight::Auto => {}
}
- match t.style.letter_spacing {
- LetterSpacing::Pixels(v) if v != 0.0 => out.push_str(&format!("letter-spacing: {}px;\n", num(v))),
- LetterSpacing::Percent(p) if p != 0.0 => out.push_str(&format!("letter-spacing: {}em;\n", num(p / 100.0))),
+ match s.letter_spacing {
+ LetterSpacing::Pixels(v) if v != 0.0 => put("letter-spacing", tok(doc, n, &["letterSpacing"], px(v), t)),
+ LetterSpacing::Percent(p) if p != 0.0 => put("letter-spacing", format!("{}em", num(p / 100.0))),
_ => {}
}
}
out
}
-// ------------------------------------------------------------------ HTML
+// ------------------------------------------------------------------ HTML and React markup
/// A CSS class name from a layer name ("Card / Title" → "card-title"), unique in `used`.
-fn class_name(name: &str, used: &mut Vec) -> String {
+pub(crate) fn class_name(name: &str, used: &mut Vec) -> String {
let mut base = String::new();
for c in name.chars().flat_map(char::to_lowercase) {
if c.is_ascii_alphanumeric() {
@@ -282,30 +392,111 @@ fn html_escape(s: &str) -> String {
s.replace('&', "&").replace('<', "<").replace('>', ">").replace('\n', " ")
}
+/// Text as JSX children: HTML escapes, braces as expressions, line breaks as ` `.
+fn jsx_text(s: &str) -> String {
+ let mut out = String::new();
+ for c in s.chars().take(10_000) {
+ match c {
+ '&' => out.push_str("&"),
+ '<' => out.push_str("<"),
+ '>' => out.push_str(">"),
+ '{' => out.push_str("{\"{\"}"),
+ '}' => out.push_str("{\"}\"}"),
+ '\n' => out.push_str(" "),
+ '\r' => {}
+ c => out.push(c),
+ }
+ }
+ out
+}
+
/// The whole tree under `id` as nested HTML with a stylesheet: auto layout frames become
/// flexboxes, other children are placed absolutely in their parent, text becomes paragraphs.
fn html(doc: &Document, id: NodeId) -> String {
- let mut out = Html { used: Vec::new(), markup: String::new(), styles: String::new() };
- out.node(doc, id, None, 0);
- format!("{}\n\n", out.markup, out.styles)
+ let ctx = Ctx::new(doc, id);
+ let mut out = Markup::new(&ctx, false);
+ out.node(id, None, 0, "");
+ format!("{}\n\n", out.markup, out.tokens.root_rule(), out.styles)
}
-/// HTML output being built: the class names taken, the markup and the stylesheet.
-struct Html {
+/// Markup being built for a layer tree: HTML (`class`) or React JSX (`className`, component
+/// parameters, instances as calls), with its stylesheet.
+pub(crate) struct Markup<'c, 'a> {
+ ctx: &'c Ctx<'a>,
+ jsx: bool,
used: Vec,
- markup: String,
- styles: String,
+ pub markup: String,
+ pub styles: String,
+ pub tokens: Tokens,
+ /// The root's class (default: from its name) and extra JSX attributes.
+ pub root_class: Option,
+ pub root_attrs: String,
+ /// Each layer's path in the tree (names with sibling counts), class and declarations, so
+ /// variants can be compared layer by layer.
+ pub rules: Vec<(String, String, String)>,
}
-impl Html {
- fn node(&mut self, doc: &Document, id: NodeId, parent: Option<&Node>, depth: usize) {
- let Some(n) = doc.node(id) else { return };
- let class = class_name(&n.name, &mut self.used);
- let pad = indent(depth);
+impl<'c, 'a> Markup<'c, 'a> {
+ pub fn new(ctx: &'c Ctx<'a>, jsx: bool) -> Self {
+ Markup {
+ ctx,
+ jsx,
+ used: Vec::new(),
+ markup: String::new(),
+ styles: String::new(),
+ tokens: Tokens::default(),
+ root_class: None,
+ root_attrs: String::new(),
+ rules: Vec::new(),
+ }
+ }
+
+ /// JSX indents by two spaces, HTML by four.
+ fn pad(&self, depth: usize) -> String {
+ if self.jsx { " ".repeat(depth) } else { indent(depth) }
+ }
+
+ /// A layer, shown only while its boolean parameter is on (JSX).
+ pub fn node(&mut self, id: NodeId, parent: Option<&Node>, depth: usize, path: &str) {
+ let Some(n) = self.ctx.doc.node(id) else { return };
+ if self.jsx
+ && id != self.ctx.root
+ && let Some(p) = self.ctx.param(n, "visible")
+ {
+ let pad = self.pad(depth);
+ self.markup.push_str(&format!("{pad}{{{} && (\n", p.ident));
+ self.element(n, parent, depth + 1, path);
+ self.markup.push_str(&format!("{pad})}}\n"));
+ return;
+ }
+ self.element(n, parent, depth, path);
+ }
+
+ fn rule(&mut self, path: &str, class: &str, decls: String) {
+ self.styles.push_str(&format!(".{class} {{\n"));
+ for line in decls.lines() {
+ self.styles.push_str(&format!(" {line}\n"));
+ }
+ self.styles.push_str("}\n");
+ self.rules.push((path.to_string(), class.to_string(), decls));
+ }
+
+ fn element(&mut self, n: &Node, parent: Option<&Node>, depth: usize, path: &str) {
+ let doc = self.ctx.doc;
+ let root = n.id == self.ctx.root;
+ let class = match self.root_class.take().filter(|_| root) {
+ Some(c) => {
+ self.used.push(c.clone());
+ c
+ }
+ None => class_name(&n.name, &mut self.used),
+ };
+ let pad = self.pad(depth);
+ let attr = if self.jsx { "className" } else { "class" };
// How it sits in its parent: in the flex flow, or placed at its position.
let mut decls = String::new();
let in_flow = parent.is_some_and(|p| p.layout.is_some()) && !n.child_layout.absolute;
- if depth == 0 {
+ if root {
decls.push_str("position: relative;\n");
} else if in_flow {
let vertical = parent.and_then(|p| p.layout.as_ref()).is_some_and(|l| l.mode == LayoutMode::Vertical);
@@ -325,36 +516,71 @@ impl Html {
decls.push_str(&format!("transform: rotate({}deg);\ntransform-origin: 0 0;\n", num(-rot)));
}
}
- let mut own = css_decls(n);
- if n.layout.is_none() && !n.children.is_empty() && depth > 0 && in_flow {
+ // Nested instances are calls (JSX), in a wrapper that places them when needed.
+ if self.jsx && !root && matches!(n.kind, NodeKind::Instance { .. }) {
+ let at = depth + usize::from(!decls.is_empty());
+ let inner = match self.ctx.param(n, "mainComponent") {
+ Some(p) => Some(format!("{}{{{}}}\n", self.pad(at), p.ident)),
+ None => self.ctx.call(n, CodeLang::React, at),
+ };
+ if let Some(inner) = inner {
+ if decls.is_empty() {
+ self.markup.push_str(&inner);
+ } else {
+ self.markup.push_str(&format!("{pad}\n{inner}{pad}
\n"));
+ self.rule(path, &class, decls);
+ }
+ return;
+ }
+ }
+ let hugs = n.layout.is_some() || matches!(n.kind, NodeKind::Text(_));
+ let hug = (hugs && n.child_layout.sizing_h == Sizing::Hug, hugs && n.child_layout.sizing_v == Sizing::Hug);
+ let mut own = css_decls(doc, n, &mut self.tokens, hug);
+ if n.layout.is_none() && !n.children.is_empty() && !root && in_flow {
own.push_str("position: relative;\n");
}
- // Hugging sides take their content's size.
- if n.layout.is_some() || matches!(n.kind, NodeKind::Text(_)) {
- if n.child_layout.sizing_h == Sizing::Hug {
- own = own.replace(&format!("width: {}px;\n", num(n.width)), "");
- }
- if n.child_layout.sizing_v == Sizing::Hug {
- own = own.replace(&format!("height: {}px;\n", num(n.height)), "");
- }
- }
decls.push_str(&own);
- self.styles.push_str(&format!(".{class} {{\n"));
- for line in decls.lines() {
- self.styles.push_str(&format!(" {line}\n"));
- }
- self.styles.push_str("}\n");
+ self.rule(path, &class, decls);
+ let attrs = if root { self.root_attrs.clone() } else { String::new() };
+ let open = |tag: &str| format!("{pad}<{tag} {attr}=\"{class}\"{attrs}>");
match &n.kind {
- NodeKind::Text(t) => self.markup.push_str(&format!("{pad}{}
\n", html_escape(&t.characters))),
+ NodeKind::Text(t) => {
+ let body = match self.ctx.param(n, "characters").filter(|_| self.jsx) {
+ Some(p) => format!("{{{}}}", p.ident),
+ None if self.jsx => jsx_text(&t.characters),
+ None => html_escape(&t.characters),
+ };
+ self.markup.push_str(&format!("{}{body}\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%)**