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.
This commit is contained in:
Ijas Hussain
2026-10-17 12:31:58 +05:30
parent 40c744da10
commit 95b91e602b
8 changed files with 193 additions and 9 deletions
+2
View File
@@ -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. |
+130 -4
View File
@@ -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<CommandSpec> {
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-<collection>=\"<mode>\"]), 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-<collection>=\"<mode>\"] | 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<Value> {
"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>) -> 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<Value> {
let cmd = "variable.import";
let v =
+51 -1
View File
@@ -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}");
}
}
+1
View File
@@ -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"
+1
View File
@@ -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"
+2 -1
View File
@@ -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();
+1 -1
View File
@@ -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
+5 -2
View File
@@ -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-<collection>="<mode>"]`, 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-<collection>="<mode>"]`, 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