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"], "