Files
figcraft/crates/engine/src/cmd/variables_io.rs
T
bflatastic f3012ce6e4 M3: styles and variables behaviour suite and fixes
**A styles and variables behaviour suite written from the public documentation, and fixes for every divergence it found.** Each documented rule (what a style holds, creating from a selection, applying, editing, detaching, deleting a used style, organising by folders and order, descriptions; variable types, modes and the default mode, aliases, scopes, mode inheritance, bindings by field and type, deleting a bound variable, code syntax, number variables in pixels) is a table-driven case with a one-line derivation; where the documentation is silent the case says so and pins the behaviour.

**Fixes (10 of the suite's 18 tests failed on the old code, counting the missing settings below)**
- **Editing a styled property unlinks that style**: a fill, stroke, effect or layout grid list edited away from its style (through `node.set`, a paint row, an added fill, or a variable bound to it) drops the link, as text chars already did. Before, the layer kept a stale link and the next style edit silently overwrote the user's edit. Moving or renaming a layer keeps its links.
- **Applying a style replaces variables bound to what it sets** (the fill list for a paint style, the typography for a text style); other bindings stay. Before, the variable won again on the next edit.
- **Text styles go on text layers only**: applying one to a mixed selection leaves the other layers alone (they used to get a stray text style link); a selection without text is refused.
- **Deleting a bound variable unbinds the layers using it**: they keep the value they show; variables aliasing it take its values per mode (docs silent on aliases). Before, the binding dangled. `variable.deleteCollection` now does the same for aliases from other collections into it.
- **Bindings are type-checked**: a variable of a type the field doesn't take (a colour on `width`, a number on `visible`) is refused; scopes still only filter pickers. Number variables can fill text content (shown as text).
- **Aliases are checked when a variable is created** (`variable.create {value: {alias}}` took any name, of any type).
- **Style names** are trimmed per `/` folder part; empty names are refused.

**Missing documented settings, added**
- **Organising styles**: `style.move {styles, before?, folder?}` reorders and moves into a folder (renaming the style to match), `style.duplicate` puts "<name> copy" right below the original, `style.renameFolder` renames a folder's styles. The page's **Local styles** list (nothing selected) is now grouped by type and folder with swatches and text sizes: click opens the style editor, right-click offers Edit style / Duplicate / Delete, a row dragged by its grip moves (into the folder it lands in), right-click on a folder renames it.
- **Modes**: Duplicate mode (`variable.addMode {from}`), Set as default and Move column left/right (`variable.moveMode {collection, mode, to}`); layers on Auto follow a new default, layers set to a mode keep theirs.
- **Code syntax** per platform (Web, iOS, Android): `variable.update {codeSyntax}`, three fields in Edit variable, carried by the variables JSON (import and export); generated CSS and the CSS variables export use the Web name (`var(--x)` or `--x`).
- **Number variable scopes**: font weight, paragraph spacing, paragraph indent and text content (and `fontWeight`, `paragraphSpacing`, `paragraphIndent` bind with their scopes).

New strings go through `tr(...)` with `en.toml` / `es.toml` entries, new commands have Spanish labels; the new rows, fields and menus have accessible names. Each edit stays one undo step.
2026-10-12 00:57:43 +07:00

207 lines
9.9 KiB
Rust

//! 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.
use std::collections::{BTreeMap, BTreeSet};
use figcraft_doc::*;
use serde_json::{Value, json};
use super::CommandSpec;
use crate::codegen::{css_var_name, css_var_of};
use crate::library::{is_remote_collection, local_collections};
use crate::params::*;
use crate::{EngineError, Result, Session, bad, spec};
pub fn specs() -> Vec<CommandSpec> {
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-<collection>=\"<mode>\"]), path? (write it there)} → {text}, or {path} when written",
false,
export
),
spec!(
"variable.import",
"Import variables",
"{variables (the local variables JSON, as variable.export writes it: an object, JSON text or a path)} collections join this file's collection of the same name (values by mode name; new modes and variables are added, a variable of another type is skipped), others are added → {collections (added), variables (added), updated, warnings}",
true,
import
),
]
}
fn export(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "variable.export";
let doc = s.doc()?;
let colls: Vec<&Collection> = match get(p, "collections") {
None | Some(Value::Null) => local_collections(doc).collect(),
Some(Value::Array(a)) => a
.iter()
.map(|x| x.as_str().and_then(|k| doc.variables.collection(k)).ok_or_else(|| bad(cmd, format!("no collection {x}"))))
.collect::<Result<_>>()?,
Some(_) => return Err(bad(cmd, "collections: [collection ids or names]")),
};
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),
_ => return Err(bad(cmd, "format: json|css")),
};
match string(p, "path") {
Some(path) => {
super::file::write(&path, text.as_bytes())?;
Ok(json!({"path": path}))
}
None => Ok(json!({"text": text})),
}
}
/// `text` as a CSS comment or quoted string body.
fn css_safe(text: &str) -> String {
text.replace("*/", "* /").replace(['"', '\\', '\n'], " ")
}
/// 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 {
let mut out = String::new();
for c in colls {
let attr = css_var_name(&c.name);
for (i, m) in c.modes.iter().enumerate() {
let sel = if i == 0 { ":root".to_string() } else { format!("[data-{}=\"{}\"]", attr.trim_start_matches('-'), css_safe(&m.name)) };
out.push_str(&format!("/* {} · {} */\n{sel} {{\n", css_safe(&c.name), css_safe(&m.name)));
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::String(t)) => format!("\"{}\"", css_safe(t)),
Some(VariableValue::Boolean(b)) => b.to_string(),
Some(VariableValue::Alias(a)) => match vars.get(a) {
Some(t) => format!("var({})", css_var_of(t)),
None => continue,
},
None => continue,
};
out.push_str(&format!(" {}: {value};\n", css_var_of(v)));
}
out.push_str("}\n");
}
}
out
}
fn import(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "variable.import";
let v =
super::file::variables_param(cmd, p)?.ok_or_else(|| bad(cmd, "`variables` (the variables JSON: an object, JSON text or a path) required"))?;
let (src, mut warnings) = figcraft_format::rest::read_variables(&v).ok_or_else(|| bad(cmd, "not a variables JSON"))?;
let doc = s.doc_mut()?;
// Imported collection → (this file's collection, imported mode → its mode).
let mut colls: BTreeMap<CollectionId, (CollectionId, BTreeMap<ModeId, ModeId>)> = BTreeMap::new();
let mut added_colls = 0usize;
for c in &src.collections {
// Never a library copy (read-only) or an extended collection (its modes are inherited).
let same = doc.variables.collections.iter().find(|x| x.name == c.name && x.extends.is_none() && !is_remote_collection(doc, &x.id));
let cid = match same.map(|x| x.id.clone()) {
Some(id) => id,
None => {
let id = doc.new_collection_id();
let name = super::variables::free_collection_name(doc, &c.name);
doc.variables.collections.push(Collection { id: id.clone(), name, modes: Vec::new(), extends: None });
added_colls += 1;
id
}
};
let mut modes = BTreeMap::new();
for m in &c.modes {
let coll = doc.variables.collections.iter().find(|x| x.id == cid);
let mid = match coll.and_then(|x| x.modes.iter().find(|y| y.name == m.name)) {
Some(y) => y.id.clone(),
None => {
let first = coll.and_then(|x| x.default_mode()).cloned();
let Some(id) = doc.new_mode_id(&cid) else { continue };
if let Some(x) = doc.variables.collections.iter_mut().find(|x| x.id == cid) {
x.modes.push(Mode { id: id.clone(), name: m.name.clone() });
}
// Its variables start with their default mode's values (as with variable.addMode).
for var in doc.variables.variables.iter_mut().filter(|x| x.collection == cid) {
if let Some(val) = first.as_ref().and_then(|f| var.values.get(f)).cloned() {
var.values.insert(id.clone(), val);
}
}
id
}
};
modes.insert(m.id.clone(), mid);
}
colls.insert(c.id.clone(), (cid, modes));
}
// Every variable's id here first (aliases point at any of them), then the values.
let mut ids: BTreeMap<VariableId, VariableId> = BTreeMap::new();
let mut added = 0usize;
let mut updated = 0usize;
for v in &src.variables {
let Some((cid, _)) = colls.get(&v.collection) else { continue };
match doc.variables.variables.iter().find(|x| x.collection == *cid && x.name == v.name) {
Some(x) if x.kind != v.kind => {
warnings.push(format!("`{}` is a {} variable in this file: the {} one was skipped", v.name, x.kind.id(), v.kind.id()));
}
Some(x) => {
ids.insert(v.id.clone(), x.id.clone());
updated += 1;
}
None => {
let id = doc.new_variable_id();
let blank = Variable {
id: id.clone(),
collection: cid.clone(),
values: BTreeMap::new(),
extends: None,
overrides: BTreeSet::new(),
..v.clone()
};
doc.variables.variables.push(blank);
ids.insert(v.id.clone(), id);
added += 1;
}
}
}
for v in &src.variables {
let (Some(local), Some((cid, modes))) = (ids.get(&v.id), colls.get(&v.collection)) else { continue };
for (m, val) in &v.values {
let Some(lm) = modes.get(m) else { continue };
let val = match val {
VariableValue::Alias(a) => {
let Some(var) = doc.variables.variables.iter().find(|x| x.id == *local) else { continue };
let target = ids.get(a).ok_or_else(|| "its alias target wasn't imported".to_string());
match target.and_then(|t| super::variables::alias_target(&doc.variables, var, t)) {
Ok(t) => VariableValue::Alias(t),
Err(e) => {
warnings.push(format!("`{}`: {e}", v.name));
continue;
}
}
}
other => other.clone(),
};
if let Some(slot) = doc.variables.variables.iter_mut().find(|x| x.id == *local) {
slot.values.insert(lm.clone(), val);
}
}
// A value in every mode of its collection: modes the JSON lacks take the default mode's
// (a new variable whose values all failed starts like one made with variable.create).
let all: Vec<ModeId> =
doc.variables.collections.iter().find(|x| x.id == *cid).map(|x| x.modes.iter().map(|m| m.id.clone()).collect()).unwrap_or_default();
if let Some(slot) = doc.variables.variables.iter_mut().find(|x| x.id == *local) {
let first = all.iter().find_map(|m| slot.values.get(m)).cloned().unwrap_or_else(|| VariableValue::default_for(slot.kind));
for m in all {
slot.values.entry(m).or_insert_with(|| first.clone());
}
}
}
Ok(json!({"collections": added_colls, "variables": added, "updated": updated, "warnings": warnings}))
}