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 `