feat(dev-mode): Leptos code with component properties, instance calls and Code Connect (#240)

* feat(dev-mode): Leptos code for a layer tree (#223)

inspect.code takes language: leptos, and the Inspect panel lists it: the
HTML markup inside a #[component] fn's view!, with the stylesheet after it
as for React. Text is Rust string literals (line breaks as <br/>) so the
macro parses it; px or rem like the other web languages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(dev-mode): Leptos components take their properties, instances are calls (#223)

Leptos joins React in component code: a component or set is a #[component]
fn with #[prop] defaults (variant enums with as_str, bool, String, ViewFn
for instance swaps, Option<ViewFn> for slots), layers bound to booleans
show through .then, variants differ by data-* CSS rules, and instances
(also inside layer trees) are calls passing the values they change. Names
are snake_case, Rust keywords get a trailing _. Code Connect takes leptos
too (use <path>::<Name>; view! markup). Markup now takes the language
instead of jsx/leptos flags; React and Leptos share the stylesheet pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Justin John <babyjjbabyjj549@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bflatastic <bombayapirat@gmail.com>
This commit is contained in:
Justin John 2026-10-14 09:02:14 +05:30 committed by GitHub
parent fb8ace5bf4
commit 24c5617230
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 348 additions and 91 deletions

View File

@ -191,7 +191,7 @@ exposes to agents, and exported at 2x by FigCraft's own renderer. The source fil
</td> </td>
<td width="50%" valign="top"> <td width="50%" valign="top">
<h3>Prototype and hand off</h3> <h3>Prototype and hand off</h3>
Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like <code>count + 1</code>), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations in colour-coded categories, a ready-for-dev list with a focus view and Compare changes (before / after with property-level diffs since a frame was marked ready), 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. Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like <code>count + 1</code>), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations in colour-coded categories, a ready-for-dev list with a focus view and Compare changes (before / after with property-level diffs since a frame was marked ready), and code (CSS, HTML, React, Leptos, 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.
</td> </td>
</tr> </tr>
</table> </table>

View File

@ -37,7 +37,7 @@ const MAX_WARNINGS: usize = 50;
/// The languages a component can be connected in. /// The languages a component can be connected in.
pub fn language(s: &str) -> Option<CodeLang> { pub fn language(s: &str) -> Option<CodeLang> {
CodeLang::from_id(s).filter(|l| matches!(l, CodeLang::React | CodeLang::SwiftUi | CodeLang::Compose)) CodeLang::from_id(s).filter(|l| matches!(l, CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose))
} }
/// The layer `id`'s links are stored on: its component set, else its component (an instance's /// The layer `id`'s links are stored on: its component set, else its component (an instance's
@ -65,6 +65,7 @@ pub fn import_line(link: &CodeLink, lang: CodeLang) -> String {
match lang { match lang {
_ if path.is_empty() => String::new(), _ if path.is_empty() => String::new(),
CodeLang::React => format!("import {{ {} }} from {};", link.name.split('.').next().unwrap_or_default(), js_string(path)), CodeLang::React => format!("import {{ {} }} from {};", link.name.split('.').next().unwrap_or_default(), js_string(path)),
CodeLang::Leptos => format!("use {path}::{};", link.name.split('.').next().unwrap_or_default()),
_ => format!("import {path}"), _ => format!("import {path}"),
} }
} }
@ -172,6 +173,7 @@ fn expr(doc: &Document, c: &Component, p: &Param, v: &Value, lang: CodeLang) ->
let swapped = (p.kind == ParamKind::InstanceSwap).then(|| id_of(v)).flatten().and_then(|i| link(doc, i, lang)); let swapped = (p.kind == ParamKind::InstanceSwap).then(|| id_of(v)).flatten().and_then(|i| link(doc, i, lang));
match (swapped, lang) { match (swapped, lang) {
(Some(l), CodeLang::React) => format!("<{} />", l.name), (Some(l), CodeLang::React) => format!("<{} />", l.name),
(Some(l), CodeLang::Leptos) => format!("|| view! {{ <{} /> }}", l.name),
(Some(l), CodeLang::SwiftUi) => format!("{}()", l.name), (Some(l), CodeLang::SwiftUi) => format!("{}()", l.name),
(Some(l), _) => format!("{{ {}() }}", l.name), (Some(l), _) => format!("{{ {}() }}", l.name),
(None, _) => value_expr(doc, c, p, v, lang), (None, _) => value_expr(doc, c, p, v, lang),
@ -191,9 +193,10 @@ pub(crate) fn render(doc: &Document, c: &Component, values: &[(String, Value)],
Piece::Value { name, inner } => match find(c, values, name) { Piece::Value { name, inner } => match find(c, values, name) {
Some((p, v)) => { Some((p, v)) => {
let e = expr(doc, c, p, v, lang); let e = expr(doc, c, p, v, lang);
// JSX: a plain string reads best as an attribute value, anything else is an // JSX and `view!`: a plain string reads best as an attribute value, anything
// expression in braces (JSX attribute strings have no escapes). // else is an expression in braces (JSX attribute strings have no escapes).
if lang != CodeLang::React || (out.ends_with('=') && e.starts_with('"') && !e.contains('\\')) { let markup = matches!(lang, CodeLang::React | CodeLang::Leptos);
if !markup || (out.ends_with('=') && e.starts_with('"') && !e.contains('\\')) {
out.push_str(&e); out.push_str(&e);
} else { } else {
out.push_str(&format!("{{{e}}}")); out.push_str(&format!("{{{e}}}"));
@ -215,6 +218,9 @@ fn default_template(c: &Component, lang: CodeLang, name: &str) -> String {
let params: Vec<&str> = c.params.iter().filter(|p| p.kind != ParamKind::Slot).map(|p| p.ident.as_str()).collect(); let params: Vec<&str> = c.params.iter().filter(|p| p.kind != ParamKind::Slot).map(|p| p.ident.as_str()).collect();
match lang { match lang {
CodeLang::React => format!("<{name}{} />", params.iter().map(|p| format!(" {p}={{{p}}}")).collect::<String>()), CodeLang::React => format!("<{name}{} />", params.iter().map(|p| format!(" {p}={{{p}}}")).collect::<String>()),
CodeLang::Leptos => {
format!("<{name}{} />", params.iter().map(|p| format!(" {}={{{p}}}", crate::component_codegen::rust_ident(p))).collect::<String>())
}
CodeLang::SwiftUi => format!("{name}({})", params.iter().map(|p| format!("{p}: {{{p}}}")).collect::<Vec<_>>().join(", ")), CodeLang::SwiftUi => format!("{name}({})", params.iter().map(|p| format!("{p}: {{{p}}}")).collect::<Vec<_>>().join(", ")),
_ => format!("{name}({})", params.iter().map(|p| format!("{p} = {{{p}}}")).collect::<Vec<_>>().join(", ")), _ => format!("{name}({})", params.iter().map(|p| format!("{p} = {{{p}}}")).collect::<Vec<_>>().join(", ")),
} }
@ -234,11 +240,13 @@ pub fn check(c: &Component, link: &CodeLink, lang: CodeLang) -> std::result::Res
let path = &link.import_path; let path = &link.import_path;
let path_ok = match lang { let path_ok = match lang {
CodeLang::React => !path.chars().any(|ch| ch.is_control() || ch == '"' || ch == '\\'), CodeLang::React => !path.chars().any(|ch| ch.is_control() || ch == '"' || ch == '\\'),
CodeLang::Leptos => path.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == ':'),
_ => path.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '.'), _ => path.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '.'),
}; };
if path.chars().count() > MAX_IMPORT || !path_ok { if path.chars().count() > MAX_IMPORT || !path_ok {
return Err(match lang { return Err(match lang {
CodeLang::React => format!("importPath: a module path without quotes, up to {MAX_IMPORT} characters"), CodeLang::React => format!("importPath: a module path without quotes, up to {MAX_IMPORT} characters"),
CodeLang::Leptos => format!("importPath: a Rust module path (letters, digits, _ and ::), up to {MAX_IMPORT} characters"),
_ => format!("importPath: a module or package name (letters, digits, _ and .), up to {MAX_IMPORT} characters"), _ => format!("importPath: a module or package name (letters, digits, _ and .), up to {MAX_IMPORT} characters"),
}); });
} }
@ -274,7 +282,7 @@ fn text(cmd: &str, p: &Value, key: &str) -> Result<Option<String>> {
/// The `language` parameter (default React). /// The `language` parameter (default React).
fn lang_param(cmd: &str, p: &Value) -> Result<CodeLang> { fn lang_param(cmd: &str, p: &Value) -> Result<CodeLang> {
match text(cmd, p, "language")? { match text(cmd, p, "language")? {
Some(l) => language(&l).ok_or_else(|| bad(cmd, "language: react|swiftui|compose")), Some(l) => language(&l).ok_or_else(|| bad(cmd, "language: react|leptos|swiftui|compose")),
None => Ok(CodeLang::React), None => Ok(CodeLang::React),
} }
} }
@ -304,7 +312,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!( spec!(
"codeConnect.set", "codeConnect.set",
"Connect to code", "Connect to code",
"{id? (component, set or instance; default the selection), language?: react|swiftui|compose (default react), importPath?, name? (default the generated name), template? (default a call passing every property)} links the component (its set, for a variant) to its code component; fields left out keep their current value. Placeholders: {property} (its name or parameter name) and {property ? \"a\" : \"b\"}; other {…} stay as written → {component, language, code (the snippet for the layer), unknown (placeholder names that aren't properties)}", "{id? (component, set or instance; default the selection), language?: react|leptos|swiftui|compose (default react), importPath?, name? (default the generated name), template? (default a call passing every property)} links the component (its set, for a variant) to its code component; fields left out keep their current value. Placeholders: {property} (its name or parameter name) and {property ? \"a\" : \"b\"}; other {…} stay as written → {component, language, code (the snippet for the layer), unknown (placeholder names that aren't properties)}",
true, true,
|s, p| { |s, p| {
let cmd = "codeConnect.set"; let cmd = "codeConnect.set";
@ -419,7 +427,7 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
let (Some(component), Some(lang), Some(name), Some(template)) = let (Some(component), Some(lang), Some(name), Some(template)) =
(field("component"), field("language").and_then(language), field("name"), e.get("template").and_then(Value::as_str)) (field("component"), field("language").and_then(language), field("name"), e.get("template").and_then(Value::as_str))
else { else {
warnings.push(format!("link {}: needs component, language (react|swiftui|compose), name and template strings", i + 1)); warnings.push(format!("link {}: needs component, language (react|leptos|swiftui|compose), name and template strings", i + 1));
continue; continue;
}; };
let link = CodeLink { let link = CodeLink {

View File

@ -65,6 +65,15 @@ fn connected_instances_show_the_real_snippet() {
); );
assert_eq!(code(&mut s, i, "swiftui"), "import AcmeUI\n\nCard(title: \"Say \\\"hi\\\"\", style: .plain)\n"); assert_eq!(code(&mut s, i, "swiftui"), "import AcmeUI\n\nCard(title: \"Say \\\"hi\\\"\", style: .plain)\n");
assert!(code(&mut s, i, "compose").contains("@Composable"), "other languages stay generated"); assert!(code(&mut s, i, "compose").contains("@Composable"), "other languages stay generated");
// Leptos: a `use` line and `view!` markup (strings with escapes in braces).
ex(
&mut s,
"codeConnect.set",
json!({"id": c, "language": "leptos", "importPath": "crate::ui", "template": "<Card title={title} dotted={Show dot} />"}),
);
assert_eq!(code(&mut s, i, "leptos"), "use crate::ui::Card;\n\n<Card title={\"Say \\\"hi\\\"\"} dotted={false} />\n");
assert!(s.execute("codeConnect.set", &json!({"id": c, "language": "leptos", "importPath": "crate/ui"})).is_err(), "a Rust path");
ex(&mut s, "codeConnect.remove", json!({"id": c, "language": "leptos"}));
// inspect.componentCode: the snippet as the usage, and the link. // inspect.componentCode: the snippet as the usage, and the link.
let v = ex(&mut s, "inspect.componentCode", json!({"id": i, "language": "swiftui"})); let v = ex(&mut s, "inspect.componentCode", json!({"id": i, "language": "swiftui"}));
assert_eq!(v["usage"], "Card(title: \"Say \\\"hi\\\"\", style: .plain)\n"); assert_eq!(v["usage"], "Card(title: \"Say \\\"hi\\\"\", style: .plain)\n");

View File

@ -18,13 +18,13 @@ pub fn specs() -> Vec<CommandSpec> {
vec![spec!( vec![spec!(
"inspect.code", "inspect.code",
"Code for layer", "Code for layer",
"{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; a component connected to code (codeConnect.set) gives its import and snippet instead), unit?: px|rem (CSS, HTML and React; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}", "{id?, language?: css|html|react|leptos|swiftui|compose (default css; html, react and leptos are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead), unit?: px|rem (CSS, HTML, React and Leptos; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}",
false, false,
|s, p| { |s, p| {
let cmd = "inspect.code"; let cmd = "inspect.code";
let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a layer"))?; let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a layer"))?;
let lang = match string(p, "language") { let lang = match string(p, "language") {
Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|swiftui|compose"))?, Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|swiftui|compose"))?,
None => CodeLang::Css, None => CodeLang::Css,
}; };
let rem = match string(p, "unit").map(|u| u.to_ascii_lowercase()).as_deref() { let rem = match string(p, "unit").map(|u| u.to_ascii_lowercase()).as_deref() {
@ -42,7 +42,7 @@ pub fn specs() -> Vec<CommandSpec> {
let connected = crate::code_connect::link(doc, id, lang).is_some(); let connected = crate::code_connect::link(doc, id, lang).is_some();
let code = code_for(doc, id, lang); let code = code_for(doc, id, lang);
let code = match rem { let code = match rem {
Some(root) if matches!(lang, CodeLang::Css | CodeLang::Html | CodeLang::React) => px_to_rem(&code, root), Some(root) if lang.is_web() => px_to_rem(&code, root),
_ => code, _ => code,
}; };
Ok(json!({"code": code, "language": lang.id(), "connected": connected})) Ok(json!({"code": code, "language": lang.id(), "connected": connected}))
@ -59,17 +59,20 @@ pub enum CodeLang {
Html, Html,
/// A React function component (TSX) with its stylesheet. /// A React function component (TSX) with its stylesheet.
React, React,
/// A Leptos component (`view!` markup) with its stylesheet.
Leptos,
SwiftUi, SwiftUi,
Compose, Compose,
} }
impl CodeLang { impl CodeLang {
pub const ALL: [CodeLang; 5] = [CodeLang::Css, CodeLang::Html, CodeLang::React, CodeLang::SwiftUi, CodeLang::Compose]; pub const ALL: [CodeLang; 6] = [CodeLang::Css, CodeLang::Html, CodeLang::React, CodeLang::Leptos, CodeLang::SwiftUi, CodeLang::Compose];
pub fn id(self) -> &'static str { pub fn id(self) -> &'static str {
match self { match self {
CodeLang::Css => "css", CodeLang::Css => "css",
CodeLang::Html => "html", CodeLang::Html => "html",
CodeLang::React => "react", CodeLang::React => "react",
CodeLang::Leptos => "leptos",
CodeLang::SwiftUi => "swiftui", CodeLang::SwiftUi => "swiftui",
CodeLang::Compose => "compose", CodeLang::Compose => "compose",
} }
@ -79,10 +82,15 @@ impl CodeLang {
CodeLang::Css => "CSS", CodeLang::Css => "CSS",
CodeLang::Html => "HTML + CSS", CodeLang::Html => "HTML + CSS",
CodeLang::React => "React", CodeLang::React => "React",
CodeLang::Leptos => "Leptos",
CodeLang::SwiftUi => "SwiftUI", CodeLang::SwiftUi => "SwiftUI",
CodeLang::Compose => "Compose", CodeLang::Compose => "Compose",
} }
} }
/// Languages whose lengths are CSS pixels (they can be shown in rem).
pub fn is_web(self) -> bool {
matches!(self, CodeLang::Css | CodeLang::Html | CodeLang::React | CodeLang::Leptos)
}
pub fn from_id(s: &str) -> Option<CodeLang> { pub fn from_id(s: &str) -> Option<CodeLang> {
if s.eq_ignore_ascii_case("tsx") || s.eq_ignore_ascii_case("jsx") { if s.eq_ignore_ascii_case("tsx") || s.eq_ignore_ascii_case("jsx") {
return Some(CodeLang::React); return Some(CodeLang::React);
@ -136,7 +144,7 @@ pub fn code_for(doc: &Document, id: NodeId, lang: CodeLang) -> String {
match lang { match lang {
CodeLang::Css => css(doc, id), CodeLang::Css => css(doc, id),
CodeLang::Html => html(doc, id), CodeLang::Html => html(doc, id),
CodeLang::React | CodeLang::SwiftUi | CodeLang::Compose => crate::component_codegen::code(doc, id, lang), CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose => crate::component_codegen::code(doc, id, lang),
} }
} }
@ -561,16 +569,18 @@ fn jsx_text(s: &str) -> String {
/// flexboxes, other children are placed absolutely in their parent, text becomes paragraphs. /// flexboxes, other children are placed absolutely in their parent, text becomes paragraphs.
fn html(doc: &Document, id: NodeId) -> String { fn html(doc: &Document, id: NodeId) -> String {
let ctx = Ctx::new(doc, id); let ctx = Ctx::new(doc, id);
let mut out = Markup::new(&ctx, false); let mut out = Markup::new(&ctx, CodeLang::Html);
out.node(id, None, 0, ""); out.node(id, None, 0, "");
format!("{}\n<style>\n{}{}</style>\n", out.markup, out.tokens.root_rule(), out.styles) format!("{}\n<style>\n{}{}</style>\n", out.markup, out.tokens.root_rule(), out.styles)
} }
/// Markup being built for a layer tree: HTML (`class`) or React JSX (`className`, component /// Markup being built for a layer tree: HTML (`class`), React JSX (`className`) or Leptos `view!`
/// parameters, instances as calls), with its stylesheet. /// markup (text as Rust string literals); the last two with component parameters and instances
/// as calls. With its stylesheet.
pub(crate) struct Markup<'c, 'a> { pub(crate) struct Markup<'c, 'a> {
ctx: &'c Ctx<'a>, ctx: &'c Ctx<'a>,
jsx: bool, /// Html, React or Leptos.
lang: CodeLang,
used: Vec<String>, used: Vec<String>,
pub markup: String, pub markup: String,
pub styles: String, pub styles: String,
@ -584,10 +594,10 @@ pub(crate) struct Markup<'c, 'a> {
} }
impl<'c, 'a> Markup<'c, 'a> { impl<'c, 'a> Markup<'c, 'a> {
pub fn new(ctx: &'c Ctx<'a>, jsx: bool) -> Self { pub fn new(ctx: &'c Ctx<'a>, lang: CodeLang) -> Self {
Markup { Markup {
ctx, ctx,
jsx, lang,
used: Vec::new(), used: Vec::new(),
markup: String::new(), markup: String::new(),
styles: String::new(), styles: String::new(),
@ -598,22 +608,34 @@ impl<'c, 'a> Markup<'c, 'a> {
} }
} }
/// JSX indents by two spaces, HTML by four. /// JSX indents by two spaces, HTML and Leptos by four.
fn pad(&self, depth: usize) -> String { fn pad(&self, depth: usize) -> String {
if self.jsx { " ".repeat(depth) } else { indent(depth) } if self.lang == CodeLang::React { " ".repeat(depth) } else { indent(depth) }
} }
/// A layer, shown only while its boolean parameter is on (JSX). /// Component code (React or Leptos): parameters and instances as calls.
fn code(&self) -> bool {
self.lang != CodeLang::Html
}
/// A parameter's name in the generated code.
fn ident(&self, p: &Param) -> String {
if self.lang == CodeLang::Leptos { crate::component_codegen::rust_ident(&p.ident) } else { p.ident.clone() }
}
/// A layer, shown only while its boolean parameter is on (component code).
pub fn node(&mut self, id: NodeId, parent: Option<&Node>, depth: usize, path: &str) { pub fn node(&mut self, id: NodeId, parent: Option<&Node>, depth: usize, path: &str) {
let Some(n) = self.ctx.doc.node(id) else { return }; let Some(n) = self.ctx.doc.node(id) else { return };
if self.jsx if self.code()
&& id != self.ctx.root && id != self.ctx.root
&& let Some(p) = self.ctx.param(n, "visible") && let Some(p) = self.ctx.param(n, "visible")
{ {
let pad = self.pad(depth); let (pad, p) = (self.pad(depth), self.ident(p));
self.markup.push_str(&format!("{pad}{{{} && (\n", p.ident)); let (open, close) =
if self.lang == CodeLang::Leptos { (format!("{{{p}.then(|| view! {{"), "})}") } else { (format!("{{{p} && ("), ")}") };
self.markup.push_str(&format!("{pad}{open}\n"));
self.element(n, parent, depth + 1, path); self.element(n, parent, depth + 1, path);
self.markup.push_str(&format!("{pad})}}\n")); self.markup.push_str(&format!("{pad}{close}\n"));
return; return;
} }
self.element(n, parent, depth, path); self.element(n, parent, depth, path);
@ -639,7 +661,7 @@ impl<'c, 'a> Markup<'c, 'a> {
None => class_name(&n.name, &mut self.used), None => class_name(&n.name, &mut self.used),
}; };
let pad = self.pad(depth); let pad = self.pad(depth);
let attr = if self.jsx { "className" } else { "class" }; let attr = if self.lang == CodeLang::React { "className" } else { "class" };
// How it sits in its parent: in the flex flow, or placed at its position. // How it sits in its parent: in the flex flow, or placed at its position.
let mut decls = String::new(); let mut decls = String::new();
let in_flow = parent.is_some_and(|p| p.layout.is_some()) && !n.child_layout.absolute; let in_flow = parent.is_some_and(|p| p.layout.is_some()) && !n.child_layout.absolute;
@ -663,12 +685,13 @@ impl<'c, 'a> Markup<'c, 'a> {
decls.push_str(&format!("transform: rotate({}deg);\ntransform-origin: 0 0;\n", num(-rot))); decls.push_str(&format!("transform: rotate({}deg);\ntransform-origin: 0 0;\n", num(-rot)));
} }
} }
// Nested instances are calls (JSX), in a wrapper that places them when needed. // Nested instances are calls (component code), in a wrapper that places them when needed.
if self.jsx && !root && matches!(n.kind, NodeKind::Instance { .. }) { if self.code() && !root && matches!(n.kind, NodeKind::Instance { .. }) {
let at = depth + usize::from(!decls.is_empty()); let at = depth + usize::from(!decls.is_empty());
let run = if self.lang == CodeLang::Leptos { ".run()" } else { "" };
let inner = match self.ctx.param(n, "mainComponent") { let inner = match self.ctx.param(n, "mainComponent") {
Some(p) => Some(format!("{}{{{}}}\n", self.pad(at), p.ident)), Some(p) => Some(format!("{}{{{}{run}}}\n", self.pad(at), self.ident(p))),
None => self.ctx.call(n, CodeLang::React, at), None => self.ctx.call(n, self.lang, at),
}; };
if let Some(inner) = inner { if let Some(inner) = inner {
if decls.is_empty() { if decls.is_empty() {
@ -692,19 +715,26 @@ impl<'c, 'a> Markup<'c, 'a> {
let open = |tag: &str| format!("{pad}<{tag} {attr}=\"{class}\"{attrs}>"); let open = |tag: &str| format!("{pad}<{tag} {attr}=\"{class}\"{attrs}>");
match &n.kind { match &n.kind {
NodeKind::Text(t) => { NodeKind::Text(t) => {
let body = match self.ctx.param(n, "characters").filter(|_| self.jsx) { let body = match (self.ctx.param(n, "characters").filter(|_| self.code()), self.lang) {
Some(p) => format!("{{{}}}", p.ident), (Some(p), CodeLang::Leptos) => format!("{{{}.clone()}}", self.ident(p)),
None if self.jsx => jsx_text(&t.characters), (Some(p), _) => format!("{{{}}}", p.ident),
None => html_escape(&t.characters), (None, CodeLang::React) => jsx_text(&t.characters),
// `view!` takes text as string literals and void elements self-closed.
(None, CodeLang::Leptos) => {
t.characters.chars().take(10_000).collect::<String>().lines().map(|l| format!("{l:?}")).collect::<Vec<_>>().join("<br/>")
}
(None, _) => html_escape(&t.characters),
}; };
self.markup.push_str(&format!("{}{body}</p>\n", open("p"))); self.markup.push_str(&format!("{}{body}</p>\n", open("p")));
} }
_ => { _ => {
let kids = self.ctx.kids(n); let kids = self.ctx.kids(n);
let slot = self.ctx.param(n, "slot").filter(|_| self.jsx); let slot = self.ctx.param(n, "slot").filter(|_| self.code());
let leptos = self.lang == CodeLang::Leptos;
let open = open("div"); let open = open("div");
if let Some(p) = slot.filter(|_| kids.is_empty()) { if let Some(p) = slot.filter(|_| kids.is_empty()) {
self.markup.push_str(&format!("{open}{{{}}}</div>\n", p.ident)); let content = if leptos { format!("{}.as_ref().map(|c| c.run())", self.ident(p)) } else { p.ident.clone() };
self.markup.push_str(&format!("{open}{{{content}}}</div>\n"));
} else if kids.is_empty() || self.ctx.too_deep(depth) { } else if kids.is_empty() || self.ctx.too_deep(depth) {
self.markup.push_str(&format!("{open}</div>\n")); self.markup.push_str(&format!("{open}</div>\n"));
} else { } else {
@ -712,6 +742,10 @@ impl<'c, 'a> Markup<'c, 'a> {
// A slot shows the instance's content, or else the main's. // A slot shows the instance's content, or else the main's.
let (p1, p2) = (self.pad(depth + 1), self.pad(depth + 2)); let (p1, p2) = (self.pad(depth + 1), self.pad(depth + 2));
let inner = match slot { let inner = match slot {
Some(p) if leptos => {
self.markup.push_str(&format!("{p1}{{match &{} {{ Some(c) => c.run(), None => view! {{\n", self.ident(p)));
depth + 2
}
Some(p) => { Some(p) => {
self.markup.push_str(&format!("{p1}{{{} ?? (\n{p2}<>\n", p.ident)); self.markup.push_str(&format!("{p1}{{{} ?? (\n{p2}<>\n", p.ident));
depth + 3 depth + 3
@ -726,8 +760,10 @@ impl<'c, 'a> Markup<'c, 'a> {
seen.push(&kn.name); seen.push(&kn.name);
self.node(k, Some(n), inner, &format!("{path}/{}#{count}", kn.name)); self.node(k, Some(n), inner, &format!("{path}/{}#{count}", kn.name));
} }
if slot.is_some() { match slot {
self.markup.push_str(&format!("{p2}</>\n{p1})}}\n")); Some(_) if leptos => self.markup.push_str(&format!("{p1}}}.into_any() }}}}\n")),
Some(_) => self.markup.push_str(&format!("{p2}</>\n{p1})}}\n")),
None => {}
} }
self.markup.push_str(&format!("{pad}</div>\n")); self.markup.push_str(&format!("{pad}</div>\n"));
} }

View File

@ -1,9 +1,9 @@
//! Component code: a component (or component set) becomes a React, SwiftUI or Jetpack Compose //! Component code: a component (or component set) becomes a React, Leptos, SwiftUI or Jetpack
//! component whose properties are its parameters (a variant property → a union or enum, boolean → //! 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 → //! 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 //! optional content), and an instance becomes a call passing the values it sets. Variants of a set
//! share one definition (the set's default variant); in React their differences become CSS rules on //! share one definition (the set's default variant); in React and Leptos their differences become CSS
//! `data-*` attributes. Returned by `inspect.componentCode` and used by `inspect.code`. A component //! rules on `data-*` attributes. Returned by `inspect.componentCode` and used by `inspect.code`. A component
//! connected to code (see [`crate::code_connect`]) shows its real snippet instead. //! connected to code (see [`crate::code_connect`]) shows its real snippet instead.
use figcraft_doc::*; use figcraft_doc::*;
@ -67,14 +67,14 @@ pub fn specs() -> Vec<CommandSpec> {
vec![spec!( vec![spec!(
"inspect.componentCode", "inspect.componentCode",
"Component code", "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; the connected snippet when the component is connected to code), connected: {importPath, name, template, import, code} | null}", "{id? (component, set or instance; default the selection), language?: react|leptos|swiftui|compose (default react)} → {component, name, set, properties: [{name, param, type, default, value? (instances)}], definition, usage? (instances; the connected snippet when the component is connected to code), connected: {importPath, name, template, import, code} | null}",
false, false,
|s, p| { |s, p| {
let cmd = "inspect.componentCode"; let cmd = "inspect.componentCode";
let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component or instance"))?; 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)) { let lang = match string(p, "language").map(|l| CodeLang::from_id(&l)) {
Some(Some(l @ (CodeLang::React | CodeLang::SwiftUi | CodeLang::Compose))) => l, Some(Some(l @ (CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose))) => l,
Some(_) => return Err(bad(cmd, "language: react|swiftui|compose")), Some(_) => return Err(bad(cmd, "language: react|leptos|swiftui|compose")),
None => CodeLang::React, None => CodeLang::React,
}; };
let doc = s.doc()?; let doc = s.doc()?;
@ -107,9 +107,9 @@ pub fn specs() -> Vec<CommandSpec> {
)] )]
} }
/// Code for any layer in React, SwiftUI or Compose: an instance's call and its component's /// Code for any layer in React, Leptos, 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 /// definition, a component's (or set's) definition, or the layer tree (React and Leptos wrap it
/// function component). /// in a function component).
pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String { pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String {
let Some(n) = doc.node(id) else { return String::new() }; let Some(n) = doc.node(id) else { return String::new() };
if let Some(code) = crate::code_connect::link(doc, id, lang).and_then(|l| crate::code_connect::connected_code(doc, id, lang, l)) { if let Some(code) = crate::code_connect::link(doc, id, lang).and_then(|l| crate::code_connect::connected_code(doc, id, lang, l)) {
@ -120,7 +120,10 @@ pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String {
(NodeKind::Instance { .. }, Some(c)) => format!("{}\n{}", usage(doc, id, lang, 0).unwrap_or_default(), definition(doc, &c, lang)), (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), (NodeKind::Component { .. } | NodeKind::ComponentSet { .. }, Some(c)) => definition(doc, &c, lang),
_ => match lang { _ => match lang {
CodeLang::React => react(doc, &Component { comp: id, set: None, type_name: component_type_name(&n.name), params: Vec::new() }), CodeLang::React | CodeLang::Leptos => {
let c = Component { comp: id, set: None, type_name: component_type_name(&n.name), params: Vec::new() };
if lang == CodeLang::Leptos { leptos(doc, &c) } else { react(doc, &c) }
}
CodeLang::SwiftUi => { CodeLang::SwiftUi => {
let mut out = String::new(); let mut out = String::new();
swift(&Ctx::new(doc, id), id, 0, &mut out, &[]); swift(&Ctx::new(doc, id), id, 0, &mut out, &[]);
@ -358,6 +361,52 @@ fn ident(name: &str, used: &mut Vec<String>) -> String {
out out
} }
/// Rust's keywords, and `children` (Leptos passes a component's children by that name).
const RUST_RESERVED: &[&str] = &[
"abstract", "as", "async", "await", "become", "box", "break", "children", "const", "continue", "crate", "do", "dyn", "else", "enum", "extern",
"false", "final", "fn", "for", "gen", "if", "impl", "in", "let", "loop", "macro", "match", "mod", "move", "mut", "override", "priv", "pub",
"ref", "return", "self", "static", "struct", "super", "trait", "true", "try", "type", "typeof", "unsafe", "unsized", "use", "virtual", "where",
"while", "yield",
];
/// A parameter's Rust name: its camelCase name in snake_case (`showIcon` → `show_icon`), with a
/// trailing `_` when Rust reserves it (`type_`; camelCase names have no `_`, so it stays unique).
pub(crate) fn rust_ident(ident: &str) -> String {
let mut out = String::new();
for (i, ch) in ident.chars().enumerate() {
if ch.is_ascii_uppercase() {
if i > 0 {
out.push('_');
}
out.push(ch.to_ascii_lowercase());
} else {
out.push(ch);
}
}
if RUST_RESERVED.contains(&out.as_str()) {
out.push('_');
}
out
}
/// A Rust type name: `Self` is taken.
fn rust_type(t: &str) -> String {
if t == "Self" { "SelfComponent".into() } else { t.to_string() }
}
/// A Leptos variant enum's name: the component's name and the property's, never the props struct
/// `#[component]` makes (`ButtonProps`).
fn leptos_enum(c: &Component, p: &Param) -> String {
let t = rust_type(&c.type_name);
let name = format!("{t}{}", p.enum_name);
if name == format!("{t}Props") { format!("{name}Variant") } else { name }
}
/// Rust enum variants for variant values: [`cases`] in PascalCase, `Self` taken.
fn rust_cases(values: &[String]) -> Vec<String> {
cases(values, false).into_iter().map(|c| if c == "Self" { "SelfValue".into() } else { c }).collect()
}
/// Enum case names for variant values, unique: Swift's lowerCamel (`.small`, `` .`default` ``), /// Enum case names for variant values, unique: Swift's lowerCamel (`.small`, `` .`default` ``),
/// Kotlin's PascalCase (`Small`). /// Kotlin's PascalCase (`Small`).
fn cases(values: &[String], swift: bool) -> Vec<String> { fn cases(values: &[String], swift: bool) -> Vec<String> {
@ -383,6 +432,11 @@ fn cases(values: &[String], swift: bool) -> Vec<String> {
.collect() .collect()
} }
/// A Rust string literal.
pub(crate) fn rust_string(s: &str) -> String {
format!("{:?}", s.chars().take(10_000).collect::<String>())
}
/// A JavaScript string literal. /// A JavaScript string literal.
pub(crate) fn js_string(s: &str) -> String { pub(crate) fn js_string(s: &str) -> String {
let s: String = s.chars().take(10_000).collect(); let s: String = s.chars().take(10_000).collect();
@ -405,6 +459,11 @@ pub(crate) fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value
match (&p.kind, lang) { match (&p.kind, lang) {
(ParamKind::Boolean, _) => value.as_bool().unwrap_or(false).to_string(), (ParamKind::Boolean, _) => value.as_bool().unwrap_or(false).to_string(),
(ParamKind::Text, CodeLang::React) => js_string(value.as_str().unwrap_or("")), (ParamKind::Text, CodeLang::React) => js_string(value.as_str().unwrap_or("")),
(ParamKind::Text, CodeLang::Leptos) => rust_string(value.as_str().unwrap_or("")),
(ParamKind::Variant(opts), CodeLang::Leptos) => {
let i = opts.iter().position(|o| Some(o.as_str()) == value.as_str()).unwrap_or(0);
format!("{}::{}", leptos_enum(c, p), rust_cases(opts).get(i).cloned().unwrap_or_else(|| "Unknown".into()))
}
(ParamKind::Text, l) => literal(value.as_str().unwrap_or(""), l == CodeLang::Compose), (ParamKind::Text, l) => literal(value.as_str().unwrap_or(""), l == CodeLang::Compose),
(ParamKind::Variant(opts), l) => { (ParamKind::Variant(opts), l) => {
let v = value.as_str().unwrap_or(""); let v = value.as_str().unwrap_or("");
@ -420,6 +479,8 @@ pub(crate) fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value
let t = id_of(value).and_then(|i| component_type(doc, i)); let t = id_of(value).and_then(|i| component_type(doc, i));
match (t, l) { match (t, l) {
(Some(t), CodeLang::React) => format!("<{t} />"), (Some(t), CodeLang::React) => format!("<{t} />"),
(Some(t), CodeLang::Leptos) => format!("|| view! {{ <{} /> }}", rust_type(&t)),
(None, CodeLang::Leptos) => "|| ()".into(),
(Some(t), CodeLang::SwiftUi) => format!("AnyView({t}())"), (Some(t), CodeLang::SwiftUi) => format!("AnyView({t}())"),
(Some(t), _) => format!("{{ {t}() }}"), (Some(t), _) => format!("{{ {t}() }}"),
(None, CodeLang::React) => "null".into(), (None, CodeLang::React) => "null".into(),
@ -486,10 +547,17 @@ pub fn usage(doc: &Document, id: NodeId, lang: CodeLang, depth: usize) -> Option
} }
let t = &c.type_name; let t = &c.type_name;
Some(match lang { Some(match lang {
CodeLang::React => { CodeLang::React | CodeLang::Leptos => {
let leptos = lang == CodeLang::Leptos;
// Plain strings read best as attribute values. // Plain strings read best as attribute values.
let attrs: String = let attrs: String = args
args.iter().map(|(name, expr, plain)| if *plain { format!(" {name}={expr}") } else { format!(" {name}={{{expr}}}") }).collect(); .iter()
.map(|(name, expr, plain)| {
let name = if leptos { rust_ident(name) } else { name.to_string() };
if *plain { format!(" {name}={expr}") } else { format!(" {name}={{{expr}}}") }
})
.collect();
let t = if leptos { rust_type(t) } else { t.clone() };
format!("{pad}<{t}{attrs} />\n") format!("{pad}<{t}{attrs} />\n")
} }
CodeLang::SwiftUi => format!("{pad}{t}({})\n", args.iter().map(|(n, e, _)| format!("{n}: {e}")).collect::<Vec<_>>().join(", ")), CodeLang::SwiftUi => format!("{pad}{t}({})\n", args.iter().map(|(n, e, _)| format!("{n}: {e}")).collect::<Vec<_>>().join(", ")),
@ -504,13 +572,20 @@ fn slot_content(doc: &Document, slot: NodeId, lang: CodeLang, depth: usize) -> S
let kids = ctx.kids(n); let kids = ctx.kids(n);
match lang { match lang {
CodeLang::React => { CodeLang::React => {
let mut m = Markup::new(&ctx, true); let mut m = Markup::new(&ctx, CodeLang::React);
for k in kids { for k in kids {
m.node(k, Some(n), depth + 2, ""); m.node(k, Some(n), depth + 2, "");
} }
let (p1, p2) = (" ".repeat(depth), " ".repeat(depth + 1)); let (p1, p2) = (" ".repeat(depth), " ".repeat(depth + 1));
format!("(\n{p2}<>\n{}{p2}</>\n{p1})", m.markup) format!("(\n{p2}<>\n{}{p2}</>\n{p1})", m.markup)
} }
CodeLang::Leptos => {
let mut m = Markup::new(&ctx, CodeLang::Leptos);
for k in kids {
m.node(k, Some(n), depth + 1, "");
}
format!("|| view! {{\n{}{}}}", m.markup, indent(depth))
}
CodeLang::SwiftUi => { CodeLang::SwiftUi => {
let mut out = String::new(); let mut out = String::new();
for k in kids { for k in kids {
@ -535,6 +610,7 @@ pub fn definition(doc: &Document, c: &Component, lang: CodeLang) -> String {
match lang { match lang {
CodeLang::SwiftUi => swift_definition(doc, c), CodeLang::SwiftUi => swift_definition(doc, c),
CodeLang::Compose => compose_definition(doc, c), CodeLang::Compose => compose_definition(doc, c),
CodeLang::Leptos => leptos(doc, c),
_ => react(doc, c), _ => react(doc, c),
} }
} }
@ -545,23 +621,29 @@ fn data_attr(p: &Param) -> String {
format!("data-{}", class_name(&p.prop, &mut used)) format!("data-{}", class_name(&p.prop, &mut used))
} }
/// A TSX function component with its props interface, and its stylesheet (variables as custom /// `c`'s markup in React or Leptos (two levels in) and its stylesheet (variables as custom
/// properties, other variants as rules on their `data-*` attributes). /// properties, other variants as rules on their `data-*` attributes): (root class, markup, CSS).
fn react(doc: &Document, c: &Component) -> String { fn web(doc: &Document, c: &Component, lang: CodeLang) -> (String, String, String) {
let ctx = Ctx { doc, root: c.comp, comp: Some(c), base: 2 }; 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_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 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 variants: Vec<&Param> = c.params.iter().filter(|p| matches!(p.kind, ParamKind::Variant(_))).collect();
let pass = |root: NodeId| { let pass = |root: NodeId| {
let vctx = Ctx { doc, root, comp: Some(c), base: 2 }; let vctx = Ctx { doc, root, comp: Some(c), base: 2 };
let mut m = Markup::new(&vctx, true); let mut m = Markup::new(&vctx, lang);
m.root_class = Some(root_class.clone()); m.root_class = Some(root_class.clone());
m.node(root, None, 2, ""); m.node(root, None, 2, "");
m.rules m.rules
}; };
let mut m = Markup::new(&ctx, true); let mut m = Markup::new(&ctx, lang);
m.root_class = Some(root_class.clone()); m.root_class = Some(root_class.clone());
m.root_attrs = variants.iter().map(|p| format!(" {}={{{}}}", data_attr(p), p.ident)).collect(); m.root_attrs = variants
.iter()
.map(|p| match lang {
CodeLang::Leptos => format!(" {}={{{}.as_str()}}", data_attr(p), rust_ident(&p.ident)),
_ => format!(" {}={{{}}}", data_attr(p), p.ident),
})
.collect();
m.node(c.comp, None, 2, ""); m.node(c.comp, None, 2, "");
// Other variants: what differs from this one, layer by layer. // Other variants: what differs from this one, layer by layer.
let mut variant_rules = String::new(); let mut variant_rules = String::new();
@ -584,6 +666,13 @@ fn react(doc: &Document, c: &Component) -> String {
variant_rules.push_str(&format!("{sel} {{\n{}}}\n", diff.iter().map(|l| format!(" {l}\n")).collect::<String>())); variant_rules.push_str(&format!("{sel} {{\n{}}}\n", diff.iter().map(|l| format!(" {l}\n")).collect::<String>()));
} }
} }
let css = format!("{}{}{variant_rules}", m.tokens.root_rule(), m.styles);
(root_class, m.markup, css)
}
/// A TSX function component with its props interface, and its stylesheet.
fn react(doc: &Document, c: &Component) -> String {
let (root_class, markup, css) = web(doc, c, CodeLang::React);
let t = &c.type_name; let t = &c.type_name;
let mut out = format!("import \"./{root_class}.css\";\n\n"); let mut out = format!("import \"./{root_class}.css\";\n\n");
if c.params.is_empty() { if c.params.is_empty() {
@ -610,7 +699,46 @@ fn react(doc: &Document, c: &Component) -> String {
.collect(); .collect();
out.push_str(&format!("export function {t}({{\n{}}}: {t}Props) {{\n", args.iter().map(|a| format!(" {a},\n")).collect::<String>())); out.push_str(&format!("export function {t}({{\n{}}}: {t}Props) {{\n", args.iter().map(|a| format!(" {a},\n")).collect::<String>()));
} }
out.push_str(&format!(" return (\n{} );\n}}\n\n/* {root_class}.css */\n{}{}{variant_rules}", m.markup, m.tokens.root_rule(), m.styles)); out.push_str(&format!(" return (\n{markup} );\n}}\n\n/* {root_class}.css */\n{css}"));
out
}
/// A Leptos `#[component]` with its variant enums and props (defaults as `#[prop]` attributes),
/// and its stylesheet.
fn leptos(doc: &Document, c: &Component) -> String {
let (root_class, markup, css) = web(doc, c, CodeLang::Leptos);
let mut out = String::from("use leptos::prelude::*;\n\n");
for p in &c.params {
let ParamKind::Variant(opts) = &p.kind else { continue };
let (e, cases) = (leptos_enum(c, p), rust_cases(opts));
let default = opts.iter().position(|o| Some(o.as_str()) == p.default.as_str()).unwrap_or(0);
out.push_str(&format!("#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]\npub enum {e} {{\n"));
for (i, case) in cases.iter().enumerate() {
out.push_str(&format!("{} {case},\n", if i == default { " #[default]\n" } else { "" }));
}
out.push_str(&format!("}}\n\nimpl {e} {{\n /// The variant's name, as its `data-*` attribute.\n pub fn as_str(self) -> &'static str {{\n match self {{\n"));
for (o, case) in opts.iter().zip(&cases) {
out.push_str(&format!(" Self::{case} => {},\n", rust_string(o)));
}
out.push_str(" }\n }\n}\n\n");
}
let t = rust_type(&c.type_name);
out.push_str(&format!("#[component]\npub fn {t}("));
if !c.params.is_empty() {
out.push('\n');
for p in &c.params {
let default = value_expr(doc, c, p, &p.default, CodeLang::Leptos);
let (attr, ty) = match &p.kind {
ParamKind::Variant(_) => ("optional".to_string(), leptos_enum(c, p)),
ParamKind::Boolean => (format!("default = {default}"), "bool".into()),
ParamKind::Text => (format!("into, default = {default}.to_string()"), "String".into()),
ParamKind::InstanceSwap => (format!("into, default = ({default}).into()"), "ViewFn".into()),
ParamKind::Slot => ("optional, into".to_string(), "Option<ViewFn>".into()),
};
out.push_str(&format!(" #[prop({attr})] {}: {ty},\n", rust_ident(&p.ident)));
}
}
out.push_str(&format!(") -> impl IntoView {{\n view! {{\n{markup} }}\n}}\n\n/* {root_class}.css */\n{css}"));
out out
} }

View File

@ -1,5 +1,5 @@
//! Component code: components become React / SwiftUI / Compose components whose properties are //! Component code: components become React / Leptos / SwiftUI / Compose components whose
//! parameters, and instances become calls passing the values they set. //! properties are parameters, and instances become calls passing the values they set.
use crate::Session; use crate::Session;
use crate::codegen::{CodeLang, code_for}; use crate::codegen::{CodeLang, code_for};
@ -71,6 +71,18 @@ fn components_take_their_properties_as_parameters() {
for want in ["@Composable\nfun Card(", " showDot: Boolean = true,", " title: String = \"Hello\",", "if (showDot) {", "text = title,"] { 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}"); assert!(kt.contains(want), "Compose has {want:?}:\n{kt}");
} }
let leptos = code(&mut s, c, "leptos");
for want in [
"use leptos::prelude::*;\n\n#[component]\npub fn Card(\n",
" #[prop(default = true)] show_dot: bool,\n",
" #[prop(into, default = \"Hello\".to_string())] title: String,\n",
") -> impl IntoView {\n view! {\n",
"{show_dot.then(|| view! {",
">{title.clone()}</p>",
"/* card.css */",
] {
assert!(leptos.contains(want), "Leptos has {want:?}:\n{leptos}");
}
// The command describes the properties. // The command describes the properties.
let v = component_code(&mut s, c, "react"); let v = component_code(&mut s, c, "react");
assert_eq!(v["name"], "Card"); assert_eq!(v["name"], "Card");
@ -105,11 +117,13 @@ fn instances_are_calls_passing_the_values_they_set() {
assert!(react.contains("export function Card({")); 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, "swiftui")["usage"], "Card(showDot: false, title: \"Buy\")\n");
assert_eq!(component_code(&mut s, i, "compose")["usage"], "Card(showDot = false, title = \"Buy\")\n"); assert_eq!(component_code(&mut s, i, "compose")["usage"], "Card(showDot = false, title = \"Buy\")\n");
assert_eq!(component_code(&mut s, i, "leptos")["usage"], "<Card show_dot={false} title=\"Buy\" />\n");
// Text that needs escaping is an expression in JSX and escaped per language. // 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"}})); ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Title": "Say \"hi\" $5\nnow"}}));
assert_eq!(component_code(&mut s, i, "react")["usage"], "<Card showDot={false} title={\"Say \\\"hi\\\" $5\\nnow\"} />\n"); assert_eq!(component_code(&mut s, i, "react")["usage"], "<Card showDot={false} title={\"Say \\\"hi\\\" $5\\nnow\"} />\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, "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"); assert_eq!(component_code(&mut s, i, "compose")["usage"], "Card(showDot = false, title = \"Say \\\"hi\\\" \\$5\\nnow\")\n");
assert_eq!(component_code(&mut s, i, "leptos")["usage"], "<Card show_dot={false} title={\"Say \\\"hi\\\" $5\\nnow\"} />\n");
} }
#[test] #[test]
@ -125,6 +139,9 @@ fn nested_instances_in_a_layer_tree_are_calls() {
assert!(!react.contains("Hello"), "the instance's layers aren't spelled out: {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, "swiftui").contains("Card(title: \"Inbox\")"));
assert!(code(&mut s, screen, "compose").contains("Card(title = \"Inbox\")")); assert!(code(&mut s, screen, "compose").contains("Card(title = \"Inbox\")"));
let leptos = code(&mut s, screen, "leptos");
assert!(leptos.contains("pub fn HomeScreen() -> impl IntoView {") && leptos.contains("<Card title=\"Inbox\" />"), "{leptos}");
assert!(!leptos.contains("Hello"), "{leptos}");
// HTML and CSS stay plain markup. // HTML and CSS stay plain markup.
assert!(code(&mut s, screen, "html").contains("Inbox")); assert!(code(&mut s, screen, "html").contains("Inbox"));
} }
@ -156,12 +173,23 @@ fn variant_sets_share_one_definition() {
let kt = code(&mut s, set, "compose"); let kt = code(&mut s, set, "compose");
assert!(kt.contains("enum class ButtonState { Default, Hover }"), "{kt}"); assert!(kt.contains("enum class ButtonState { Default, Hover }"), "{kt}");
assert!(kt.contains(" state: ButtonState = ButtonState.Default,"), "{kt}"); assert!(kt.contains(" state: ButtonState = ButtonState.Default,"), "{kt}");
let leptos = code(&mut s, set, "leptos");
for want in [
"#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]\npub enum ButtonState {\n #[default]\n Default,\n Hover,\n}",
" Self::Hover => \"Hover\",",
" #[prop(optional)] state: ButtonState,",
"data-state={state.as_str()}",
".button[data-state=\"Hover\"] .bg {\n background: #0000FF;",
] {
assert!(leptos.contains(want), "Leptos has {want:?}:\n{leptos}");
}
// An instance showing the hover variant passes it. // An instance showing the hover variant passes it.
let i = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 600}))); 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"}})); ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"State": "Hover"}}));
assert_eq!(component_code(&mut s, i, "react")["usage"], "<Button state=\"Hover\" />\n"); assert_eq!(component_code(&mut s, i, "react")["usage"], "<Button state=\"Hover\" />\n");
assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Button(state: .hover)\n"); assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Button(state: .hover)\n");
assert_eq!(component_code(&mut s, i, "compose")["usage"], "Button(state = ButtonState.Hover)\n"); assert_eq!(component_code(&mut s, i, "compose")["usage"], "Button(state = ButtonState.Hover)\n");
assert_eq!(component_code(&mut s, i, "leptos")["usage"], "<Button state=ButtonState::Hover />\n");
} }
#[test] #[test]
@ -184,11 +212,15 @@ fn instance_swap_properties_take_views() {
assert!(swift.contains(" var icon: AnyView = AnyView(HeartIcon())"), "{swift}"); assert!(swift.contains(" var icon: AnyView = AnyView(HeartIcon())"), "{swift}");
let kt = code(&mut s, chip, "compose"); let kt = code(&mut s, chip, "compose");
assert!(kt.contains(" icon: @Composable () -> Unit = { HeartIcon() },") && kt.contains("icon()"), "{kt}"); assert!(kt.contains(" icon: @Composable () -> Unit = { HeartIcon() },") && kt.contains("icon()"), "{kt}");
let leptos = code(&mut s, chip, "leptos");
assert!(leptos.contains(" #[prop(into, default = (|| view! { <HeartIcon /> }).into())] icon: ViewFn,"), "{leptos}");
assert!(leptos.contains("{icon.run()}"), "{leptos}");
let i = idv(&ex(&mut s, "instance.create", json!({"component": chip, "x": 0, "y": 100}))); let i = idv(&ex(&mut s, "instance.create", json!({"component": chip, "x": 0, "y": 100})));
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Icon": star}})); ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Icon": star}}));
assert_eq!(component_code(&mut s, i, "react")["usage"], "<Chip icon={<StarIcon />} />\n"); assert_eq!(component_code(&mut s, i, "react")["usage"], "<Chip icon={<StarIcon />} />\n");
assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Chip(icon: AnyView(StarIcon()))\n"); assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Chip(icon: AnyView(StarIcon()))\n");
assert_eq!(component_code(&mut s, i, "compose")["usage"], "Chip(icon = { StarIcon() })\n"); assert_eq!(component_code(&mut s, i, "compose")["usage"], "Chip(icon = { StarIcon() })\n");
assert_eq!(component_code(&mut s, i, "leptos")["usage"], "<Chip icon={|| view! { <StarIcon /> }} />\n");
} }
#[test] #[test]
@ -203,6 +235,9 @@ fn slots_take_optional_content() {
assert!(react.contains(" content?: React.ReactNode;") && react.contains("{content ?? ("), "{react}"); assert!(react.contains(" content?: React.ReactNode;") && react.contains("{content ?? ("), "{react}");
assert!(code(&mut s, panel, "swiftui").contains("var content: AnyView? = nil")); assert!(code(&mut s, panel, "swiftui").contains("var content: AnyView? = nil"));
assert!(code(&mut s, panel, "compose").contains("content: (@Composable () -> Unit)? = null,")); assert!(code(&mut s, panel, "compose").contains("content: (@Composable () -> Unit)? = null,"));
let leptos = code(&mut s, panel, "leptos");
assert!(leptos.contains(" #[prop(optional, into)] content: Option<ViewFn>,"), "{leptos}");
assert!(leptos.contains("{match &content { Some(c) => c.run(), None => view! {\n") && leptos.contains("}.into_any() }}\n"), "{leptos}");
// An instance filling the slot passes its content. // An instance filling the slot passes its content.
let g = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 400, "width": 40, "height": 20, "name": "Badge"}))); let g = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 400, "width": 40, "height": 20, "name": "Badge"})));
let badge = idv(&ex(&mut s, "component.create", json!({"ids": [g]}))); let badge = idv(&ex(&mut s, "component.create", json!({"ids": [g]})));
@ -211,6 +246,8 @@ fn slots_take_optional_content() {
ex(&mut s, "instance.slotInsert", json!({"id": i, "slot": "Content", "component": badge, "replace": true})); ex(&mut s, "instance.slotInsert", json!({"id": i, "slot": "Content", "component": badge, "replace": true}));
let usage = component_code(&mut s, i, "react")["usage"].as_str().unwrap_or_default().to_string(); let usage = component_code(&mut s, i, "react")["usage"].as_str().unwrap_or_default().to_string();
assert!(usage.starts_with("<Panel content={(\n") && usage.contains("<Badge />"), "{usage}"); assert!(usage.starts_with("<Panel content={(\n") && usage.contains("<Badge />"), "{usage}");
let usage = component_code(&mut s, i, "leptos")["usage"].as_str().unwrap_or_default().to_string();
assert!(usage.starts_with("<Panel content={|| view! {\n") && usage.contains("<Badge />") && usage.ends_with("}} />\n"), "{usage}");
} }
#[test] #[test]
@ -225,6 +262,8 @@ fn identifiers_are_escaped_and_unique() {
("label", "text", json!("b")), ("label", "text", json!("b")),
("2 columns", "boolean", json!(false)), ("2 columns", "boolean", json!(false)),
("Color", "variant", json!("Red")), ("Color", "variant", json!("Red")),
("type", "text", json!("c")),
("Props", "variant", json!("Self")),
] { ] {
ex(&mut s, "component.addProperty", json!({"id": c, "name": name, "type": ty, "default": default})); ex(&mut s, "component.addProperty", json!({"id": c, "name": name, "type": ty, "default": default}));
} }
@ -242,6 +281,20 @@ fn identifiers_are_escaped_and_unique() {
assert_eq!(color.as_deref(), Some("ColorVariant"), "never shadows SwiftUI's Color"); assert_eq!(color.as_deref(), Some("ColorVariant"), "never shadows SwiftUI's Color");
let swift = code_for(doc, c, CodeLang::SwiftUi); let swift = code_for(doc, c, CodeLang::SwiftUi);
assert!(swift.contains("struct TextComponent: View {") && swift.contains("enum ColorVariant: String, CaseIterable {"), "{swift}"); assert!(swift.contains("struct TextComponent: View {") && swift.contains("enum ColorVariant: String, CaseIterable {"), "{swift}");
// Rust: snake_case, keywords suffixed, never the props struct's name or `Self`.
let leptos = code_for(doc, c, CodeLang::Leptos);
for want in [
"pub fn TextComponent(",
"] default_value: bool,",
"] show_icon: bool,",
"] p2_columns: bool,",
"] type_: String,",
"pub enum TextComponentPropsVariant {",
" SelfValue,",
"Self::SelfValue => \"Self\",",
] {
assert!(leptos.contains(want), "Leptos has {want:?}:\n{leptos}");
}
// Errors. // Errors.
let r = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))); let r = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})));
assert!(s.execute("inspect.componentCode", &json!({"id": r})).is_err(), "not a component"); assert!(s.execute("inspect.componentCode", &json!({"id": r})).is_err(), "not a component");

View File

@ -57,3 +57,23 @@ fn class_names_are_valid_and_deep_trees_are_bounded() {
// Nesting stops at the depth limit instead of running away. // Nesting stops at the depth limit instead of running away.
assert!(code.matches("<div").count() <= 14, "{}", code.matches("<div").count()); assert!(code.matches("<div").count() <= 14, "{}", code.matches("<div").count());
} }
#[test]
fn leptos_wraps_the_markup_in_a_component_with_text_as_string_literals() {
let mut s = Session::new();
let card = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 120})).unwrap());
s.execute("node.set", &json!({"id": card, "props": {"name": "Card"}})).unwrap();
let t = id(s.execute("text.create", &json!({"text": "Say \"hi\" {x}\nسلام", "x": 0, "y": 60})).unwrap());
s.execute("node.reparent", &json!({"ids": [t], "parent": card})).unwrap();
let code = code_for(s.doc().unwrap(), card, CodeLang::Leptos);
assert!(
code.starts_with("use leptos::prelude::*;\n\n#[component]\npub fn Card() -> impl IntoView {\n view! {\n <div class=\"card\">"),
"{code}"
);
// Quotes and braces stay inside Rust string literals; lines break with a self-closed tag.
assert!(code.contains(r#">"Say \"hi\" {x}"<br/>"سلام"</p>"#), "{code}");
assert!(code.contains("/* card.css */\n") && code.contains(".card {\n position: relative;\n"), "{code}");
let out = s.execute("inspect.code", &json!({"id": card, "language": "leptos", "unit": "rem"})).unwrap();
assert!(out["code"].as_str().unwrap().contains("width: 12.5rem;"), "{}", out["code"]);
}

View File

@ -211,7 +211,7 @@ status = "draft — needs native review"
"Changes on main" = "Änderungen in der Hauptdatei" "Changes on main" = "Änderungen in der Hauptdatei"
"Changes on the branch" = "Änderungen im Branch" "Changes on the branch" = "Änderungen im Branch"
"Changes unknown" = "Änderungen unbekannt" "Changes unknown" = "Änderungen unbekannt"
"Choose React, SwiftUI or Compose above to connect this component to its code." = "Wählen Sie oben React, SwiftUI oder Compose, um diese Komponente mit ihrem Code zu verbinden." "Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "Wählen Sie oben React, Leptos, SwiftUI oder Compose, um diese Komponente mit ihrem Code zu verbinden."
"Choose another profile to change it." = "Wählen Sie ein anderes Profil, um es zu ändern." "Choose another profile to change it." = "Wählen Sie ein anderes Profil, um es zu ändern."
"Choose collection" = "Sammlung wählen" "Choose collection" = "Sammlung wählen"
"Choose image" = "Bild wählen" "Choose image" = "Bild wählen"

View File

@ -216,7 +216,7 @@ name = "English"
"Changes on main" = "Changes on main" "Changes on main" = "Changes on main"
"Changes on the branch" = "Changes on the branch" "Changes on the branch" = "Changes on the branch"
"Changes unknown" = "Changes unknown" "Changes unknown" = "Changes unknown"
"Choose React, SwiftUI or Compose above to connect this component to its code." = "Choose React, SwiftUI or Compose above to connect this component to its code." "Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "Choose React, Leptos, SwiftUI or Compose above to connect this component to its code."
"Choose another profile to change it." = "Choose another profile to change it." "Choose another profile to change it." = "Choose another profile to change it."
"Choose collection" = "Choose collection" "Choose collection" = "Choose collection"
"Choose image" = "Choose image" "Choose image" = "Choose image"

View File

@ -217,7 +217,7 @@ status = "draft — needs native review"
"Changes on main" = "Cambios en la principal" "Changes on main" = "Cambios en la principal"
"Changes on the branch" = "Cambios en la rama" "Changes on the branch" = "Cambios en la rama"
"Changes unknown" = "Cambios desconocidos" "Changes unknown" = "Cambios desconocidos"
"Choose React, SwiftUI or Compose above to connect this component to its code." = "Elige React, SwiftUI o Compose arriba para conectar este componente con su código." "Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "Elige React, Leptos, SwiftUI o Compose arriba para conectar este componente con su código."
"Choose another profile to change it." = "Elige otro perfil para cambiarlo." "Choose another profile to change it." = "Elige otro perfil para cambiarlo."
"Choose collection" = "Elegir colección" "Choose collection" = "Elegir colección"
"Choose image" = "Elegir imagen" "Choose image" = "Elegir imagen"

View File

@ -211,7 +211,7 @@ status = "draft — needs native review"
"Changes on main" = "Modifications dans le fichier principal" "Changes on main" = "Modifications dans le fichier principal"
"Changes on the branch" = "Modifications dans la branche" "Changes on the branch" = "Modifications dans la branche"
"Changes unknown" = "Modifications inconnues" "Changes unknown" = "Modifications inconnues"
"Choose React, SwiftUI or Compose above to connect this component to its code." = "Choisissez React, SwiftUI ou Compose ci-dessus pour relier ce composant à son code." "Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "Choisissez React, Leptos, SwiftUI ou Compose ci-dessus pour relier ce composant à son code."
"Choose another profile to change it." = "Choisissez un autre profil pour le changer." "Choose another profile to change it." = "Choisissez un autre profil pour le changer."
"Choose collection" = "Choisir une collection" "Choose collection" = "Choisir une collection"
"Choose image" = "Choisir une image" "Choose image" = "Choisir une image"

View File

@ -213,7 +213,7 @@ status = "draft — needs native review"
"Changes on main" = "メインの変更" "Changes on main" = "メインの変更"
"Changes on the branch" = "ブランチの変更" "Changes on the branch" = "ブランチの変更"
"Changes unknown" = "変更内容は不明" "Changes unknown" = "変更内容は不明"
"Choose React, SwiftUI or Compose above to connect this component to its code." = "上で React、SwiftUI、Compose のいずれかを選び、このコンポーネントをコードに接続。" "Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "上で React、Leptos、SwiftUI、Compose のいずれかを選び、このコンポーネントをコードに接続。"
"Choose another profile to change it." = "変更するには別のプロファイルを選択。" "Choose another profile to change it." = "変更するには別のプロファイルを選択。"
"Choose collection" = "コレクションを選択" "Choose collection" = "コレクションを選択"
"Choose image" = "画像を選択" "Choose image" = "画像を選択"

View File

@ -212,7 +212,7 @@ status = "draft — needs native review"
"Changes on main" = "메인의 변경 사항" "Changes on main" = "메인의 변경 사항"
"Changes on the branch" = "브랜치의 변경 사항" "Changes on the branch" = "브랜치의 변경 사항"
"Changes unknown" = "변경 내용 알 수 없음" "Changes unknown" = "변경 내용 알 수 없음"
"Choose React, SwiftUI or Compose above to connect this component to its code." = "위에서 React, SwiftUI 또는 Compose를 선택해 이 컴포넌트를 코드에 연결하세요." "Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "위에서 React, Leptos, SwiftUI 또는 Compose를 선택해 이 컴포넌트를 코드에 연결하세요."
"Choose another profile to change it." = "변경하려면 다른 프로필을 선택하세요." "Choose another profile to change it." = "변경하려면 다른 프로필을 선택하세요."
"Choose collection" = "컬렉션 선택" "Choose collection" = "컬렉션 선택"
"Choose image" = "이미지 선택" "Choose image" = "이미지 선택"

View File

@ -210,7 +210,7 @@ status = "draft — needs native review"
"Changes on main" = "Alterações no principal" "Changes on main" = "Alterações no principal"
"Changes on the branch" = "Alterações na branch" "Changes on the branch" = "Alterações na branch"
"Changes unknown" = "Alterações desconhecidas" "Changes unknown" = "Alterações desconhecidas"
"Choose React, SwiftUI or Compose above to connect this component to its code." = "Escolha React, SwiftUI ou Compose acima para conectar este componente ao código dele." "Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "Escolha React, Leptos, SwiftUI ou Compose acima para conectar este componente ao código dele."
"Choose another profile to change it." = "Escolha outro perfil para alterá-lo." "Choose another profile to change it." = "Escolha outro perfil para alterá-lo."
"Choose collection" = "Escolher coleção" "Choose collection" = "Escolher coleção"
"Choose image" = "Escolher imagem" "Choose image" = "Escolher imagem"

View File

@ -213,7 +213,7 @@ status = "draft — needs native review"
"Changes on main" = "主文件上的更改" "Changes on main" = "主文件上的更改"
"Changes on the branch" = "分支上的更改" "Changes on the branch" = "分支上的更改"
"Changes unknown" = "更改未知" "Changes unknown" = "更改未知"
"Choose React, SwiftUI or Compose above to connect this component to its code." = "在上方选择 React、SwiftUI 或 Compose,将此组件关联到其代码。" "Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "在上方选择 React、Leptos、SwiftUI 或 Compose,将此组件关联到其代码。"
"Choose another profile to change it." = "选择其他配置文件以更改。" "Choose another profile to change it." = "选择其他配置文件以更改。"
"Choose collection" = "选择集合" "Choose collection" = "选择集合"
"Choose image" = "选择图片" "Choose image" = "选择图片"

View File

@ -396,7 +396,7 @@ pub fn connect_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId
let text = if connectable { let text = if connectable {
tr("Link this component to its code component: instances then show your real snippet with their values.") tr("Link this component to its code component: instances then show your real snippet with their values.")
} else { } else {
tr("Choose React, SwiftUI or Compose above to connect this component to its code.") tr("Choose React, Leptos, SwiftUI or Compose above to connect this component to its code.")
}; };
widgets::note(ui, text, t.text_secondary); widgets::note(ui, text, t.text_secondary);
return; return;

View File

@ -2238,7 +2238,7 @@ fn inspect(app: &mut FigApp, ui: &mut Ui) {
let copy = widgets::section_header(ui, tr("Code"), &[(Icon::Export, tr("Copy"))], false).is_some(); let copy = widgets::section_header(ui, tr("Code"), &[(Icon::Export, tr("Copy"))], false).is_some();
// Pixels or rem (of a 16 px root) for the web languages. // Pixels or rem (of a 16 px root) for the web languages.
let rem_id = Id::new("inspect_rem"); let rem_id = Id::new("inspect_rem");
let web = matches!(lang, CodeLang::Css | CodeLang::Html | CodeLang::React); let web = lang.is_web();
let rem = web && ui.data(|d| d.get_temp::<bool>(rem_id)).unwrap_or(false); let rem = web && ui.data(|d| d.get_temp::<bool>(rem_id)).unwrap_or(false);
row(ui, 32.0, |ui| { row(ui, 32.0, |ui| {
let resp = widgets::dropdown(ui, lang.label(), 120.0, Some(Icon::Code)); let resp = widgets::dropdown(ui, lang.label(), 120.0, Some(Icon::Code));

View File

@ -10,13 +10,13 @@ inside generated layer trees. Remaining: 0–1 h.
|---|---|---| |---|---|---|
| Dev Mode toggle (Shift+D), Inspect panel | done | | | Dev Mode toggle (Shift+D), Inspect panel | done | |
| Properties with their styles and variables; redlines on hover | done | positions from the frame around the layer (groups aren't frames), rotation, radius (CSS corner order), opacity, blend; colours in hex, RGB, CSS, HSL or HSB (`inspect.properties {colorFormat}`, the Inspect tab's Color format) | | Properties with their styles and variables; redlines on hover | done | positions from the frame around the layer (groups aren't frames), rotation, radius (CSS corner order), opacity, blend; colours in hex, RGB, CSS, HSL or HSB (`inspect.properties {colorFormat}`, the Inspect tab's Color format) |
| Code: CSS, iOS (SwiftUI), Android (Compose) | done | plus HTML and React (ours); components take properties as parameters, instances are calls; CSS in px or rem (`inspect.code {unit, rootFontSize}`), with rotation, blend modes, gradient directions and kinds (linear, radial, conic), text alignment, case and decoration | | Code: CSS, iOS (SwiftUI), Android (Compose) | done | plus HTML, React and Leptos (ours; Leptos: `#[component]` functions with `#[prop]` defaults, variant enums and `view!` markup); components take properties as parameters, instances are calls; CSS in px or rem (`inspect.code {unit, rootFontSize}`), with rotation, blend modes, gradient directions and kinds (linear, radial, conic), text alignment, case and decoration |
| Assets list with export | done | layers with export settings, icons (small, vector shapes only: SVG) and images (PNG); the icon rule is ours | | Assets list with export | done | layers with export settings, icons (small, vector shapes only: SVG) and images (PNG); the icon rule is ours |
| Annotations: pinned properties, categories, coloured callouts, filter | done | | | Annotations: pinned properties, categories, coloured callouts, filter | done | |
| Ready for dev list, statuses, "Changed" after edits | done | | | Ready for dev list, statuses, "Changed" after edits | done | |
| Focus view (one frame, previous / next) | done | | | Focus view (one frame, previous / next) | done | |
| Compare changes (before/after side by side or overlaid, property diffs) | done | only against the snapshot taken when a frame was marked ready; not against any named version | | Compare changes (before/after side by side or overlaid, property diffs) | done | only against the snapshot taken when a frame was marked ready; not against any named version |
| Code connect (map components to repository code) | done | per language (React, SwiftUI, Compose): import path, name and a template with `{property}` and `{property ? "a" : "b"}` placeholders; instances (also inside generated layer trees) show the snippet with their values; edited in the Inspect panel with a live preview; `figcraft.connect.json` export/import. Inside a generated layer tree, connected instances' imports aren't collected | | Code connect (map components to repository code) | done | per language (React, Leptos, SwiftUI, Compose): import path, name and a template with `{property}` and `{property ? "a" : "b"}` placeholders; instances (also inside generated layer trees) show the snippet with their values; edited in the Inspect panel with a live preview; `figcraft.connect.json` export/import. Inside a generated layer tree, connected instances' imports aren't collected |
| Plugins in Dev Mode (Jira, GitHub, etc.) | partial | FigCraft's own WASM plugins that only read run from the Inspect tab; no plugin UI at run time, no third-party integrations | | Plugins in Dev Mode (Jira, GitHub, etc.) | partial | FigCraft's own WASM plugins that only read run from the Inspect tab; no plugin UI at run time, no third-party integrations |
| VS Code extension | out of scope | FigCraft's MCP server and control channel already serve design data, code and connected snippets to agents and editors | | VS Code extension | out of scope | FigCraft's MCP server and control channel already serve design data, code and connected snippets to agents and editors |
| Dev resources links on layers | done | a URL and a name per link on any layer (`devResource.*`, saved, synced, undoable); instances list their main component's (and its set's) read-only; URLs checked as http/https and never fetched (no page titles: a link without a name is named after its URL); the Inspect tab lists, adds, edits and removes them; `get_node` returns them | | Dev resources links on layers | done | a URL and a name per link on any layer (`devResource.*`, saved, synced, undoable); instances list their main component's (and its set's) read-only; URLs checked as http/https and never fetched (no page titles: a link without a name is named after its URL); the Inspect tab lists, adds, edits and removes them; `get_node` returns them |

View File

@ -218,7 +218,7 @@ Or in a project's `.mcp.json`:
| Tool | What it does | | Tool | What it does |
|---|---| |---|---|
| `list_commands {filter?}` | Every command: id, label, menu, shortcut, undoable, params doc. | | `list_commands {filter?}` | Every command: id, label, menu, shortcut, undoable, params doc. |
| `run_command {command, params}` | Run any command (e.g. `align.left`, `layout.add`, `component.create`, `instance.place` (drop an instance at a page point into the frame or auto layout slot under it), `component.exposeInstance` / `instance.exposed` (nested instance properties on instances), `component.makeSlot` / `instance.slotInsert` / `instance.resetSlot` (slots, below), `inspect.code` (CSS, HTML + CSS for a whole frame, React, SwiftUI or Jetpack Compose for a layer; components take their properties as parameters and instances are calls, see Dev Mode below), `inspect.properties` (layout, auto layout, typography, fills, strokes and effects with their style and variable names; `colorFormat`: hex, rgb, css, hsl or hsb), `inspect.assets` (layers under the selection with export settings, icons and images, each with its `kind`), `inspect.code` takes `unit: rem` and `rootFontSize` for CSS, HTML and React, `style.create`, `variable.bind`, `variable.update` (rename, describe, scope), `variable.set` with `{alias}`, `variable.renameMode` / `variable.removeMode`, `variable.deleteCollection`, `proto.interaction.add`, `codeConnect.set` (components linked to real code, see Dev Mode below)). | | `run_command {command, params}` | Run any command (e.g. `align.left`, `layout.add`, `component.create`, `instance.place` (drop an instance at a page point into the frame or auto layout slot under it), `component.exposeInstance` / `instance.exposed` (nested instance properties on instances), `component.makeSlot` / `instance.slotInsert` / `instance.resetSlot` (slots, below), `inspect.code` (CSS, HTML + CSS for a whole frame, React, Leptos, SwiftUI or Jetpack Compose for a layer; components take their properties as parameters and instances are calls, see Dev Mode below), `inspect.properties` (layout, auto layout, typography, fills, strokes and effects with their style and variable names; `colorFormat`: hex, rgb, css, hsl or hsb), `inspect.assets` (layers under the selection with export settings, icons and images, each with its `kind`), `inspect.code` takes `unit: rem` and `rootFontSize` for CSS, HTML, React and Leptos, `style.create`, `variable.bind`, `variable.update` (rename, describe, scope), `variable.set` with `{alias}`, `variable.renameMode` / `variable.removeMode`, `variable.deleteCollection`, `proto.interaction.add`, `codeConnect.set` (components linked to real code, see Dev Mode below)). |
| `batch {commands: [{command, params}], label?}` | Several commands as one undo step (`command.batch`); all-or-nothing for open documents and their document state. Failed inner commands are removed from the journal; preferences, clipboard, tool state and UI requests are not rolled back. | | `batch {commands: [{command, params}], label?}` | Several commands as one undo step (`command.batch`); all-or-nothing for open documents and their document state. Failed inner commands are removed from the journal; preferences, clipboard, tool state and UI requests are not rolled back. |
| `create_node {type, x, y, width, height, parent, index, name, text, path, props}` | Create a frame, rectangle, ellipse, polygon, star, line, arrow, text, vector, section, component or slice; returns `{id, node}`. | | `create_node {type, x, y, width, height, parent, index, name, text, path, props}` | Create a frame, rectangle, ellipse, polygon, star, line, arrow, text, vector, section, component or slice; returns `{id, node}`. |
| `set_properties {ids, props}` | `node.set` on layers (geometry, fills, strokes, effects, corner radius, auto layout, sizing, constraints, masks (`isMask`, `maskType: alpha\|vector\|luminance`), handoff status (`devStatus: none\|readyForDev\|completed`; marking again clears "Changed"), a section's `sectionContentsHidden`, text…); returns the updated layers. | | `set_properties {ids, props}` | `node.set` on layers (geometry, fills, strokes, effects, corner radius, auto layout, sizing, constraints, masks (`isMask`, `maskType: alpha\|vector\|luminance`), handoff status (`devStatus: none\|readyForDev\|completed`; marking again clears "Changed"), a section's `sectionContentsHidden`, text…); returns the updated layers. |
@ -1171,27 +1171,30 @@ permissions: without it nothing is downloaded); `plugin.directory.publish {path
### Dev Mode ### Dev Mode
- **Component code:** `inspect.code {id, language: react|swiftui|compose}` on a component, a - **Component code:** `inspect.code {id, language: react|leptos|swiftui|compose}` on a component,
component set or a variant writes the component as a reusable unit: React (TSX) with a props a component set or a variant writes the component as a reusable unit: React (TSX) with a props
interface, SwiftUI with a `View` struct, Compose with a `@Composable` function. Its component interface, Leptos with a `#[component]` function (`#[prop]` defaults, `view!` markup), SwiftUI
properties are the parameters, with their defaults: variant properties become string unions with a `View` struct, Compose with a `@Composable` function. Its component properties are the
(React) or enums, booleans become flags around the layers they show, text properties strings, parameters, with their defaults: variant properties become string unions (React) or enums,
instance swaps views (`React.ReactNode`, `AnyView`, a composable lambda) and slots optional booleans become flags around the layers they show, text properties strings, instance swaps views
content. Every variant of a set shares one definition; in React the other variants' differences (`React.ReactNode`, Leptos `ViewFn`, `AnyView`, a composable lambda) and slots optional content.
become CSS rules on `data-*` attributes. On an instance (or a layer tree containing instances) Every variant of a set shares one definition; in React and Leptos the other variants'
each instance is a call passing only the values it changes, e.g. `<Button state="Hover" differences become CSS rules on `data-*` attributes. On an instance (or a layer tree containing
label="Buy" />`, `Button(state: .hover, label: "Buy")`, `Button(state = ButtonState.Hover, label = instances) each instance is a call passing only the values it changes, e.g. `<Button
"Buy")`. Names are escaped and unique (keywords get a suffix, `default` → `defaultValue`; names state="Hover" label="Buy" />`, `<Button state=ButtonState::Hover label="Buy" />`, `Button(state:
that would shadow framework types get one too, `Text` → `TextComponent`). `inspect.componentCode .hover, label: "Buy")`, `Button(state = ButtonState.Hover, label = "Buy")`. Names are escaped and
unique (keywords get a suffix, `default` → `defaultValue`, Rust's `type` → `type_`; Leptos names
are snake_case; names that would shadow framework types get one too, `Text` → `TextComponent`). `inspect.componentCode
{id?, language?}` returns the parts: `{component, name, set, properties: [{name, param, type, {id?, language?}` returns the parts: `{component, name, set, properties: [{name, param, type,
default, value? (instances)}], definition, usage? (instances)}`. default, value? (instances)}], definition, usage? (instances)}`.
- **Code connect:** link a component (a variant links its set) to the code component it is built - **Code connect:** link a component (a variant links its set) to the code component it is built
as, per language (`react`, `swiftui`, `compose`): `codeConnect.set {id?, language?, importPath?, as, per language (`react`, `leptos`, `swiftui`, `compose`): `codeConnect.set {id?, language?, importPath?,
name?, template?}` (undoable; fields left out keep their value; the defaults are the generated name?, template?}` (undoable; fields left out keep their value; the defaults are the generated
name and a call passing every property) → `{component, language, code, unknown}`. Templates are name and a call passing every property) → `{component, language, code, unknown}`. Templates are
text, never executed: `{property}` (its name or parameter name) is the instance's value escaped text, never executed: `{property}` (its name or parameter name) is the instance's value escaped
for the language (React keeps JSX's braces, so `size={size}` gives `size="Large"` and for the language (React and Leptos keep the markup's braces, so `size={size}` gives
`disabled={disabled}` gives `disabled={true}`), `{property ? "a" : "b"}` inserts one of two `size="Large"` and `disabled={disabled}` gives `disabled={true}`; Leptos imports with `use
<importPath>::<name>;`), `{property ? "a" : "b"}` inserts one of two
strings, any other `{…}` stays as written (`unknown` lists placeholder names that aren't strings, any other `{…}` stays as written (`unknown` lists placeholder names that aren't
properties). Then `inspect.code` on an instance or the component returns the import line and the properties). Then `inspect.code` on an instance or the component returns the import line and the
snippet instead of generated code (`connected: true`), connected instances inside a generated snippet instead of generated code (`connected: true`), connected instances inside a generated