diff --git a/crates/engine/src/dev/codegen.rs b/crates/engine/src/dev/codegen.rs
index 5064c64f..093a8fd9 100644
--- a/crates/engine/src/dev/codegen.rs
+++ b/crates/engine/src/dev/codegen.rs
@@ -14,6 +14,23 @@ use crate::component_codegen::{Component, Param};
use crate::params::*;
use crate::{bad, spec};
+mod arkui;
+mod compose;
+mod css;
+mod html;
+mod swift;
+
+pub(crate) use arkui::arkui;
+pub(crate) use compose::{compose, kotlin_color};
+pub(crate) use css::Tokens;
+#[cfg(test)]
+pub(crate) use css::css_decls;
+use css::{css, tok};
+pub use css::{css_var_name, css_var_of};
+use html::html;
+pub(crate) use html::{Markup, class_name};
+pub(crate) use swift::swift;
+
pub fn specs() -> Vec {
vec![spec!(
"inspect.code",
@@ -363,354 +380,6 @@ pub(crate) fn indent(depth: usize) -> String {
" ".repeat(depth)
}
-// ------------------------------------------------------------------ CSS
-
-/// The CSS custom properties generated code uses: name → fallback value, in first-use order.
-#[derive(Default)]
-pub(crate) struct Tokens {
- vars: Vec<(String, String)>,
- /// Name variables as Tailwind theme variables (`--color-accent`), not by their CSS name.
- pub tailwind: bool,
- /// The file's theme names (computed on first use).
- theme: Option,
-}
-
-impl Tokens {
- /// `var(--name, value)`, remembering the variable.
- fn var(&mut self, doc: &Document, variable: &Variable, value: String) -> String {
- let name =
- if self.tailwind { self.theme.get_or_insert_with(|| crate::tailwind::Theme::new(doc)).name(variable) } else { css_var_of(variable) };
- let out = format!("var({name}, {value})");
- if !self.vars.iter().any(|(n, _)| *n == name) {
- self.vars.push((name, value));
- }
- out
- }
-
- /// A `:root` rule declaring the variables (empty without any).
- pub fn root_rule(&self) -> String {
- if self.vars.is_empty() {
- return String::new();
- }
- let decls: String = self.vars.iter().map(|(n, v)| format!(" {n}: {v};\n")).collect();
- format!(":root {{\n{decls}}}\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('-');
- }
- }
- let trimmed = out.trim_end_matches('-');
- if trimmed.len() <= 2 { "--variable".into() } else { trimmed.to_string() }
-}
-
-/// A variable's CSS custom property: its Web code syntax (`var(--x)` or `--x`) when it has one,
-/// else made from its name.
-pub fn css_var_of(v: &Variable) -> String {
- let Some(web) = v.code_syntax.get("WEB").map(|w| w.trim()) else { return css_var_name(&v.name) };
- let inner = web.strip_prefix("var(").map_or(web, |r| r.split([',', ')']).next().unwrap_or("").trim());
- let valid = inner.len() > 2 && inner.starts_with("--") && inner.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_');
- if valid { inner.chars().take(80).collect() } else { css_var_name(inner) }
-}
-
-/// `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(doc, v, 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",
- MainAlign::SpaceAround => "space-around",
- MainAlign::SpaceEvenly => "space-evenly",
- };
- 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 l.wrap {
- // The lines: spread, or packed where the items align (baseline: at the start).
- let spread = l.content_align == ContentAlign::SpaceBetween;
- put(
- "align-content",
- if spread {
- "space-between"
- } else if align == "baseline" {
- "flex-start"
- } else {
- 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 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 {
- put("opacity", tok(doc, n, &["opacity"], num(f64::from(n.opacity)), t));
- }
- if !matches!(n.blend, figcraft_color::BlendMode::Normal | figcraft_color::BlendMode::PassThrough) {
- put("mix-blend-mode", n.blend.css().into());
- }
- // Rotation turns counter-clockwise about the top-left corner (the position given); CSS
- // rotate turns clockwise.
- let rot = figcraft_geom::rotation_degrees(n.transform);
- if rot.abs() > 0.01 && rot.is_finite() {
- put("transform", format!("rotate({}deg)", num(-rot)));
- put("transform-origin", "top left".into());
- }
- 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" };
- 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) => put("background", css_gradient(g, n.width, n.height)),
- PaintKind::Image(_) => put("background", "url(image.png) center / cover".into()),
- PaintKind::Pattern(_) => put("background", "url(pattern.png) repeat".into()),
- }
- }
- for (key, value) in css_borders(doc, n, t) {
- put(&key, value);
- }
- let shadows: Vec = n
- .effects
- .iter()
- .filter(|e| e.visible)
- .filter_map(|e| match &e.kind {
- EffectKind::DropShadow(s) => {
- Some(format!("{}px {}px {}px {}px {}", num(s.offset.0), num(s.offset.1), num(s.radius), num(s.spread), s.color.to_css()))
- }
- EffectKind::InnerShadow(s) => {
- Some(format!("inset {}px {}px {}px {}px {}", num(s.offset.0), num(s.offset.1), num(s.radius), num(s.spread), s.color.to_css()))
- }
- _ => None,
- })
- .collect();
- if !shadows.is_empty() {
- put("box-shadow", shadows.join(", "));
- }
- for e in n.effects.iter().filter(|e| e.visible) {
- match e.kind {
- // CSS blurs are uniform: a progressive blur has no CSS equivalent and is left out.
- EffectKind::LayerBlur(ref b) if !b.is_progressive() => put("filter", format!("blur({}px)", num(b.radius / 2.0))),
- EffectKind::BackgroundBlur(ref b) if !b.is_progressive() => put("backdrop-filter", format!("blur({}px)", num(b.radius / 2.0))),
- // CSS has no refraction: the frost is the nearest.
- EffectKind::Glass(ref g) => put("backdrop-filter", format!("blur({}px)", num(g.sanitized().frost / 2.0))),
- _ => {}
- }
- }
- 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 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 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))),
- _ => {}
- }
- match tx.align_h {
- TextAlignH::Left => {}
- TextAlignH::Center => put("text-align", "center".into()),
- TextAlignH::Right => put("text-align", "right".into()),
- TextAlignH::Justified => put("text-align", "justify".into()),
- }
- match s.case {
- TextCase::Original => {}
- TextCase::Upper => put("text-transform", "uppercase".into()),
- TextCase::Lower => put("text-transform", "lowercase".into()),
- TextCase::Title => put("text-transform", "capitalize".into()),
- TextCase::SmallCaps => put("font-variant", "small-caps".into()),
- }
- match s.decoration {
- TextDecoration::None => {}
- TextDecoration::Underline => put("text-decoration", "underline".into()),
- TextDecoration::Strikethrough => put("text-decoration", "line-through".into()),
- }
- if s.decoration != TextDecoration::None {
- let l = s.look();
- let len = |m: LineHeight| match m {
- LineHeight::Auto => None,
- LineHeight::Pixels(v) => Some(px(v)),
- LineHeight::Percent(p) => Some(format!("{}%", num(p))),
- };
- match l.style {
- figcraft_doc::DecorationStyle::Solid => {}
- figcraft_doc::DecorationStyle::Dotted => put("text-decoration-style", "dotted".into()),
- figcraft_doc::DecorationStyle::Wavy => put("text-decoration-style", "wavy".into()),
- }
- if let Some(v) = len(l.thickness) {
- put("text-decoration-thickness", v);
- }
- if let Some(c) = l.color {
- put("text-decoration-color", c.to_css());
- }
- if s.decoration == TextDecoration::Underline {
- if let Some(v) = len(l.offset) {
- put("text-underline-offset", v);
- }
- // Browsers skip ink by default; the canvas only when asked.
- if !l.skip_ink {
- put("text-decoration-skip-ink", "none".into());
- }
- }
- }
- if tx.leading_trim == figcraft_doc::LeadingTrim::CapHeight {
- put("text-box", "trim-both cap alphabetic".into());
- }
- }
- out
-}
-
-/// A gradient fill on a `w`×`h` box as a CSS background: linear along its handles' direction
-/// (CSS angles: 0deg up, 90deg right), radial with its handles' radii, angular as a conic
-/// gradient from its start direction.
-fn css_gradient(g: &figcraft_color::Gradient, w: f64, h: f64) -> String {
- let stops = g.sorted_stops().iter().map(|s| format!("{} {}%", s.color.to_css(), (s.position * 100.0).round())).collect::>().join(", ");
- let (dx, dy) = ((g.to.0 - g.from.0) * w, (g.to.1 - g.from.1) * h);
- let angle = if dx == 0.0 && dy == 0.0 { 180.0 } else { dx.atan2(-dy).to_degrees().rem_euclid(360.0) };
- let pct = |v: f64| format!("{}%", num(v * 100.0));
- let at = format!("at {} {}", pct(g.from.0), pct(g.from.1));
- match g.kind {
- figcraft_color::GradientKind::Linear => format!("linear-gradient({}deg, {stops})", num(angle)),
- figcraft_color::GradientKind::Radial | figcraft_color::GradientKind::Diamond => {
- let (rx, ry) = (((g.width.0 - g.from.0) * w).hypot((g.width.1 - g.from.1) * h), dx.hypot(dy));
- let rel = |r: f64, side: f64| if side > 0.0 { pct(r / side) } else { "50%".into() };
- format!("radial-gradient({} {} {at}, {stops})", rel(rx, w), rel(ry, h))
- }
- figcraft_color::GradientKind::Angular => format!("conic-gradient(from {}deg {at}, {stops})", num(angle)),
- }
-}
-
-// ------------------------------------------------------------------ HTML and React markup
-
-/// A word for the node's kind, naming layers whose name transliterates to nothing ("###" →
-/// `.text`, `.frame`), instead of the anonymous `layer-`.
-fn kind_word(n: &Node) -> &str {
- match &n.kind {
- NodeKind::Page { .. } => "page",
- NodeKind::Frame => "frame",
- NodeKind::Group => "group",
- NodeKind::Section { .. } => "section",
- NodeKind::Rectangle => "rectangle",
- NodeKind::Ellipse { .. } => "ellipse",
- NodeKind::Polygon { .. } => "polygon",
- NodeKind::Star { .. } => "star",
- NodeKind::Line => "line",
- NodeKind::Vector { .. } => "vector",
- NodeKind::Text(_) => "text",
- NodeKind::BooleanOp { .. } => "boolean",
- NodeKind::Component { .. } | NodeKind::ComponentSet { .. } => "component",
- NodeKind::Instance { .. } => "instance",
- NodeKind::Slice => "slice",
- }
-}
-
-/// A CSS class name from a layer name ("Card / Title" → "card-title"), unique in `used`. The name
-/// is transliterated to ASCII first, so non-Latin names stay readable: 로그인 화면 →
-/// `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber` (per-codepoint transliteration, the slug
-/// libraries' way); a name left with nothing (symbols only) takes `kind`, and one starting with a
-/// digit keeps the `layer-` prefix.
-pub(crate) fn class_name(name: &str, kind: &str, used: &mut Vec) -> String {
- let mut base = String::new();
- for c in deunicode::deunicode(name).chars().flat_map(char::to_lowercase) {
- if c.is_ascii_alphanumeric() {
- base.push(c);
- } else if !base.ends_with('-') && !base.is_empty() {
- base.push('-');
- }
- }
- // The 40-character cut lands on a word boundary when there is one past the first word.
- let base = if base.chars().count() > 40 {
- let cut: String = base.chars().take(40).collect();
- match cut.rfind('-') {
- Some(i) if i > 0 => cut[..i].to_string(),
- _ => cut,
- }
- } else {
- base
- };
- let base = base.trim_end_matches('-');
- let base = if base.is_empty() {
- kind.to_string()
- } else if base.starts_with(|c: char| c.is_ascii_digit()) {
- format!("layer-{base}")
- } else {
- base.to_string()
- };
- let name = (1..).map(|k| if k == 1 { base.clone() } else { format!("{base}-{k}") }).find(|n| !used.contains(n)).unwrap_or(base);
- used.push(name.clone());
- name
-}
-
/// The index of `markup`'s first tag-closing `>`, skipping `>` inside quoted attribute values.
fn tag_end(markup: &str) -> Option {
let mut quote = None;
@@ -740,855 +409,6 @@ fn with_v_if(markup: &str, guard: Option<&str>) -> String {
format!("{} v-if=\"{guard}\"{}", &markup[..at], &markup[at..])
}
-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 ctx = Ctx::new(doc, id);
- let mut out = Markup::new(&ctx, CodeLang::Html);
- out.node(id, None, 0, "");
- format!("{}\n\n", out.markup, out.tokens.root_rule(), out.styles)
-}
-
-/// Markup being built for a layer tree: HTML (`class`), React JSX (`className`), Vue
-/// (`{{ }}` interpolation, `v-if`, `:attr` bindings) or Leptos `view!` markup (text as Rust
-/// string literals); the last three with component parameters and instances as calls. With its
-/// stylesheet.
-pub(crate) struct Markup<'c, 'a> {
- ctx: &'c Ctx<'a>,
- /// Html, React, Vue or Leptos.
- lang: CodeLang,
- used: Vec,
- 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,
- /// A Vue `v-if` guard for the element about to be written: Vue templates can't wrap an
- /// element in an expression, so a boolean parameter's layer carries the condition itself.
- v_if: Option,
- /// A prefix for every generated class name (slot content joins the caller's stylesheet, where
- /// two slots' layer names may repeat).
- pub class_prefix: Option,
- /// 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)>,
- /// Instances as their own (overridden) layers instead of component calls, except those
- /// connected to code (page code).
- pub inline: bool,
- /// Auto layout frames and text as fluid boxes (`width: 100%` up to their width) for pages.
- pub fluid: bool,
- /// With `fluid`, other layers at least this share of the root's width are fluid too (a
- /// divider, a banner image); smaller ones (icons) keep their size. Default: none.
- pub fluid_leaves: f64,
-}
-
-impl<'c, 'a> Markup<'c, 'a> {
- pub fn new(ctx: &'c Ctx<'a>, lang: CodeLang) -> Self {
- Markup {
- ctx,
- lang,
- used: Vec::new(),
- markup: String::new(),
- styles: String::new(),
- tokens: Tokens::default(),
- root_class: None,
- root_attrs: String::new(),
- v_if: None,
- class_prefix: None,
- rules: Vec::new(),
- inline: false,
- fluid: false,
- fluid_leaves: f64::INFINITY,
- }
- }
-
- /// JSX and Vue templates indent by two spaces, HTML and Leptos by four.
- fn pad(&self, depth: usize) -> String {
- if matches!(self.lang, CodeLang::React | CodeLang::Vue) { " ".repeat(depth) } else { indent(depth) }
- }
-
- /// 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) {
- let Some(n) = self.ctx.doc.node(id) else { return };
- if self.code()
- && id != self.ctx.root
- && let Some(p) = self.ctx.param(n, "visible")
- {
- // A Vue template holds no arbitrary expressions, so the guard rides on the element.
- if self.lang == CodeLang::Vue {
- self.v_if = Some(self.ident(p));
- self.element(n, parent, depth, path);
- self.v_if = None;
- return;
- }
- let (pad, p) = (self.pad(depth), self.ident(p));
- 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.markup.push_str(&format!("{pad}{close}\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 => {
- let class = class_name(&n.name, kind_word(n), &mut self.used);
- match &self.class_prefix {
- Some(prefix) => format!("{prefix}{class}"),
- None => class,
- }
- }
- };
- let pad = self.pad(depth);
- 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.
- let mut decls = String::new();
- let in_flow = parent.is_some_and(|p| p.layout.is_some()) && !n.child_layout.absolute;
- 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);
- let (main, cross) =
- if vertical { (n.child_layout.sizing_v, n.child_layout.sizing_h) } else { (n.child_layout.sizing_h, n.child_layout.sizing_v) };
- if main == Sizing::Fill {
- decls.push_str("flex: 1 1 0;\n");
- }
- if cross == Sizing::Fill {
- decls.push_str("align-self: stretch;\n");
- }
- } else {
- let c = n.transform.as_coeffs();
- decls.push_str(&format!("position: absolute;\nleft: {}px;\ntop: {}px;\n", num(c[4]), num(c[5])));
- let rot = figcraft_geom::rotation_degrees(n.transform);
- if rot.abs() > 0.01 {
- decls.push_str(&format!("transform: rotate({}deg);\ntransform-origin: 0 0;\n", num(-rot)));
- }
- }
- // Nested instances are calls (component code), in a wrapper that places them when needed.
- // Page code inlines instances, except those connected to code (codeConnect.set): their
- // snippet, e.g. a design-system `
\n", open("p")));
- }
- _ => {
- let kids = self.ctx.kids(n);
- let slot = self.ctx.param(n, "slot").filter(|_| self.code());
- let vue = self.lang == CodeLang::Vue;
- let leptos = self.lang == CodeLang::Leptos;
- let open = open("div");
- let slot_ident = slot.map(|p| match self.ctx.comp {
- Some(c) => crate::component_codegen::slot_name(c, p),
- None => class_name(&p.ident, "slot", &mut Vec::new()),
- });
- if let Some(p) = slot.filter(|_| kids.is_empty()) {
- let content = if leptos {
- format!("{{{}}}.as_ref().map(|c| c.run())", self.ident(p))
- } else if vue {
- // Vue's own slot element, named so an instance's content lands in the right one.
- match slot_ident.clone() {
- Some(name) => format!(""),
- None => "".into(),
- }
- } else {
- format!("{{{}}}", p.ident)
- };
- self.markup.push_str(&format!("{open}{content}\n"));
- } else if kids.is_empty() || self.ctx.too_deep(depth) {
- self.markup.push_str(&format!("{open}\n"));
- } else {
- 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(_) if vue => {
- let name = slot_ident.clone().map(|n| format!(" name=\"{n}\"")).unwrap_or_default();
- self.markup.push_str(&format!("{p1}\n"));
- depth + 2
- }
- 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) => {
- 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 {
- 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));
- }
- match slot {
- Some(_) if vue => self.markup.push_str(&format!("{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}\n"));
- }
- }
- }
- }
-}
-
-/// `decls` for a box that follows the page: a fixed width becomes `width: 100%` capped at that
-/// width (or just the full width for boxes as wide as the page), and a frame's fixed height a
-/// minimum, so content can wrap and grow.
-// ponytail: width-based heuristic; fixed-size leaves (icons, images, shapes) keep their pixels.
-fn fluid(decls: &str, full: bool, frame: bool) -> String {
- let mut out = String::with_capacity(decls.len() + 32);
- for line in decls.lines() {
- if let Some(w) = line.strip_prefix("width: ") {
- out.push_str("width: 100%;\n");
- if !full {
- out.push_str(&format!("max-width: {w}\n"));
- }
- } else if let Some(h) = line.strip_prefix("height: ").filter(|_| frame) {
- out.push_str(&format!("min-height: {h}\n"));
- } else {
- out.push_str(line);
- out.push('\n');
- }
- }
- out
-}
-
-// ------------------------------------------------------------------ SwiftUI
-
-fn swift_color(c: Color) -> String {
- format!("Color(red: {}, green: {}, blue: {}, opacity: {})", num(f64::from(c.r)), num(f64::from(c.g)), num(f64::from(c.b)), num(f64::from(c.a)))
-}
-
-fn swift_weight(w: u16) -> &'static str {
- match w {
- 0..=150 => ".thin",
- 151..=250 => ".ultraLight",
- 251..=350 => ".light",
- 351..=450 => ".regular",
- 451..=550 => ".medium",
- 551..=650 => ".semibold",
- 651..=750 => ".bold",
- 751..=850 => ".heavy",
- _ => ".black",
- }
-}
-
-/// 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);
-}
-
-/// A layer name safe for a single-line `//` comment: line breaks become spaces.
-fn one_line(name: &str) -> String {
- name.replace(['\n', '\r', '\u{85}', '\u{2028}', '\u{2029}'], " ")
-}
-
-fn swift_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[String]) {
- let doc = ctx.doc;
- let pad = indent(depth);
- if ctx.too_deep(depth) {
- out.push_str(&format!("{pad}// {} …\n", one_line(&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).map(|(code, _)| code),
- };
- 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) => {
- 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),
- num(t.style.font_size),
- swift_weight(t.style.weight())
- ));
- if let Some(c) = first_fill(n) {
- mods.push(format!(".foregroundColor({})", swift_color(c)));
- }
- }
- NodeKind::Ellipse { .. } => {
- out.push_str(&format!("{pad}Ellipse()\n"));
- if let Some(c) = first_fill(n) {
- mods.push(format!(".fill({})", swift_color(c)));
- }
- mods.push(format!(".frame(width: {}, height: {})", num(n.width), num(n.height)));
- }
- NodeKind::Rectangle => {
- out.push_str(&format!("{pad}RoundedRectangle(cornerRadius: {})\n", num(radius(n))));
- if let Some(c) = first_fill(n) {
- mods.push(format!(".fill({})", swift_color(c)));
- }
- mods.push(format!(".frame(width: {}, height: {})", num(n.width), num(n.height)));
- }
- k if k.is_frame_like() || matches!(k, NodeKind::Group) => {
- 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"));
- // 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);
- }
- 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) {
- mods.push(format!(".padding(EdgeInsets(top: {}, leading: {}, bottom: {}, trailing: {}))", num(t), num(lft), num(b), num(r)));
- }
- }
- mods.push(format!(".frame(width: {}, height: {}, alignment: .topLeading)", num(n.width), num(n.height)));
- if let Some(c) = first_fill(n) {
- mods.push(format!(".background({})", swift_color(c)));
- }
- if radius(n) > 0.0 {
- mods.push(format!(".clipShape(RoundedRectangle(cornerRadius: {}))", num(radius(n))));
- }
- }
- _ => {
- out.push_str(&format!("{pad}// {}: export as an image asset\n{pad}Image({})\n", n.kind.label(), literal(&n.name, false)));
- mods.push(".resizable()".into());
- mods.push(format!(".frame(width: {}, height: {})", num(n.width), num(n.height)));
- }
- }
- if let Some(c) = stroke_color(n) {
- mods.push(format!(
- ".overlay(RoundedRectangle(cornerRadius: {}).stroke({}, lineWidth: {}))",
- num(radius(n)),
- swift_color(c),
- num(n.stroke.weight)
- ));
- }
- if let Some(s) = drop_shadow(n) {
- mods.push(format!(
- ".shadow(color: {}, radius: {}, x: {}, y: {})",
- swift_color(s.color),
- num(s.radius / 2.0),
- num(s.offset.0),
- num(s.offset.1)
- ));
- }
- 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"));
- }
-}
-
-// ------------------------------------------------------------------ Jetpack Compose
-
-/// `Color(0xAARRGGBB)`, as Compose and Flutter both write a colour.
-pub(crate) fn kotlin_color(c: Color) -> String {
- let [r, g, b, a] = c.to_rgba8();
- format!("Color(0x{a:02X}{r:02X}{g:02X}{b:02X})")
-}
-
-/// The Modifier chain for a layer (size, shadow, background, border, alpha, padding).
-fn modifier(n: &Node, shape: &str, padding: bool) -> String {
- let mut m = format!("Modifier.size({}.dp, {}.dp)", num(n.width), num(n.height));
- if let Some(s) = drop_shadow(n) {
- m.push_str(&format!(".shadow({}.dp, {shape})", num(s.radius / 2.0)));
- }
- if let Some(c) = first_fill(n) {
- m.push_str(&format!(".background({}, {shape})", kotlin_color(c)));
- }
- if let Some(c) = stroke_color(n) {
- m.push_str(&format!(".border({}.dp, {}, {shape})", num(n.stroke.weight), kotlin_color(c)));
- }
- if n.opacity < 1.0 {
- m.push_str(&format!(".alpha({}f)", num(f64::from(n.opacity))));
- }
- if padding && let Some(l) = &n.layout {
- let [t, r, b, lft] = l.padding;
- if [t, r, b, lft].iter().any(|v| *v > 0.0) {
- m.push_str(&format!(".padding(start = {}.dp, top = {}.dp, end = {}.dp, bottom = {}.dp)", num(lft), num(t), num(r), num(b)));
- }
- }
- m
-}
-
-/// 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 ctx.too_deep(depth) {
- out.push_str(&format!("{pad}// {} …\n", one_line(&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).map(|(code, _)| code),
- };
- 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 = {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
- ));
- }
- 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 = 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((_, l)) => format!(
- "Row(\n{pad} modifier = {},\n{pad} horizontalArrangement = Arrangement.spacedBy({}.dp),\n{pad} verticalAlignment = Alignment.CenterVertically,\n{pad}) {{",
- modifier(n, &shape, true),
- num(l.gap)
- ),
- 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(base + 1), num(x), num(y)));
- compose(ctx, *c, base + 2, out);
- out.push_str(&format!("{}}}\n", indent(base + 1)));
- } else {
- 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!(
- "{pad}// {}: export as an image asset\n{pad}Image(painter = painterResource(R.drawable.asset), contentDescription = {}, modifier = Modifier.size({}.dp, {}.dp))\n",
- n.kind.label(),
- literal(&n.name, true),
- num(n.width),
- num(n.height)
- )),
- }
-}
-
-// ------------------------------------------------------------------ ArkUI (ArkTS)
-
-/// A colour as ArkUI writes it: `'#RRGGBB'`, or `'#AARRGGBB'` with transparency.
-fn ark_color(c: Color) -> String {
- let [r, g, b, a] = c.to_rgba8();
- if a == 255 { format!("'#{r:02X}{g:02X}{b:02X}'") } else { format!("'#{a:02X}{r:02X}{g:02X}{b:02X}'") }
-}
-
-/// A layer's corner radii as `.borderRadius(...)` (uniform or per corner).
-fn ark_radius(n: &Node, mods: &mut Vec) {
- if let Some(r) = n.corners.uniform() {
- if r > 0.0 {
- mods.push(format!(".borderRadius({})", num(r)));
- }
- } else if n.corners.radii.iter().any(|r| *r > 0.0) {
- let [tl, tr, br, bl] = n.corners.radii;
- mods.push(format!(".borderRadius({{ topLeft: {}, topRight: {}, bottomRight: {}, bottomLeft: {} }})", num(tl), num(tr), num(br), num(bl)));
- }
-}
-
-/// ArkUI for a layer and its subtree at `depth`; `extra` attributes go last (a free child's
-/// position). A layer a boolean parameter shows sits in an `if`.
-pub(crate) fn arkui(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 (this.{}) {{\n", p.ident));
- arkui_layer(ctx, n, depth + 1, out, extra);
- out.push_str(&format!("{pad}}}\n"));
- return;
- }
- arkui_layer(ctx, n, depth, out, extra);
-}
-
-fn arkui_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[String]) {
- let doc = ctx.doc;
- let pad = indent(depth);
- if ctx.too_deep(depth) {
- out.push_str(&format!("{pad}// {} …\n", one_line(&n.name)));
- return;
- }
- // Nested instances are calls; an instance swap parameter is a builder.
- let swap = ctx.param(n, "mainComponent").filter(|_| n.id != ctx.root);
- let call = match swap {
- Some(p) => Some(format!("{pad}this.{}()\n", p.ident)),
- None => ctx.call(n, CodeLang::ArkUi, depth).map(|(code, _)| code),
- };
- if let Some(call) = call {
- if let Some(p) = swap.filter(|_| !extra.is_empty()) {
- // The builder returns nothing, so a position goes on a container around the call.
- out.push_str(&format!("{pad}Stack() {{\n{pad} this.{}()\n{pad}}}\n", p.ident));
- for m in extra {
- out.push_str(&format!("{pad}{m}\n"));
- }
- return;
- }
- 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) => {
- let text = ctx.param(n, "characters").map_or_else(|| literal(&t.characters, false), |p| format!("this.{}", p.ident));
- out.push_str(&format!("{pad}Text({text})\n"));
- let s = &t.style;
- mods.push(format!(".fontSize({})", num(s.font_size)));
- mods.push(format!(".fontWeight({})", s.weight().clamp(100, 900)));
- mods.push(format!(".fontFamily({})", literal(&s.font_family, false)));
- if s.italic() {
- mods.push(".fontStyle(FontStyle.Italic)".into());
- }
- if let Some(c) = first_fill(n) {
- mods.push(format!(".fontColor({})", ark_color(c)));
- }
- match s.line_height {
- LineHeight::Pixels(v) => mods.push(format!(".lineHeight({})", num(v))),
- LineHeight::Percent(p) => mods.push(format!(".lineHeight('{}%')", num(p))),
- LineHeight::Auto => {}
- }
- match s.letter_spacing {
- LetterSpacing::Pixels(v) if v != 0.0 => mods.push(format!(".letterSpacing({})", num(v))),
- LetterSpacing::Percent(p) if p != 0.0 => mods.push(format!(".letterSpacing('{}%')", num(p))),
- _ => {}
- }
- match t.align_h {
- TextAlignH::Left => {}
- TextAlignH::Center => mods.push(".textAlign(TextAlign.Center)".into()),
- TextAlignH::Right => mods.push(".textAlign(TextAlign.End)".into()),
- TextAlignH::Justified => mods.push(".textAlign(TextAlign.JUSTIFY)".into()),
- }
- // Aligning needs a box to align in: the text layer's own width.
- if !matches!(t.align_h, TextAlignH::Left) {
- mods.push(format!(".width({})", num(n.width)));
- }
- match s.case {
- TextCase::Upper => mods.push(".textCase(TextCase.UpperCase)".into()),
- TextCase::Lower => mods.push(".textCase(TextCase.LowerCase)".into()),
- _ => {}
- }
- match s.decoration {
- TextDecoration::Underline => mods.push(".decoration({ type: TextDecorationType.Underline })".into()),
- TextDecoration::Strikethrough => mods.push(".decoration({ type: TextDecorationType.LineThrough })".into()),
- TextDecoration::None => {}
- }
- }
- NodeKind::Ellipse { .. } => {
- out.push_str(&format!("{pad}Ellipse({{ width: {}, height: {} }})\n", num(n.width), num(n.height)));
- if let Some(c) = first_fill(n) {
- mods.push(format!(".fill({})", ark_color(c)));
- }
- if let Some(c) = stroke_color(n) {
- mods.push(format!(".stroke({})", ark_color(c)));
- mods.push(format!(".strokeWidth({})", num(n.stroke.weight)));
- }
- }
- NodeKind::Rectangle => {
- out.push_str(&format!("{pad}Column()\n"));
- if let Some(c) = first_fill(n) {
- mods.push(format!(".backgroundColor({})", ark_color(c)));
- }
- ark_radius(n, &mut mods);
- mods.push(format!(".width({})", num(n.width)));
- mods.push(format!(".height({})", num(n.height)));
- }
- k if k.is_frame_like() || matches!(k, NodeKind::Group) => {
- let open = match n.layout.as_ref().map(|l| (l.mode, l)) {
- Some((LayoutMode::Vertical, l)) => format!("Column({{ space: {} }})", num(l.gap)),
- Some((_, l)) => format!("Row({{ space: {} }})", num(l.gap)),
- None => "Stack({ alignContent: Alignment.TopStart })".to_string(),
- };
- out.push_str(&format!("{pad}{open} {{\n"));
- match ctx.param(n, "slot") {
- // A slot shows the instance's content, or else the main's (its default builder).
- Some(p) => out.push_str(&format!("{pad} this.{}()\n", p.ident)),
- None => {
- for c in ctx.kids(n) {
- // Free children sit at their position inside a Stack, and so does a child
- // that ignores its auto layout parent.
- let pos: Vec = match doc.node(c).filter(|cn| n.layout.is_none() || cn.child_layout.absolute) {
- Some(cn) => {
- let [_, _, _, _, x, y] = cn.transform.as_coeffs();
- vec![format!(".position({{ x: {}, y: {} }})", num(x), num(y))]
- }
- None => Vec::new(),
- };
- arkui(ctx, c, depth + 1, out, &pos);
- }
- }
- }
- 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) {
- mods.push(format!(".padding({{ top: {}, right: {}, bottom: {}, left: {} }})", num(t), num(r), num(b), num(lft)));
- }
- let justify = match l.main_align {
- MainAlign::Min => None,
- MainAlign::Center => Some("FlexAlign.Center"),
- MainAlign::Max => Some("FlexAlign.End"),
- MainAlign::SpaceBetween => Some("FlexAlign.SpaceBetween"),
- MainAlign::SpaceAround => Some("FlexAlign.SpaceAround"),
- MainAlign::SpaceEvenly => Some("FlexAlign.SpaceEvenly"),
- };
- if let Some(j) = justify {
- mods.push(format!(".justifyContent({j})"));
- }
- let cross = match l.cross_align {
- CrossAlign::Min => None,
- CrossAlign::Center => Some("Center"),
- CrossAlign::Max => Some(if l.mode == LayoutMode::Vertical { "End" } else { "Bottom" }),
- CrossAlign::Baseline => None,
- };
- if let Some(c) = cross {
- let ty = if l.mode == LayoutMode::Vertical { "HorizontalAlign" } else { "VerticalAlign" };
- mods.push(format!(".alignItems({ty}.{c})"));
- }
- }
- mods.push(format!(".width({})", num(n.width)));
- mods.push(format!(".height({})", num(n.height)));
- if let Some(c) = first_fill(n) {
- mods.push(format!(".backgroundColor({})", ark_color(c)));
- }
- if radius(n) > 0.0 {
- mods.push(".clip(true)".into());
- }
- ark_radius(n, &mut mods);
- }
- _ => {
- out.push_str(&format!("{pad}// {}: export as an image asset\n{pad}Image($r('app.media.asset'))\n", n.kind.label()));
- mods.push(format!(".width({})", num(n.width)));
- mods.push(format!(".height({})", num(n.height)));
- }
- }
- // Ellipses take their stroke as a shape attribute (above).
- if !matches!(n.kind, NodeKind::Ellipse { .. })
- && let Some(c) = stroke_color(n)
- {
- mods.push(format!(".border({{ width: {}, color: {} }})", num(n.stroke.weight), ark_color(c)));
- }
- if let Some(s) = drop_shadow(n) {
- mods.push(format!(
- ".shadow({{ radius: {}, color: {}, offsetX: {}, offsetY: {} }})",
- num(s.radius / 2.0),
- ark_color(s.color),
- num(s.offset.0),
- num(s.offset.1)
- ));
- }
- if n.opacity < 1.0 {
- mods.push(format!(".opacity({})", num(f64::from(n.opacity))));
- }
- let rot = figcraft_geom::rotation_degrees(n.transform);
- if rot.abs() > 0.01 && rot.is_finite() {
- mods.push(format!(".rotate({{ angle: {} }})", num(-rot)));
- }
- mods.extend(extra.iter().cloned());
- for m in mods {
- out.push_str(&format!("{pad} {m}\n"));
- }
-}
-
#[cfg(test)]
mod tests {
use super::*;
diff --git a/crates/engine/src/dev/codegen/arkui.rs b/crates/engine/src/dev/codegen/arkui.rs
new file mode 100644
index 00000000..1d9816f6
--- /dev/null
+++ b/crates/engine/src/dev/codegen/arkui.rs
@@ -0,0 +1,228 @@
+use super::swift::one_line;
+use super::*;
+
+// ------------------------------------------------------------------ ArkUI (ArkTS)
+
+/// A colour as ArkUI writes it: `'#RRGGBB'`, or `'#AARRGGBB'` with transparency.
+fn ark_color(c: Color) -> String {
+ let [r, g, b, a] = c.to_rgba8();
+ if a == 255 { format!("'#{r:02X}{g:02X}{b:02X}'") } else { format!("'#{a:02X}{r:02X}{g:02X}{b:02X}'") }
+}
+
+/// A layer's corner radii as `.borderRadius(...)` (uniform or per corner).
+fn ark_radius(n: &Node, mods: &mut Vec) {
+ if let Some(r) = n.corners.uniform() {
+ if r > 0.0 {
+ mods.push(format!(".borderRadius({})", num(r)));
+ }
+ } else if n.corners.radii.iter().any(|r| *r > 0.0) {
+ let [tl, tr, br, bl] = n.corners.radii;
+ mods.push(format!(".borderRadius({{ topLeft: {}, topRight: {}, bottomRight: {}, bottomLeft: {} }})", num(tl), num(tr), num(br), num(bl)));
+ }
+}
+
+/// ArkUI for a layer and its subtree at `depth`; `extra` attributes go last (a free child's
+/// position). A layer a boolean parameter shows sits in an `if`.
+pub(crate) fn arkui(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 (this.{}) {{\n", p.ident));
+ arkui_layer(ctx, n, depth + 1, out, extra);
+ out.push_str(&format!("{pad}}}\n"));
+ return;
+ }
+ arkui_layer(ctx, n, depth, out, extra);
+}
+
+fn arkui_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[String]) {
+ let doc = ctx.doc;
+ let pad = indent(depth);
+ if ctx.too_deep(depth) {
+ out.push_str(&format!("{pad}// {} …\n", one_line(&n.name)));
+ return;
+ }
+ // Nested instances are calls; an instance swap parameter is a builder.
+ let swap = ctx.param(n, "mainComponent").filter(|_| n.id != ctx.root);
+ let call = match swap {
+ Some(p) => Some(format!("{pad}this.{}()\n", p.ident)),
+ None => ctx.call(n, CodeLang::ArkUi, depth).map(|(code, _)| code),
+ };
+ if let Some(call) = call {
+ if let Some(p) = swap.filter(|_| !extra.is_empty()) {
+ // The builder returns nothing, so a position goes on a container around the call.
+ out.push_str(&format!("{pad}Stack() {{\n{pad} this.{}()\n{pad}}}\n", p.ident));
+ for m in extra {
+ out.push_str(&format!("{pad}{m}\n"));
+ }
+ return;
+ }
+ 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) => {
+ let text = ctx.param(n, "characters").map_or_else(|| literal(&t.characters, false), |p| format!("this.{}", p.ident));
+ out.push_str(&format!("{pad}Text({text})\n"));
+ let s = &t.style;
+ mods.push(format!(".fontSize({})", num(s.font_size)));
+ mods.push(format!(".fontWeight({})", s.weight().clamp(100, 900)));
+ mods.push(format!(".fontFamily({})", literal(&s.font_family, false)));
+ if s.italic() {
+ mods.push(".fontStyle(FontStyle.Italic)".into());
+ }
+ if let Some(c) = first_fill(n) {
+ mods.push(format!(".fontColor({})", ark_color(c)));
+ }
+ match s.line_height {
+ LineHeight::Pixels(v) => mods.push(format!(".lineHeight({})", num(v))),
+ LineHeight::Percent(p) => mods.push(format!(".lineHeight('{}%')", num(p))),
+ LineHeight::Auto => {}
+ }
+ match s.letter_spacing {
+ LetterSpacing::Pixels(v) if v != 0.0 => mods.push(format!(".letterSpacing({})", num(v))),
+ LetterSpacing::Percent(p) if p != 0.0 => mods.push(format!(".letterSpacing('{}%')", num(p))),
+ _ => {}
+ }
+ match t.align_h {
+ TextAlignH::Left => {}
+ TextAlignH::Center => mods.push(".textAlign(TextAlign.Center)".into()),
+ TextAlignH::Right => mods.push(".textAlign(TextAlign.End)".into()),
+ TextAlignH::Justified => mods.push(".textAlign(TextAlign.JUSTIFY)".into()),
+ }
+ // Aligning needs a box to align in: the text layer's own width.
+ if !matches!(t.align_h, TextAlignH::Left) {
+ mods.push(format!(".width({})", num(n.width)));
+ }
+ match s.case {
+ TextCase::Upper => mods.push(".textCase(TextCase.UpperCase)".into()),
+ TextCase::Lower => mods.push(".textCase(TextCase.LowerCase)".into()),
+ _ => {}
+ }
+ match s.decoration {
+ TextDecoration::Underline => mods.push(".decoration({ type: TextDecorationType.Underline })".into()),
+ TextDecoration::Strikethrough => mods.push(".decoration({ type: TextDecorationType.LineThrough })".into()),
+ TextDecoration::None => {}
+ }
+ }
+ NodeKind::Ellipse { .. } => {
+ out.push_str(&format!("{pad}Ellipse({{ width: {}, height: {} }})\n", num(n.width), num(n.height)));
+ if let Some(c) = first_fill(n) {
+ mods.push(format!(".fill({})", ark_color(c)));
+ }
+ if let Some(c) = stroke_color(n) {
+ mods.push(format!(".stroke({})", ark_color(c)));
+ mods.push(format!(".strokeWidth({})", num(n.stroke.weight)));
+ }
+ }
+ NodeKind::Rectangle => {
+ out.push_str(&format!("{pad}Column()\n"));
+ if let Some(c) = first_fill(n) {
+ mods.push(format!(".backgroundColor({})", ark_color(c)));
+ }
+ ark_radius(n, &mut mods);
+ mods.push(format!(".width({})", num(n.width)));
+ mods.push(format!(".height({})", num(n.height)));
+ }
+ k if k.is_frame_like() || matches!(k, NodeKind::Group) => {
+ let open = match n.layout.as_ref().map(|l| (l.mode, l)) {
+ Some((LayoutMode::Vertical, l)) => format!("Column({{ space: {} }})", num(l.gap)),
+ Some((_, l)) => format!("Row({{ space: {} }})", num(l.gap)),
+ None => "Stack({ alignContent: Alignment.TopStart })".to_string(),
+ };
+ out.push_str(&format!("{pad}{open} {{\n"));
+ match ctx.param(n, "slot") {
+ // A slot shows the instance's content, or else the main's (its default builder).
+ Some(p) => out.push_str(&format!("{pad} this.{}()\n", p.ident)),
+ None => {
+ for c in ctx.kids(n) {
+ // Free children sit at their position inside a Stack, and so does a child
+ // that ignores its auto layout parent.
+ let pos: Vec = match doc.node(c).filter(|cn| n.layout.is_none() || cn.child_layout.absolute) {
+ Some(cn) => {
+ let [_, _, _, _, x, y] = cn.transform.as_coeffs();
+ vec![format!(".position({{ x: {}, y: {} }})", num(x), num(y))]
+ }
+ None => Vec::new(),
+ };
+ arkui(ctx, c, depth + 1, out, &pos);
+ }
+ }
+ }
+ 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) {
+ mods.push(format!(".padding({{ top: {}, right: {}, bottom: {}, left: {} }})", num(t), num(r), num(b), num(lft)));
+ }
+ let justify = match l.main_align {
+ MainAlign::Min => None,
+ MainAlign::Center => Some("FlexAlign.Center"),
+ MainAlign::Max => Some("FlexAlign.End"),
+ MainAlign::SpaceBetween => Some("FlexAlign.SpaceBetween"),
+ MainAlign::SpaceAround => Some("FlexAlign.SpaceAround"),
+ MainAlign::SpaceEvenly => Some("FlexAlign.SpaceEvenly"),
+ };
+ if let Some(j) = justify {
+ mods.push(format!(".justifyContent({j})"));
+ }
+ let cross = match l.cross_align {
+ CrossAlign::Min => None,
+ CrossAlign::Center => Some("Center"),
+ CrossAlign::Max => Some(if l.mode == LayoutMode::Vertical { "End" } else { "Bottom" }),
+ CrossAlign::Baseline => None,
+ };
+ if let Some(c) = cross {
+ let ty = if l.mode == LayoutMode::Vertical { "HorizontalAlign" } else { "VerticalAlign" };
+ mods.push(format!(".alignItems({ty}.{c})"));
+ }
+ }
+ mods.push(format!(".width({})", num(n.width)));
+ mods.push(format!(".height({})", num(n.height)));
+ if let Some(c) = first_fill(n) {
+ mods.push(format!(".backgroundColor({})", ark_color(c)));
+ }
+ if radius(n) > 0.0 {
+ mods.push(".clip(true)".into());
+ }
+ ark_radius(n, &mut mods);
+ }
+ _ => {
+ out.push_str(&format!("{pad}// {}: export as an image asset\n{pad}Image($r('app.media.asset'))\n", n.kind.label()));
+ mods.push(format!(".width({})", num(n.width)));
+ mods.push(format!(".height({})", num(n.height)));
+ }
+ }
+ // Ellipses take their stroke as a shape attribute (above).
+ if !matches!(n.kind, NodeKind::Ellipse { .. })
+ && let Some(c) = stroke_color(n)
+ {
+ mods.push(format!(".border({{ width: {}, color: {} }})", num(n.stroke.weight), ark_color(c)));
+ }
+ if let Some(s) = drop_shadow(n) {
+ mods.push(format!(
+ ".shadow({{ radius: {}, color: {}, offsetX: {}, offsetY: {} }})",
+ num(s.radius / 2.0),
+ ark_color(s.color),
+ num(s.offset.0),
+ num(s.offset.1)
+ ));
+ }
+ if n.opacity < 1.0 {
+ mods.push(format!(".opacity({})", num(f64::from(n.opacity))));
+ }
+ let rot = figcraft_geom::rotation_degrees(n.transform);
+ if rot.abs() > 0.01 && rot.is_finite() {
+ mods.push(format!(".rotate({{ angle: {} }})", num(-rot)));
+ }
+ mods.extend(extra.iter().cloned());
+ for m in mods {
+ out.push_str(&format!("{pad} {m}\n"));
+ }
+}
diff --git a/crates/engine/src/dev/codegen/compose.rs b/crates/engine/src/dev/codegen/compose.rs
new file mode 100644
index 00000000..5915ca9b
--- /dev/null
+++ b/crates/engine/src/dev/codegen/compose.rs
@@ -0,0 +1,131 @@
+use super::swift::one_line;
+use super::*;
+
+// ------------------------------------------------------------------ Jetpack Compose
+
+/// `Color(0xAARRGGBB)`, as Compose and Flutter both write a colour.
+pub(crate) fn kotlin_color(c: Color) -> String {
+ let [r, g, b, a] = c.to_rgba8();
+ format!("Color(0x{a:02X}{r:02X}{g:02X}{b:02X})")
+}
+
+/// The Modifier chain for a layer (size, shadow, background, border, alpha, padding).
+fn modifier(n: &Node, shape: &str, padding: bool) -> String {
+ let mut m = format!("Modifier.size({}.dp, {}.dp)", num(n.width), num(n.height));
+ if let Some(s) = drop_shadow(n) {
+ m.push_str(&format!(".shadow({}.dp, {shape})", num(s.radius / 2.0)));
+ }
+ if let Some(c) = first_fill(n) {
+ m.push_str(&format!(".background({}, {shape})", kotlin_color(c)));
+ }
+ if let Some(c) = stroke_color(n) {
+ m.push_str(&format!(".border({}.dp, {}, {shape})", num(n.stroke.weight), kotlin_color(c)));
+ }
+ if n.opacity < 1.0 {
+ m.push_str(&format!(".alpha({}f)", num(f64::from(n.opacity))));
+ }
+ if padding && let Some(l) = &n.layout {
+ let [t, r, b, lft] = l.padding;
+ if [t, r, b, lft].iter().any(|v| *v > 0.0) {
+ m.push_str(&format!(".padding(start = {}.dp, top = {}.dp, end = {}.dp, bottom = {}.dp)", num(lft), num(t), num(r), num(b)));
+ }
+ }
+ m
+}
+
+/// 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 ctx.too_deep(depth) {
+ out.push_str(&format!("{pad}// {} …\n", one_line(&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).map(|(code, _)| code),
+ };
+ 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 = {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
+ ));
+ }
+ 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 = 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((_, l)) => format!(
+ "Row(\n{pad} modifier = {},\n{pad} horizontalArrangement = Arrangement.spacedBy({}.dp),\n{pad} verticalAlignment = Alignment.CenterVertically,\n{pad}) {{",
+ modifier(n, &shape, true),
+ num(l.gap)
+ ),
+ 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(base + 1), num(x), num(y)));
+ compose(ctx, *c, base + 2, out);
+ out.push_str(&format!("{}}}\n", indent(base + 1)));
+ } else {
+ 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!(
+ "{pad}// {}: export as an image asset\n{pad}Image(painter = painterResource(R.drawable.asset), contentDescription = {}, modifier = Modifier.size({}.dp, {}.dp))\n",
+ n.kind.label(),
+ literal(&n.name, true),
+ num(n.width),
+ num(n.height)
+ )),
+ }
+}
diff --git a/crates/engine/src/dev/codegen/css.rs b/crates/engine/src/dev/codegen/css.rs
new file mode 100644
index 00000000..c7982652
--- /dev/null
+++ b/crates/engine/src/dev/codegen/css.rs
@@ -0,0 +1,288 @@
+use super::*;
+
+// ------------------------------------------------------------------ CSS
+
+/// The CSS custom properties generated code uses: name → fallback value, in first-use order.
+#[derive(Default)]
+pub(crate) struct Tokens {
+ vars: Vec<(String, String)>,
+ /// Name variables as Tailwind theme variables (`--color-accent`), not by their CSS name.
+ pub tailwind: bool,
+ /// The file's theme names (computed on first use).
+ theme: Option,
+}
+
+impl Tokens {
+ /// `var(--name, value)`, remembering the variable.
+ fn var(&mut self, doc: &Document, variable: &Variable, value: String) -> String {
+ let name =
+ if self.tailwind { self.theme.get_or_insert_with(|| crate::tailwind::Theme::new(doc)).name(variable) } else { css_var_of(variable) };
+ let out = format!("var({name}, {value})");
+ if !self.vars.iter().any(|(n, _)| *n == name) {
+ self.vars.push((name, value));
+ }
+ out
+ }
+
+ /// A `:root` rule declaring the variables (empty without any).
+ pub fn root_rule(&self) -> String {
+ if self.vars.is_empty() {
+ return String::new();
+ }
+ let decls: String = self.vars.iter().map(|(n, v)| format!(" {n}: {v};\n")).collect();
+ format!(":root {{\n{decls}}}\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('-');
+ }
+ }
+ let trimmed = out.trim_end_matches('-');
+ if trimmed.len() <= 2 { "--variable".into() } else { trimmed.to_string() }
+}
+
+/// A variable's CSS custom property: its Web code syntax (`var(--x)` or `--x`) when it has one,
+/// else made from its name.
+pub fn css_var_of(v: &Variable) -> String {
+ let Some(web) = v.code_syntax.get("WEB").map(|w| w.trim()) else { return css_var_name(&v.name) };
+ let inner = web.strip_prefix("var(").map_or(web, |r| r.split([',', ')']).next().unwrap_or("").trim());
+ let valid = inner.len() > 2 && inner.starts_with("--") && inner.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_');
+ if valid { inner.chars().take(80).collect() } else { css_var_name(inner) }
+}
+
+/// `value`, or a reference to the variable bound to the first of `fields` that has one.
+pub(super) 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(doc, v, value),
+ None => value,
+ }
+}
+
+pub(super) 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",
+ MainAlign::SpaceAround => "space-around",
+ MainAlign::SpaceEvenly => "space-evenly",
+ };
+ 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 l.wrap {
+ // The lines: spread, or packed where the items align (baseline: at the start).
+ let spread = l.content_align == ContentAlign::SpaceBetween;
+ put(
+ "align-content",
+ if spread {
+ "space-between"
+ } else if align == "baseline" {
+ "flex-start"
+ } else {
+ 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 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 {
+ put("opacity", tok(doc, n, &["opacity"], num(f64::from(n.opacity)), t));
+ }
+ if !matches!(n.blend, figcraft_color::BlendMode::Normal | figcraft_color::BlendMode::PassThrough) {
+ put("mix-blend-mode", n.blend.css().into());
+ }
+ // Rotation turns counter-clockwise about the top-left corner (the position given); CSS
+ // rotate turns clockwise.
+ let rot = figcraft_geom::rotation_degrees(n.transform);
+ if rot.abs() > 0.01 && rot.is_finite() {
+ put("transform", format!("rotate({}deg)", num(-rot)));
+ put("transform-origin", "top left".into());
+ }
+ 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" };
+ 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) => put("background", css_gradient(g, n.width, n.height)),
+ PaintKind::Image(_) => put("background", "url(image.png) center / cover".into()),
+ PaintKind::Pattern(_) => put("background", "url(pattern.png) repeat".into()),
+ }
+ }
+ for (key, value) in css_borders(doc, n, t) {
+ put(&key, value);
+ }
+ let shadows: Vec = n
+ .effects
+ .iter()
+ .filter(|e| e.visible)
+ .filter_map(|e| match &e.kind {
+ EffectKind::DropShadow(s) => {
+ Some(format!("{}px {}px {}px {}px {}", num(s.offset.0), num(s.offset.1), num(s.radius), num(s.spread), s.color.to_css()))
+ }
+ EffectKind::InnerShadow(s) => {
+ Some(format!("inset {}px {}px {}px {}px {}", num(s.offset.0), num(s.offset.1), num(s.radius), num(s.spread), s.color.to_css()))
+ }
+ _ => None,
+ })
+ .collect();
+ if !shadows.is_empty() {
+ put("box-shadow", shadows.join(", "));
+ }
+ for e in n.effects.iter().filter(|e| e.visible) {
+ match e.kind {
+ // CSS blurs are uniform: a progressive blur has no CSS equivalent and is left out.
+ EffectKind::LayerBlur(ref b) if !b.is_progressive() => put("filter", format!("blur({}px)", num(b.radius / 2.0))),
+ EffectKind::BackgroundBlur(ref b) if !b.is_progressive() => put("backdrop-filter", format!("blur({}px)", num(b.radius / 2.0))),
+ // CSS has no refraction: the frost is the nearest.
+ EffectKind::Glass(ref g) => put("backdrop-filter", format!("blur({}px)", num(g.sanitized().frost / 2.0))),
+ _ => {}
+ }
+ }
+ 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 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 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))),
+ _ => {}
+ }
+ match tx.align_h {
+ TextAlignH::Left => {}
+ TextAlignH::Center => put("text-align", "center".into()),
+ TextAlignH::Right => put("text-align", "right".into()),
+ TextAlignH::Justified => put("text-align", "justify".into()),
+ }
+ match s.case {
+ TextCase::Original => {}
+ TextCase::Upper => put("text-transform", "uppercase".into()),
+ TextCase::Lower => put("text-transform", "lowercase".into()),
+ TextCase::Title => put("text-transform", "capitalize".into()),
+ TextCase::SmallCaps => put("font-variant", "small-caps".into()),
+ }
+ match s.decoration {
+ TextDecoration::None => {}
+ TextDecoration::Underline => put("text-decoration", "underline".into()),
+ TextDecoration::Strikethrough => put("text-decoration", "line-through".into()),
+ }
+ if s.decoration != TextDecoration::None {
+ let l = s.look();
+ let len = |m: LineHeight| match m {
+ LineHeight::Auto => None,
+ LineHeight::Pixels(v) => Some(px(v)),
+ LineHeight::Percent(p) => Some(format!("{}%", num(p))),
+ };
+ match l.style {
+ figcraft_doc::DecorationStyle::Solid => {}
+ figcraft_doc::DecorationStyle::Dotted => put("text-decoration-style", "dotted".into()),
+ figcraft_doc::DecorationStyle::Wavy => put("text-decoration-style", "wavy".into()),
+ }
+ if let Some(v) = len(l.thickness) {
+ put("text-decoration-thickness", v);
+ }
+ if let Some(c) = l.color {
+ put("text-decoration-color", c.to_css());
+ }
+ if s.decoration == TextDecoration::Underline {
+ if let Some(v) = len(l.offset) {
+ put("text-underline-offset", v);
+ }
+ // Browsers skip ink by default; the canvas only when asked.
+ if !l.skip_ink {
+ put("text-decoration-skip-ink", "none".into());
+ }
+ }
+ }
+ if tx.leading_trim == figcraft_doc::LeadingTrim::CapHeight {
+ put("text-box", "trim-both cap alphabetic".into());
+ }
+ }
+ out
+}
+
+/// A gradient fill on a `w`×`h` box as a CSS background: linear along its handles' direction
+/// (CSS angles: 0deg up, 90deg right), radial with its handles' radii, angular as a conic
+/// gradient from its start direction.
+fn css_gradient(g: &figcraft_color::Gradient, w: f64, h: f64) -> String {
+ let stops = g.sorted_stops().iter().map(|s| format!("{} {}%", s.color.to_css(), (s.position * 100.0).round())).collect::>().join(", ");
+ let (dx, dy) = ((g.to.0 - g.from.0) * w, (g.to.1 - g.from.1) * h);
+ let angle = if dx == 0.0 && dy == 0.0 { 180.0 } else { dx.atan2(-dy).to_degrees().rem_euclid(360.0) };
+ let pct = |v: f64| format!("{}%", num(v * 100.0));
+ let at = format!("at {} {}", pct(g.from.0), pct(g.from.1));
+ match g.kind {
+ figcraft_color::GradientKind::Linear => format!("linear-gradient({}deg, {stops})", num(angle)),
+ figcraft_color::GradientKind::Radial | figcraft_color::GradientKind::Diamond => {
+ let (rx, ry) = (((g.width.0 - g.from.0) * w).hypot((g.width.1 - g.from.1) * h), dx.hypot(dy));
+ let rel = |r: f64, side: f64| if side > 0.0 { pct(r / side) } else { "50%".into() };
+ format!("radial-gradient({} {} {at}, {stops})", rel(rx, w), rel(ry, h))
+ }
+ figcraft_color::GradientKind::Angular => format!("conic-gradient(from {}deg {at}, {stops})", num(angle)),
+ }
+}
diff --git a/crates/engine/src/dev/codegen/html.rs b/crates/engine/src/dev/codegen/html.rs
new file mode 100644
index 00000000..3ae746f1
--- /dev/null
+++ b/crates/engine/src/dev/codegen/html.rs
@@ -0,0 +1,390 @@
+use super::css::{Tokens, css_decls};
+use super::*;
+
+// ------------------------------------------------------------------ HTML and React markup
+
+/// A word for the node's kind, naming layers whose name transliterates to nothing ("###" →
+/// `.text`, `.frame`), instead of the anonymous `layer-`.
+fn kind_word(n: &Node) -> &str {
+ match &n.kind {
+ NodeKind::Page { .. } => "page",
+ NodeKind::Frame => "frame",
+ NodeKind::Group => "group",
+ NodeKind::Section { .. } => "section",
+ NodeKind::Rectangle => "rectangle",
+ NodeKind::Ellipse { .. } => "ellipse",
+ NodeKind::Polygon { .. } => "polygon",
+ NodeKind::Star { .. } => "star",
+ NodeKind::Line => "line",
+ NodeKind::Vector { .. } => "vector",
+ NodeKind::Text(_) => "text",
+ NodeKind::BooleanOp { .. } => "boolean",
+ NodeKind::Component { .. } | NodeKind::ComponentSet { .. } => "component",
+ NodeKind::Instance { .. } => "instance",
+ NodeKind::Slice => "slice",
+ }
+}
+
+/// A CSS class name from a layer name ("Card / Title" → "card-title"), unique in `used`. The name
+/// is transliterated to ASCII first, so non-Latin names stay readable: 로그인 화면 →
+/// `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber` (per-codepoint transliteration, the slug
+/// libraries' way); a name left with nothing (symbols only) takes `kind`, and one starting with a
+/// digit keeps the `layer-` prefix.
+pub(crate) fn class_name(name: &str, kind: &str, used: &mut Vec) -> String {
+ let mut base = String::new();
+ for c in deunicode::deunicode(name).chars().flat_map(char::to_lowercase) {
+ if c.is_ascii_alphanumeric() {
+ base.push(c);
+ } else if !base.ends_with('-') && !base.is_empty() {
+ base.push('-');
+ }
+ }
+ // The 40-character cut lands on a word boundary when there is one past the first word.
+ let base = if base.chars().count() > 40 {
+ let cut: String = base.chars().take(40).collect();
+ match cut.rfind('-') {
+ Some(i) if i > 0 => cut[..i].to_string(),
+ _ => cut,
+ }
+ } else {
+ base
+ };
+ let base = base.trim_end_matches('-');
+ let base = if base.is_empty() {
+ kind.to_string()
+ } else if base.starts_with(|c: char| c.is_ascii_digit()) {
+ format!("layer-{base}")
+ } else {
+ base.to_string()
+ };
+ let name = (1..).map(|k| if k == 1 { base.clone() } else { format!("{base}-{k}") }).find(|n| !used.contains(n)).unwrap_or(base);
+ used.push(name.clone());
+ name
+}
+
+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.
+pub(super) fn html(doc: &Document, id: NodeId) -> String {
+ let ctx = Ctx::new(doc, id);
+ let mut out = Markup::new(&ctx, CodeLang::Html);
+ out.node(id, None, 0, "");
+ format!("{}\n\n", out.markup, out.tokens.root_rule(), out.styles)
+}
+
+/// Markup being built for a layer tree: HTML (`class`), React JSX (`className`), Vue
+/// (`{{ }}` interpolation, `v-if`, `:attr` bindings) or Leptos `view!` markup (text as Rust
+/// string literals); the last three with component parameters and instances as calls. With its
+/// stylesheet.
+pub(crate) struct Markup<'c, 'a> {
+ ctx: &'c Ctx<'a>,
+ /// Html, React, Vue or Leptos.
+ lang: CodeLang,
+ used: Vec,
+ 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,
+ /// A Vue `v-if` guard for the element about to be written: Vue templates can't wrap an
+ /// element in an expression, so a boolean parameter's layer carries the condition itself.
+ v_if: Option,
+ /// A prefix for every generated class name (slot content joins the caller's stylesheet, where
+ /// two slots' layer names may repeat).
+ pub class_prefix: Option,
+ /// 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)>,
+ /// Instances as their own (overridden) layers instead of component calls, except those
+ /// connected to code (page code).
+ pub inline: bool,
+ /// Auto layout frames and text as fluid boxes (`width: 100%` up to their width) for pages.
+ pub fluid: bool,
+ /// With `fluid`, other layers at least this share of the root's width are fluid too (a
+ /// divider, a banner image); smaller ones (icons) keep their size. Default: none.
+ pub fluid_leaves: f64,
+}
+
+impl<'c, 'a> Markup<'c, 'a> {
+ pub fn new(ctx: &'c Ctx<'a>, lang: CodeLang) -> Self {
+ Markup {
+ ctx,
+ lang,
+ used: Vec::new(),
+ markup: String::new(),
+ styles: String::new(),
+ tokens: Tokens::default(),
+ root_class: None,
+ root_attrs: String::new(),
+ v_if: None,
+ class_prefix: None,
+ rules: Vec::new(),
+ inline: false,
+ fluid: false,
+ fluid_leaves: f64::INFINITY,
+ }
+ }
+
+ /// JSX and Vue templates indent by two spaces, HTML and Leptos by four.
+ fn pad(&self, depth: usize) -> String {
+ if matches!(self.lang, CodeLang::React | CodeLang::Vue) { " ".repeat(depth) } else { indent(depth) }
+ }
+
+ /// 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) {
+ let Some(n) = self.ctx.doc.node(id) else { return };
+ if self.code()
+ && id != self.ctx.root
+ && let Some(p) = self.ctx.param(n, "visible")
+ {
+ // A Vue template holds no arbitrary expressions, so the guard rides on the element.
+ if self.lang == CodeLang::Vue {
+ self.v_if = Some(self.ident(p));
+ self.element(n, parent, depth, path);
+ self.v_if = None;
+ return;
+ }
+ let (pad, p) = (self.pad(depth), self.ident(p));
+ 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.markup.push_str(&format!("{pad}{close}\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 => {
+ let class = class_name(&n.name, kind_word(n), &mut self.used);
+ match &self.class_prefix {
+ Some(prefix) => format!("{prefix}{class}"),
+ None => class,
+ }
+ }
+ };
+ let pad = self.pad(depth);
+ 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.
+ let mut decls = String::new();
+ let in_flow = parent.is_some_and(|p| p.layout.is_some()) && !n.child_layout.absolute;
+ 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);
+ let (main, cross) =
+ if vertical { (n.child_layout.sizing_v, n.child_layout.sizing_h) } else { (n.child_layout.sizing_h, n.child_layout.sizing_v) };
+ if main == Sizing::Fill {
+ decls.push_str("flex: 1 1 0;\n");
+ }
+ if cross == Sizing::Fill {
+ decls.push_str("align-self: stretch;\n");
+ }
+ } else {
+ let c = n.transform.as_coeffs();
+ decls.push_str(&format!("position: absolute;\nleft: {}px;\ntop: {}px;\n", num(c[4]), num(c[5])));
+ let rot = figcraft_geom::rotation_degrees(n.transform);
+ if rot.abs() > 0.01 {
+ decls.push_str(&format!("transform: rotate({}deg);\ntransform-origin: 0 0;\n", num(-rot)));
+ }
+ }
+ // Nested instances are calls (component code), in a wrapper that places them when needed.
+ // Page code inlines instances, except those connected to code (codeConnect.set): their
+ // snippet, e.g. a design-system `