mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-24 10:27:00 -06:00
Merge pull request #1008 from lucasmartins-ai/feat/css-export-px
variable.export css: number lengths in px
This commit is contained in:
@@ -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>\"]; numbers in px unless layers or scopes use them as opacity or font weight), path? (write it there)} → {text}, or {path} when written",
|
||||
false,
|
||||
export
|
||||
),
|
||||
@@ -46,7 +46,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let text = match string(p, "format").as_deref().unwrap_or("json") {
|
||||
"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),
|
||||
"css" => css(doc, &colls),
|
||||
_ => return Err(bad(cmd, "format: json|css")),
|
||||
};
|
||||
match string(p, "path") {
|
||||
@@ -66,7 +66,11 @@ fn css_safe(text: &str) -> String {
|
||||
/// Each collection's variables as custom properties (named as generated code names them): the
|
||||
/// default mode's values on `:root`, every other mode's on `[data-<collection>="<mode>"]`;
|
||||
/// aliases are `var(--name)`.
|
||||
fn css(vars: &Variables, colls: &[&Collection]) -> String {
|
||||
fn css(doc: &Document, colls: &[&Collection]) -> String {
|
||||
let vars = &doc.variables;
|
||||
// Lengths need a unit where CSS reads them (`border-radius: var(--radius)`); opacity and
|
||||
// font weight don't.
|
||||
let roles = crate::token_roles::Roles::new(doc);
|
||||
let mut out = String::new();
|
||||
for c in colls {
|
||||
let attr = css_var_name(&c.name);
|
||||
@@ -76,7 +80,8 @@ fn css(vars: &Variables, colls: &[&Collection]) -> String {
|
||||
for v in vars.variables.iter().filter(|v| v.collection == c.id) {
|
||||
let value = match v.values.get(&m.id) {
|
||||
Some(VariableValue::Color(col)) => col.to_css(),
|
||||
Some(VariableValue::Number(x)) => trim_num(*x),
|
||||
Some(VariableValue::Number(x)) if roles.role(v) == crate::token_roles::NumberRole::Unitless => trim_num(*x),
|
||||
Some(VariableValue::Number(x)) => format!("{}px", trim_num(*x)),
|
||||
Some(VariableValue::String(t)) => format!("\"{}\"", css_safe(t)),
|
||||
Some(VariableValue::Boolean(b)) => b.to_string(),
|
||||
Some(VariableValue::Alias(a)) => match vars.get(a) {
|
||||
|
||||
@@ -62,6 +62,7 @@ pub mod synthetic_ds;
|
||||
pub mod templates;
|
||||
pub mod textedit;
|
||||
pub mod textpreview;
|
||||
pub mod token_roles;
|
||||
pub mod tools;
|
||||
pub mod vectoredit;
|
||||
pub mod widgets;
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
//! What a file's number variables measure, so exported tokens carry the right unit: a radius,
|
||||
//! font size, spacing or other length is in pixels, opacity and font weight are bare numbers.
|
||||
//! A number's role comes from the layer fields bound to it, else its scopes, and an alias target
|
||||
//! with no role of its own takes the role of the variables that read it (the target of an opacity
|
||||
//! alias stays unitless). A number nothing tells about is a length: design tokens mostly are.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use figcraft_doc::{Document, Variable, VariableId, VariableType, VariableValue};
|
||||
|
||||
use crate::codegen::num;
|
||||
|
||||
/// What a number variable measures.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum NumberRole {
|
||||
Radius,
|
||||
FontSize,
|
||||
Spacing,
|
||||
/// Another length (stroke, line height, letter spacing, effects): pixels.
|
||||
Length,
|
||||
/// Opacity or font weight: no unit.
|
||||
Unitless,
|
||||
}
|
||||
|
||||
fn role_of_field(field: &str) -> Option<NumberRole> {
|
||||
Some(match field {
|
||||
"cornerRadius" | "topLeftRadius" | "topRightRadius" | "bottomRightRadius" | "bottomLeftRadius" => NumberRole::Radius,
|
||||
"fontSize" => NumberRole::FontSize,
|
||||
"opacity" | "fontWeight" => NumberRole::Unitless,
|
||||
f if f == "gap" || f == "counterAxisSpacing" || f.starts_with("padding") || f == "width" || f == "height" => NumberRole::Spacing,
|
||||
f if f.starts_with("min") || f.starts_with("max") => NumberRole::Spacing,
|
||||
"strokeWeight" | "lineHeight" | "letterSpacing" | "paragraphSpacing" | "paragraphIndent" => NumberRole::Length,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn role_of_scope(scope: &str) -> Option<NumberRole> {
|
||||
Some(match scope {
|
||||
"CORNER_RADIUS" => NumberRole::Radius,
|
||||
"FONT_SIZE" => NumberRole::FontSize,
|
||||
"GAP" | "WIDTH_HEIGHT" => NumberRole::Spacing,
|
||||
"OPACITY" | "FONT_WEIGHT" => NumberRole::Unitless,
|
||||
"STROKE_FLOAT" | "EFFECT_FLOAT" | "LINE_HEIGHT" | "LETTER_SPACING" | "PARAGRAPH_SPACING" | "PARAGRAPH_INDENT" => NumberRole::Length,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The roles of a file's number variables, computed once per export.
|
||||
#[derive(Default)]
|
||||
pub struct Roles(HashMap<VariableId, NumberRole>);
|
||||
|
||||
impl Roles {
|
||||
pub fn new(doc: &Document) -> Self {
|
||||
let vars = &doc.variables.variables;
|
||||
let mut roles: HashMap<VariableId, NumberRole> = HashMap::new();
|
||||
for n in doc.nodes() {
|
||||
for (field, id) in &n.bound {
|
||||
if let Some(r) = role_of_field(field) {
|
||||
roles.entry(id.clone()).or_insert(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
for v in vars.iter().filter(|v| v.kind == VariableType::Number) {
|
||||
if let Some(r) = v.scopes.as_ref().and_then(|s| s.iter().find_map(|s| role_of_scope(s))) {
|
||||
roles.entry(v.id.clone()).or_insert(r);
|
||||
}
|
||||
}
|
||||
// Until a pass adds nothing: each pass adds a role or stops, so this ends within one pass
|
||||
// per variable, however long a chain is.
|
||||
loop {
|
||||
let mut changed = false;
|
||||
for v in vars {
|
||||
let Some(r) = roles.get(&v.id).copied() else { continue };
|
||||
for value in v.values.values() {
|
||||
if let VariableValue::Alias(target) = value
|
||||
&& !roles.contains_key(target)
|
||||
{
|
||||
roles.insert(target.clone(), r);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Roles(roles)
|
||||
}
|
||||
|
||||
/// What `v` measures; a number nothing tells about is a length.
|
||||
pub fn role(&self, v: &Variable) -> NumberRole {
|
||||
self.0.get(&v.id).copied().unwrap_or(NumberRole::Length)
|
||||
}
|
||||
|
||||
/// A number variable's CSS value: pixels for lengths, bare for opacity and font weight.
|
||||
pub fn number_css(&self, v: &Variable, x: f64) -> String {
|
||||
if self.role(v) == NumberRole::Unitless { num(x) } else { format!("{}px", num(x)) }
|
||||
}
|
||||
}
|
||||
@@ -214,9 +214,9 @@ fn variables_export_and_import_round_trip() {
|
||||
let css = ex(&mut s, "variable.export", json!({"format": "css", "collections": ["Theme"]}))["text"].as_str().unwrap().to_string();
|
||||
for want in [
|
||||
":root {\n",
|
||||
" --radius: 4;\n",
|
||||
" --radius: 4px;\n",
|
||||
"[data-theme=\"Dark\"] {\n",
|
||||
" --radius: 6.5;\n",
|
||||
" --radius: 6.5px;\n",
|
||||
" --accent: var(--color-primary);\n",
|
||||
" --label: \"Say hi \";\n",
|
||||
] {
|
||||
@@ -397,3 +397,39 @@ fn paints_replaced_through_node_set_keep_unchanged_stop_bindings() {
|
||||
assert_eq!(stops(&s, r), ["FFFFFF", "0000FF"]);
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_export_gives_lengths_a_unit_and_keeps_opacity_bare() {
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "Brand"}));
|
||||
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "card", "type": "number", "value": 12}));
|
||||
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "fade", "type": "number", "value": 0.5}));
|
||||
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "half", "type": "number", "value": 0.5}));
|
||||
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "dim", "type": "number", "value": 1}));
|
||||
ex(&mut s, "variable.set", json!({"variable": "dim", "value": {"alias": "half"}}));
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100})));
|
||||
let g = idv(&ex(&mut s, "frame.create", json!({"x": 200, "y": 0, "width": 100, "height": 100})));
|
||||
ex(&mut s, "node.set", json!({"id": f, "props": {"cornerRadius": 12, "opacity": 0.5}}));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [f], "field": "cornerRadius", "variable": "card"}));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [f], "field": "opacity", "variable": "fade"}));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [g], "field": "opacity", "variable": "dim"}));
|
||||
let css = ex(&mut s, "variable.export", json!({"format": "css"}))["text"].as_str().unwrap_or_default().to_string();
|
||||
// A radius is a length; opacity stays a number, also through an alias it reads.
|
||||
assert!(css.contains("--card: 12px;") && css.contains("--fade: 0.5;") && css.contains("--half: 0.5;"), "{css}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_a_long_alias_chain_keeps_its_role_to_the_end() {
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "Brand"}));
|
||||
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "o0", "type": "number", "value": 0.5}));
|
||||
// o12 -> o11 -> … -> o0: longer than any fixed number of rounds would reach.
|
||||
for i in 1..=12 {
|
||||
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": format!("o{i}"), "type": "number", "value": 1}));
|
||||
ex(&mut s, "variable.set", json!({"variable": format!("o{i}"), "value": {"alias": format!("o{}", i - 1)}}));
|
||||
}
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100})));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [f], "field": "opacity", "variable": "o12"}));
|
||||
let css = ex(&mut s, "variable.export", json!({"format": "css"}))["text"].as_str().unwrap_or_default().to_string();
|
||||
assert!(css.contains("--o0: 0.5;"), "{css}");
|
||||
}
|
||||
|
||||
+3
-1
@@ -965,7 +965,9 @@ 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
|
||||
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
|
||||
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