Files
figcraft/crates/engine/src/cmd/variables.rs
T
bflatastic d3ebb39706 M1: stroke styles: each side's stroke weight binds to a number variable
Colour styles already applied to strokes (the stroke section's style picker,
style.apply target stroke, edits following, detach). What was missing for
strokes: the per-side weights as their own bindable fields
(strokeTopWeight/Right/Bottom/Left: node.set keys, STROKE_FLOAT scope,
stroke override group, REST boundVariables import). Setting or binding one
side turns on individual strokes with the other sides at the weight. The
Design panel's per-side fields are bindable, two to a row so a variable's
name fits.
2026-10-11 19:31:02 +07:00

388 lines
17 KiB
Rust

//! Editing variables, modes and collections (the Variables editor): rename, describe, scope,
//! duplicate and delete; extended collections (overrides, keeping them in step with what they
//! extend); alias checks; and which scope a bindable property belongs to.
use std::collections::BTreeSet;
use figcraft_doc::*;
use serde_json::{Value, json};
use super::lists::BoundList;
use super::{CommandSpec, ok};
use crate::params::*;
use crate::{Result, Session, bad, spec};
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!(
"variable.update",
"Edit variable",
"{variable, name?, description?, scopes? ([scope ids]; null or [\"ALL_SCOPES\"] = every supported property, [] = hidden from pickers), hiddenFromPublishing? (left out when the file is published as a library)}",
true,
update
),
spec!("variable.duplicate", "Duplicate variable", "{variable} → {id}", true, duplicate),
spec!("variable.renameMode", "Rename mode", "{collection, mode, name}", true, rename_mode),
spec!("variable.removeMode", "Delete mode", "{collection, mode} (a collection keeps at least one mode)", true, remove_mode),
spec!("variable.renameCollection", "Rename collection", "{collection, name}", true, rename_collection),
spec!(
"variable.deleteCollection",
"Delete collection",
"{collection} (with its variables; bound layers keep their values)",
true,
delete_collection
),
spec!(
"variable.resetOverride",
"Reset variable override",
"{variable (a variable of an extended collection inherited from the collection it extends), mode? (id or name; every overridden mode when omitted)} the value follows the inherited one again",
true,
reset_override
),
]
}
/// The first of `base`, `base 2`, `base 3`… that no collection is named.
pub(crate) fn free_collection_name(doc: &Document, base: &str) -> String {
(1..1000)
.map(|k| if k == 1 { base.to_string() } else { format!("{base} {k}") })
.find(|n| !doc.variables.collections.iter().any(|c| c.name == *n))
.unwrap_or_else(|| base.to_string())
}
/// The error for editing what an extended collection inherits.
fn inherited(cmd: &str, what: &str) -> crate::EngineError {
bad(cmd, format!("{what} comes from the collection it extends (edit it there)"))
}
/// Refuse mode edits of extended collections (they have the modes of the collection they extend).
pub(crate) fn check_modes_editable(doc: &Document, cmd: &str, collection: &str) -> Result<()> {
match doc.variables.collection(collection) {
Some(c) if c.extends.is_some() => Err(inherited(cmd, "an extended collection's modes")),
_ => Ok(()),
}
}
/// A variable name with each `/`-separated group part trimmed; None when nothing is left.
fn clean_name(name: &str) -> Option<String> {
let parts: Vec<&str> = name.split('/').map(str::trim).filter(|s| !s.is_empty()).collect();
let out = parts.join("/");
(!out.is_empty()).then(|| out.chars().take(500).collect())
}
fn update(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "variable.update";
let v = req_string(cmd, p, "variable")?;
let doc = s.doc_mut()?;
let var = doc.variables.get(&v).cloned().ok_or_else(|| bad(cmd, "no such variable"))?;
let name = match string(p, "name") {
Some(_) if var.extends.is_some() => return Err(inherited(cmd, "an inherited variable's name")),
Some(n) => {
let n = clean_name(&n).ok_or_else(|| bad(cmd, "name can't be empty"))?;
if doc.variables.variables.iter().any(|x| x.id != var.id && x.collection == var.collection && x.name == n) {
return Err(bad(cmd, format!("a variable named `{n}` already exists in this collection")));
}
Some(n)
}
None => None,
};
let scopes = match get(p, "scopes") {
None => None,
Some(Value::Null) => Some(None),
Some(Value::Array(a)) => {
let mut ids = Vec::with_capacity(a.len());
for x in a {
let id = x.as_str().ok_or_else(|| bad(cmd, "scopes: [scope ids]"))?;
if !scope_known(var.kind, id) {
return Err(bad(cmd, format!("`{id}` isn't a scope for {} variables", var.kind.id())));
}
ids.push(id);
}
// Every scope listed is the same as no limit.
Some(normalize_scopes(var.kind, ids))
}
Some(_) => return Err(bad(cmd, "scopes: [scope ids] or null")),
};
let slot = doc.variables.variables.iter_mut().find(|x| x.id == var.id).ok_or_else(|| bad(cmd, "no such variable"))?;
if let Some(n) = name {
slot.name = n;
}
if let Some(d) = string(p, "description") {
slot.description = d.chars().take(10_000).collect();
}
if let Some(sc) = scopes {
slot.scopes = sc;
}
if let Some(h) = boolean(p, "hiddenFromPublishing") {
slot.hidden_from_publishing = h;
}
ok()
}
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "variable.duplicate";
let v = req_string(cmd, p, "variable")?;
let doc = s.doc_mut()?;
let pos = doc.variables.variables.iter().position(|x| x.id == v || x.name == v).ok_or_else(|| bad(cmd, "no such variable"))?;
let mut copy = doc.variables.variables.get(pos).cloned().ok_or_else(|| bad(cmd, "no such variable"))?;
let taken = |n: &str| doc.variables.variables.iter().any(|x| x.collection == copy.collection && x.name == n);
let name = (1..1000)
.map(|k| if k == 1 { format!("{} copy", copy.name) } else { format!("{} copy {k}", copy.name) })
.find(|n| !taken(n))
.ok_or_else(|| bad(cmd, "too many copies"))?;
copy.id = doc.new_variable_id();
copy.name = name;
// A copy of an inherited variable is the extended collection's own.
copy.extends = None;
copy.overrides.clear();
let id = copy.id.clone();
doc.variables.variables.insert(pos + 1, copy);
Ok(json!({"id": id}))
}
/// The index of collection `c` (id or name).
fn collection_index(doc: &Document, cmd: &str, c: &str) -> Result<usize> {
doc.variables.collections.iter().position(|x| x.id == c || x.name == c).ok_or_else(|| bad(cmd, "no such collection"))
}
/// Collection `c`'s index and the id of its mode `m` (id or name).
fn mode_of(doc: &Document, cmd: &str, p: &Value) -> Result<(usize, ModeId)> {
let ci = collection_index(doc, cmd, &req_string(cmd, p, "collection")?)?;
let m = req_string(cmd, p, "mode")?;
let mode = doc
.variables
.collections
.get(ci)
.and_then(|c| c.modes.iter().find(|x| x.id == m || x.name == m))
.map(|x| x.id.clone())
.ok_or_else(|| bad(cmd, "no such mode"))?;
check_modes_editable(doc, cmd, doc.variables.collections.get(ci).map_or("", |c| &c.id))?;
Ok((ci, mode))
}
fn rename_mode(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "variable.renameMode";
let name = req_string(cmd, p, "name")?.trim().chars().take(200).collect::<String>();
if name.is_empty() {
return Err(bad(cmd, "name can't be empty"));
}
let doc = s.doc_mut()?;
let (ci, mode) = mode_of(doc, cmd, p)?;
if let Some(m) = doc.variables.collections.get_mut(ci).and_then(|c| c.modes.iter_mut().find(|x| x.id == mode)) {
m.name = name;
}
ok()
}
fn remove_mode(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "variable.removeMode";
let doc = s.doc_mut()?;
let (ci, mode) = mode_of(doc, cmd, p)?;
let coll = doc.variables.collections.get_mut(ci).ok_or_else(|| bad(cmd, "no such collection"))?;
if coll.modes.len() < 2 {
return Err(bad(cmd, "a collection keeps at least one mode"));
}
coll.modes.retain(|m| m.id != mode);
let cid = coll.id.clone();
for v in doc.variables.variables.iter_mut().filter(|v| v.collection == cid) {
v.values.remove(&mode);
}
// Layers set to the deleted mode go back to the default.
let users: Vec<NodeId> = doc.nodes().filter(|n| n.var_modes.get(&cid) == Some(&mode)).map(|n| n.id).collect();
for id in users {
doc.get_mut(id)?.var_modes.remove(&cid);
}
ok()
}
fn rename_collection(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "variable.renameCollection";
let name = req_string(cmd, p, "name")?.trim().chars().take(200).collect::<String>();
if name.is_empty() {
return Err(bad(cmd, "name can't be empty"));
}
let doc = s.doc_mut()?;
let ci = collection_index(doc, cmd, &req_string(cmd, p, "collection")?)?;
if doc.variables.collections.iter().enumerate().any(|(i, c)| i != ci && c.name == name) {
return Err(bad(cmd, format!("a collection named `{name}` already exists")));
}
if let Some(c) = doc.variables.collections.get_mut(ci) {
c.name = name;
}
ok()
}
fn delete_collection(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "variable.deleteCollection";
let doc = s.doc_mut()?;
let ci = collection_index(doc, cmd, &req_string(cmd, p, "collection")?)?;
let cid = doc.variables.collections.remove(ci).id;
let gone: BTreeSet<VariableId> = doc.variables.variables.iter().filter(|v| v.collection == cid).map(|v| v.id.clone()).collect();
doc.variables.variables.retain(|v| v.collection != cid);
// Layers keep their current values but no longer follow the deleted variables or modes.
let users: Vec<NodeId> =
doc.nodes().filter(|n| n.var_modes.contains_key(&cid) || n.bound.values().any(|v| gone.contains(v))).map(|n| n.id).collect();
for id in users {
let n = doc.get_mut(id)?;
n.var_modes.remove(&cid);
n.bound.retain(|_, v| !gone.contains(v));
}
Ok(json!({"deletedVariables": gone.len()}))
}
fn reset_override(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "variable.resetOverride";
let v = req_string(cmd, p, "variable")?;
let doc = s.doc_mut()?;
let var = doc.variables.get(&v).cloned().ok_or_else(|| bad(cmd, "no such variable"))?;
let parent = var.extends.as_deref().and_then(|x| doc.variables.get(x)).cloned();
let parent = parent.ok_or_else(|| bad(cmd, "only variables an extended collection inherits have overrides"))?;
let modes: Vec<ModeId> = match string(p, "mode") {
Some(m) => {
let coll = doc.variables.collection(&var.collection).ok_or_else(|| bad(cmd, "variable has no collection"))?;
vec![coll.find_mode(&m).ok_or_else(|| bad(cmd, "no such mode"))?.id.clone()]
}
None => var.overrides.iter().cloned().collect(),
};
if let Some(slot) = doc.variables.variables.iter_mut().find(|x| x.id == var.id) {
for m in modes {
slot.overrides.remove(&m);
if let Some(val) = parent.values.get(&m) {
slot.values.insert(m, val.clone());
}
}
}
ok()
}
/// Keep extended collections in step with the collections they extend (after extending one or
/// updating library copies): the same modes, one inheriting variable per variable of the parent
/// (named like it, with its values in every mode not overridden) and values in every mode for
/// their own variables. When a parent collection or variable is gone, what extended it becomes a
/// plain collection or variable, keeping its values.
pub fn sync_extensions(doc: &mut Document) {
let exts: Vec<(CollectionId, Option<Collection>)> = doc
.variables
.collections
.iter()
.filter_map(|c| Some((c.id.clone(), c.extends.as_ref().map(|p| doc.variables.collections.iter().find(|x| x.id == *p).cloned())?)))
.collect();
for (cid, parent) in exts {
let vars = &mut doc.variables.variables;
// shortcut: an extension of an extension isn't followed (extending is offered for library
// collections only), upgrade if extended collections get published and extended again.
let Some(parent) = parent.filter(|p| p.extends.is_none()) else {
for v in vars.iter_mut().filter(|v| v.collection == cid) {
v.extends = None;
v.overrides.clear();
}
if let Some(c) = doc.variables.collections.iter_mut().find(|c| c.id == cid) {
c.extends = None;
}
continue;
};
let parents: Vec<Variable> = vars.iter().filter(|v| v.collection == parent.id).cloned().collect();
let modes: Vec<ModeId> = parent.modes.iter().map(|m| m.id.clone()).collect();
for v in vars.iter_mut().filter(|v| v.collection == cid) {
match v.extends.as_ref().map(|e| parents.iter().find(|p| p.id == *e)) {
Some(Some(p)) => {
if v.kind != p.kind {
v.kind = p.kind;
v.overrides.clear();
}
v.name.clone_from(&p.name);
v.overrides.retain(|m| modes.contains(m));
let own = std::mem::take(&mut v.values);
v.values =
p.values.iter().map(|(m, x)| (m.clone(), own.get(m).filter(|_| v.overrides.contains(m)).unwrap_or(x).clone())).collect();
}
Some(None) => {
v.extends = None;
v.overrides.clear();
}
None => {}
}
// Its own variables (and orphans) keep a value in every mode: new modes start with the
// default mode's.
if v.extends.is_none() {
let first = modes.iter().find_map(|m| v.values.get(m)).or_else(|| v.values.values().next()).cloned();
v.values.retain(|m, _| modes.contains(m));
if let Some(f) = first {
for m in &modes {
v.values.entry(m.clone()).or_insert_with(|| f.clone());
}
}
}
}
let missing: Vec<Variable> =
parents.into_iter().filter(|p| !vars.iter().any(|v| v.collection == cid && v.extends.as_ref() == Some(&p.id))).collect();
if let Some(c) = doc.variables.collections.iter_mut().find(|c| c.id == cid)
&& c.modes != parent.modes
{
c.modes = parent.modes.clone();
}
for p in missing {
let id = doc.new_variable_id();
let extends = Some(p.id.clone());
doc.variables.variables.push(Variable { id, collection: cid.clone(), extends, hidden_from_publishing: false, ..p });
}
}
}
/// Check that `var` may alias `target` (id or name): it exists, has the same type and doesn't
/// lead back to `var`. Returns the target's id.
pub fn alias_target(vars: &Variables, var: &Variable, target: &str) -> std::result::Result<VariableId, String> {
let t = vars.get(target).ok_or_else(|| format!("no variable `{target}` to alias"))?;
if t.kind != var.kind {
return Err(format!("can't alias a {} variable to a {} one", var.kind.id(), t.kind.id()));
}
let mut stack = vec![t.id.clone()];
let mut seen = BTreeSet::new();
while let Some(cur) = stack.pop() {
if cur == var.id {
return Err("the alias would refer back to itself".into());
}
if !seen.insert(cur.clone()) || seen.len() > 10_000 {
continue;
}
for v in vars.get(&cur).map(|x| x.values.values()).into_iter().flatten() {
if let VariableValue::Alias(a) = v
&& let Some(next) = vars.get(a)
{
stack.push(next.id.clone());
}
}
}
Ok(t.id.clone())
}
/// The variable scope of bindable property `field` on `node` (None: any variable of the type fits).
pub fn field_scope(doc: &Document, node: NodeId, field: &str) -> Option<&'static str> {
Some(match field {
"width" | "height" | "minWidth" | "maxWidth" | "minHeight" | "maxHeight" => "WIDTH_HEIGHT",
"gap" | "columnGap" | "rowGap" | "crossGap" | "padding" | "paddingHorizontal" | "paddingVertical" | "paddingTop" | "paddingRight"
| "paddingBottom" | "paddingLeft" => "GAP",
"cornerRadius" | "topLeftRadius" | "topRightRadius" | "bottomRightRadius" | "bottomLeftRadius" => "CORNER_RADIUS",
"opacity" => "OPACITY",
"strokeWeight" | "strokeTopWeight" | "strokeRightWeight" | "strokeBottomWeight" | "strokeLeftWeight" => "STROKE_FLOAT",
"fontSize" => "FONT_SIZE",
"lineHeight" => "LINE_HEIGHT",
"letterSpacing" => "LETTER_SPACING",
"characters" => "TEXT_CONTENT",
"fontFamily" => "FONT_FAMILY",
"fontStyle" => "FONT_STYLE",
f => match super::lists::list_binding(f)? {
(BoundList::Strokes, _, _) => "STROKE_COLOR",
(BoundList::Effects, _, "color") => "EFFECT_COLOR",
(BoundList::Effects, _, _) => "EFFECT_FLOAT",
(BoundList::Fills, _, _) => match doc.node(node).map(|n| &n.kind) {
Some(NodeKind::Text(_)) => "TEXT_FILL",
Some(k) if k.is_frame_like() => "FRAME_FILL",
_ => "SHAPE_FILL",
},
// Layout grid fields take any number variable.
(BoundList::Grids, _, _) => return None,
},
})
}