diff --git a/crates/engine/src/code_connect.rs b/crates/engine/src/code_connect.rs index 46340636..d63f4978 100644 --- a/crates/engine/src/code_connect.rs +++ b/crates/engine/src/code_connect.rs @@ -37,7 +37,7 @@ const MAX_WARNINGS: usize = 50; /// The languages a component can be connected in. pub fn language(s: &str) -> Option { - CodeLang::from_id(s).filter(|l| matches!(l, CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose)) + CodeLang::from_id(s).filter(|l| matches!(l, CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::ArkUi)) } /// The layer `id`'s links are stored on: its component set, else its component (an instance's @@ -64,7 +64,7 @@ pub fn import_line(link: &CodeLink, lang: CodeLang) -> String { let path = &link.import_path; match lang { _ if path.is_empty() => String::new(), - CodeLang::React => format!("import {{ {} }} from {};", link.name.split('.').next().unwrap_or_default(), js_string(path)), + CodeLang::React | CodeLang::ArkUi => format!("import {{ {} }} from {};", link.name.split('.').next().unwrap_or_default(), js_string(path)), CodeLang::Leptos => format!("use {path}::{};", link.name.split('.').next().unwrap_or_default()), _ => format!("import {path}"), } @@ -175,6 +175,7 @@ fn expr(doc: &Document, c: &Component, p: &Param, v: &Value, lang: CodeLang) -> (Some(l), CodeLang::React) => format!("<{} />", l.name), (Some(l), CodeLang::Leptos) => format!("|| view! {{ <{} /> }}", l.name), (Some(l), CodeLang::SwiftUi) => format!("{}()", l.name), + (Some(l), CodeLang::ArkUi) => format!("() => {{ {}() }}", l.name), (Some(l), _) => format!("{{ {}() }}", l.name), (None, _) => value_expr(doc, c, p, v, lang), } @@ -222,6 +223,7 @@ fn default_template(c: &Component, lang: CodeLang, name: &str) -> String { format!("<{name}{} />", params.iter().map(|p| format!(" {}={{{p}}}", crate::component_codegen::rust_ident(p))).collect::()) } CodeLang::SwiftUi => format!("{name}({})", params.iter().map(|p| format!("{p}: {{{p}}}")).collect::>().join(", ")), + CodeLang::ArkUi => format!("{name}({{ {} }})", params.iter().map(|p| format!("{p}: {{{p}}}")).collect::>().join(", ")), _ => format!("{name}({})", params.iter().map(|p| format!("{p} = {{{p}}}")).collect::>().join(", ")), } } @@ -239,13 +241,14 @@ pub fn check(c: &Component, link: &CodeLink, lang: CodeLang) -> std::result::Res } let path = &link.import_path; let path_ok = match lang { - CodeLang::React => !path.chars().any(|ch| ch.is_control() || ch == '"' || ch == '\\'), + CodeLang::React | CodeLang::ArkUi => !path.chars().any(|ch| ch.is_control() || ch == '"' || ch == '\\'), CodeLang::Leptos => path.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == ':'), _ => path.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '.'), }; if path.chars().count() > MAX_IMPORT || !path_ok { return Err(match lang { CodeLang::React => format!("importPath: a module path without quotes, up to {MAX_IMPORT} characters"), + CodeLang::ArkUi => format!("importPath: a module path without quotes, up to {MAX_IMPORT} characters"), CodeLang::Leptos => format!("importPath: a Rust module path (letters, digits, _ and ::), up to {MAX_IMPORT} characters"), _ => format!("importPath: a module or package name (letters, digits, _ and .), up to {MAX_IMPORT} characters"), }); @@ -282,7 +285,7 @@ fn text(cmd: &str, p: &Value, key: &str) -> Result> { /// The `language` parameter (default React). fn lang_param(cmd: &str, p: &Value) -> Result { match text(cmd, p, "language")? { - Some(l) => language(&l).ok_or_else(|| bad(cmd, "language: react|leptos|swiftui|compose")), + Some(l) => language(&l).ok_or_else(|| bad(cmd, "language: react|leptos|swiftui|compose|arkui")), None => Ok(CodeLang::React), } } @@ -312,7 +315,7 @@ pub fn specs() -> Vec { spec!( "codeConnect.set", "Connect to code", - "{id? (component, set or instance; default the selection), language?: react|leptos|swiftui|compose (default react), importPath?, name? (default the generated name), template? (default a call passing every property)} links the component (its set, for a variant) to its code component; fields left out keep their current value. Placeholders: {property} (its name or parameter name) and {property ? \"a\" : \"b\"}; other {…} stay as written → {component, language, code (the snippet for the layer), unknown (placeholder names that aren't properties)}", + "{id? (component, set or instance; default the selection), language?: react|leptos|swiftui|compose|arkui (default react), importPath?, name? (default the generated name), template? (default a call passing every property)} links the component (its set, for a variant) to its code component; fields left out keep their current value. Placeholders: {property} (its name or parameter name) and {property ? \"a\" : \"b\"}; other {…} stay as written → {component, language, code (the snippet for the layer), unknown (placeholder names that aren't properties)}", true, |s, p| { let cmd = "codeConnect.set"; @@ -427,7 +430,7 @@ fn import(s: &mut Session, p: &Value) -> Result { let (Some(component), Some(lang), Some(name), Some(template)) = (field("component"), field("language").and_then(language), field("name"), e.get("template").and_then(Value::as_str)) else { - warnings.push(format!("link {}: needs component, language (react|leptos|swiftui|compose), name and template strings", i + 1)); + warnings.push(format!("link {}: needs component, language (react|leptos|swiftui|compose|arkui), name and template strings", i + 1)); continue; }; let link = CodeLink { diff --git a/crates/engine/src/code_connect_tests.rs b/crates/engine/src/code_connect_tests.rs index 9d657d5f..6cdd8e67 100644 --- a/crates/engine/src/code_connect_tests.rs +++ b/crates/engine/src/code_connect_tests.rs @@ -72,6 +72,16 @@ fn connected_instances_show_the_real_snippet() { json!({"id": c, "language": "leptos", "importPath": "crate::ui", "template": ""}), ); assert_eq!(code(&mut s, i, "leptos"), "use crate::ui::Card;\n\n\n"); + // ArkUI: an ArkTS import and an object-literal call. + ex( + &mut s, + "codeConnect.set", + json!({"id": c, "language": "arkui", "importPath": "@acme/arkui", "template": "Card({ title: {title}, dotted: {Show dot} })"}), + ); + assert_eq!(code(&mut s, i, "arkui"), "import { Card } from \"@acme/arkui\";\n\nCard({ title: \"Say \\\"hi\\\"\", dotted: false })\n"); + let v = ex(&mut s, "inspect.componentCode", json!({"id": i, "language": "arkui"})); + assert_eq!(v["usage"], "Card({ title: \"Say \\\"hi\\\"\", dotted: false })\n"); + assert_eq!(v["connected"]["import"], "import { Card } from \"@acme/arkui\";"); assert!(s.execute("codeConnect.set", &json!({"id": c, "language": "leptos", "importPath": "crate/ui"})).is_err(), "a Rust path"); ex(&mut s, "codeConnect.remove", json!({"id": c, "language": "leptos"})); // inspect.componentCode: the snippet as the usage, and the link. @@ -105,7 +115,7 @@ fn connected_instances_show_the_real_snippet() { assert!(code(&mut s, i, "swiftui").starts_with("import AcmeUI")); ex(&mut s, "edit.undo", json!({})); assert!(code(&mut s, i, "react").starts_with("import { Card }")); - assert_eq!(ex(&mut s, "codeConnect.remove", json!({"id": c}))["removed"], 2); + assert_eq!(ex(&mut s, "codeConnect.remove", json!({"id": c}))["removed"], 3); assert!(s.execute("codeConnect.remove", &json!({"id": c})).is_err(), "nothing left"); } diff --git a/crates/engine/src/codegen.rs b/crates/engine/src/codegen.rs index 0871f886..efca85d4 100644 --- a/crates/engine/src/codegen.rs +++ b/crates/engine/src/codegen.rs @@ -1,9 +1,9 @@ //! Code for a layer, as shown by the Inspect panel and returned by `inspect.code`: CSS for the -//! layer itself, HTML or React for its subtree, and SwiftUI or Jetpack Compose (stacks for auto -//! layout, absolute offsets otherwise). Components become components whose properties are -//! parameters and instances become calls passing their values (see [`crate::component_codegen`]); -//! variables bound to a layer become CSS custom properties. The output is a starting point, not a -//! pixel-exact translation. +//! layer itself, HTML or React for its subtree, and SwiftUI, Jetpack Compose or ArkUI (HarmonyOS; +//! stacks for auto layout, absolute offsets otherwise). Components become components whose +//! properties are parameters and instances become calls passing their values (see +//! [`crate::component_codegen`]); variables bound to a layer become CSS custom properties. The +//! output is a starting point, not a pixel-exact translation. use figcraft_color::{Color, PaintKind}; use figcraft_doc::*; @@ -18,13 +18,13 @@ pub fn specs() -> Vec { vec![spec!( "inspect.code", "Code for layer", - "{id?, language?: css|html|react|leptos|swiftui|compose (default css; html, react and leptos are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead), unit?: px|rem (CSS, HTML, React and Leptos; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}", + "{id?, language?: css|html|react|leptos|swiftui|compose|arkui (default css; html, react and leptos are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead), unit?: px|rem (CSS, HTML, React and Leptos; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}", false, |s, p| { let cmd = "inspect.code"; let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a layer"))?; let lang = match string(p, "language") { - Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|swiftui|compose"))?, + Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|swiftui|compose|arkui"))?, None => CodeLang::Css, }; let rem = match string(p, "unit").map(|u| u.to_ascii_lowercase()).as_deref() { @@ -63,10 +63,13 @@ pub enum CodeLang { Leptos, SwiftUi, Compose, + /// HarmonyOS ArkUI: an ArkTS struct (`@Component`, `build`). + ArkUi, } impl CodeLang { - pub const ALL: [CodeLang; 6] = [CodeLang::Css, CodeLang::Html, CodeLang::React, CodeLang::Leptos, CodeLang::SwiftUi, CodeLang::Compose]; + pub const ALL: [CodeLang; 7] = + [CodeLang::Css, CodeLang::Html, CodeLang::React, CodeLang::Leptos, CodeLang::SwiftUi, CodeLang::Compose, CodeLang::ArkUi]; pub fn id(self) -> &'static str { match self { CodeLang::Css => "css", @@ -75,6 +78,7 @@ impl CodeLang { CodeLang::Leptos => "leptos", CodeLang::SwiftUi => "swiftui", CodeLang::Compose => "compose", + CodeLang::ArkUi => "arkui", } } pub fn label(self) -> &'static str { @@ -85,6 +89,7 @@ impl CodeLang { CodeLang::Leptos => "Leptos", CodeLang::SwiftUi => "SwiftUI", CodeLang::Compose => "Compose", + CodeLang::ArkUi => "ArkUI", } } /// Languages whose lengths are CSS pixels (they can be shown in rem). @@ -95,6 +100,9 @@ impl CodeLang { if s.eq_ignore_ascii_case("tsx") || s.eq_ignore_ascii_case("jsx") { return Some(CodeLang::React); } + if s.eq_ignore_ascii_case("arkts") { + return Some(CodeLang::ArkUi); + } CodeLang::ALL.into_iter().find(|l| l.id().eq_ignore_ascii_case(s) || l.label().eq_ignore_ascii_case(s)) } } @@ -194,7 +202,7 @@ pub fn code_for(doc: &Document, id: NodeId, lang: CodeLang) -> String { match lang { CodeLang::Css => css(doc, id), CodeLang::Html => html(doc, id), - CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose => crate::component_codegen::code(doc, id, lang), + CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::ArkUi => crate::component_codegen::code(doc, id, lang), } } @@ -1135,6 +1143,218 @@ fn compose_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String) { } } +// ------------------------------------------------------------------ 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 call = match ctx.param(n, "mainComponent").filter(|_| n.id != ctx.root) { + Some(p) => Some(format!("{pad}this.{}()\n", p.ident)), + None => ctx.call(n, CodeLang::ArkUi, depth), + }; + if let Some(call) = call { + out.push_str(&call); + for m in extra { + out.push_str(&format!("{pad} {m}\n")); + } + return; + } + let mut mods: Vec = Vec::new(); + match &n.kind { + NodeKind::Text(t) => { + 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()), + } + 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. + let pos: Vec = match doc.node(c).filter(|_| n.layout.is_none()) { + 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::*; @@ -1174,6 +1394,33 @@ mod tests { assert!(s.execute("inspect.code", &json!({"id": f, "language": "cobol"})).is_err()); } + #[test] + fn arkui_for_an_auto_layout_card() { + let mut s = Session::new(); + let f = card(&mut s); + let doc = s.doc().unwrap(); + let ark = code_for(doc, f, CodeLang::ArkUi); + for want in [ + "Row({ space: ", + "Text(\"Say \\\"hi\\\" $5\\nnow\")", + ".fontSize(", + ".fontWeight(", + "Ellipse({ width: 24, height: 24 })", + ".backgroundColor('#FFFFFF')", + ".borderRadius(12)", + ".width(", + ".height(100)", + ".clip(true)", + ] { + assert!(ark.contains(want), "ArkUI has {want:?}:\n{ark}"); + } + // Through the command, and "arkts" reads as ArkUI too. + let v = s.execute("inspect.code", &json!({"id": f, "language": "arkui"})).unwrap(); + assert_eq!(v["code"].as_str().unwrap(), ark); + assert_eq!(v["language"], "arkui"); + assert_eq!(s.execute("inspect.code", &json!({"id": f, "language": "arkts"})).unwrap()["language"], "arkui"); + } + #[test] fn free_children_get_offsets_and_deep_trees_are_capped() { let mut s = Session::new(); @@ -1189,6 +1436,9 @@ mod tests { assert!(sw.contains("…"), "depth capped"); let kt = code_for(doc, root, CodeLang::Compose); assert!(kt.contains("Modifier.offset(7.dp, 9.dp)")); + let ark = code_for(doc, root, CodeLang::ArkUi); + assert!(ark.contains(".position({ x: 7, y: 9 })"), "{ark}"); + assert!(ark.contains("…"), "depth capped"); assert!(code_for(doc, NodeId(999_999), CodeLang::Css).is_empty()); } @@ -1204,7 +1454,7 @@ mod tests { .unwrap(); } let doc = s.doc().unwrap(); - for lang in [CodeLang::SwiftUi, CodeLang::Compose] { + for lang in [CodeLang::SwiftUi, CodeLang::Compose, CodeLang::ArkUi] { let code = code_for(doc, root, lang); assert!(code.contains("// deep evil() more() …\n"), "{code}"); for line in code.lines() { diff --git a/crates/engine/src/component_codegen.rs b/crates/engine/src/component_codegen.rs index 1416b15f..c52693c5 100644 --- a/crates/engine/src/component_codegen.rs +++ b/crates/engine/src/component_codegen.rs @@ -1,16 +1,17 @@ -//! Component code: a component (or component set) becomes a React, Leptos, SwiftUI or Jetpack -//! Compose component whose properties are its parameters (a variant property → a union or enum, boolean → -//! a flag showing the layers bound to it, text → a string, instance swap → a view parameter, slot → -//! optional content), and an instance becomes a call passing the values it sets. Variants of a set -//! share one definition (the set's default variant); in React and Leptos their differences become CSS -//! rules on `data-*` attributes. Returned by `inspect.componentCode` and used by `inspect.code`. A component -//! connected to code (see [`crate::code_connect`]) shows its real snippet instead. +//! Component code: a component (or component set) becomes a React, Leptos, SwiftUI, Jetpack +//! Compose or ArkUI component whose properties are its parameters (a variant property → a union or +//! enum, boolean → a flag showing the layers bound to it, text → a string, instance swap → a view +//! parameter, slot → optional content), and an instance becomes a call passing the values it sets. +//! Variants of a set share one definition (the set's default variant); in React and Leptos their +//! differences become CSS rules on `data-*` attributes. Returned by `inspect.componentCode` and +//! used by `inspect.code`. A component connected to code (see [`crate::code_connect`]) shows its +//! real snippet instead. use figcraft_doc::*; use serde_json::{Value, json}; use crate::cmd::CommandSpec; -use crate::codegen::{CodeLang, Ctx, Markup, class_name, compose, indent, literal, swift}; +use crate::codegen::{CodeLang, Ctx, Markup, arkui, class_name, compose, indent, literal, swift}; use crate::components::{prop_default_value, slot_edited, slots_of, variant_options, variant_props, variant_set}; use crate::params::*; use crate::{bad, spec}; @@ -67,14 +68,14 @@ pub fn specs() -> Vec { vec![spec!( "inspect.componentCode", "Component code", - "{id? (component, set or instance; default the selection), language?: react|leptos|swiftui|compose (default react)} → {component, name, set, properties: [{name, param, type, default, value? (instances)}], definition, usage? (instances; the connected snippet when the component is connected to code), connected: {importPath, name, template, import, code} | null}", + "{id? (component, set or instance; default the selection), language?: react|leptos|swiftui|compose|arkui (default react)} → {component, name, set, properties: [{name, param, type, default, value? (instances)}], definition, usage? (instances; the connected snippet when the component is connected to code), connected: {importPath, name, template, import, code} | null}", false, |s, p| { let cmd = "inspect.componentCode"; let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component or instance"))?; let lang = match string(p, "language").map(|l| CodeLang::from_id(&l)) { - Some(Some(l @ (CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose))) => l, - Some(_) => return Err(bad(cmd, "language: react|leptos|swiftui|compose")), + Some(Some(l @ (CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::ArkUi))) => l, + Some(_) => return Err(bad(cmd, "language: react|leptos|swiftui|compose|arkui")), None => CodeLang::React, }; let doc = s.doc()?; @@ -107,9 +108,9 @@ pub fn specs() -> Vec { )] } -/// Code for any layer in React, Leptos, SwiftUI or Compose: an instance's call and its component's -/// definition, a component's (or set's) definition, or the layer tree (React and Leptos wrap it -/// in a function component). +/// Code for any layer in React, Leptos, SwiftUI, Compose or ArkUI: an instance's call and its +/// component's definition, a component's (or set's) definition, or the layer tree (React and +/// Leptos wrap it in a function component). pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String { let Some(n) = doc.node(id) else { return String::new() }; if let Some(code) = crate::code_connect::link(doc, id, lang).and_then(|l| crate::code_connect::connected_code(doc, id, lang, l)) { @@ -129,6 +130,11 @@ pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String { swift(&Ctx::new(doc, id), id, 0, &mut out, &[]); out } + CodeLang::ArkUi => { + let mut out = String::new(); + arkui(&Ctx::new(doc, id), id, 0, &mut out, &[]); + out + } _ => { let mut out = String::new(); compose(&Ctx::new(doc, id), id, 0, &mut out); @@ -289,7 +295,7 @@ const RESERVED: &[&str] = &[ "yield", ]; -/// Types the generated SwiftUI and Compose code names: a variant enum may not shadow them. +/// Types the generated SwiftUI, Compose and ArkUI code names: a variant enum may not shadow them. const TYPES: &[&str] = &[ "Alignment", "AnyView", @@ -298,6 +304,7 @@ const TYPES: &[&str] = &[ "Boolean", "Box", "CaseIterable", + "Circle", "CircleShape", "Color", "Column", @@ -306,21 +313,30 @@ const TYPES: &[&str] = &[ "EdgeInsets", "Ellipse", "EmptyView", + "FlexAlign", "Font", + "FontStyle", "FontWeight", "Group", "HStack", + "HorizontalAlign", "Image", "Modifier", + "Rect", "RectangleShape", "RoundedCornerShape", "RoundedRectangle", "Row", "Shape", + "Stack", "String", "Text", + "TextAlign", + "TextCase", + "TextDecorationType", "Unit", "VStack", + "VerticalAlign", "View", "ZStack", ]; @@ -482,6 +498,9 @@ pub(crate) fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value (Some(t), CodeLang::Leptos) => format!("|| view! {{ <{} /> }}", rust_type(&t)), (None, CodeLang::Leptos) => "|| ()".into(), (Some(t), CodeLang::SwiftUi) => format!("AnyView({t}())"), + // ArkUI takes a builder: `@BuilderParam` is a `() => void`. + (Some(t), CodeLang::ArkUi) => format!("() => {{ {t}() }}"), + (None, CodeLang::ArkUi) => "() => {}".into(), (Some(t), _) => format!("{{ {t}() }}"), (None, CodeLang::React) => "null".into(), (None, CodeLang::SwiftUi) => "AnyView(EmptyView())".into(), @@ -561,6 +580,11 @@ pub fn usage(doc: &Document, id: NodeId, lang: CodeLang, depth: usize) -> Option format!("{pad}<{t}{attrs} />\n") } CodeLang::SwiftUi => format!("{pad}{t}({})\n", args.iter().map(|(n, e, _)| format!("{n}: {e}")).collect::>().join(", ")), + CodeLang::ArkUi => { + let list = args.iter().map(|(n, e, _)| format!("{n}: {e}")).collect::>().join(", "); + // ArkTS calls take an object literal, or nothing at all. + if list.is_empty() { format!("{pad}{t}()\n") } else { format!("{pad}{t}({{ {list} }})\n") } + } _ => format!("{pad}{t}({})\n", args.iter().map(|(n, e, _)| format!("{n} = {e}")).collect::>().join(", ")), }) } @@ -593,6 +617,13 @@ fn slot_content(doc: &Document, slot: NodeId, lang: CodeLang, depth: usize) -> S } format!("AnyView(Group {{\n{out}{}}})", indent(depth + 1)) } + CodeLang::ArkUi => { + let mut out = String::new(); + for k in kids { + arkui(&ctx, k, depth + 2, &mut out, &[]); + } + format!("() => {{\n{out}{}}}", indent(depth + 1)) + } _ => { let mut out = String::new(); for k in kids { @@ -611,6 +642,7 @@ pub fn definition(doc: &Document, c: &Component, lang: CodeLang) -> String { CodeLang::SwiftUi => swift_definition(doc, c), CodeLang::Compose => compose_definition(doc, c), CodeLang::Leptos => leptos(doc, c), + CodeLang::ArkUi => arkui_definition(doc, c), _ => react(doc, c), } } @@ -807,3 +839,67 @@ fn compose_definition(doc: &Document, c: &Component) -> String { out.push_str("}\n"); out } + +/// An ArkUI (`@Component`) struct: variant enums, `@Prop` parameters, a default builder for each +/// instance swap and slot, and the `build` body. +fn arkui_definition(doc: &Document, c: &Component) -> String { + let ctx = Ctx { doc, root: c.comp, comp: Some(c), base: 1 }; + let mut out = String::new(); + for p in &c.params { + if let ParamKind::Variant(opts) = &p.kind { + out.push_str(&format!("enum {} {{ {} }}\n\n", enum_name(c, p, false), cases(opts, false).join(", "))); + } + } + // A default builder per instance swap or slot, named apart from every parameter. + let mut used: Vec = c.params.iter().map(|p| p.ident.clone()).collect(); + let mut builders: Vec<(String, String)> = Vec::new(); + for p in &c.params { + if !matches!(p.kind, ParamKind::InstanceSwap | ParamKind::Slot) { + continue; + } + let base = format!("default{}Builder", capitalized(&p.ident)); + 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()); + builders.push((p.ident.clone(), name)); + } + out.push_str("@Component\nexport struct "); + out.push_str(&c.type_name); + out.push_str(" {\n"); + for p in &c.params { + match &p.kind { + ParamKind::Variant(_) => { + out.push_str(&format!(" @Prop {}: {} = {}\n", p.ident, enum_name(c, p, false), value_expr(doc, c, p, &p.default, CodeLang::ArkUi))) + } + ParamKind::Boolean => out.push_str(&format!(" @Prop {}: boolean = {}\n", p.ident, p.default.as_bool().unwrap_or(false))), + ParamKind::Text => out.push_str(&format!(" @Prop {}: string = {}\n", p.ident, value_expr(doc, c, p, &p.default, CodeLang::ArkUi))), + ParamKind::InstanceSwap | ParamKind::Slot => { + let name = builders.iter().find(|(ident, _)| *ident == p.ident).map_or("", |(_, name)| name.as_str()); + out.push_str(&format!(" @BuilderParam {}: () => void = this.{name}\n", p.ident)); + } + } + } + for (ident, name) in &builders { + let Some(p) = c.params.iter().find(|p| p.ident == *ident) else { continue }; + out.push_str(&format!("\n @Builder\n {name}() {{\n")); + match &p.kind { + ParamKind::InstanceSwap => { + if let Some(t) = id_of(&p.default).and_then(|i| component_type(doc, i)) { + out.push_str(&format!(" {t}()\n")); + } + } + ParamKind::Slot => { + let layer = slots_of(doc, c.comp).into_iter().find(|(_, slot)| slot == &p.prop).map(|(layer, _)| layer); + let kids = layer.and_then(|l| doc.node(l)).map(|n| ctx.kids(n)).unwrap_or_default(); + for k in kids { + arkui(&ctx, k, 2, &mut out, &[]); + } + } + _ => {} + } + out.push_str(" }\n"); + } + out.push_str("\n build() {\n"); + arkui(&ctx, c.comp, 2, &mut out, &[]); + out.push_str(" }\n}\n"); + out +} diff --git a/crates/engine/src/component_codegen_tests.rs b/crates/engine/src/component_codegen_tests.rs index 4cd3d776..dc279724 100644 --- a/crates/engine/src/component_codegen_tests.rs +++ b/crates/engine/src/component_codegen_tests.rs @@ -71,6 +71,17 @@ fn components_take_their_properties_as_parameters() { for want in ["@Composable\nfun Card(", " showDot: Boolean = true,", " title: String = \"Hello\",", "if (showDot) {", "text = title,"] { assert!(kt.contains(want), "Compose has {want:?}:\n{kt}"); } + let ark = code(&mut s, c, "arkui"); + for want in [ + "@Component\nexport struct Card {", + " @Prop showDot: boolean = true", + " @Prop title: string = \"Hello\"", + " build() {", + " if (this.showDot) {", + "Text(this.title)", + ] { + assert!(ark.contains(want), "ArkUI has {want:?}:\n{ark}"); + } let leptos = code(&mut s, c, "leptos"); for want in [ "use leptos::prelude::*;\n\n#[component]\npub fn Card(\n", @@ -117,12 +128,14 @@ fn instances_are_calls_passing_the_values_they_set() { assert!(react.contains("export function Card({")); assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Card(showDot: false, title: \"Buy\")\n"); assert_eq!(component_code(&mut s, i, "compose")["usage"], "Card(showDot = false, title = \"Buy\")\n"); + assert_eq!(component_code(&mut s, i, "arkui")["usage"], "Card({ showDot: false, title: \"Buy\" })\n"); assert_eq!(component_code(&mut s, i, "leptos")["usage"], "\n"); // Text that needs escaping is an expression in JSX and escaped per language. ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Title": "Say \"hi\" $5\nnow"}})); assert_eq!(component_code(&mut s, i, "react")["usage"], "\n"); assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Card(showDot: false, title: \"Say \\\"hi\\\" $5\\nnow\")\n"); assert_eq!(component_code(&mut s, i, "compose")["usage"], "Card(showDot = false, title = \"Say \\\"hi\\\" \\$5\\nnow\")\n"); + assert_eq!(component_code(&mut s, i, "arkui")["usage"], "Card({ showDot: false, title: \"Say \\\"hi\\\" $5\\nnow\" })\n"); assert_eq!(component_code(&mut s, i, "leptos")["usage"], "\n"); } @@ -173,6 +186,9 @@ fn variant_sets_share_one_definition() { let kt = code(&mut s, set, "compose"); assert!(kt.contains("enum class ButtonState { Default, Hover }"), "{kt}"); assert!(kt.contains(" state: ButtonState = ButtonState.Default,"), "{kt}"); + let ark = code(&mut s, set, "arkui"); + assert!(ark.contains("enum ButtonState { Default, Hover }"), "{ark}"); + assert!(ark.contains(" @Prop state: ButtonState = ButtonState.Default"), "{ark}"); let leptos = code(&mut s, set, "leptos"); for want in [ "#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]\npub enum ButtonState {\n #[default]\n Default,\n Hover,\n}", @@ -189,6 +205,7 @@ fn variant_sets_share_one_definition() { assert_eq!(component_code(&mut s, i, "react")["usage"], "