mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-27 03:46:50 -06:00
Merge pull request #726 from ijashuzain/feat/variables-dart-export
M6: export variables as Dart constants for Flutter
This commit is contained in:
@@ -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>\"]; 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-<collection>=\"<mode>\"]; 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<Value> {
|
||||
.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>) -> 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<Value> {
|
||||
let cmd = "variable.import";
|
||||
let v =
|
||||
|
||||
@@ -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})")
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
+5
-1
@@ -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-<collection>="<mode>"]`, 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
|
||||
|
||||
Reference in New Issue
Block a user