diff --git a/crates/engine/src/cmd/variables_io.rs b/crates/engine/src/cmd/variables_io.rs index eee312c2..6bd5bb40 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-=\"\"]; numbers in px unless layers or scopes use them as opacity or font weight | tailwind: a Tailwind CSS v4 stylesheet, the default mode as an @theme block named the way inspect.code's tailwind utilities read them: --color-*, --radius-*, --text-*, --spacing-*, --font-*, unique per file), 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-=\"\"]; numbers in px unless layers or scopes use them as opacity or font weight | tailwind: a Tailwind CSS v4 stylesheet, the default mode as an @theme block named the way inspect.code's tailwind utilities read them: --color-*, --radius-*, --text-*, --spacing-*, --font-*, unique per file) | 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 ), @@ -48,7 +48,8 @@ fn export(s: &mut Session, p: &Value) -> Result { .map_err(|e| EngineError::Other(e.to_string()))?, "css" => css(doc, &colls, false), "tailwind" => css(doc, &colls, true), - _ => return Err(bad(cmd, "format: json|css|tailwind")), + "dart" => dart(&doc.variables, &colls), + _ => return Err(bad(cmd, "format: json|css|tailwind|dart")), }; match string(p, "path") { Some(path) => { @@ -108,6 +109,143 @@ fn css(doc: &Document, colls: &[&Collection], tailwind: bool) -> 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", +]; + +/// 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", + "external", + "factory", + "Function", + "get", + "implements", + "import", + "int", + "interface", + "late", + "library", + "List", + "mixin", + "num", + "Object", + "operator", + "part", + "required", + "set", + "static", + "String", + "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()) || 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 +} + +/// `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() +} + +/// 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)) => ("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()), + _ => 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/codegen.rs b/crates/engine/src/codegen.rs index 7cf270a4..5064c64f 100644 --- a/crates/engine/src/codegen.rs +++ b/crates/engine/src/codegen.rs @@ -1236,7 +1236,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})") } diff --git a/crates/engine/src/variables_io_tests.rs b/crates/engine/src/variables_io_tests.rs index 5eea5b30..e8a0239e 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}; @@ -372,6 +372,86 @@ fn variant_properties_follow_string_variables() { 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}"); + } +} + +#[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})); + } + 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 [ + "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;", + // 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}"); + } +} + #[test] fn paints_replaced_through_node_set_keep_unchanged_stop_bindings() { let mut s = Session::new(); diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 56dfeea8..b7f61c95 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -228,6 +228,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 abcd9ee2..682e2f5d 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -230,6 +230,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 372e3f04..9939cfc9 100644 --- a/crates/ui-egui/src/variables_panel.rs +++ b/crates/ui-egui/src/variables_panel.rs @@ -183,7 +183,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 7b92b061..aeb1e50d 100644 --- a/docs/components-parity.md +++ b/docs/components-parity.md @@ -70,7 +70,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; `variableCollections` and `variables` read under `meta` or at the top level, a warning when no variables are found or fewer than the collections list); 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; `variableCollections` and `variables` read under `meta` or at the top level, a warning when no variables are found or fewer than the collections list); 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 90b2b8b2..744b4a0a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1090,7 +1090,11 @@ path?}` writes this file's collections (default: all but library copies) as a lo 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)`, numbers in px unless the layers bound to them, their scopes or the variables aliasing them make them an -opacity or a font weight, so `border-radius: var(--radius)` reads `12px`), and +opacity or a font weight, so `border-radius: var(--radius)` reads `12px`) 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; `variableCollections` and `variables` under `meta` or at the top level) reads one back: a collection joins this file's collection of the same name