From 95b91e602bdf2e35bcc302e766001e4a9fb16d29 Mon Sep 17 00:00:00 2001 From: Ijas Hussain Date: Sat, 17 Oct 2026 12:31:58 +0530 Subject: [PATCH 1/5] M6: export variables as Dart constants for Flutter variable.export takes format "dart", and Local variables > Export... offers "Dart constants for Flutter...". The file has an abstract final class per collection and mode (the default mode named after the collection, the others after the collection and the mode) with a static const Color, double, String or bool per variable. Names become camelCase Dart identifiers, kept apart when two read the same, with reserved words escaped; strings are escaped for Dart; aliases are resolved to their value in that mode, so each class stands alone. A generated file passes the Flutter analyzer and its constants work in const widgets. --- ROADMAP.md | 2 + crates/engine/src/cmd/variables_io.rs | 134 +++++++++++++++++++++++- crates/engine/src/variables_io_tests.rs | 52 ++++++++- crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/locales/es.toml | 1 + crates/ui-egui/src/variables_panel.rs | 3 +- docs/components-parity.md | 2 +- docs/mcp.md | 7 +- 8 files changed, 193 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index bbde8bc5..bb645c5f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-17** · Variables as Dart constants for Flutter (M6): `variable.export {format: "dart"}` and Local variables ▸ Export… ▸ Dart constants for Flutter write a Dart file with an `abstract final class` per collection and mode (the default mode named after the collection, the others after the collection and the mode) and a `static const` `Color`, `double`, `String` or `bool` per variable. Names become camelCase Dart identifiers (kept apart when two read the same, reserved words escaped), strings are escaped for Dart, and aliases are resolved to their value in that mode, so each class stands alone. Checked with the Flutter analyzer on a generated file used in `const` widgets. Tests in `variables_io_tests`. Numbers unchanged. - **2026-10-17** · Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. - **2026-10-14** · Corner handles win over the corner-radius handles (M1, #292): a press on the inner half of a corner handle's square picked the radius handle 10 px in, so the drag neither resized nor (dragging outward) rounded anything. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the link); further in the radius handle still answers. Regression test in the transform behaviour suite. Numbers unchanged. - **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. @@ -314,6 +315,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Variables as Dart constants for Flutter (M6): `variable.export {format: "dart"}` and Export… ▸ Dart constants for Flutter in Local variables; a class per collection and mode, aliases resolved, names made valid Dart. Numbers unchanged. | | 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. 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. | diff --git a/crates/engine/src/cmd/variables_io.rs b/crates/engine/src/cmd/variables_io.rs index 99a78640..7cd92ae2 100644 --- a/crates/engine/src/cmd/variables_io.rs +++ b/crates/engine/src/cmd/variables_io.rs @@ -1,6 +1,6 @@ //! Variables import and export: a file's collections as the local variables JSON (the shape the -//! REST importer reads, so exports round-trip) or as CSS custom properties, and such JSON read -//! back into a file. +//! REST importer reads, so exports round-trip), as CSS custom properties or as Dart constants for +//! Flutter, and such JSON read back into a file. use std::collections::{BTreeMap, BTreeSet}; @@ -18,7 +18,7 @@ pub fn specs() -> Vec { spec!( "variable.export", "Export variables", - "{collections? [ids or names; default every collection of this file, not library copies], format? (json (default): the local variables JSON that variable.import reads | css: custom properties, the default mode on :root and each other mode on [data-=\"\"]), path? (write it there)} → {text}, or {path} when written", + "{collections? [ids or names; default every collection of this file, not library copies], format? (json (default): the local variables JSON that variable.import reads | css: custom properties, the default mode on :root and each other mode on [data-=\"\"] | dart: a Dart file for Flutter, a class of constants per collection and mode (Color, double, String, bool), aliases resolved), path? (write it there)} → {text}, or {path} when written", false, export ), @@ -47,7 +47,8 @@ fn export(s: &mut Session, p: &Value) -> Result { "json" => serde_json::to_string_pretty(&figcraft_format::rest::export_variables(&doc.variables, &colls)) .map_err(|e| EngineError::Other(e.to_string()))?, "css" => css(&doc.variables, &colls), - _ => return Err(bad(cmd, "format: json|css")), + "dart" => dart(&doc.variables, &colls), + _ => return Err(bad(cmd, "format: json|css|dart")), }; match string(p, "path") { Some(path) => { @@ -93,6 +94,131 @@ fn css(vars: &Variables, colls: &[&Collection]) -> String { out } +/// Words Dart never takes as a name. +const DART_RESERVED: &[&str] = &[ + "assert", "await", "break", "case", "catch", "class", "const", "continue", "default", "do", "else", "enum", "extends", "false", "final", + "finally", "for", "if", "in", "is", "new", "null", "rethrow", "return", "super", "switch", "this", "throw", "true", "try", "var", "void", + "while", "with", "yield", +]; + +/// Words Dart takes as a member's name but not as a class's. +const DART_BUILT_IN: &[&str] = &[ + "abstract", + "as", + "covariant", + "deferred", + "dynamic", + "export", + "extension", + "external", + "factory", + "Function", + "get", + "implements", + "import", + "interface", + "late", + "library", + "mixin", + "operator", + "part", + "required", + "set", + "static", + "typedef", +]; + +/// `name` as a Dart identifier: its words (anything but ASCII letters and digits separates them) +/// in camelCase, or PascalCase for a type; never empty, never starting with a digit or reserved. +fn dart_ident(name: &str, pascal: bool) -> String { + let mut out = String::new(); + for w in name.split(|c: char| !c.is_ascii_alphanumeric()).filter(|w| !w.is_empty()) { + let mut cs = w.chars(); + let Some(first) = cs.next() else { continue }; + if out.is_empty() && !pascal { + out.push(first.to_ascii_lowercase()); + } else { + out.push(first.to_ascii_uppercase()); + } + out.extend(cs); + } + if out.is_empty() || out.starts_with(|c: char| c.is_ascii_digit()) { + out.insert(0, if pascal { 'V' } else { 'v' }); + } + if DART_RESERVED.contains(&out.as_str()) || (pascal && DART_BUILT_IN.contains(&out.as_str())) { + out.push('_'); + } + out +} + +/// `base`, or `base2`, `base3`… when `taken` has it already; the result is taken. +fn dart_unique(base: String, taken: &mut BTreeSet) -> String { + let mut name = base.clone(); + let mut n = 2; + while !taken.insert(name.clone()) { + name = format!("{base}{n}"); + n += 1; + } + name +} + +/// `text` inside a Dart single-quoted string. +fn dart_string(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + for c in text.chars() { + match c { + '\\' => out.push_str("\\\\"), + '\'' => out.push_str("\\'"), + '$' => out.push_str("\\$"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + c if c.is_control() => out.push_str(&format!("\\u{{{:x}}}", u32::from(c))), + c => out.push(c), + } + } + out +} + +/// `text` on one line of a Dart comment. +fn dart_comment(text: &str) -> String { + text.chars().map(|c| if c.is_control() { ' ' } else { c }).collect() +} + +/// Each collection's variables as Dart constants for Flutter: a class per mode (the default mode's +/// named after the collection, the others after the collection and the mode) with a `static const` +/// per variable. Aliases are resolved to their value in that mode (a variable of another +/// collection in that collection's default mode), so each class stands alone. +fn dart(vars: &Variables, colls: &[&Collection]) -> String { + let mut out = String::from("// Variables exported from FigCraft.\n\nimport 'dart:ui';\n"); + let mut classes = BTreeSet::new(); + for c in colls { + let base = dart_ident(&c.name, true); + let mut members = BTreeSet::new(); + let names: Vec<(&Variable, String)> = + vars.variables.iter().filter(|v| v.collection == c.id).map(|v| (v, dart_unique(dart_ident(&v.name, false), &mut members))).collect(); + for (i, m) in c.modes.iter().enumerate() { + let class = dart_unique(if i == 0 { base.clone() } else { format!("{base}{}", dart_ident(&m.name, true)) }, &mut classes); + out.push_str(&format!("\n/// {} · {}\nabstract final class {class} {{\n", dart_comment(&c.name), dart_comment(&m.name))); + let modes = BTreeMap::from([(c.id.clone(), m.id.clone())]); + for (v, name) in &names { + let (ty, value) = match vars.resolve(&v.id, &modes) { + Some(VariableValue::Color(col)) => { + let [r, g, b, a] = col.to_rgba8(); + ("Color", format!("Color(0x{a:02X}{r:02X}{g:02X}{b:02X})")) + } + Some(VariableValue::Number(x)) if x.is_finite() => ("double", trim_num(x)), + Some(VariableValue::String(t)) => ("String", format!("'{}'", dart_string(&t))), + Some(VariableValue::Boolean(b)) => ("bool", b.to_string()), + _ => continue, + }; + out.push_str(&format!(" static const {ty} {name} = {value};\n")); + } + out.push_str("}\n"); + } + } + out +} + fn import(s: &mut Session, p: &Value) -> Result { let cmd = "variable.import"; let v = diff --git a/crates/engine/src/variables_io_tests.rs b/crates/engine/src/variables_io_tests.rs index 5c7305bb..cb8d7adb 100644 --- a/crates/engine/src/variables_io_tests.rs +++ b/crates/engine/src/variables_io_tests.rs @@ -1,5 +1,5 @@ //! Extended variable collections (overrides, library updates, reset), variables import and -//! export (JSON round trip, merging by name, CSS, junk), gradient stops bound to variables and +//! export (JSON round trip, merging by name, CSS, Dart, junk), gradient stops bound to variables and //! variant properties driven by string variables. use figcraft_color::{Color, ColorStop, Gradient, GradientKind, Paint, PaintKind}; @@ -371,3 +371,53 @@ fn variant_properties_follow_string_variables() { assert_eq!(main(&s), variants[1]); healthy(&s); } + +#[test] +fn variables_export_as_dart_constants_for_flutter() { + let mut s = Session::new(); + theme(&mut s); + let dart = ex(&mut s, "variable.export", json!({"format": "dart"}))["text"].as_str().unwrap().to_string(); + for want in [ + "import 'dart:ui';\n", + "/// Theme · Light\nabstract final class Theme {\n", + " static const Color colorPrimary = Color(0x800000FF);\n", + " static const double radius = 4;\n", + " static const String label = 'Say \"hi\"';\n", + " static const bool on = false;\n", + " static const Color accent = Color(0xFF000000);\n", + "/// Theme · Dark\nabstract final class ThemeDark {\n", + " static const double radius = 6.5;\n", + // An alias is its target's value in that mode. + " static const Color accent = Color(0xFFFFFFFF);\n", + ] { + assert!(dart.contains(want), "{want:?} in\n{dart}"); + } + // Every class lists every variable once. + assert_eq!(dart.matches("static const").count(), 10, "{dart}"); +} + +#[test] +fn dart_export_names_are_valid_dart() { + let mut s = Session::new(); + ex(&mut s, "variable.createCollection", json!({"name": "2 brand-tokens", "modes": ["Default", "high contrast"]})); + ex(&mut s, "variable.createCollection", json!({"name": "Brand tokens"})); + for name in ["space/2xl", "class", "a b", "a-b", "\u{65e5}\u{672c}", "it's $5\\"] { + ex(&mut s, "variable.create", json!({"collection": "2 brand-tokens", "name": name, "type": "string", "value": name})); + } + let dart = ex(&mut s, "variable.export", json!({"format": "dart"}))["text"].as_str().unwrap().to_string(); + for want in [ + "abstract final class V2BrandTokens {", + "abstract final class V2BrandTokensHighContrast {", + "abstract final class BrandTokens {", + "static const String space2xl = 'space/2xl';", + "static const String class_ = 'class';", + // Two names that read the same in Dart stay apart. + "static const String aB = 'a b';", + "static const String aB2 = 'a-b';", + // Nothing usable in a name: still a name. + "static const String v = '\u{65e5}\u{672c}';", + "static const String itS5 = 'it\\'s \\$5\\\\';", + ] { + assert!(dart.contains(want), "{want:?} in\n{dart}"); + } +} diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index c9b6df62..dba711be 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -204,6 +204,7 @@ name = "English" "Convert to frame" = "Convert to frame" "Convert to section" = "Convert to section" "CSS custom properties…" = "CSS custom properties…" +"Dart constants for Flutter…" = "Dart constants for Flutter…" "Can edit" = "Can edit" "Can't read this library: {e}" = "Can't read this library: {e}" "Cancel" = "Cancel" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index ff3e5a79..f738f4d6 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -204,6 +204,7 @@ status = "draft — needs native review" "Color profile: {name}" = "Perfil de color: {name}" "Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Estilos de color y texto, variables Claro y Oscuro, botones, campos y tarjetas" "CSS custom properties…" = "Propiedades personalizadas CSS…" +"Dart constants for Flutter…" = "Constantes de Dart para Flutter…" "Can edit" = "Puede editar" "Can't read this library: {e}" = "No se puede leer esta biblioteca: {e}" "Cancel" = "Cancelar" diff --git a/crates/ui-egui/src/variables_panel.rs b/crates/ui-egui/src/variables_panel.rs index 5476eaf1..d273ba48 100644 --- a/crates/ui-egui/src/variables_panel.rs +++ b/crates/ui-egui/src/variables_panel.rs @@ -181,7 +181,8 @@ fn header(app: &mut FigApp, ui: &mut Ui, open: &mut bool) { let mut bar = ui.new_child(UiBuilder::new().max_rect(bar).layout(egui::Layout::right_to_left(egui::Align::Center))); let export = widgets::button(&mut bar, tr("Export…"), false, true); crate::right::menu_popup(&export).show(|ui| { - for (label, format) in [(tr("Variables JSON…"), "json"), (tr("CSS custom properties…"), "css")] { + for (label, format) in [(tr("Variables JSON…"), "json"), (tr("CSS custom properties…"), "css"), (tr("Dart constants for Flutter…"), "dart")] + { if menu_item(ui, label, "") { export_dialog(app, format); ui.close(); diff --git a/docs/components-parity.md b/docs/components-parity.md index 659676fd..b973ba20 100644 --- a/docs/components-parity.md +++ b/docs/components-parity.md @@ -69,7 +69,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Explicit modes per frame, library collections' modes | done | | | Gradient stops and variant properties bound to variables | done | a stop's binding follows it when earlier stops are removed | | Extended collections | done | library collections, including a library's own extended collections (chains through libraries); overrides per mode with reset; follow library updates, variables new to the library collection being an update of their own; modes inherited. Checked with synthetic libraries only ([gaps](gaps.md) #14) | -| Variables import/export (JSON) | done | the REST local variables shape (round-trips; merges by collection, mode and variable name); CSS custom properties export; in the browser the export arrives as a download (import there still needs a file picker) | +| Variables import/export (JSON) | done | the REST local variables shape (round-trips; merges by collection, mode and variable name); CSS custom properties and Dart constants (Flutter) export; in the browser the export arrives as a download (import there still needs a file picker) | | Expressions in prototype set-variable actions | done | see [prototyping-parity](prototyping-parity.md) | ## Revision history diff --git a/docs/mcp.md b/docs/mcp.md index 64228ea7..2b3ef2bd 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -816,8 +816,11 @@ frame back to Auto, inheriting its parent's mode). The same JSON carries variables between FigCraft files: `variable.export {collections?, format?, path?}` writes this file's collections (default: all but library copies) as a local variables response (`format: "json"`, the default) or as CSS custom properties (`format: "css"`: the default -mode on `:root`, each other mode on `[data-=""]`, aliases as `var(--name)`), and -returns `{text}` (or writes `path` and returns `{path}`). `variable.import {variables}` (an object, +mode on `:root`, each other mode on `[data-=""]`, aliases as `var(--name)`) or as +Dart constants for Flutter (`format: "dart"`: an `abstract final class` per collection and mode, the +default mode's named after the collection and the others after the collection and the mode, with a +`static const` `Color`, `double`, `String` or `bool` per variable; names become camelCase Dart +identifiers kept apart, aliases are resolved to their value in that mode), and returns `{text}` (or writes `path` and returns `{path}`). `variable.import {variables}` (an object, JSON text or a path) reads one back: a collection joins this file's collection of the same name (values matched by mode name, new modes and variables added, a same-named variable of another type skipped with a warning; library copies and extended collections are never targets), others are From 2a8db20d48a65b0ca4c375b04955102227a8ec73 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 26 Oct 2026 23:28:45 +0700 Subject: [PATCH 2/5] #726: drop the ROADMAP rows --- ROADMAP.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d2aa433b..e403b02d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,7 +132,6 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log -- **2026-10-17** · Variables as Dart constants for Flutter (M6): `variable.export {format: "dart"}` and Local variables ▸ Export… ▸ Dart constants for Flutter write a Dart file with an `abstract final class` per collection and mode (the default mode named after the collection, the others after the collection and the mode) and a `static const` `Color`, `double`, `String` or `bool` per variable. Names become camelCase Dart identifiers (kept apart when two read the same, reserved words escaped), strings are escaped for Dart, and aliases are resolved to their value in that mode, so each class stands alone. Checked with the Flutter analyzer on a generated file used in `const` widgets. Tests in `variables_io_tests`. Numbers unchanged. - **2026-10-25** · egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. - **2026-10-24** · Open, Save and the folder dialogs start in the folder of the last file or folder picked in the run (#1290): the Linux portal does not remember it between dialogs as the macOS and Windows panels do. A folder deleted since falls back to the system's choice. No numbers change. - **2026-10-24** · Boards (M12): board pages get a dot grid under their layers (drawn by the canvas render after the page colour, thinned out when zoomed out); a selected connector shows its ends as handles, and dragging one onto another object re-attaches it there, onto empty canvas leaves it free (`board.reattach`, one undo step, also over MCP); connectors can be curved, and elbows go around the objects they join when the short way would cross them. Checked in the app, through MCP and in the web build ([boards-parity](docs/boards-parity.md)). @@ -348,7 +347,6 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| -| 2026-10-17 | minor | Variables as Dart constants for Flutter (M6): `variable.export {format: "dart"}` and Export… ▸ Dart constants for Flutter in Local variables; a class per collection and mode, aliases resolved, names made valid Dart. Numbers unchanged. | | 2026-10-25 | minor | egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. | | 2026-10-24 | minor | Server memory (#1134): when the last person leaves a file nobody edited, figcraft-server keeps the client ids it issued in the log instead of rewriting the snapshot (compaction still follows `compact_after`/`compact_mb` and edits), and a snapshot is encoded straight into its file buffer, without a second copy of the body; the file format is unchanged. Numbers unchanged | | 2026-10-24 | minor | Boards (M12): a dot grid on board pages under the layers, connector ends dragged onto other objects or empty canvas (`board.reattach`), curved connectors, elbows that go around what they join. Boards row ~35% → ~37%, ~20% → ~21%, 30–60 h → 28–56 h; headline numbers unchanged (boards are outside the weighted sum) | From a683e510451f80c01ba37acebdaae76ec7eb934c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 26 Oct 2026 23:31:15 +0700 Subject: [PATCH 3/5] #726: Dart names never shadow Dart types (classes and constants) --- crates/engine/src/cmd/variables_io.rs | 13 +++++++++++-- crates/engine/src/variables_io_tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/variables_io.rs b/crates/engine/src/cmd/variables_io.rs index 853209f4..dd48dca6 100644 --- a/crates/engine/src/cmd/variables_io.rs +++ b/crates/engine/src/cmd/variables_io.rs @@ -116,12 +116,16 @@ const DART_RESERVED: &[&str] = &[ "while", "with", "yield", ]; -/// Words Dart takes as a member's name but not as a class's. +/// Dart's built-in identifiers (no type may take them), and the types the constants use or name, +/// which a class or constant of that name would shadow. const DART_BUILT_IN: &[&str] = &[ "abstract", "as", + "bool", + "Color", "covariant", "deferred", + "double", "dynamic", "export", "extension", @@ -131,15 +135,20 @@ const DART_BUILT_IN: &[&str] = &[ "get", "implements", "import", + "int", "interface", "late", "library", + "List", "mixin", + "num", + "Object", "operator", "part", "required", "set", "static", + "String", "typedef", ]; @@ -160,7 +169,7 @@ fn dart_ident(name: &str, pascal: bool) -> String { if out.is_empty() || out.starts_with(|c: char| c.is_ascii_digit()) { out.insert(0, if pascal { 'V' } else { 'v' }); } - if DART_RESERVED.contains(&out.as_str()) || (pascal && DART_BUILT_IN.contains(&out.as_str())) { + if DART_RESERVED.contains(&out.as_str()) || DART_BUILT_IN.contains(&out.as_str()) { out.push('_'); } out diff --git a/crates/engine/src/variables_io_tests.rs b/crates/engine/src/variables_io_tests.rs index 8cc6be80..482c220a 100644 --- a/crates/engine/src/variables_io_tests.rs +++ b/crates/engine/src/variables_io_tests.rs @@ -422,6 +422,28 @@ fn dart_export_names_are_valid_dart() { } } +#[test] +fn dart_export_names_never_shadow_dart_types() { + let mut s = Session::new(); + ex(&mut s, "variable.createCollection", json!({"name": "Color"})); + ex(&mut s, "variable.createCollection", json!({"name": "String"})); + for name in ["double", "int", "bool", "num"] { + ex(&mut s, "variable.create", json!({"collection": "Color", "name": name, "type": "number", "value": 1})); + } + let dart = ex(&mut s, "variable.export", json!({"format": "dart"}))["text"].as_str().unwrap().to_string(); + // A class `Color` would hide dart:ui's `Color`, a constant `double` the type of those after it. + for want in [ + "abstract final class Color_ {", + "abstract final class String_ {", + "static const double double_ = 1;", + "static const double int_ = 1;", + "static const double bool_ = 1;", + "static const double num_ = 1;", + ] { + assert!(dart.contains(want), "{want:?} in\n{dart}"); + } +} + #[test] fn paints_replaced_through_node_set_keep_unchanged_stop_bindings() { let mut s = Session::new(); From 5a5175cc7ec67e3b32a872c2837a58cdaf3580d8 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 26 Oct 2026 23:35:17 +0700 Subject: [PATCH 4/5] #726: write Dart numbers at full precision --- crates/engine/src/cmd/variables_io.rs | 8 +++++++- crates/engine/src/variables_io_tests.rs | 8 ++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/variables_io.rs b/crates/engine/src/cmd/variables_io.rs index dd48dca6..6dd57615 100644 --- a/crates/engine/src/cmd/variables_io.rs +++ b/crates/engine/src/cmd/variables_io.rs @@ -203,6 +203,12 @@ fn dart_string(text: &str) -> String { out } +/// `x` at full precision as a Dart literal: whole numbers as integers (`4`), the rest in Rust's +/// shortest round-trip form (`0.125`, `1e21`), which Dart reads as the same double. +fn dart_number(x: f64) -> String { + if x.fract() == 0.0 && x.abs() < 1e15 { format!("{x}") } else { format!("{x:?}") } +} + /// `text` on one line of a Dart comment. fn dart_comment(text: &str) -> String { text.chars().map(|c| if c.is_control() { ' ' } else { c }).collect() @@ -230,7 +236,7 @@ fn dart(vars: &Variables, colls: &[&Collection]) -> String { let [r, g, b, a] = col.to_rgba8(); ("Color", format!("Color(0x{a:02X}{r:02X}{g:02X}{b:02X})")) } - Some(VariableValue::Number(x)) if x.is_finite() => ("double", trim_num(x)), + Some(VariableValue::Number(x)) if x.is_finite() => ("double", dart_number(x)), Some(VariableValue::String(t)) => ("String", format!("'{}'", dart_string(&t))), Some(VariableValue::Boolean(b)) => ("bool", b.to_string()), _ => continue, diff --git a/crates/engine/src/variables_io_tests.rs b/crates/engine/src/variables_io_tests.rs index 482c220a..e8a0239e 100644 --- a/crates/engine/src/variables_io_tests.rs +++ b/crates/engine/src/variables_io_tests.rs @@ -430,6 +430,9 @@ fn dart_export_names_never_shadow_dart_types() { for name in ["double", "int", "bool", "num"] { ex(&mut s, "variable.create", json!({"collection": "Color", "name": name, "type": "number", "value": 1})); } + ex(&mut s, "variable.create", json!({"collection": "Color", "name": "ratio", "type": "number", "value": 0.125})); + ex(&mut s, "variable.create", json!({"collection": "Color", "name": "third", "type": "number", "value": 1.0 / 3.0})); + ex(&mut s, "variable.create", json!({"collection": "Color", "name": "huge", "type": "number", "value": 1.5e21})); let dart = ex(&mut s, "variable.export", json!({"format": "dart"}))["text"].as_str().unwrap().to_string(); // A class `Color` would hide dart:ui's `Color`, a constant `double` the type of those after it. for want in [ @@ -439,6 +442,11 @@ fn dart_export_names_never_shadow_dart_types() { "static const double int_ = 1;", "static const double bool_ = 1;", "static const double num_ = 1;", + // Numbers keep their full precision. + "static const double ratio = 0.125;", + "static const double third = 0.3333333333333333;", + // Too big for an exact integer literal: an exponent, still a double. + "static const double huge = 1.5e21;", ] { assert!(dart.contains(want), "{want:?} in\n{dart}"); } From 56ed786e0e0fad06c2ff79b1c74d5ff74b540502 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 26 Oct 2026 23:36:54 +0700 Subject: [PATCH 5/5] #726: reuse codegen's kotlin_color for Dart colours --- crates/engine/src/cmd/variables_io.rs | 5 +---- crates/engine/src/codegen.rs | 3 ++- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/variables_io.rs b/crates/engine/src/cmd/variables_io.rs index 6dd57615..6bd5bb40 100644 --- a/crates/engine/src/cmd/variables_io.rs +++ b/crates/engine/src/cmd/variables_io.rs @@ -232,10 +232,7 @@ fn dart(vars: &Variables, colls: &[&Collection]) -> String { let modes = BTreeMap::from([(c.id.clone(), m.id.clone())]); for (v, name) in &names { let (ty, value) = match vars.resolve(&v.id, &modes) { - Some(VariableValue::Color(col)) => { - let [r, g, b, a] = col.to_rgba8(); - ("Color", format!("Color(0x{a:02X}{r:02X}{g:02X}{b:02X})")) - } + Some(VariableValue::Color(col)) => ("Color", crate::codegen::kotlin_color(col)), Some(VariableValue::Number(x)) if x.is_finite() => ("double", dart_number(x)), Some(VariableValue::String(t)) => ("String", format!("'{}'", dart_string(&t))), Some(VariableValue::Boolean(b)) => ("bool", b.to_string()), diff --git a/crates/engine/src/codegen.rs b/crates/engine/src/codegen.rs index 520728bc..55d1d9a4 100644 --- a/crates/engine/src/codegen.rs +++ b/crates/engine/src/codegen.rs @@ -1083,7 +1083,8 @@ fn swift_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[Str // ------------------------------------------------------------------ Jetpack Compose -fn kotlin_color(c: Color) -> String { +/// `Color(0xAARRGGBB)`, as Compose and Flutter both write a colour. +pub(crate) fn kotlin_color(c: Color) -> String { let [r, g, b, a] = c.to_rgba8(); format!("Color(0x{a:02X}{r:02X}{g:02X}{b:02X})") }