mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-30 13:26:54 -06:00
Merge pull request #669 from yelintong/feat/flutter-codegen
M7: Flutter code generation
This commit is contained in:
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}");
|
||||
|
||||
Reference in New Issue
Block a user