From e4562a00722757c91902195f14316c36594212e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=B6=E6=9E=97=E6=A9=A6?= <108113065+yelintong@users.noreply.github.com> Date: Fri, 16 Oct 2026 18:40:59 +0800 Subject: [PATCH 1/2] M7: Flutter code generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Code generation gains a Flutter target (ours; the incumbent generates CSS, iOS and Android only), which makes Dart the seventh language: `language: flutter` (with `dart` as an alias, so `CodeLang::from_id` maps both, `id()` is `flutter` and the label `Flutter`). - `inspect.code` writes a Dart widget tree for the layer: a `Container` for a box, with its size, padding and a `BoxDecoration` (colour, radius, border, shadows); `Row` / `Column` for auto layout, with `spacing`, `crossAxisAlignment` and `textBaseline`, and `stretch` wherever a child fills the cross axis; `Expanded` for a child that fills the main axis; `Stack` with `Positioned` for free children; `Text` with a `TextStyle` (family, size, weight, style, height, letter spacing, decoration and alignment); `Image.asset` for images; `Opacity` for layer opacity; `SizedBox` for a capped subtree. Colours are `Color(0xAARRGGBB)` and `$` is escaped in string literals, as in Compose. - `inspect.componentCode` writes a `StatelessWidget` for a component or set: properties become constructor parameters with their defaults, a variant property becomes a Dart enum (lowerCamel members, never a keyword), a boolean a `bool`, a text a `String`, a swap a `Widget` and a slot a `List?`. Instances are calls that pass only what they change, and an instance's snippet puts the `import 'package:flutter/material.dart';` line before the call, since Dart's imports come first. - Code connect takes Flutter too: `codeConnect.set {language: flutter}` stores an import path, a name and a template, and the snippet carries a Dart `import '…';` line. The default template passes named parameters (`Card(size: {size})`), as SwiftUI does, and import paths are checked as Dart imports (letters, digits, `_ . / : -`). - The Inspect panel's language picker iterates `CodeLang::ALL`, so Flutter appears there and in the Connect to code note; the 13 UI catalogs and the component playground's language list follow. - Docs: README, ROADMAP, docs/mcp.md, docs/gaps.md, docs/components-parity.md, docs/file-format-parity.md, docs/target-app-parity.md and docs/dev-mode-parity.md. - Tests: Flutter cases in the codegen, component codegen and inspect behaviour suites. --- README.md | 2 +- ROADMAP.md | 3 +- crates/doc/src/node.rs | 2 +- crates/engine/src/code_connect.rs | 16 +- crates/engine/src/codegen.rs | 404 ++++++++++++++++++- crates/engine/src/component_codegen.rs | 152 ++++++- crates/engine/src/component_codegen_tests.rs | 43 ++ crates/engine/src/inspect_behaviour_tests.rs | 13 +- crates/engine/src/playground.rs | 4 +- crates/ui-egui/locales/cs.toml | 2 +- crates/ui-egui/locales/de.toml | 2 +- crates/ui-egui/locales/en.toml | 2 +- crates/ui-egui/locales/es.toml | 2 +- crates/ui-egui/locales/fr.toml | 2 +- crates/ui-egui/locales/id.toml | 2 +- crates/ui-egui/locales/ja.toml | 2 +- crates/ui-egui/locales/ko.toml | 2 +- crates/ui-egui/locales/pt-BR.toml | 2 +- crates/ui-egui/locales/ru.toml | 2 +- crates/ui-egui/locales/sv.toml | 2 +- crates/ui-egui/locales/tr.toml | 2 +- crates/ui-egui/locales/zh-Hans.toml | 2 +- crates/ui-egui/src/dev_mode.rs | 2 +- docs/components-parity.md | 2 +- docs/dev-mode-parity.md | 7 +- docs/file-format-parity.md | 2 +- docs/gaps.md | 4 +- docs/mcp.md | 28 +- docs/target-app-parity.md | 2 +- 29 files changed, 633 insertions(+), 79 deletions(-) diff --git a/README.md b/README.md index 0910bf9e..96bef198 100644 --- a/README.md +++ b/README.md @@ -191,7 +191,7 @@ exposes to agents, and exported at 2x by FigCraft's own renderer. The source fil

Prototype and hand off

- Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like count + 1), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations in colour-coded categories, a ready-for-dev list with a focus view and Compare changes (before / after with property-level diffs since a frame was marked ready), and code (CSS, HTML, React, Leptos, SwiftUI, Compose) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF. + Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like count + 1), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations in colour-coded categories, a ready-for-dev list with a focus view and Compare changes (before / after with property-level diffs since a frame was marked ready), and code (CSS, HTML, React, Leptos, SwiftUI, Compose, Flutter) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF. diff --git a/ROADMAP.md b/ROADMAP.md index c3c12085..9a3fe224 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Flutter code generation; bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -313,6 +313,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Flutter code (ours; the incumbent generates CSS, iOS and Android only): `inspect.code`, `inspect.componentCode` and `codeConnect.set` take `language: flutter` (`dart` is an alias): a `StatelessWidget` for a component (properties as constructor parameters with their defaults, instances as calls, variants as a Dart enum, swaps a `Widget`, slots a `List?`) and a Dart widget tree for a layer (`Container` with a `BoxDecoration`, `Row`/`Column` with `spacing`, `Expanded`, `Stack` + `Positioned`, `Text` with a `TextStyle`, `Image.asset`, `Opacity`), plus Dart `import '…';` lines in Code Connect. The Dev Mode language list includes it. Numbers unchanged | | 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged | | 2026-10-15 | minor | Drop files anywhere to open them (M7): dragging files over the window shows a "Drop to open" (home) or "Drop to import" (canvas) overlay. On the home screen each supported file opens as a document of its own and other files are turned away with a toast; on the canvas the files import at the drop point. Both wait behind a small card that is on screen before a large file blocks the window. A canvas import stays with the document and page that received the drop even if the tab or page changes meanwhile, and is cancelled with a message if that document or page is gone. Numbers unchanged. | | 2026-10-15 | minor | M7: system font discovery skips entries observed as non-regular files before opening them, avoiding the reproduced named-pipe stall. Regular-file symlinks remain supported; directory depth, font limits and priorities are unchanged. Numbers unchanged. | diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 00f7d559..d43b4b2f 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -218,7 +218,7 @@ pub struct Annotation { /// (`figcraft_engine::code_connect`). #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct CodeLink { - /// `react`, `swiftui` or `compose`. + /// `react`, `swiftui`, `compose` or `flutter`. pub language: String, /// What the snippet imports from (`@acme/ui`, a Swift module, a Kotlin name). #[serde(default, skip_serializing_if = "String::is_empty")] diff --git a/crates/engine/src/code_connect.rs b/crates/engine/src/code_connect.rs index 46340636..f554632a 100644 --- a/crates/engine/src/code_connect.rs +++ b/crates/engine/src/code_connect.rs @@ -37,7 +37,7 @@ const MAX_WARNINGS: usize = 50; /// The languages a component can be connected in. pub fn language(s: &str) -> Option { - CodeLang::from_id(s).filter(|l| matches!(l, CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose)) + CodeLang::from_id(s).filter(|l| matches!(l, CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::Flutter)) } /// The layer `id`'s links are stored on: its component set, else its component (an instance's @@ -66,6 +66,7 @@ pub fn import_line(link: &CodeLink, lang: CodeLang) -> String { _ if path.is_empty() => String::new(), CodeLang::React => format!("import {{ {} }} from {};", link.name.split('.').next().unwrap_or_default(), js_string(path)), CodeLang::Leptos => format!("use {path}::{};", link.name.split('.').next().unwrap_or_default()), + CodeLang::Flutter => format!("import '{}';", path.replace(['\'', '\\'], "")), _ => format!("import {path}"), } } @@ -175,6 +176,7 @@ fn expr(doc: &Document, c: &Component, p: &Param, v: &Value, lang: CodeLang) -> (Some(l), CodeLang::React) => format!("<{} />", l.name), (Some(l), CodeLang::Leptos) => format!("|| view! {{ <{} /> }}", l.name), (Some(l), CodeLang::SwiftUi) => format!("{}()", l.name), + (Some(l), CodeLang::Flutter) => format!("{}()", l.name), (Some(l), _) => format!("{{ {}() }}", l.name), (None, _) => value_expr(doc, c, p, v, lang), } @@ -221,7 +223,7 @@ fn default_template(c: &Component, lang: CodeLang, name: &str) -> String { CodeLang::Leptos => { format!("<{name}{} />", params.iter().map(|p| format!(" {}={{{p}}}", crate::component_codegen::rust_ident(p))).collect::()) } - CodeLang::SwiftUi => format!("{name}({})", params.iter().map(|p| format!("{p}: {{{p}}}")).collect::>().join(", ")), + CodeLang::SwiftUi | CodeLang::Flutter => format!("{name}({})", params.iter().map(|p| format!("{p}: {{{p}}}")).collect::>().join(", ")), _ => format!("{name}({})", params.iter().map(|p| format!("{p} = {{{p}}}")).collect::>().join(", ")), } } @@ -241,12 +243,16 @@ pub fn check(c: &Component, link: &CodeLink, lang: CodeLang) -> std::result::Res let path_ok = match lang { CodeLang::React => !path.chars().any(|ch| ch.is_control() || ch == '"' || ch == '\\'), CodeLang::Leptos => path.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || 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 { return Err(match lang { CodeLang::React => 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::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"), }); } @@ -282,7 +288,7 @@ fn text(cmd: &str, p: &Value, key: &str) -> Result> { /// The `language` parameter (default React). fn lang_param(cmd: &str, p: &Value) -> Result { match text(cmd, p, "language")? { - Some(l) => language(&l).ok_or_else(|| bad(cmd, "language: react|leptos|swiftui|compose")), + Some(l) => language(&l).ok_or_else(|| bad(cmd, "language: react|leptos|swiftui|compose|flutter")), None => Ok(CodeLang::React), } } @@ -312,7 +318,7 @@ pub fn specs() -> Vec { spec!( "codeConnect.set", "Connect to code", - "{id? (component, set or instance; default the selection), language?: react|leptos|swiftui|compose (default react), importPath?, name? (default the generated name), template? (default a call passing every property)} links the component (its set, for a variant) to its code component; fields left out keep their current value. Placeholders: {property} (its name or parameter name) and {property ? \"a\" : \"b\"}; other {…} stay as written → {component, language, code (the snippet for the layer), unknown (placeholder names that aren't properties)}", + "{id? (component, set or instance; default the selection), language?: react|leptos|swiftui|compose|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"; @@ -427,7 +433,7 @@ fn import(s: &mut Session, p: &Value) -> Result { let (Some(component), Some(lang), Some(name), Some(template)) = (field("component"), field("language").and_then(language), field("name"), e.get("template").and_then(Value::as_str)) else { - warnings.push(format!("link {}: needs component, language (react|leptos|swiftui|compose), name and template strings", i + 1)); + warnings.push(format!("link {}: needs component, language (react|leptos|swiftui|compose|flutter), name and template strings", i + 1)); continue; }; let link = CodeLink { diff --git a/crates/engine/src/codegen.rs b/crates/engine/src/codegen.rs index 7d35229f..273d84ad 100644 --- a/crates/engine/src/codegen.rs +++ b/crates/engine/src/codegen.rs @@ -1,6 +1,6 @@ //! Code for a layer, as shown by the Inspect panel and returned by `inspect.code`: CSS for the -//! layer itself, HTML or React for its subtree, and SwiftUI or Jetpack Compose (stacks for auto -//! layout, absolute offsets otherwise). Components become components whose properties are +//! layer itself, HTML or React for its subtree, and SwiftUI, Jetpack Compose 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. @@ -18,13 +18,13 @@ pub fn specs() -> Vec { vec![spec!( "inspect.code", "Code for layer", - "{id?, language?: css|html|react|leptos|swiftui|compose (default css; html, react and leptos are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead), unit?: px|rem (CSS, HTML, React and Leptos; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}", + "{id?, language?: css|html|react|leptos|swiftui|compose|flutter (default css; html, react and leptos are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead), unit?: px|rem (CSS, HTML, React and Leptos; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}", false, |s, p| { let cmd = "inspect.code"; let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a layer"))?; let lang = match string(p, "language") { - Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|swiftui|compose"))?, + Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|swiftui|compose|flutter"))?, None => CodeLang::Css, }; let rem = match string(p, "unit").map(|u| u.to_ascii_lowercase()).as_deref() { @@ -63,10 +63,13 @@ pub enum CodeLang { Leptos, SwiftUi, Compose, + /// A Flutter `StatelessWidget` (Dart) with its widget tree. + Flutter, } impl CodeLang { - pub const ALL: [CodeLang; 6] = [CodeLang::Css, CodeLang::Html, CodeLang::React, CodeLang::Leptos, CodeLang::SwiftUi, CodeLang::Compose]; + pub const ALL: [CodeLang; 7] = + [CodeLang::Css, CodeLang::Html, CodeLang::React, CodeLang::Leptos, CodeLang::SwiftUi, CodeLang::Compose, CodeLang::Flutter]; pub fn id(self) -> &'static str { match self { CodeLang::Css => "css", @@ -75,6 +78,7 @@ impl CodeLang { CodeLang::Leptos => "leptos", CodeLang::SwiftUi => "swiftui", CodeLang::Compose => "compose", + CodeLang::Flutter => "flutter", } } pub fn label(self) -> &'static str { @@ -85,16 +89,25 @@ impl CodeLang { CodeLang::Leptos => "Leptos", CodeLang::SwiftUi => "SwiftUI", CodeLang::Compose => "Compose", + CodeLang::Flutter => "Flutter", } } /// Languages whose lengths are CSS pixels (they can be shown in rem). pub fn is_web(self) -> bool { matches!(self, CodeLang::Css | CodeLang::Html | CodeLang::React | CodeLang::Leptos) } + /// 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 { if s.eq_ignore_ascii_case("tsx") || s.eq_ignore_ascii_case("jsx") { return Some(CodeLang::React); } + 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)) } } @@ -194,7 +207,9 @@ pub fn code_for(doc: &Document, id: NodeId, lang: CodeLang) -> String { match lang { CodeLang::Css => css(doc, id), CodeLang::Html => html(doc, id), - CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose => crate::component_codegen::code(doc, id, lang), + CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::Flutter => { + crate::component_codegen::code(doc, id, lang) + } } } @@ -270,8 +285,8 @@ fn radius(n: &Node) -> f64 { n.corners.uniform().unwrap_or_else(|| n.corners.radii.iter().copied().fold(0.0, f64::max)) } -/// Text content as a string literal (escaped for Swift; Kotlin also escapes `$`). -pub(crate) fn literal(s: &str, kotlin: bool) -> String { +/// Text content as a string literal (escaped for Swift; Kotlin and Dart also escape `$`). +pub(crate) fn literal(s: &str, escape_dollar: bool) -> String { let mut out = String::from("\""); for c in s.chars().take(10_000) { match c { @@ -280,7 +295,7 @@ pub(crate) fn literal(s: &str, kotlin: bool) -> String { '\n' | '\u{2028}' => out.push_str("\\n"), '\r' => {} '\t' => out.push_str("\\t"), - '$' if kotlin => out.push_str("\\$"), + '$' if escape_dollar => out.push_str("\\$"), c if c.is_control() => {} c => out.push(c), } @@ -986,7 +1001,8 @@ fn swift_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[Str // ------------------------------------------------------------------ Jetpack Compose -fn kotlin_color(c: Color) -> String { +/// A colour literal as `Color(0xAARRGGBB)` (Kotlin and Dart). +fn argb_color(c: Color) -> String { let [r, g, b, a] = c.to_rgba8(); format!("Color(0x{a:02X}{r:02X}{g:02X}{b:02X})") } @@ -998,10 +1014,10 @@ fn modifier(n: &Node, shape: &str, padding: bool) -> String { m.push_str(&format!(".shadow({}.dp, {shape})", num(s.radius / 2.0))); } if let Some(c) = first_fill(n) { - m.push_str(&format!(".background({}, {shape})", kotlin_color(c))); + m.push_str(&format!(".background({}, {shape})", argb_color(c))); } if let Some(c) = stroke_color(n) { - m.push_str(&format!(".border({}.dp, {}, {shape})", num(n.stroke.weight), kotlin_color(c))); + m.push_str(&format!(".border({}.dp, {}, {shape})", num(n.stroke.weight), argb_color(c))); } if n.opacity < 1.0 { m.push_str(&format!(".alpha({}f)", num(f64::from(n.opacity)))); @@ -1050,7 +1066,7 @@ fn compose_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String) { let shape = if radius(n) > 0.0 { format!("RoundedCornerShape({}.dp)", num(radius(n))) } else { "RectangleShape".to_string() }; match &n.kind { NodeKind::Text(t) => { - let color = first_fill(n).map(kotlin_color).unwrap_or_else(|| "Color.Unspecified".into()); + let color = first_fill(n).map(argb_color).unwrap_or_else(|| "Color.Unspecified".into()); let text = ctx.param(n, "characters").map_or_else(|| literal(&t.characters, true), |p| p.ident.clone()); out.push_str(&format!( "{pad}Text(\n{pad} text = {text},\n{pad} fontSize = {}.sp,\n{pad} fontWeight = FontWeight.W{},\n{pad} color = {color},\n{pad})\n", @@ -1112,6 +1128,312 @@ fn compose_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String) { } } +// ------------------------------------------------------------------ Flutter + +/// 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, +} + +/// 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 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 { + 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 { + let mut parts: Vec = Vec::new(); + if let Some(c) = first_fill(n) { + parts.push(format!("color: {}", argb_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: {})", argb_color(c), num(n.stroke.weight))); + } + if let Some(s) = drop_shadow(n) { + parts.push(format!( + "boxShadow: [BoxShadow(color: {}, blurRadius: {}, offset: Offset({}, {}))]", + argb_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::())) +} + +/// 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()", n.name)); + return; + } + let mut depth = depth; + let mut close: Vec = 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), + }; + 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 = 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: {}", argb_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::())); + } + 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"); + out.push_str(&format!("{pad}Container(\n{inner}width: {},\n{inner}height: {},\n", num(n.width), num(n.height))); + if 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: ")); + let mut kids = String::new(); + flutter_children(ctx, n, &children, slot, depth + 1, &mut kids); + 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`. +fn flutter_children(ctx: &Ctx, n: &Node, children: &[NodeId], slot: Option<&Param>, depth: usize, out: &mut String) { + let doc = ctx.doc; + let (pad, inner) = (flutter_indent(depth), flutter_indent(depth + 1)); + // Where each child sits, and whether one of them asks the line to stretch. + let mut placed: Vec<(NodeId, Place)> = 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() { + 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 && !cn.child_layout.absolute { 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)); + } + match n.layout.as_ref() { + Some(l) => { + out.push_str(&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))); + } + } + None => out.push_str(&format!("{pad}Stack(\n{inner}alignment: Alignment.topLeft,\n")), + } + out.push_str(&format!("{inner}children: [\n")); + let list = flutter_indent(depth + 2); + // A slot shows the instance's content, or else the main's. + 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 + 2 + usize::from(slot.is_some()); + for (id, place) in placed { + flutter(ctx, id, kid, out, place); + out.push_str(",\n"); + } + if slot.is_some() { + out.push_str(&format!("{list}],\n")); + } + out.push_str(&format!("{inner}],\n{pad})")); +} + #[cfg(test)] mod tests { use super::*; @@ -1168,4 +1490,60 @@ mod tests { assert!(kt.contains("Modifier.offset(7.dp, 9.dp)")); assert!(code_for(doc, NodeId(999_999), CodeLang::Css).is_empty()); } + + #[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_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}"); + } } diff --git a/crates/engine/src/component_codegen.rs b/crates/engine/src/component_codegen.rs index 1416b15f..fe879acd 100644 --- a/crates/engine/src/component_codegen.rs +++ b/crates/engine/src/component_codegen.rs @@ -1,5 +1,6 @@ -//! Component code: a component (or component set) becomes a React, Leptos, SwiftUI or Jetpack -//! Compose component whose properties are its parameters (a variant property → a union or enum, boolean → +//! Component code: a component (or component set) becomes a React, Leptos, SwiftUI, Jetpack +//! Compose 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 @@ -10,7 +11,7 @@ use figcraft_doc::*; use serde_json::{Value, json}; use crate::cmd::CommandSpec; -use crate::codegen::{CodeLang, Ctx, Markup, class_name, compose, indent, literal, swift}; +use crate::codegen::{CodeLang, Ctx, FLUTTER_IMPORT, Markup, Place, 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::*; use crate::{bad, spec}; @@ -67,14 +68,14 @@ pub fn specs() -> Vec { vec![spec!( "inspect.componentCode", "Component code", - "{id? (component, set or instance; default the selection), language?: react|leptos|swiftui|compose (default react)} → {component, name, set, properties: [{name, param, type, default, value? (instances)}], definition, usage? (instances; the connected snippet when the component is connected to code), connected: {importPath, name, template, import, code} | null}", + "{id? (component, set or instance; default the selection), language?: react|leptos|swiftui|compose|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").map(|l| CodeLang::from_id(&l)) { - Some(Some(l @ (CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose))) => l, - Some(_) => return Err(bad(cmd, "language: react|leptos|swiftui|compose")), + Some(Some(l @ (CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::Flutter))) => l, + Some(_) => return Err(bad(cmd, "language: react|leptos|swiftui|compose|flutter")), None => CodeLang::React, }; let doc = s.doc()?; @@ -107,9 +108,9 @@ pub fn specs() -> Vec { )] } -/// Code for any layer in React, Leptos, SwiftUI or Compose: an instance's call and its component's -/// definition, a component's (or set's) definition, or the layer tree (React and Leptos wrap it -/// in a function component). +/// Code for any layer in React, Leptos, SwiftUI, Compose or 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). 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)) { @@ -117,7 +118,15 @@ pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String { } let comp = main_of(doc, id).and_then(|m| describe(doc, m)); 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 => { @@ -129,6 +138,11 @@ pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String { swift(&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); @@ -289,36 +303,57 @@ const RESERVED: &[&str] = &[ "yield", ]; -/// Types the generated SwiftUI and Compose code names: a variant enum may not shadow them. +/// Types the generated SwiftUI, Compose and 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", "CircleShape", "Color", "Column", "Composable", + "CrossAxisAlignment", "Edge", "EdgeInsets", "Ellipse", "EmptyView", + "Expanded", "Font", + "FontStyle", "FontWeight", "Group", "HStack", "Image", + "MainAxisAlignment", "Modifier", + "Offset", + "Opacity", + "Positioned", "RectangleShape", "RoundedCornerShape", "RoundedRectangle", "Row", "Shape", + "SizedBox", + "Stack", + "StatelessWidget", "String", "Text", + "TextAlign", + "TextBaseline", + "TextDecoration", + "TextStyle", "Unit", "VStack", "View", @@ -432,6 +467,13 @@ fn cases(values: &[String], swift: bool) -> Vec { .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 { + 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::()) @@ -464,16 +506,18 @@ 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::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)); @@ -482,9 +526,11 @@ 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}()"), (Some(t), _) => format!("{{ {t}() }}"), (None, CodeLang::React) => "null".into(), (None, CodeLang::SwiftUi) => "AnyView(EmptyView())".into(), + (None, CodeLang::Flutter) => "const SizedBox.shrink()".into(), (None, _) => "{}".into(), } } @@ -520,7 +566,7 @@ pub(crate) fn instance_values(doc: &Document, id: NodeId, c: &Component) -> Opti pub fn usage(doc: &Document, id: NodeId, lang: CodeLang, depth: usize) -> Option { let c = main_of(doc, id).and_then(|m| describe(doc, m))?; let values = instance_values(doc, id, &c)?; - let pad = if lang == CodeLang::React { " ".repeat(depth) } else { indent(depth) }; + let pad = if matches!(lang, CodeLang::React | CodeLang::Flutter) { " ".repeat(depth) } else { indent(depth) }; // A connected component: its real snippet. shortcut: inside a layer tree it brings no import // line; collect them at the top of the tree's code when people ask for it. if let Some(link) = crate::code_connect::link(doc, id, lang) { @@ -560,7 +606,9 @@ pub fn usage(doc: &Document, id: NodeId, lang: CodeLang, depth: usize) -> Option let t = if leptos { rust_type(t) } else { t.clone() }; format!("{pad}<{t}{attrs} />\n") } - CodeLang::SwiftUi => format!("{pad}{t}({})\n", args.iter().map(|(n, e, _)| format!("{n}: {e}")).collect::>().join(", ")), + CodeLang::SwiftUi | CodeLang::Flutter => { + format!("{pad}{t}({})\n", args.iter().map(|(n, e, _)| format!("{n}: {e}")).collect::>().join(", ")) + } _ => format!("{pad}{t}({})\n", args.iter().map(|(n, e, _)| format!("{n} = {e}")).collect::>().join(", ")), }) } @@ -593,6 +641,15 @@ fn slot_content(doc: &Document, slot: NodeId, lang: CodeLang, depth: usize) -> S } format!("AnyView(Group {{\n{out}{}}})", indent(depth + 1)) } + 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)) + } _ => { let mut out = String::new(); for k in kids { @@ -610,6 +667,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), _ => react(doc, c), } @@ -807,3 +865,63 @@ fn compose_definition(doc: &Document, c: &Component) -> String { out.push_str("}\n"); 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?".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 { + 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)), + } +} diff --git a/crates/engine/src/component_codegen_tests.rs b/crates/engine/src/component_codegen_tests.rs index 4cd3d776..ffe59057 100644 --- a/crates/engine/src/component_codegen_tests.rs +++ b/crates/engine/src/component_codegen_tests.rs @@ -83,6 +83,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"); @@ -117,12 +128,14 @@ fn instances_are_calls_passing_the_values_they_set() { assert!(react.contains("export function Card({")); assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Card(showDot: false, title: \"Buy\")\n"); assert_eq!(component_code(&mut s, i, "compose")["usage"], "Card(showDot = false, title = \"Buy\")\n"); + assert_eq!(component_code(&mut s, i, "flutter")["usage"], "Card(showDot: false, title: \"Buy\")\n"); assert_eq!(component_code(&mut s, i, "leptos")["usage"], "\n"); // Text that needs escaping is an expression in JSX and escaped per language. ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Title": "Say \"hi\" $5\nnow"}})); assert_eq!(component_code(&mut s, i, "react")["usage"], "\n"); assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Card(showDot: false, title: \"Say \\\"hi\\\" $5\\nnow\")\n"); assert_eq!(component_code(&mut s, i, "compose")["usage"], "Card(showDot = false, title = \"Say \\\"hi\\\" \\$5\\nnow\")\n"); + assert_eq!(component_code(&mut s, i, "flutter")["usage"], "Card(showDot: false, title: \"Say \\\"hi\\\" \\$5\\nnow\")\n"); assert_eq!(component_code(&mut s, i, "leptos")["usage"], "\n"); } @@ -139,6 +152,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(""), "{leptos}"); assert!(!leptos.contains("Hello"), "{leptos}"); @@ -173,6 +188,10 @@ fn variant_sets_share_one_definition() { let kt = code(&mut s, set, "compose"); assert!(kt.contains("enum class ButtonState { Default, Hover }"), "{kt}"); assert!(kt.contains(" state: ButtonState = ButtonState.Default,"), "{kt}"); + let 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 leptos = code(&mut s, set, "leptos"); for want in [ "#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]\npub enum ButtonState {\n #[default]\n Default,\n Hover,\n}", @@ -189,6 +208,7 @@ fn variant_sets_share_one_definition() { assert_eq!(component_code(&mut s, i, "react")["usage"], "