Merge pull request #669 from yelintong/feat/flutter-codegen

M7: Flutter code generation
This commit is contained in:
BFlat
2026-10-29 13:37:49 +07:00
committed by GitHub
39 changed files with 747 additions and 79 deletions
+2 -2
View File
@@ -52,7 +52,7 @@ pub fn specs() -> Vec<CommandSpec> {
vec![spec!(
"component.playground",
"Component playground",
"{id? (an instance, a component or a component set; default the selection), properties? {name: value} (variant, boolean, text and instance-swap properties, as instance.setProperties takes them), language? css|html|react|leptos|vue|swiftui|compose|arkui (default react), path? (write the preview there as a PNG), scale? (the PNG's, default 2)} → {component, properties: [{name, type, options?, value}], language, code, width, height, path?} the component with those property values, tried on a copy: its code and its look. Nothing in the document changes",
"{id? (an instance, a component or a component set; default the selection), properties? {name: value} (variant, boolean, text and instance-swap properties, as instance.setProperties takes them), language? css|html|react|leptos|vue|swiftui|compose|arkui|flutter (default react), path? (write the preview there as a PNG), scale? (the PNG's, default 2)} → {component, properties: [{name, type, options?, value}], language, code, width, height, path?} the component with those property values, tried on a copy: its code and its look. Nothing in the document changes",
false,
|s, p| {
let cmd = "component.playground";
@@ -63,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
Some(_) => return Err(bad(cmd, "properties: {name: value}")),
};
let lang = match string(p, "language") {
Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|vue|swiftui|compose|arkui"))?,
Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|vue|swiftui|compose|arkui|flutter"))?,
None => CodeLang::React,
};
let (copy, inst) = build(s.doc()?, target, &values)?;
@@ -106,6 +106,17 @@ fn components_take_their_properties_as_parameters() {
] {
assert!(leptos.contains(want), "Leptos has {want:?}:\n{leptos}");
}
let dart = code(&mut s, c, "flutter");
for want in [
"import 'package:flutter/material.dart';\n\nclass Card extends StatelessWidget {\n",
" const Card({\n super.key,\n this.showDot = true,\n this.title = \"Hello\",\n });\n",
" final bool showDot;\n final String title;\n",
" Widget build(BuildContext context) {\n return Container(",
"if (showDot)\n",
"Text(\n title,",
] {
assert!(dart.contains(want), "Flutter has {want:?}:\n{dart}");
}
// The command describes the properties.
let v = component_code(&mut s, c, "react");
assert_eq!(v["name"], "Card");
@@ -140,6 +151,7 @@ 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, "flutter")["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"], "<Card show_dot={false} title=\"Buy\" />\n");
// Text that needs escaping is an expression in JSX and escaped per language.
@@ -147,6 +159,7 @@ fn instances_are_calls_passing_the_values_they_set() {
assert_eq!(component_code(&mut s, i, "react")["usage"], "<Card showDot={false} title={\"Say \\\"hi\\\" $5\\nnow\"} />\n");
assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Card(showDot: false, title: \"Say \\\"hi\\\" $5\\nnow\")\n");
assert_eq!(component_code(&mut s, i, "compose")["usage"], "Card(showDot = false, title = \"Say \\\"hi\\\" \\$5\\nnow\")\n");
assert_eq!(component_code(&mut s, i, "flutter")["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"], "<Card show_dot={false} title={\"Say \\\"hi\\\" $5\\nnow\"} />\n");
// A quoted title becomes a binding whose attribute value is escaped: the expression itself
@@ -175,6 +188,8 @@ fn nested_instances_in_a_layer_tree_are_calls() {
assert!(!react.contains("Hello"), "the instance's layers aren't spelled out: {react}");
assert!(code(&mut s, screen, "swiftui").contains("Card(title: \"Inbox\")"));
assert!(code(&mut s, screen, "compose").contains("Card(title = \"Inbox\")"));
let dart = code(&mut s, screen, "flutter");
assert!(dart.contains("Card(title: \"Inbox\")") && !dart.contains("Hello"), "{dart}");
let leptos = code(&mut s, screen, "leptos");
assert!(leptos.contains("pub fn HomeScreen() -> impl IntoView {") && leptos.contains("<Card title=\"Inbox\" />"), "{leptos}");
assert!(!leptos.contains("Hello"), "{leptos}");
@@ -232,6 +247,19 @@ fn variant_sets_share_one_definition() {
assert_eq!(component_code(&mut s, i, "leptos")["usage"], "<Button state=ButtonState::Hover />\n");
}
#[test]
fn flutter_variant_sets_share_one_enum() {
let mut s = Session::new();
let (set, _, _) = button_set(&mut s);
let dart = code(&mut s, set, "flutter");
assert!(dart.contains("enum ButtonState { defaultValue, hover }\n"), "{dart}");
assert!(dart.contains(" this.state = ButtonState.defaultValue,\n"), "{dart}");
assert!(dart.contains(" final ButtonState state;\n"), "{dart}");
let i = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 600})));
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"State": "Hover"}}));
assert_eq!(component_code(&mut s, i, "flutter")["usage"], "Button(state: ButtonState.hover)\n");
}
#[test]
fn instance_swap_properties_take_views() {
let mut s = Session::new();
@@ -252,6 +280,10 @@ fn instance_swap_properties_take_views() {
assert!(swift.contains(" var icon: AnyView = AnyView(HeartIcon())"), "{swift}");
let kt = code(&mut s, chip, "compose");
assert!(kt.contains(" icon: @Composable () -> Unit = { HeartIcon() },") && kt.contains("icon()"), "{kt}");
let dart = code(&mut s, chip, "flutter");
assert!(dart.contains(" final Widget icon;\n") && dart.contains(" this.icon = const HeartIcon(),\n"), "{dart}");
assert!(dart.contains("child: icon\n"), "the nested instance is the parameter: {dart}");
assert!(!dart.contains("child: Container("), "the swap's widget tree isn't spelled out: {dart}");
let ark = code(&mut s, chip, "arkui");
for want in [
" @BuilderParam icon: () => void = this.defaultIconBuilder",
@@ -272,6 +304,7 @@ fn instance_swap_properties_take_views() {
assert_eq!(component_code(&mut s, i, "react")["usage"], "<Chip icon={<StarIcon />} />\n");
assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Chip(icon: AnyView(StarIcon()))\n");
assert_eq!(component_code(&mut s, i, "compose")["usage"], "Chip(icon = { StarIcon() })\n");
assert_eq!(component_code(&mut s, i, "flutter")["usage"], "Chip(icon: StarIcon())\n");
assert_eq!(component_code(&mut s, i, "arkui")["usage"], "Chip({ icon: () => { StarIcon() } })\n");
assert_eq!(component_code(&mut s, i, "leptos")["usage"], "<Chip icon={|| view! { <StarIcon /> }} />\n");
assert_eq!(component_code(&mut s, i, "vue")["usage"], "<Chip :icon=\"StarIcon\" />\n");
@@ -289,6 +322,9 @@ fn slots_take_optional_content() {
assert!(react.contains(" content?: React.ReactNode;") && react.contains("{content ?? ("), "{react}");
assert!(code(&mut s, panel, "swiftui").contains("var content: AnyView? = nil"));
assert!(code(&mut s, panel, "compose").contains("content: (@Composable () -> Unit)? = null,"));
let dart = code(&mut s, panel, "flutter");
assert!(dart.contains(" final List<Widget>? content;\n") && dart.contains(" this.content,\n"), "{dart}");
assert!(dart.contains("...?content,\n") && dart.contains("if (content == null) ...[\n"), "{dart}");
let ark = code(&mut s, panel, "arkui");
for want in [
" @BuilderParam content: () => void = this.defaultContentBuilder",
@@ -305,11 +341,14 @@ fn slots_take_optional_content() {
let badge = idv(&ex(&mut s, "component.create", json!({"ids": [g]})));
let i = idv(&ex(&mut s, "instance.create", json!({"component": panel, "x": 400, "y": 0})));
assert_eq!(component_code(&mut s, i, "react")["usage"], "<Panel />\n", "the main's content isn't passed");
assert_eq!(component_code(&mut s, i, "flutter")["usage"], "Panel()\n", "the main's content isn't passed");
ex(&mut s, "instance.slotInsert", json!({"id": i, "slot": "Content", "component": badge, "replace": true}));
let usage = component_code(&mut s, i, "react")["usage"].as_str().unwrap_or_default().to_string();
assert!(usage.starts_with("<Panel content={(\n") && usage.contains("<Badge />"), "{usage}");
let usage = component_code(&mut s, i, "leptos")["usage"].as_str().unwrap_or_default().to_string();
assert!(usage.starts_with("<Panel content={|| view! {\n") && usage.contains("<Badge />") && usage.ends_with("}} />\n"), "{usage}");
let usage = component_code(&mut s, i, "flutter")["usage"].as_str().unwrap_or_default().to_string();
assert!(usage.starts_with("Panel(content: [\n") && usage.contains("Badge()"), "{usage}");
let usage = component_code(&mut s, i, "arkui")["usage"].as_str().unwrap_or_default().to_string();
assert!(usage.starts_with("Panel({ content: () => {\n") && usage.contains("Badge()") && usage.ends_with("} })\n"), "{usage}");
}
@@ -359,10 +398,22 @@ fn identifiers_are_escaped_and_unique() {
] {
assert!(leptos.contains(want), "Leptos has {want:?}:\n{leptos}");
}
// Dart: lowerCamel members, keywords suffixed, never a type the widget tree names.
let dart = code_for(doc, c, CodeLang::Flutter);
for want in [
"class TextComponent extends StatelessWidget {",
"enum TextComponentColorVariant { red }\n",
" this.defaultValue = false,",
" this.p2Columns = false,",
" final String type;",
] {
assert!(dart.contains(want), "Flutter has {want:?}:\n{dart}");
}
// Errors.
let r = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})));
assert!(s.execute("inspect.componentCode", &json!({"id": r})).is_err(), "not a component");
assert!(s.execute("inspect.componentCode", &json!({"id": c, "language": "html"})).is_err());
assert!(s.execute("inspect.componentCode", &json!({"id": c, "language": "flutter"})).is_ok());
assert!(s.execute("inspect.componentCode", &json!({"id": 999_999})).is_err());
}
+16 -6
View File
@@ -37,8 +37,9 @@ const MAX_WARNINGS: usize = 50;
/// The languages a component can be connected in.
pub fn language(s: &str) -> Option<CodeLang> {
CodeLang::from_id(s)
.filter(|l| matches!(l, CodeLang::React | CodeLang::Leptos | CodeLang::Vue | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::ArkUi))
CodeLang::from_id(s).filter(|l| {
matches!(l, CodeLang::React | CodeLang::Leptos | CodeLang::Vue | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::ArkUi | CodeLang::Flutter)
})
}
/// The layer `id`'s links are stored on: its component set, else its component (an instance's
@@ -69,6 +70,7 @@ pub fn import_line(link: &CodeLink, lang: CodeLang) -> String {
CodeLang::Leptos => format!("use {path}::{};", link.name.split('.').next().unwrap_or_default()),
// A single-file component is a default export.
CodeLang::Vue => format!("import {} from {};", link.name.split('.').next().unwrap_or_default(), js_string(path)),
CodeLang::Flutter => format!("import '{}';", path.replace(['\'', '\\'], "")),
_ => format!("import {path}"),
}
}
@@ -273,6 +275,7 @@ fn expr(doc: &Document, c: &Component, p: &Param, v: &Value, lang: CodeLang) ->
(Some(l), CodeLang::Vue) => l.name.clone(),
(Some(l), CodeLang::SwiftUi) => format!("{}()", l.name),
(Some(l), CodeLang::ArkUi) => format!("() => {{ {}() }}", l.name),
(Some(l), CodeLang::Flutter) => format!("{}()", l.name),
(Some(l), _) => format!("{{ {}() }}", l.name),
(None, _) => value_expr(doc, c, p, v, lang),
}
@@ -456,7 +459,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::<String>())
}
CodeLang::Vue => format!("<{name}{} />", params.iter().map(|p| format!(" :{p}={{{p}}}")).collect::<String>()),
CodeLang::SwiftUi => format!("{name}({})", params.iter().map(|p| format!("{p}: {{{p}}}")).collect::<Vec<_>>().join(", ")),
CodeLang::SwiftUi | CodeLang::Flutter => format!("{name}({})", params.iter().map(|p| format!("{p}: {{{p}}}")).collect::<Vec<_>>().join(", ")),
CodeLang::ArkUi => format!("{name}({{ {} }})", params.iter().map(|p| format!("{p}: {{{p}}}")).collect::<Vec<_>>().join(", ")),
_ => format!("{name}({})", params.iter().map(|p| format!("{p} = {{{p}}}")).collect::<Vec<_>>().join(", ")),
}
@@ -478,6 +481,7 @@ pub fn check(c: &Component, link: &CodeLink, lang: CodeLang) -> std::result::Res
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 == ':'),
CodeLang::Vue => path.chars().all(|ch| ch.is_ascii_alphanumeric() || "_./@-".contains(ch)),
CodeLang::Flutter => path.chars().all(|ch| ch.is_ascii_alphanumeric() || "_./:-".contains(ch)),
_ => path.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '.'),
};
if path.chars().count() > MAX_IMPORT || !path_ok {
@@ -486,6 +490,9 @@ pub fn check(c: &Component, link: &CodeLink, lang: CodeLang) -> std::result::Res
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"),
CodeLang::Vue => format!("importPath: a module path (letters, digits, _ . / @ and -), up to {MAX_IMPORT} characters"),
CodeLang::Flutter => {
format!("importPath: a Dart import (package:acme/card.dart; letters, digits, _ . / : and -), up to {MAX_IMPORT} characters")
}
_ => format!("importPath: a module or package name (letters, digits, _ and .), up to {MAX_IMPORT} characters"),
});
}
@@ -521,7 +528,7 @@ fn text(cmd: &str, p: &Value, key: &str) -> Result<Option<String>> {
/// The `language` parameter (default React).
fn lang_param(cmd: &str, p: &Value) -> Result<CodeLang> {
match text(cmd, p, "language")? {
Some(l) => language(&l).ok_or_else(|| bad(cmd, "language: react|leptos|vue|swiftui|compose|arkui")),
Some(l) => language(&l).ok_or_else(|| bad(cmd, "language: react|leptos|vue|swiftui|compose|arkui|flutter")),
None => Ok(CodeLang::React),
}
}
@@ -551,7 +558,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"codeConnect.set",
"Connect to code",
"{id? (component, set or instance; default the selection), language?: react|leptos|vue|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)}",
"{id? (component, set or instance; default the selection), language?: react|leptos|vue|swiftui|compose|arkui|flutter (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";
@@ -666,7 +673,10 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
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|vue|swiftui|compose|arkui), name and template strings", i + 1));
warnings.push(format!(
"link {}: needs component, language (react|leptos|vue|swiftui|compose|arkui|flutter), name and template strings",
i + 1
));
continue;
};
let link = CodeLink {
+129 -7
View File
@@ -1,7 +1,7 @@
//! 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, 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
//! layer itself, HTML or React for its subtree, and SwiftUI, Jetpack Compose, ArkUI (HarmonyOS) or
//! Flutter (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.
@@ -20,6 +20,7 @@ use crate::{bad, spec};
mod arkui;
mod compose;
mod css;
mod flutter;
mod html;
mod swift;
@@ -30,6 +31,7 @@ pub(crate) use css::Tokens;
pub(crate) use css::css_decls;
use css::{css, tok};
pub use css::{css_var_name, css_var_of};
pub(crate) use flutter::{FLUTTER_IMPORT, Place, flutter, flutter_indent};
use html::html;
pub(crate) use html::{Markup, class_name};
pub(crate) use swift::swift;
@@ -38,13 +40,15 @@ pub fn specs() -> Vec<CommandSpec> {
vec![spec!(
"inspect.code",
"Code for layer",
"{id?, language?: css|html|react|leptos|vue|tailwind|swiftui|compose|arkui (default css; html, react, leptos and vue 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; tailwind is React with Tailwind CSS v4 utilities for pages: fluid auto layout boxes, instances inlined with their overrides, bound variables as theme utilities), mobile? (tailwind: a mobile frame with the same layer names; the code is mobile first with the desktop layer's classes behind lg:), unit?: px|rem (CSS, HTML, React, Leptos and Vue; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}",
"{id?, language?: css|html|react|leptos|vue|tailwind|swiftui|compose|arkui|flutter (default css; html, react, leptos and vue 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; tailwind is React with Tailwind CSS v4 utilities for pages: fluid auto layout boxes, instances inlined with their overrides, bound variables as theme utilities), mobile? (tailwind: a mobile frame with the same layer names; the code is mobile first with the desktop layer's classes behind lg:), unit?: px|rem (CSS, HTML, React, Leptos and Vue; 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|vue|tailwind|swiftui|compose|arkui"))?,
Some(l) => {
CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|vue|tailwind|swiftui|compose|arkui|flutter"))?
}
None => CodeLang::Css,
};
let rem = match string(p, "unit").map(|u| u.to_ascii_lowercase()).as_deref() {
@@ -96,10 +100,12 @@ pub enum CodeLang {
Compose,
/// HarmonyOS ArkUI: an ArkTS struct (`@Component`, `build`).
ArkUi,
/// A Flutter `StatelessWidget` (Dart) with its widget tree.
Flutter,
}
impl CodeLang {
pub const ALL: [CodeLang; 9] = [
pub const ALL: [CodeLang; 10] = [
CodeLang::Css,
CodeLang::Html,
CodeLang::React,
@@ -109,6 +115,7 @@ impl CodeLang {
CodeLang::SwiftUi,
CodeLang::Compose,
CodeLang::ArkUi,
CodeLang::Flutter,
];
pub fn id(self) -> &'static str {
match self {
@@ -121,6 +128,7 @@ impl CodeLang {
CodeLang::SwiftUi => "swiftui",
CodeLang::Compose => "compose",
CodeLang::ArkUi => "arkui",
CodeLang::Flutter => "flutter",
}
}
pub fn label(self) -> &'static str {
@@ -134,6 +142,7 @@ impl CodeLang {
CodeLang::SwiftUi => "SwiftUI",
CodeLang::Compose => "Compose",
CodeLang::ArkUi => "ArkUI",
CodeLang::Flutter => "Flutter",
}
}
/// Languages whose lengths are CSS pixels that can be shown in rem (not Tailwind: its lengths
@@ -141,6 +150,11 @@ impl CodeLang {
pub fn is_web(self) -> bool {
matches!(self, CodeLang::Css | CodeLang::Html | CodeLang::React | CodeLang::Leptos | CodeLang::Vue)
}
/// Languages whose strings interpolate `$` (Kotlin's `$name`, Dart's `$name`), where a literal
/// `$` has to be escaped.
pub fn escapes_dollar(self) -> bool {
matches!(self, CodeLang::Compose | CodeLang::Flutter)
}
pub fn from_id(s: &str) -> Option<CodeLang> {
if s.eq_ignore_ascii_case("tsx") || s.eq_ignore_ascii_case("jsx") {
return Some(CodeLang::React);
@@ -151,6 +165,9 @@ impl CodeLang {
if s.eq_ignore_ascii_case("arkts") {
return Some(CodeLang::ArkUi);
}
if s.eq_ignore_ascii_case("dart") {
return Some(CodeLang::Flutter);
}
CodeLang::ALL.into_iter().find(|l| l.id().eq_ignore_ascii_case(s) || l.label().eq_ignore_ascii_case(s))
}
}
@@ -262,7 +279,7 @@ pub fn code_for(doc: &Document, id: NodeId, lang: CodeLang) -> String {
CodeLang::Css => css(doc, id),
CodeLang::Html => html(doc, id),
CodeLang::Tailwind => crate::tailwind::code(doc, id, None),
CodeLang::React | CodeLang::Leptos | CodeLang::Vue | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::ArkUi => {
CodeLang::React | CodeLang::Leptos | CodeLang::Vue | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::ArkUi | CodeLang::Flutter => {
crate::component_codegen::code(doc, id, lang)
}
}
@@ -549,6 +566,111 @@ mod tests {
assert!(ark.contains(&format!(".position({{ x: {}, y: {} }})", num(x), num(y))), "the absolute child keeps its place: {ark}");
}
#[test]
fn flutter_builds_a_widget_tree() {
let mut s = Session::new();
let f = card(&mut s);
let doc = s.doc().unwrap();
let dart = code_for(doc, f, CodeLang::Flutter);
// The card hugs its content, so its width is the layout's, not the 200 it was created with.
let w = doc.node(f).map(|n| n.width).unwrap_or_default();
assert!(dart.starts_with(&format!("Container(\n width: {},\n height: 100,\n", num(w))), "{dart}");
assert!(dart.contains("borderRadius: BorderRadius.circular(12)"), "{dart}");
assert!(dart.contains("child: Row(\n"), "{dart}");
assert!(dart.contains(" Text(\n \"Say \\\"hi\\\" \\$5\\nnow\",\n"), "escaped: {dart}");
assert!(dart.contains("fontWeight: FontWeight.w400,"), "{dart}");
assert!(dart.contains("shape: BoxShape.circle"), "{dart}");
assert!(dart.ends_with(')'), "a bare expression, like the other native targets: {dart}");
// Through the command: `dart` names the same language, and rem is for the web only.
let v = s.execute("inspect.code", &json!({"id": f, "language": "flutter"})).unwrap();
assert_eq!(v["code"].as_str().unwrap(), dart);
assert_eq!(v["language"], "flutter");
assert_eq!(s.execute("inspect.code", &json!({"id": f, "language": "dart"})).unwrap()["language"], "flutter");
let rem = s.execute("inspect.code", &json!({"id": f, "language": "flutter", "unit": "rem"})).unwrap();
assert_eq!(rem["code"].as_str().unwrap(), dart, "rem doesn't apply to Dart");
}
#[test]
fn flutter_positions_free_children_and_bounds_the_tree() {
let mut s = Session::new();
let root = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"]).unwrap();
s.execute("shape.rectangle", &json!({"x": 7, "y": 9, "width": 5, "height": 5, "parent": root})).unwrap();
let mut parent = root;
for _ in 0..20 {
parent = id_of(&s.execute("frame.create", &json!({"x": 1, "y": 1, "width": 40, "height": 40, "parent": parent})).unwrap()["id"]).unwrap();
}
let dart = code_for(s.doc().unwrap(), root, CodeLang::Flutter);
assert!(dart.contains("Stack(\n alignment: Alignment.topLeft,\n"), "{dart}");
assert!(dart.contains("Positioned(\n left: 7,\n top: 9,\n"), "{dart}");
// A wrapper's closing parenthesis sits on its own line, not glued to the widget's.
assert!(dart.contains(" )\n ),\n"), "{dart}");
assert!(dart.contains("…"), "the depth is capped: {dart}");
assert!(dart.contains("const SizedBox()"), "a capped layer is still a widget in the list: {dart}");
}
#[test]
fn flutter_keeps_a_capped_layers_name_on_its_comment_line() {
let mut s = Session::new();
let root = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"]).unwrap();
let mut parent = root;
for _ in 0..20 {
let p = json!({"x": 1, "y": 1, "width": 40, "height": 40, "parent": parent, "name": "deep\nvoid main() {}"});
parent = id_of(&s.execute("frame.create", &p).unwrap()["id"]).unwrap();
}
let dart = code_for(s.doc().unwrap(), root, CodeLang::Flutter);
assert!(dart.contains("…"), "the depth is capped: {dart}");
assert!(!dart.lines().any(|l| l.trim_start().starts_with("void main()")), "a name never leaves its comment: {dart}");
}
#[test]
fn flutter_expands_and_stretches_fill_children() {
let mut s = Session::new();
let row = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 40})).unwrap()["id"]).unwrap();
let a = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 20, "parent": row})).unwrap()["id"]).unwrap();
s.execute("shape.rectangle", &json!({"x": 60, "y": 0, "width": 50, "height": 20, "parent": row})).unwrap();
s.execute("layout.add", &json!({"ids": [row], "direction": "horizontal"})).unwrap();
s.execute("node.set", &json!({"id": a, "props": {"sizingH": "fill", "sizingV": "fill"}})).unwrap();
let dart = code_for(s.doc().unwrap(), row, CodeLang::Flutter);
assert!(dart.contains("child: Row(\n"), "{dart}");
assert!(dart.contains("Expanded(\n"), "a child filling the main axis takes the room left: {dart}");
assert!(dart.contains("crossAxisAlignment: CrossAxisAlignment.stretch,"), "a child filling the cross axis stretches the line: {dart}");
}
#[test]
fn flutter_lifts_a_child_that_ignores_its_auto_layout_parent_out_of_the_line() {
let mut s = Session::new();
let row = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 40})).unwrap()["id"]).unwrap();
let flow = id_of(&s.execute("shape.rectangle", &json!({"x": 4, "y": 4, "width": 50, "height": 20, "parent": row})).unwrap()["id"]).unwrap();
let over = id_of(&s.execute("shape.ellipse", &json!({"x": 150, "y": 6, "width": 30, "height": 30, "parent": row})).unwrap()["id"]).unwrap();
s.execute("layout.add", &json!({"ids": [row], "direction": "horizontal"})).unwrap();
s.execute("node.set", &json!({"id": row, "props": {"padding": [4, 4, 4, 4]}})).unwrap();
// It asks for both axes, which the line would read as "stretch me and take the room left".
s.execute("node.set", &json!({"id": over, "props": {"absolute": true, "sizingH": "fill", "sizingV": "fill"}})).unwrap();
let doc = s.doc().unwrap();
let dart = code_for(doc, row, CodeLang::Flutter);
let at = |needle: &str| dart.find(needle).unwrap_or_else(|| panic!("no {needle:?}: {dart}"));
// Over the line, in a Stack at the parent's own box, at its own offsets.
let [_, _, _, _, x, y] = doc.node(over).unwrap().transform.as_coeffs();
assert!(
dart.contains(&format!("Positioned(\n left: {},\n top: {},\n", num(x), num(y))),
"the absolute child keeps its place: {dart}"
);
let stack = at("child: Stack(\n");
let fill = at("Positioned.fill(\n");
let padding = at("padding: EdgeInsets.all(4),");
let line = at("child: Row(\n");
assert!(stack < fill && fill < padding && padding < line, "the line sits in the Stack, padded: {dart}");
assert!(!dart[..padding].contains("padding: "), "the parent's padding would offset the absolute child: {dart}");
// Only the flow child is in the line, so neither `Expanded` nor a stretch comes from it.
let over_at = at("\n Positioned(\n");
let line_part = &dart[line..over_at];
assert!(line_part.contains("width: 50,"), "the flow child is in the line: {dart}");
assert!(!line_part.contains("width: 30,"), "the absolute child left the line: {dart}");
assert!(!dart.contains("Expanded(\n"), "it takes no room in the line either: {dart}");
assert!(dart.contains("crossAxisAlignment: CrossAxisAlignment.start,"), "{dart}");
assert!(doc.node(flow).is_some());
}
#[test]
fn arkui_wraps_a_positioned_instance_swap_so_the_position_lands_on_a_container() {
let mut s = Session::new();
+364
View File
@@ -0,0 +1,364 @@
use figcraft_doc::AutoLayout;
use super::swift::one_line;
use super::{
CodeLang, CrossAlign, Ctx, LayoutMode, LetterSpacing, LineHeight, MainAlign, Node, NodeId, NodeKind, Param, Sizing, TextAlignH, TextDecoration,
drop_shadow, first_fill, kotlin_color, literal, num, radius, stroke_color,
};
// ------------------------------------------------------------------ Flutter (Dart)
/// The import a generated Flutter unit starts with (a widget tree on its own needs it too).
pub(crate) const FLUTTER_IMPORT: &str = "import 'package:flutter/material.dart';\n\n";
/// Dart indents by two spaces.
pub(crate) fn flutter_indent(depth: usize) -> String {
" ".repeat(depth)
}
/// Where a child sits in its parent: in a Stack at its position in the frame, taking the room left
/// in a Row or Column, or wherever the parent puts it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) enum Place {
Flow,
At(f64, f64),
Fill,
}
/// A child and where its parent puts it.
type Placed = (NodeId, Place);
/// Dart's `FontWeight.w600` (a weight is hundreds).
fn flutter_weight(w: u16) -> String {
format!("FontWeight.w{}", (w.clamp(100, 900) / 100) * 100)
}
/// A Row or Column's main-axis alignment (`None` for the default, the start).
fn flutter_main_align(a: MainAlign) -> Option<&'static str> {
match a {
MainAlign::Min => None,
MainAlign::Center => Some("MainAxisAlignment.center"),
MainAlign::Max => Some("MainAxisAlignment.end"),
MainAlign::SpaceBetween => Some("MainAxisAlignment.spaceBetween"),
MainAlign::SpaceAround => Some("MainAxisAlignment.spaceAround"),
MainAlign::SpaceEvenly => Some("MainAxisAlignment.spaceEvenly"),
}
}
/// A Row or Column's cross-axis alignment; a flow child that fills it stretches the line instead.
fn flutter_cross_align(a: CrossAlign, stretch: bool) -> &'static str {
if stretch {
return "CrossAxisAlignment.stretch";
}
match a {
CrossAlign::Min => "CrossAxisAlignment.start",
CrossAlign::Center => "CrossAxisAlignment.center",
CrossAlign::Max => "CrossAxisAlignment.end",
CrossAlign::Baseline => "CrossAxisAlignment.baseline",
}
}
/// A frame's auto layout padding as `EdgeInsets` (`None` when it has none).
fn flutter_padding(n: &Node) -> Option<String> {
let l = n.layout.as_ref()?;
let [t, r, b, left] = l.padding;
if ![t, r, b, left].iter().any(|v| *v > 0.0) {
return None;
}
Some(if t == r && r == b && b == left {
format!("EdgeInsets.all({})", num(t))
} else if t == b && left == r {
format!("EdgeInsets.symmetric(vertical: {}, horizontal: {})", num(t), num(left))
} else {
format!("EdgeInsets.fromLTRB({}, {}, {}, {})", num(left), num(t), num(r), num(b))
})
}
/// A layer's fill, shape, corner radius, stroke and shadow as a `BoxDecoration`; `None` when it has
/// none of them. `pad` is the indentation of the constructor itself.
fn box_decoration(n: &Node, pad: &str) -> Option<String> {
let mut parts: Vec<String> = Vec::new();
if let Some(c) = first_fill(n) {
parts.push(format!("color: {}", kotlin_color(c)));
}
if matches!(n.kind, NodeKind::Ellipse { .. }) {
parts.push("shape: BoxShape.circle".into());
} else if radius(n) > 0.0 {
parts.push(format!("borderRadius: BorderRadius.circular({})", num(radius(n))));
}
if let Some(c) = stroke_color(n) {
parts.push(format!("border: Border.all(color: {}, width: {})", kotlin_color(c), num(n.stroke.weight)));
}
if let Some(s) = drop_shadow(n) {
parts.push(format!(
"boxShadow: [BoxShadow(color: {}, blurRadius: {}, offset: Offset({}, {}))]",
kotlin_color(s.color),
num(s.radius / 2.0),
num(s.offset.0),
num(s.offset.1)
));
}
if parts.is_empty() {
return None;
}
let inner = format!("{pad} ");
Some(format!("BoxDecoration(\n{}{pad})", parts.iter().map(|p| format!("{inner}{p},\n")).collect::<String>()))
}
/// Flutter for a layer and its subtree at `depth`. A layer a boolean parameter shows sits behind an
/// `if (…)`: Dart's lists take conditions, so the element itself carries it.
pub(crate) fn flutter(ctx: &Ctx, id: NodeId, depth: usize, out: &mut String, place: Place) {
let Some(n) = ctx.doc.node(id) else { return };
if id != ctx.root
&& let Some(p) = ctx.param(n, "visible")
{
out.push_str(&format!("{}if ({})\n", flutter_indent(depth), p.ident));
flutter_placed(ctx, n, depth + 1, out, place);
return;
}
flutter_placed(ctx, n, depth, out, place);
}
/// A layer at `depth` and what wraps it: where its parent puts it, then its opacity. Every closing
/// parenthesis sits at its own level, so the caller can end the element with a comma in a list.
fn flutter_placed(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, place: Place) {
if ctx.too_deep(depth) {
let pad = flutter_indent(depth);
out.push_str(&format!("{pad}// {} …\n{pad}const SizedBox()", one_line(&n.name)));
return;
}
let mut depth = depth;
let mut close: Vec<String> = Vec::new();
match place {
Place::Flow => {}
Place::At(x, y) => {
let (pad, inner) = (flutter_indent(depth), flutter_indent(depth + 1));
out.push_str(&format!("{pad}Positioned(\n{inner}left: {},\n{inner}top: {},\n{inner}child: ", num(x), num(y)));
close.push(format!("\n{pad})"));
depth += 1;
}
Place::Fill => {
let (pad, inner) = (flutter_indent(depth), flutter_indent(depth + 1));
out.push_str(&format!("{pad}Expanded(\n{inner}child: "));
close.push(format!("\n{pad})"));
depth += 1;
}
}
if n.opacity < 1.0 {
let (pad, inner) = (flutter_indent(depth), flutter_indent(depth + 1));
out.push_str(&format!("{pad}Opacity(\n{inner}opacity: {},\n{inner}child: ", num(f64::from(n.opacity))));
close.push(format!("\n{pad})"));
depth += 1;
}
// A wrapper leaves its line open ("child: "), so the widget carries no indentation of its own.
let mut body = String::new();
flutter_widget(ctx, n, depth, &mut body);
out.push_str(if close.is_empty() { body.as_str() } else { body.trim_start() });
for c in close.iter().rev() {
out.push_str(c);
}
}
/// A layer's own look and its subtree as a Flutter widget at `depth`: a complete expression whose
/// closing parenthesis sits at `depth`.
fn flutter_widget(ctx: &Ctx, n: &Node, depth: usize, out: &mut String) {
let (pad, inner) = (flutter_indent(depth), flutter_indent(depth + 1));
// Nested instances are calls; an instance swap parameter is a widget.
let call = match ctx.param(n, "mainComponent").filter(|_| n.id != ctx.root) {
Some(p) => Some(p.ident.clone()),
None => ctx.call(n, CodeLang::Flutter, depth).map(|(code, _)| code),
};
if let Some(call) = call {
out.push_str(call.trim_end_matches('\n'));
return;
}
match &n.kind {
NodeKind::Text(t) => {
let text = ctx.param(n, "characters").map_or_else(|| literal(&t.characters, true), |p| p.ident.clone());
out.push_str(&format!("{pad}Text(\n{inner}{text},\n"));
match t.align_h {
TextAlignH::Left => {}
TextAlignH::Center => out.push_str(&format!("{inner}textAlign: TextAlign.center,\n")),
TextAlignH::Right => out.push_str(&format!("{inner}textAlign: TextAlign.right,\n")),
TextAlignH::Justified => out.push_str(&format!("{inner}textAlign: TextAlign.justify,\n")),
}
let s = &t.style;
let mut style: Vec<String> = Vec::new();
if !s.font_family.is_empty() {
style.push(format!("fontFamily: {}", literal(&s.font_family, true)));
}
style.push(format!("fontSize: {}", num(s.font_size)));
style.push(format!("fontWeight: {}", flutter_weight(s.weight())));
if let Some(c) = first_fill(n) {
style.push(format!("color: {}", kotlin_color(c)));
}
if s.italic() {
style.push("fontStyle: FontStyle.italic".into());
}
match s.line_height {
// Dart's height is a multiple of the font size, not a length.
LineHeight::Pixels(v) if v > 0.0 && s.font_size > 0.0 => style.push(format!("height: {}", num(v / s.font_size))),
LineHeight::Percent(p) if p > 0.0 => style.push(format!("height: {}", num(p / 100.0))),
_ => {}
}
match s.letter_spacing {
LetterSpacing::Pixels(v) if v != 0.0 => style.push(format!("letterSpacing: {}", num(v))),
LetterSpacing::Percent(p) if p != 0.0 && s.font_size > 0.0 => style.push(format!("letterSpacing: {}", num(p / 100.0 * s.font_size))),
_ => {}
}
match s.decoration {
TextDecoration::None => {}
TextDecoration::Underline => style.push("decoration: TextDecoration.underline".into()),
TextDecoration::Strikethrough => style.push("decoration: TextDecoration.lineThrough".into()),
}
let sp = flutter_indent(depth + 2);
out.push_str(&format!("{inner}style: TextStyle(\n{}{inner}),\n{pad})", style.iter().map(|x| format!("{sp}{x},\n")).collect::<String>()));
}
NodeKind::Ellipse { .. } | NodeKind::Rectangle => {
out.push_str(&format!("{pad}Container(\n{inner}width: {},\n{inner}height: {},\n", num(n.width), num(n.height)));
if let Some(d) = box_decoration(n, &inner) {
out.push_str(&format!("{inner}decoration: {d},\n"));
}
out.push_str(&format!("{pad})"));
}
k if k.is_frame_like() || matches!(k, NodeKind::Group) => {
let children = ctx.kids(n);
let slot = ctx.param(n, "slot");
// The children block says whether it kept the padding for itself (an absolute child
// puts the line in a Stack at the parent's own box).
let mut kids = String::new();
let padded_inside = !(children.is_empty() && slot.is_none()) && flutter_children(ctx, n, &children, slot, depth + 1, &mut kids);
out.push_str(&format!("{pad}Container(\n{inner}width: {},\n{inner}height: {},\n", num(n.width), num(n.height)));
if !padded_inside && let Some(p) = flutter_padding(n) {
out.push_str(&format!("{inner}padding: {p},\n"));
}
if let Some(d) = box_decoration(n, &inner) {
out.push_str(&format!("{inner}decoration: {d},\n"));
}
if children.is_empty() && slot.is_none() {
out.push_str(&format!("{pad})"));
return;
}
out.push_str(&format!("{inner}alignment: Alignment.topLeft,\n{inner}child: "));
out.push_str(kids.trim_start());
out.push_str(&format!(",\n{pad})"));
}
_ => out.push_str(&format!(
"{pad}// {}: export as an image asset\n{pad}Image.asset({}, width: {}, height: {})",
n.kind.label(),
literal(&format!("{}.png", n.name), true),
num(n.width),
num(n.height)
)),
}
}
/// The Row, Column or Stack holding a frame-like layer's children, at `depth`. Returns whether it
/// took the parent's padding over (see [`flutter_widget`]).
fn flutter_children(ctx: &Ctx, n: &Node, children: &[NodeId], slot: Option<&Param>, depth: usize, out: &mut String) -> bool {
let doc = ctx.doc;
let (pad, inner) = (flutter_indent(depth), flutter_indent(depth + 1));
// Where each child sits, and whether one of the children in the line asks it to stretch.
let mut placed: Vec<Placed> = Vec::new();
let mut stretch = false;
for c in children {
let Some(cn) = doc.node(*c) else { continue };
let place = match n.layout.as_ref() {
// A child that ignores its auto layout parent keeps its own offsets: it leaves the
// line, so it neither stretches it nor takes the room left in it.
Some(_) if cn.child_layout.absolute => {
let [_, _, _, _, x, y] = cn.transform.as_coeffs();
Place::At(x, y)
}
Some(l) => {
let vertical = l.mode == LayoutMode::Vertical;
let (main, cross) = if vertical {
(cn.child_layout.sizing_v, cn.child_layout.sizing_h)
} else {
(cn.child_layout.sizing_h, cn.child_layout.sizing_v)
};
stretch |= cross == Sizing::Fill;
if main == Sizing::Fill { Place::Fill } else { Place::Flow }
}
// Free children sit at their position in the frame.
None => {
let [_, _, _, _, x, y] = cn.transform.as_coeffs();
Place::At(x, y)
}
};
placed.push((*c, place));
}
let Some(l) = n.layout.as_ref() else {
out.push_str(&format!("{pad}Stack(\n{inner}alignment: Alignment.topLeft,\n{inner}children: [\n"));
flutter_items(ctx, &placed, slot, depth + 2, out);
out.push_str(&format!("{inner}],\n{pad})"));
return false;
};
let (over, flow): (Vec<Placed>, Vec<Placed>) = placed.into_iter().partition(|(_, p)| matches!(p, Place::At(..)));
if over.is_empty() {
out.push_str(&flutter_line(ctx, l, &flow, slot, stretch, depth));
return false;
}
// A child at its own offsets goes over the line, in a Stack at the parent's own box, so its
// offsets stay the parent's and the line keeps the parent's padding.
let pad_edges = flutter_padding(n);
let list = flutter_indent(depth + 2);
let line_depth = depth + 3 + usize::from(pad_edges.is_some());
out.push_str(&format!("{pad}Stack(\n{inner}alignment: Alignment.topLeft,\n{inner}children: [\n"));
if !flow.is_empty() || slot.is_some() {
// Filling the Stack gives the line the parent's size, which `Expanded` children need.
out.push_str(&format!("{list}Positioned.fill(\n{list} child: "));
match pad_edges {
Some(p) => {
out.push_str(&format!("Padding(\n{list} padding: {p},\n{list} child: "));
out.push_str(flutter_line(ctx, l, &flow, slot, stretch, line_depth).trim_start());
out.push_str(&format!(",\n{list} )"));
}
None => out.push_str(flutter_line(ctx, l, &flow, slot, stretch, line_depth).trim_start()),
}
out.push_str(&format!(",\n{list}),\n"));
}
flutter_items(ctx, &over, None, depth + 2, out);
out.push_str(&format!("{inner}],\n{pad})"));
true
}
/// The Row or Column holding a frame's flow children, at `depth`: a complete widget expression.
fn flutter_line(ctx: &Ctx, l: &AutoLayout, flow: &[Placed], slot: Option<&Param>, stretch: bool, depth: usize) -> String {
let (pad, inner) = (flutter_indent(depth), flutter_indent(depth + 1));
let mut out = format!("{pad}{}(\n", if l.mode == LayoutMode::Vertical { "Column" } else { "Row" });
if let Some(m) = flutter_main_align(l.main_align) {
out.push_str(&format!("{inner}mainAxisAlignment: {m},\n"));
}
out.push_str(&format!("{inner}crossAxisAlignment: {},\n", flutter_cross_align(l.cross_align, stretch)));
if !stretch && matches!(l.cross_align, CrossAlign::Baseline) {
out.push_str(&format!("{inner}textBaseline: TextBaseline.alphabetic,\n"));
}
// The gap between the children; the space distributions already spread them.
if l.gap > 0.0 && flutter_main_align(l.main_align).is_none_or(|m| !m.contains("Space")) {
out.push_str(&format!("{inner}spacing: {},\n", num(l.gap)));
}
out.push_str(&format!("{inner}children: [\n"));
flutter_items(ctx, flow, slot, depth + 2, &mut out);
out.push_str(&format!("{inner}],\n{pad})"));
out
}
/// A children list (`[` already open) at `depth`; a slot shows the instance's content, or else the
/// main's.
fn flutter_items(ctx: &Ctx, kids: &[Placed], slot: Option<&Param>, depth: usize, out: &mut String) {
let list = flutter_indent(depth);
if let Some(p) = slot {
let name = &p.ident;
out.push_str(&format!("{list}...?{name},\n"));
out.push_str(&format!("{list}if ({name} == null) ...[\n"));
}
let kid = depth + usize::from(slot.is_some());
for (id, place) in kids {
flutter(ctx, *id, kid, out, *place);
out.push_str(",\n");
}
if slot.is_some() {
out.push_str(&format!("{list}],\n"));
}
}
+136 -22
View File
@@ -1,17 +1,17 @@
//! Component code: a component (or component set) becomes a React, Leptos, Vue, 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.
//! Compose, ArkUI or Flutter 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::{Document, NodeId, NodeKind, PropDef};
use serde_json::{Value, json};
use crate::cmd::CommandSpec;
use crate::codegen::{CodeLang, Ctx, Markup, arkui, class_name, compose, indent, literal, swift};
use crate::codegen::{CodeLang, Ctx, FLUTTER_IMPORT, Markup, Place, arkui, class_name, compose, flutter, flutter_indent, indent, literal, swift};
use crate::components::{prop_default_value, slot_edited, slots_of, variant_options, variant_props, variant_set};
use crate::params::{id_of, string, targets};
use crate::{bad, spec};
@@ -68,14 +68,14 @@ pub fn specs() -> Vec<CommandSpec> {
vec![spec!(
"inspect.componentCode",
"Component code",
"{id? (component, set or instance; default the selection), language?: react|leptos|vue|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}",
"{id? (component, set or instance; default the selection), language?: react|leptos|vue|swiftui|compose|arkui|flutter (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") {
// One list of code languages, shared with codeConnect (see `code_connect::language`).
Some(l) => crate::code_connect::language(&l).ok_or_else(|| bad(cmd, "language: react|leptos|vue|swiftui|compose|arkui"))?,
Some(l) => crate::code_connect::language(&l).ok_or_else(|| bad(cmd, "language: react|leptos|vue|swiftui|compose|arkui|flutter"))?,
None => CodeLang::React,
};
let doc = s.doc()?;
@@ -108,10 +108,10 @@ pub fn specs() -> Vec<CommandSpec> {
)]
}
/// Code for any layer in React, Leptos, Vue, 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), with the imports of the connected components it calls
/// at the top.
/// Code for any layer in React, Leptos, Vue, SwiftUI, Compose, ArkUI or Flutter: 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), with the imports of the connected components it
/// calls at the top.
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)) {
@@ -119,7 +119,15 @@ pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String {
}
let comp = main_of(doc, id).and_then(|m| describe(doc, m));
let code = match (&n.kind, comp) {
(NodeKind::Instance { .. }, Some(c)) => format!("{}\n{}", usage(doc, id, lang, 0).unwrap_or_default(), definition(doc, &c, lang)),
(NodeKind::Instance { .. }, Some(c)) => {
let call = usage(doc, id, lang, 0).unwrap_or_default();
let def = definition(doc, &c, lang);
match def.strip_prefix(FLUTTER_IMPORT) {
// Dart's imports come before the code that uses them.
Some(rest) if lang == CodeLang::Flutter => format!("{FLUTTER_IMPORT}{call}\n{rest}"),
_ => format!("{call}\n{def}"),
}
}
(NodeKind::Component { .. } | NodeKind::ComponentSet { .. }, Some(c)) => definition(doc, &c, lang),
_ => match lang {
CodeLang::React | CodeLang::Leptos | CodeLang::Vue => {
@@ -140,6 +148,11 @@ pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String {
arkui(&Ctx::new(doc, id), id, 0, &mut out, &[]);
out
}
CodeLang::Flutter => {
let mut out = String::new();
flutter(&Ctx::new(doc, id), id, 0, &mut out, Place::Flow);
out
}
_ => {
let mut out = String::new();
compose(&Ctx::new(doc, id), id, 0, &mut out);
@@ -328,24 +341,33 @@ const RESERVED: &[&str] = &[
"yield",
];
/// Types the generated SwiftUI, Compose and ArkUI code names: a variant enum may not shadow them.
/// Types the generated SwiftUI, Compose, ArkUI and Flutter code names: a variant enum may not shadow them.
const TYPES: &[&str] = &[
"Alignment",
"AnyView",
"Arrangement",
"Bool",
"Boolean",
"Border",
"BorderRadius",
"BorderSide",
"Box",
"BoxDecoration",
"BoxShadow",
"BoxShape",
"BuildContext",
"CaseIterable",
"Circle",
"CircleShape",
"Color",
"Column",
"Composable",
"CrossAxisAlignment",
"Edge",
"EdgeInsets",
"Ellipse",
"EmptyView",
"Expanded",
"FlexAlign",
"Font",
"FontStyle",
@@ -354,19 +376,28 @@ const TYPES: &[&str] = &[
"HStack",
"HorizontalAlign",
"Image",
"MainAxisAlignment",
"Modifier",
"Offset",
"Opacity",
"Positioned",
"Rect",
"RectangleShape",
"RoundedCornerShape",
"RoundedRectangle",
"Row",
"Shape",
"SizedBox",
"Stack",
"StatelessWidget",
"String",
"Text",
"TextAlign",
"TextBaseline",
"TextCase",
"TextDecoration",
"TextDecorationType",
"TextStyle",
"Unit",
"VStack",
"VerticalAlign",
@@ -481,6 +512,13 @@ fn cases(values: &[String], swift: bool) -> Vec<String> {
.collect()
}
/// Enum member names for variant values in Dart: lowerCamel, unique, never a keyword (a `default`
/// value becomes `defaultValue`).
fn dart_cases(values: &[String]) -> Vec<String> {
let mut used = Vec::new();
values.iter().map(|v| ident(v, &mut used)).collect()
}
/// A Rust string literal.
pub(crate) fn rust_string(s: &str) -> String {
format!("{:?}", s.chars().take(10_000).collect::<String>())
@@ -533,7 +571,7 @@ pub(crate) fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value
let i = opts.iter().position(|o| Some(o.as_str()) == value.as_str()).unwrap_or(0);
format!("{}::{}", leptos_enum(c, p), rust_cases(opts).get(i).cloned().unwrap_or_else(|| "Unknown".into()))
}
(ParamKind::Text, l) => literal(value.as_str().unwrap_or(""), l == CodeLang::Compose),
(ParamKind::Text, l) => literal(value.as_str().unwrap_or(""), l.escapes_dollar()),
(ParamKind::Variant(opts), l) => {
let v = value.as_str().unwrap_or("");
if l == CodeLang::Vue {
@@ -542,10 +580,12 @@ pub(crate) fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value
if l == CodeLang::React {
return js_string(v);
}
let swift = l == CodeLang::SwiftUi;
let i = opts.iter().position(|o| o == v).unwrap_or(0);
let case = cases(opts, swift).get(i).cloned().unwrap_or_else(|| "unknown".into());
if swift { format!(".{case}") } else { format!("{}.{case}", enum_name(c, p, false)) }
match l {
CodeLang::SwiftUi => format!(".{}", cases(opts, true).get(i).cloned().unwrap_or_else(|| "unknown".into())),
CodeLang::Flutter => format!("{}.{}", enum_name(c, p, false), dart_cases(opts).get(i).cloned().unwrap_or_else(|| "unknown".into())),
_ => format!("{}.{}", enum_name(c, p, false), cases(opts, false).get(i).cloned().unwrap_or_else(|| "Unknown".into())),
}
}
(ParamKind::InstanceSwap, l) => {
let t = id_of(value).and_then(|i| component_type(doc, i));
@@ -556,12 +596,14 @@ 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}())"),
(Some(t), CodeLang::Flutter) => format!("{t}()"),
// ArkUI takes a builder: `@BuilderParam` is a `() => void`.
(Some(t), CodeLang::ArkUi) => format!("() => {{ {t}() }}"),
(None, CodeLang::ArkUi) => "() => {}".into(),
(Some(t), _) => format!("{{ {t}() }}"),
(None, _) if js => "null".into(),
(None, CodeLang::SwiftUi) => "AnyView(EmptyView())".into(),
(None, CodeLang::Flutter) => "const SizedBox.shrink()".into(),
(None, _) => "{}".into(),
}
}
@@ -616,7 +658,7 @@ pub fn usage(doc: &Document, id: NodeId, lang: CodeLang, depth: usize) -> Option
pub(crate) fn usage_with_styles(doc: &Document, id: NodeId, lang: CodeLang, depth: usize) -> Option<(String, String)> {
let c = main_of(doc, id).and_then(|m| describe(doc, m))?;
let values = instance_values(doc, id, &c)?;
let pad = if matches!(lang, CodeLang::React | CodeLang::Vue) { " ".repeat(depth) } else { indent(depth) };
let pad = if matches!(lang, CodeLang::React | CodeLang::Vue | CodeLang::Flutter) { " ".repeat(depth) } else { indent(depth) };
// A connected component: its real snippet (its import is at the top of the code: `code`).
if let Some(link) = crate::code_connect::link(doc, id, lang) {
return Some((crate::code_connect::render(doc, &c, &values, link, lang, &pad), String::new()));
@@ -684,7 +726,9 @@ pub(crate) fn usage_with_styles(doc: &Document, id: NodeId, lang: CodeLang, dept
.collect();
if slots.is_empty() { format!("{pad}<{t}{attrs} />\n") } else { format!("{pad}<{t}{attrs}>\n{slots}{pad}</{t}>\n") }
}
CodeLang::SwiftUi => format!("{pad}{t}({})\n", args.iter().map(|(n, e, _)| format!("{n}: {e}")).collect::<Vec<_>>().join(", ")),
CodeLang::SwiftUi | CodeLang::Flutter => {
format!("{pad}{t}({})\n", args.iter().map(|(n, e, _)| format!("{n}: {e}")).collect::<Vec<_>>().join(", "))
}
CodeLang::ArkUi => {
let list = args.iter().map(|(n, e, _)| format!("{n}: {e}")).collect::<Vec<_>>().join(", ");
// ArkTS calls take an object literal, or nothing at all.
@@ -743,6 +787,15 @@ fn slot_content(doc: &Document, slot: NodeId, lang: CodeLang, depth: usize, pref
}
(format!("() => {{\n{out}{}}}", indent(depth + 1)), String::new())
}
CodeLang::Flutter => {
// A slot holds a list of widgets, so it takes however many layers the slot has.
let mut out = String::new();
for k in kids {
flutter(&ctx, k, depth + 1, &mut out, Place::Flow);
out.push_str(",\n");
}
(format!("[\n{out}{}]", flutter_indent(depth)), String::new())
}
_ => {
let mut out = String::new();
for k in kids {
@@ -760,6 +813,7 @@ pub fn definition(doc: &Document, c: &Component, lang: CodeLang) -> String {
match lang {
CodeLang::SwiftUi => swift_definition(doc, c),
CodeLang::Compose => compose_definition(doc, c),
CodeLang::Flutter => flutter_definition(doc, c),
CodeLang::Leptos => leptos(doc, c),
CodeLang::Vue => vue(doc, c),
CodeLang::ArkUi => arkui_definition(doc, c),
@@ -994,6 +1048,66 @@ fn compose_definition(doc: &Document, c: &Component) -> String {
out
}
/// A Flutter `StatelessWidget`: variant enums, a const constructor with defaults, and the widget
/// tree in `build`.
fn flutter_definition(doc: &Document, c: &Component) -> String {
let ctx = Ctx { doc, root: c.comp, comp: Some(c), base: 2 };
let mut out = String::from(FLUTTER_IMPORT);
for p in &c.params {
if let ParamKind::Variant(opts) = &p.kind {
out.push_str(&format!("enum {} {{ {} }}\n\n", enum_name(c, p, false), dart_cases(opts).join(", ")));
}
}
let t = &c.type_name;
out.push_str(&format!("class {t} extends StatelessWidget {{\n"));
if c.params.is_empty() {
out.push_str(&format!(" const {t}({{super.key}});\n"));
} else {
out.push_str(&format!(" const {t}({{\n super.key,\n"));
for p in &c.params {
match flutter_default(doc, c, p) {
Some(d) => out.push_str(&format!(" this.{} = {d},\n", p.ident)),
None => out.push_str(&format!(" this.{},\n", p.ident)),
}
}
out.push_str(" });\n\n");
for p in &c.params {
out.push_str(&format!(" final {} {};\n", flutter_type(c, p), p.ident));
}
}
out.push_str("\n @override\n Widget build(BuildContext context) {\n return ");
let mut body = String::new();
flutter(&ctx, c.comp, 2, &mut body, Place::Flow);
out.push_str(body.trim_start());
out.push_str(";\n }\n}\n");
out
}
/// A parameter's Dart type.
fn flutter_type(c: &Component, p: &Param) -> String {
match &p.kind {
ParamKind::Variant(_) => enum_name(c, p, false),
ParamKind::Boolean => "bool".into(),
ParamKind::Text => "String".into(),
ParamKind::InstanceSwap => "Widget".into(),
// A slot holds however many layers it was filled with.
ParamKind::Slot => "List<Widget>?".into(),
}
}
/// A parameter's default as a Dart constant (`None` for a slot, which has none): Dart wants its
/// defaults to be constants, so an instance swap is a `const` widget.
fn flutter_default(doc: &Document, c: &Component, p: &Param) -> Option<String> {
match &p.kind {
ParamKind::Slot => None,
ParamKind::InstanceSwap => Some(match id_of(&p.default).and_then(|i| component_type(doc, i)) {
Some(t) => format!("const {t}()"),
None => "const SizedBox.shrink()".into(),
}),
_ => Some(value_expr(doc, c, p, &p.default, CodeLang::Flutter)),
}
}
/// 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 {
+9 -4
View File
@@ -1,8 +1,9 @@
//! Inspect behaviour suite, written from the public documentation of design tools' inspect and
//! developer handoff modes: the reported layout (size, position, rotation, radius, opacity, blend),
//! colour formats, units, typography, the generated CSS, SwiftUI and Compose for common layers,
//! the asset list and redline measurements. Each rule is a table row or an assert with a one-line
//! derivation; where the documentation is silent the row says so and pins today's behaviour.
//! colour formats, units, typography, the generated CSS, SwiftUI, Compose and Flutter for common
//! layers, the asset list and redline measurements. Each rule is a table row or an assert with a
//! one-line derivation; where the documentation is silent the row says so and pins today's
//! behaviour.
// The tables read best as tuples.
#![allow(clippy::type_complexity)]
@@ -241,12 +242,16 @@ fn native_code_for_a_simple_layer() {
set(&mut s, &[r], json!({"cornerRadius": 8}));
let swift = code(&mut s, r, json!({"language": "swiftui"}));
let compose = code(&mut s, r, json!({"language": "compose"}));
let flutter = code(&mut s, r, json!({"language": "flutter"}));
// (code, expected fragment, derivation)
let rows: [(&str, &str, &str); 4] = [
let rows: [(&str, &str, &str); 7] = [
(&swift, "frame(width: 100, height: 50)", "SwiftUI sizes a view with frame, in points"),
(&swift, "Color(red: 1, green: 0, blue: 0, opacity: 1)", "docs silent: the fill as a SwiftUI colour"),
(&compose, "Modifier.size(100.dp, 50.dp)", "Compose sizes in dp"),
(&compose, "RoundedCornerShape(8.dp)", "docs silent: corner radius as a rounded shape"),
(&flutter, "width: 100,\n height: 50,", "Flutter sizes a Container in logical pixels"),
(&flutter, "Color(0xFFFF0000)", "docs silent: the fill as a Flutter colour"),
(&flutter, "borderRadius: BorderRadius.circular(8)", "docs silent: corner radius as a rounded border"),
];
for (c, want, why) in rows {
assert!(c.contains(want), "{want}: {why}\n{c}");