diff --git a/README.md b/README.md
index 2523eee..b8e0512 100644
--- a/README.md
+++ b/README.md
@@ -82,7 +82,7 @@
Components that scale
- Main components, instances with overrides that survive edits, component sets with variants, properties, swap instance, detach, reset overrides and go to main component, and an Assets panel with live thumbnails, search and drag-to-canvas into frames and auto layout. Local styles and variables (colour, number, string, boolean) with modes.
+ Main components, instances with overrides that survive edits, component sets with variants, properties, swap instance, detach, reset overrides and go to main component, and an Assets panel with live thumbnails, search and drag-to-canvas into frames and auto layout. Local styles and variables (colour, number, string, boolean) with modes, aliases and scopes, edited in a Variables table.
|
diff --git a/ROADMAP.md b/ROADMAP.md
index b42a10b..b83761e 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -146,7 +146,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent).
| Auto layout & constraints | 9 | 65% | nested hug/fill edge cases, drag-reorder across nested auto layout frames | 4–7 |
| Text | 8 | 60% | right-to-left/bidi, text style picker depth, axis hover preview | 8–12 |
| Components & variants | 9 | 60% | libraries, slots, nested override edge cases | 10–15 |
-| Styles & variables | 5 | 66% | aliasing UI, scoping, binding effects, line height and letter spacing | 4–7 |
+| Styles & variables | 5 | 72% | binding effects, line height and letter spacing, variable groups in the editor sidebar | 3–5 |
| Prototyping | 6 | 70% | scrolling inside device frames, prototype preview pane in the editor | 3–5 |
| Import/export | 6 | 50% | PDF (in progress), .fig import, export panel polish, REST import of prototypes/variables | 18–32 |
| Dev mode / inspect | 2 | 55% | asset export list in Inspect, redlines in the Inspect tab, code for whole frames as components | 4–6 |
diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs
index 5a464e1..a2b7ee5 100644
--- a/crates/doc/src/styles.rs
+++ b/crates/doc/src/styles.rs
@@ -129,6 +129,57 @@ pub struct Variable {
pub values: BTreeMap,
#[serde(default)]
pub description: String,
+ /// Properties pickers offer the variable for (scope ids from [`scopes_for`]); None = every
+ /// supported property, empty = none (hidden from pickers).
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub scopes: Option>,
+}
+
+impl Variable {
+ /// Whether pickers offer this variable for properties of `scope`.
+ pub fn in_scope(&self, scope: &str) -> bool {
+ self.scopes.as_ref().is_none_or(|list| list.iter().any(|s| s == scope || s == ALL_SCOPES || (s == "ALL_FILLS" && scope.ends_with("_FILL"))))
+ }
+}
+
+/// The scope id meaning "every supported property".
+pub const ALL_SCOPES: &str = "ALL_SCOPES";
+
+/// The scopes a variable of `kind` can be limited to: (id, label).
+pub fn scopes_for(kind: VariableType) -> &'static [(&'static str, &'static str)] {
+ match kind {
+ VariableType::Number => &[
+ ("CORNER_RADIUS", "Corner radius"),
+ ("WIDTH_HEIGHT", "Width and height"),
+ ("GAP", "Gap and padding"),
+ ("OPACITY", "Layer opacity"),
+ ("STROKE_FLOAT", "Stroke"),
+ ("EFFECT_FLOAT", "Effects"),
+ ("FONT_SIZE", "Font size"),
+ ("LINE_HEIGHT", "Line height"),
+ ("LETTER_SPACING", "Letter spacing"),
+ ],
+ VariableType::Color => &[
+ ("FRAME_FILL", "Frame fill"),
+ ("SHAPE_FILL", "Shape fill"),
+ ("TEXT_FILL", "Text fill"),
+ ("STROKE_COLOR", "Stroke"),
+ ("EFFECT_COLOR", "Effects"),
+ ],
+ VariableType::String => &[("TEXT_CONTENT", "Text content"), ("FONT_FAMILY", "Font family"), ("FONT_STYLE", "Font style")],
+ VariableType::Boolean => &[],
+ }
+}
+
+impl VariableType {
+ pub fn id(self) -> &'static str {
+ match self {
+ VariableType::Color => "color",
+ VariableType::Number => "number",
+ VariableType::String => "string",
+ VariableType::Boolean => "boolean",
+ }
+ }
}
/// All variables of a document.
diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs
index d0562d8..cb028e0 100644
--- a/crates/doc/src/tests.rs
+++ b/crates/doc/src/tests.rs
@@ -215,6 +215,7 @@ fn variables_resolve_modes_and_aliases() {
kind: VariableType::Color,
values: vals,
description: String::new(),
+ scopes: None,
});
let mut alias = std::collections::BTreeMap::new();
alias.insert("light".to_string(), VariableValue::Alias("bg".into()));
@@ -225,6 +226,7 @@ fn variables_resolve_modes_and_aliases() {
kind: VariableType::Color,
values: alias,
description: String::new(),
+ scopes: None,
});
let mut modes = std::collections::BTreeMap::new();
assert_eq!(v.resolve("surface", &modes), Some(VariableValue::Color(Color::WHITE)));
@@ -240,6 +242,7 @@ fn variables_resolve_modes_and_aliases() {
kind: VariableType::Color,
values: cyc,
description: String::new(),
+ scopes: None,
});
assert_eq!(v.resolve("loop", &std::collections::BTreeMap::new()), None);
}
diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs
index fc041b3..d9e08a6 100644
--- a/crates/engine/src/cmd/mod.rs
+++ b/crates/engine/src/cmd/mod.rs
@@ -13,6 +13,7 @@ pub mod proto;
pub mod structure;
pub mod styles;
pub mod text;
+pub mod variables;
pub mod view;
use std::collections::HashMap;
@@ -71,6 +72,7 @@ fn registry() -> &'static Registry {
list.extend(view::specs());
list.extend(text::specs());
list.extend(styles::specs());
+ list.extend(variables::specs());
list.extend(component::specs());
list.extend(proto::specs());
list.extend(comment::specs());
diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs
index d414b90..b3b6e63 100644
--- a/crates/engine/src/cmd/styles.rs
+++ b/crates/engine/src/cmd/styles.rs
@@ -104,7 +104,9 @@ pub fn specs() -> Vec {
.iter_mut()
.find(|x| x.id == c || x.name == c)
.ok_or_else(|| bad("variable.addMode", "no such collection"))?;
- let mid = format!("{}/mode{}", coll.id, coll.modes.len() + 1);
+ // A fresh id: modes can be deleted, so the count alone may repeat one.
+ let mid =
+ (coll.modes.len() + 1..).map(|k| format!("{}/mode{k}", coll.id)).find(|m| !coll.modes.iter().any(|x| x.id == *m)).unwrap_or_default();
let first = coll.modes.first().map(|m| m.id.clone());
coll.modes.push(Mode { id: mid.clone(), name });
let cid = coll.id.clone();
@@ -123,7 +125,13 @@ pub fn specs() -> Vec {
true,
var_create
),
- spec!("variable.set", "Set variable value", "{variable, mode? (name or id; default first), value}", true, var_set),
+ spec!(
+ "variable.set",
+ "Set variable value",
+ "{variable, mode? (name or id; default first), value | {alias: variable} (same type, no loops)}",
+ true,
+ var_set
+ ),
spec!("variable.delete", "Delete variable", "{variable}", true, |s, p| {
let v = req_string("variable.delete", p, "variable")?;
let doc = s.doc_mut()?;
@@ -417,6 +425,7 @@ fn var_create(s: &mut Session, p: &Value) -> Result {
kind,
values,
description: string(p, "description").unwrap_or_default(),
+ scopes: None,
});
Ok(json!({"id": id}))
}
@@ -431,7 +440,10 @@ fn var_set(s: &mut Session, p: &Value) -> Result {
Some(m) => coll.modes.iter().find(|x| x.id == m || x.name == m).map(|x| x.id.clone()).ok_or_else(|| bad(cmd, "no such mode"))?,
None => coll.default_mode().cloned().ok_or_else(|| bad(cmd, "collection has no modes"))?,
};
- let val = get(p, "value").and_then(|x| var_value(var.kind, x)).ok_or_else(|| bad(cmd, "invalid value"))?;
+ let val = match get(p, "value").and_then(|x| var_value(var.kind, x)).ok_or_else(|| bad(cmd, "invalid value"))? {
+ VariableValue::Alias(a) => VariableValue::Alias(super::variables::alias_target(&doc.variables, &var, &a).map_err(|e| bad(cmd, e))?),
+ v => v,
+ };
if let Some(slot) = doc.variables.variables.iter_mut().find(|x| x.id == var.id) {
slot.values.insert(mode, val);
}
@@ -472,7 +484,7 @@ pub fn apply_bindings(doc: &mut Document) {
}
/// `prefix:N` with N one more than the largest existing.
-fn unique_id<'a>(prefix: &str, existing: impl Iterator- ) -> String {
+pub(crate) fn unique_id<'a>(prefix: &str, existing: impl Iterator
- ) -> String {
let max = existing.filter_map(|e| e.strip_prefix(prefix)?.strip_prefix(':')?.parse::().ok()).max().unwrap_or(0);
format!("{prefix}:{}", max + 1)
}
diff --git a/crates/engine/src/cmd/variables.rs b/crates/engine/src/cmd/variables.rs
new file mode 100644
index 0000000..f21b392
--- /dev/null
+++ b/crates/engine/src/cmd/variables.rs
@@ -0,0 +1,250 @@
+//! Editing variables, modes and collections (the Variables editor): rename, describe, scope,
+//! duplicate and delete; alias checks; and which scope a bindable property belongs to.
+
+use std::collections::BTreeSet;
+
+use figcraft_doc::*;
+use serde_json::{Value, json};
+
+use super::{CommandSpec, ok};
+use crate::params::*;
+use crate::{Result, Session, bad, spec};
+
+pub fn specs() -> Vec {
+ vec![
+ spec!(
+ "variable.update",
+ "Edit variable",
+ "{variable, name?, description?, scopes? ([scope ids]; null or [\"ALL_SCOPES\"] = every supported property, [] = hidden from pickers)}",
+ 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
+ ),
+ ]
+}
+
+/// A variable name with each `/`-separated group part trimmed; None when nothing is left.
+fn clean_name(name: &str) -> Option {
+ 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 {
+ 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(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 allowed = scopes_for(var.kind);
+ let mut list: Vec = Vec::new();
+ for x in a {
+ let id = x.as_str().ok_or_else(|| bad(cmd, "scopes: [scope ids]"))?;
+ let known = id == ALL_SCOPES || (id == "ALL_FILLS" && var.kind == VariableType::Color) || allowed.iter().any(|(k, _)| *k == id);
+ if !known {
+ return Err(bad(cmd, format!("`{id}` isn't a scope for {} variables", var.kind.id())));
+ }
+ if !list.iter().any(|l| l == id) {
+ list.push(id.to_string());
+ }
+ }
+ // Every scope listed is the same as no limit.
+ let all = list.iter().any(|l| l == ALL_SCOPES) || (!allowed.is_empty() && allowed.iter().all(|(k, _)| list.iter().any(|l| l == k)));
+ Some(if all { None } else { Some(list) })
+ }
+ 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;
+ }
+ ok()
+}
+
+fn duplicate(s: &mut Session, p: &Value) -> Result {
+ 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 = super::styles::unique_id("VariableID", doc.variables.variables.iter().map(|x| x.id.as_str()));
+ copy.name = name;
+ 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 {
+ 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"))?;
+ Ok((ci, mode))
+}
+
+fn rename_mode(s: &mut Session, p: &Value) -> Result {
+ let cmd = "variable.renameMode";
+ let name = req_string(cmd, p, "name")?.trim().chars().take(200).collect::();
+ 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 {
+ 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 = 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 {
+ let cmd = "variable.renameCollection";
+ let name = req_string(cmd, p, "name")?.trim().chars().take(200).collect::();
+ 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 {
+ 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 = 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 =
+ 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()}))
+}
+
+/// 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 {
+ 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" => "STROKE_FLOAT",
+ "fontSize" => "FONT_SIZE",
+ "lineHeight" => "LINE_HEIGHT",
+ "letterSpacing" => "LETTER_SPACING",
+ "characters" => "TEXT_CONTENT",
+ "fontFamily" => "FONT_FAMILY",
+ "fontStyle" => "FONT_STYLE",
+ "stroke" | "strokes/0/color" => "STROKE_COLOR",
+ "fill" | "fills" | "fills/0" | "fills/0/color" => match doc.node(node).map(|n| &n.kind) {
+ Some(NodeKind::Text(_)) => "TEXT_FILL",
+ Some(k) if k.is_frame_like() => "FRAME_FILL",
+ _ => "SHAPE_FILL",
+ },
+ _ => return None,
+ })
+}
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index f27cb1f..ebb46d0 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -772,4 +772,6 @@ mod text_fill_tests;
#[cfg(test)]
mod variable_tests;
#[cfg(test)]
+mod variables_editor_tests;
+#[cfg(test)]
mod version_tests;
diff --git a/crates/engine/src/variables_editor_tests.rs b/crates/engine/src/variables_editor_tests.rs
new file mode 100644
index 0000000..815fcb2
--- /dev/null
+++ b/crates/engine/src/variables_editor_tests.rs
@@ -0,0 +1,116 @@
+//! The Variables editor's commands: renaming, scopes, duplicates, modes, collections and aliases.
+
+use serde_json::{Value, json};
+
+use crate::Session;
+use crate::params::id_of;
+
+fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
+ s.execute(cmd, &p).unwrap()
+}
+
+fn tokens(s: &mut Session) {
+ ex(s, "variable.createCollection", json!({"name": "Tokens", "modes": ["Light", "Dark"]}));
+ ex(s, "variable.create", json!({"collection": "Tokens", "name": "space/sm", "type": "number", "value": 8}));
+ ex(s, "variable.create", json!({"collection": "Tokens", "name": "space/md", "type": "number", "value": 16}));
+ ex(s, "variable.create", json!({"collection": "Tokens", "name": "brand", "type": "color", "value": "#0D99FF"}));
+}
+
+#[test]
+fn rename_describe_scope_and_duplicate() {
+ let mut s = Session::new();
+ tokens(&mut s);
+ ex(&mut s, "variable.update", json!({"variable": "space/sm", "name": " spacing / small ", "description": "Tight gaps"}));
+ let v = s.doc().unwrap().variables.get("spacing/small").cloned().unwrap();
+ assert_eq!(v.description, "Tight gaps");
+ // Names stay unique in a collection, and can't be blank.
+ assert!(s.execute("variable.update", &json!({"variable": "spacing/small", "name": "space/md"})).is_err());
+ assert!(s.execute("variable.update", &json!({"variable": "spacing/small", "name": " / "})).is_err());
+ // Scopes: a subset limits pickers, all of them (or ALL_SCOPES) is no limit, [] hides it.
+ ex(&mut s, "variable.update", json!({"variable": "spacing/small", "scopes": ["GAP", "CORNER_RADIUS"]}));
+ let v = s.doc().unwrap().variables.get("spacing/small").cloned().unwrap();
+ assert!(v.in_scope("GAP") && !v.in_scope("OPACITY"));
+ ex(&mut s, "variable.update", json!({"variable": "spacing/small", "scopes": ["ALL_SCOPES"]}));
+ assert_eq!(s.doc().unwrap().variables.get("spacing/small").unwrap().scopes, None);
+ ex(&mut s, "variable.update", json!({"variable": "spacing/small", "scopes": []}));
+ assert!(!s.doc().unwrap().variables.get("spacing/small").unwrap().in_scope("GAP"));
+ assert!(s.execute("variable.update", &json!({"variable": "brand", "scopes": ["GAP"]})).is_err());
+ ex(&mut s, "variable.update", json!({"variable": "brand", "scopes": ["ALL_FILLS"]}));
+ let brand = s.doc().unwrap().variables.get("brand").cloned().unwrap();
+ assert!(brand.in_scope("TEXT_FILL") && !brand.in_scope("STROKE_COLOR"));
+ // Duplicates sit after the original with a fresh id and name.
+ let id = ex(&mut s, "variable.duplicate", json!({"variable": "brand"}))["id"].as_str().unwrap().to_string();
+ let id2 = ex(&mut s, "variable.duplicate", json!({"variable": "brand"}))["id"].as_str().unwrap().to_string();
+ let vars = &s.doc().unwrap().variables;
+ assert_eq!(vars.get(&id).unwrap().name, "brand copy");
+ assert_eq!(vars.get(&id2).unwrap().name, "brand copy 2");
+ assert_eq!(vars.get(&id).unwrap().scopes, brand.scopes);
+}
+
+#[test]
+fn modes_and_collections_rename_and_delete() {
+ let mut s = Session::new();
+ tokens(&mut s);
+ let f = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"]).unwrap();
+ ex(&mut s, "variable.bind", json!({"ids": [f], "field": "width", "variable": "space/md"}));
+ ex(&mut s, "variable.set", json!({"variable": "space/md", "mode": "Dark", "value": 24}));
+ ex(&mut s, "variable.setMode", json!({"ids": [f], "collection": "Tokens", "mode": "Dark"}));
+ assert_eq!(s.doc().unwrap().node(f).unwrap().width, 24.0);
+ ex(&mut s, "variable.renameMode", json!({"collection": "Tokens", "mode": "Dark", "name": "Night"}));
+ // Deleting the mode a frame uses puts it back on the default mode.
+ ex(&mut s, "variable.removeMode", json!({"collection": "Tokens", "mode": "Night"}));
+ assert_eq!(s.doc().unwrap().node(f).unwrap().width, 16.0);
+ assert!(s.execute("variable.removeMode", &json!({"collection": "Tokens", "mode": "Light"})).is_err(), "last mode stays");
+ // A new mode gets a fresh id even after a delete.
+ let m = ex(&mut s, "variable.addMode", json!({"collection": "Tokens", "name": "Dim"}))["id"].as_str().unwrap().to_string();
+ let coll = s.doc().unwrap().variables.collection("Tokens").cloned().unwrap();
+ assert_eq!(coll.modes.iter().filter(|x| x.id == m).count(), 1);
+ ex(&mut s, "variable.createCollection", json!({"name": "Other"}));
+ assert!(s.execute("variable.renameCollection", &json!({"collection": "Tokens", "name": "Other"})).is_err());
+ ex(&mut s, "variable.renameCollection", json!({"collection": "Tokens", "name": "Primitives"}));
+ // Deleting a collection removes its variables; bound layers keep their values.
+ let r = ex(&mut s, "variable.deleteCollection", json!({"collection": "Primitives"}));
+ assert_eq!(r["deletedVariables"], 3);
+ let doc = s.doc().unwrap();
+ assert!(doc.variables.get("space/md").is_none());
+ let n = doc.node(f).unwrap();
+ assert!(n.bound.is_empty() && n.var_modes.is_empty());
+ assert_eq!(n.width, 16.0);
+ // Undo brings it all back.
+ ex(&mut s, "edit.undo", json!({}));
+ assert!(s.doc().unwrap().variables.get("space/md").is_some());
+}
+
+#[test]
+fn aliases_follow_their_target_and_refuse_loops_and_other_types() {
+ let mut s = Session::new();
+ tokens(&mut s);
+ let f = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"]).unwrap();
+ ex(&mut s, "variable.bind", json!({"ids": [f], "field": "width", "variable": "space/md"}));
+ ex(&mut s, "variable.set", json!({"variable": "space/md", "value": {"alias": "space/sm"}}));
+ // Stored by id, resolved through the target.
+ let sm = s.doc().unwrap().variables.get("space/sm").unwrap().id.clone();
+ let md = s.doc().unwrap().variables.get("space/md").cloned().unwrap();
+ assert!(md.values.values().any(|v| *v == figcraft_doc::VariableValue::Alias(sm.clone())));
+ assert_eq!(s.doc().unwrap().node(f).unwrap().width, 8.0);
+ ex(&mut s, "variable.set", json!({"variable": "space/sm", "value": 12}));
+ assert_eq!(s.doc().unwrap().node(f).unwrap().width, 12.0);
+ // sm → md would loop (md already aliases sm); self-aliases too; types must match.
+ assert!(s.execute("variable.set", &json!({"variable": "space/sm", "value": {"alias": "space/md"}})).is_err());
+ assert!(s.execute("variable.set", &json!({"variable": "space/sm", "value": {"alias": "space/sm"}})).is_err());
+ assert!(s.execute("variable.set", &json!({"variable": "space/sm", "value": {"alias": "brand"}})).is_err());
+ assert!(s.execute("variable.set", &json!({"variable": "space/sm", "value": {"alias": "nope"}})).is_err());
+}
+
+#[test]
+fn field_scopes_by_property_and_layer() {
+ let mut s = Session::new();
+ let f = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"]).unwrap();
+ let r = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5}))["id"]).unwrap();
+ let doc = s.doc().unwrap();
+ let scope = |id, field| crate::cmd::variables::field_scope(doc, id, field);
+ assert_eq!(scope(f, "fill"), Some("FRAME_FILL"));
+ assert_eq!(scope(r, "fill"), Some("SHAPE_FILL"));
+ assert_eq!(scope(f, "paddingHorizontal"), Some("GAP"));
+ assert_eq!(scope(f, "visible"), None);
+}
diff --git a/crates/ui-egui/src/colorpicker.rs b/crates/ui-egui/src/colorpicker.rs
index ae6c7ee..2cf0e8e 100644
--- a/crates/ui-egui/src/colorpicker.rs
+++ b/crates/ui-egui/src/colorpicker.rs
@@ -29,6 +29,11 @@ pub enum PickerTarget {
start: usize,
end: usize,
},
+ /// A colour variable's value in one mode.
+ Variable {
+ id: String,
+ mode: String,
+ },
}
/// The fills a text range paints with (its own, else the layer's).
@@ -55,6 +60,14 @@ fn current_paint(app: &FigApp, target: &PickerTarget) -> Option {
_ => None,
},
PickerTarget::TextRange { id, start, .. } => range_fills(doc, *id, *start)?.into_iter().next(),
+ PickerTarget::Variable { id, mode } => match doc.variables.get(id)?.values.get(mode)? {
+ figcraft_doc::VariableValue::Color(c) => {
+ let mut p = Paint::solid(c.with_alpha(1.0));
+ p.opacity = c.a;
+ Some(p)
+ }
+ _ => None,
+ },
}
}
@@ -83,6 +96,10 @@ fn write(app: &mut FigApp, target: &PickerTarget, paint: &Paint, live: bool) {
PickerTarget::TextRange { id, start, end } => {
app.session.execute("text.style", &json!({"id": id, "start": start, "end": end, "textFills": [paint]}))
}
+ PickerTarget::Variable { id, mode } => {
+ let c = paint.solid_color().unwrap_or(Color::BLACK);
+ app.session.execute("variable.set", &json!({"variable": id, "mode": mode, "value": c.with_alpha(c.a * paint.opacity).to_css_hex()}))
+ }
};
if let Err(e) = r {
app.toast(e.to_string());
@@ -356,7 +373,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
close_now = true;
}
// Paint type.
- if !matches!(target, PickerTarget::Effect { .. } | PickerTarget::PageBackground) {
+ if !matches!(target, PickerTarget::Effect { .. } | PickerTarget::PageBackground | PickerTarget::Variable { .. }) {
let kinds = [
(Icon::Fill, "Solid"),
(Icon::LayoutH, "Linear"),
diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs
index b29b674..f6f0d24 100644
--- a/crates/ui-egui/src/control.rs
+++ b/crates/ui-egui/src/control.rs
@@ -240,6 +240,12 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul
ids: app.session.selection(),
});
}
+ // The Variables editor: true / a collection (id or name) opens it, false closes it.
+ match p.get("variables") {
+ Some(Value::Bool(open)) => app.ui.variables = open.then(Default::default),
+ Some(Value::String(c)) => app.ui.variables = Some(crate::variables_panel::Editor::on(Some(c.clone()))),
+ _ => {}
+ }
// Layers panel search: a string opens it with that filter, null closes it.
if let Some(v) = p.get("layerFilter") {
app.ui.layer_search = v.is_string();
diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
index 2b24160..3e42603 100644
--- a/crates/ui-egui/src/dialogs.rs
+++ b/crates/ui-egui/src/dialogs.rs
@@ -1,6 +1,6 @@
//! Dialogs: About, Preferences, keyboard shortcuts, find & replace, variables, export.
-use egui::{Align2, Color32, Context, CornerRadius, Id, RichText, Sense, Stroke, vec2};
+use egui::{Align2, Color32, Context, CornerRadius, RichText, Sense, Stroke, vec2};
use figcraft_engine::catalog::MENU;
use serde_json::json;
@@ -153,72 +153,12 @@ pub fn show(app: &mut FigApp, ctx: &Context) {
}
app.ui.find = if open { Some((find, replace)) } else { None };
}
- if ctx.data(|d| d.get_temp::(Id::new("variables_open"))).unwrap_or(false) {
- variables(app, ctx);
- }
+ crate::variables_panel::show(app, ctx);
if app.ui.export_preview {
export_dialog(app, ctx);
}
}
-fn variables(app: &mut FigApp, ctx: &Context) {
- let t = Tokens::get(ctx);
- let mut open = true;
- let vars = app.session.doc().map(|d| d.variables.clone()).unwrap_or_default();
- egui::Window::new("Local variables").open(&mut open).default_size(vec2(640.0, 360.0)).show(ctx, |ui| {
- ui.horizontal(|ui| {
- if ui.button("+ Collection").clicked() {
- let n = vars.collections.len() + 1;
- app.run("variable.createCollection", json!({"name": format!("Collection {n}"), "modes": ["Mode 1"]}));
- }
- });
- for c in &vars.collections {
- ui.separator();
- ui.horizontal(|ui| {
- ui.label(RichText::new(&c.name).font(semibold(12.0)));
- if ui.small_button("+ Mode").clicked() {
- app.run("variable.addMode", json!({"collection": c.id, "name": format!("Mode {}", c.modes.len() + 1)}));
- }
- for ty in ["color", "number", "string", "boolean"] {
- if ui.small_button(format!("+ {ty}")).clicked() {
- let n = vars.variables.len() + 1;
- app.run("variable.create", json!({"collection": c.id, "name": format!("{ty}-{n}"), "type": ty}));
- }
- }
- });
- egui::Grid::new(("vars", c.id.as_str())).striped(true).show(ui, |ui| {
- ui.label(RichText::new("Name").color(t.text_secondary));
- for m in &c.modes {
- ui.label(RichText::new(&m.name).color(t.text_secondary));
- }
- ui.end_row();
- for v in vars.variables.iter().filter(|v| v.collection == c.id) {
- ui.label(&v.name);
- for m in &c.modes {
- let cur = v.values.get(&m.id).map(|x| x.display()).unwrap_or_default();
- let id = Id::new(("varval", v.id.as_str(), m.id.as_str()));
- if let Some(new) = crate::widgets::text_field(ui, id, &cur, 110.0, "") {
- let val = match v.kind {
- figcraft_doc::VariableType::Number => new.parse::().map(|x| json!(x)).unwrap_or(json!(0)),
- figcraft_doc::VariableType::Boolean => json!(new.trim() == "true"),
- _ => json!(new),
- };
- app.run("variable.set", json!({"variable": v.id, "mode": m.id, "value": val}));
- }
- }
- ui.end_row();
- }
- });
- }
- if vars.collections.is_empty() {
- ui.label(RichText::new("Variables store reusable values (colours, numbers, strings, booleans) with modes such as Light and Dark. Bind them to layer properties.").color(t.text_secondary));
- }
- });
- if !open {
- ctx.data_mut(|d| d.remove::(Id::new("variables_open")));
- }
-}
-
fn export_dialog(app: &mut FigApp, ctx: &Context) {
let t = Tokens::get(ctx);
let mut open = true;
diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs
index c01d8d4..03b96be 100644
--- a/crates/ui-egui/src/layers.rs
+++ b/crates/ui-egui/src/layers.rs
@@ -103,7 +103,7 @@ pub fn rail(app: &mut FigApp, ui: &mut Ui) {
app.ui.versions = Default::default();
}
None => {
- ui.data_mut(|d| d.insert_temp(Id::new("variables_open"), true));
+ app.ui.variables.get_or_insert_with(Default::default);
}
}
}
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index 516c290..03b00de 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -33,6 +33,7 @@ pub mod theme;
pub mod toolbar;
pub mod typography;
pub mod variable_ui;
+pub mod variables_panel;
pub mod vector_panel;
pub mod versions;
pub mod widgets;
@@ -100,6 +101,8 @@ pub struct UiState {
pub rename_page: Option<(NodeId, String)>,
pub main_menu: bool,
pub palette: Option,
+ /// The Variables editor, when open.
+ pub variables: Option,
pub about: bool,
pub preferences: bool,
pub shortcuts_panel: bool,
@@ -147,6 +150,7 @@ impl Default for UiState {
rename_page: None,
main_menu: false,
palette: None,
+ variables: None,
about: false,
preferences: false,
shortcuts_panel: false,
@@ -426,6 +430,7 @@ impl FigApp {
"presenting": self.ui.present.is_some(),
"mainMenu": self.ui.main_menu,
"palette": self.ui.palette.is_some(),
+ "variables": self.ui.variables.is_some(),
"versionHistory": self.ui.versions.open,
"comments": {"open": self.ui.comments.open, "draft": self.ui.comments.draft.as_ref().map(|d| [d.0, d.1]), "showResolved": self.ui.comments.show_resolved},
"frameMs": self.ui.frame_ms,
diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs
index 20908b5..44d9708 100644
--- a/crates/ui-egui/src/right.rs
+++ b/crates/ui-egui/src/right.rs
@@ -268,7 +268,7 @@ fn page_section(app: &mut FigApp, ui: &mut Ui, doc: &Document) {
if r.clicked() {
app.ui.preferences = false;
app.ui.palette = None;
- ui.data_mut(|d| d.insert_temp(Id::new("variables_open"), true));
+ app.ui.variables.get_or_insert_with(Default::default);
}
});
widgets::separator(ui);
diff --git a/crates/ui-egui/src/typography.rs b/crates/ui-egui/src/typography.rs
index dc0260b..5d30d54 100644
--- a/crates/ui-egui/src/typography.rs
+++ b/crates/ui-egui/src/typography.rs
@@ -279,7 +279,7 @@ pub fn typography(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId])
/// Run `cmd` as a live preview while scrubbing; the gesture becomes one undo step when `done`.
/// (While text is edited, run commands directly: the editing session already groups undo.)
-fn live(app: &mut FigApp, label: &str, cmd: &str, params: Value, done: bool) {
+pub(crate) fn live(app: &mut FigApp, label: &str, cmd: &str, params: Value, done: bool) {
let _ = app.session.begin_interaction(label);
if let Err(e) = app.session.execute(cmd, ¶ms) {
app.toast(e.to_string());
diff --git a/crates/ui-egui/src/variable_ui.rs b/crates/ui-egui/src/variable_ui.rs
index d3281ed..c833216 100644
--- a/crates/ui-egui/src/variable_ui.rs
+++ b/crates/ui-egui/src/variable_ui.rs
@@ -1,9 +1,10 @@
//! Binding variables to properties from the Design panel: number fields show a variable button on
//! hover and the bound variable's name once bound; colour fills bind through the style picker. The
-//! picker popover lists the document's variables of the matching type, by collection.
+//! picker popover lists the document's variables of the matching type (and scope), by collection;
+//! the Variables editor uses it to pick alias targets too.
use egui::{Align2, Color32, CornerRadius, Id, Pos2, Rect, Sense, Stroke, Ui, pos2, vec2};
-use figcraft_doc::{Document, NodeId, VariableType};
+use figcraft_doc::{Document, ModeId, NodeId, VariableId, VariableType};
use serde_json::json;
use crate::FigApp;
@@ -11,11 +12,19 @@ use crate::icons::{self, Icon};
use crate::theme::{Tokens, regular, semibold};
use crate::widgets::{self, BindEvent, FieldEdit, Lead};
-/// The open variable picker: which layers and field it binds, the variable type, where it opens.
+/// What picking a variable does.
+#[derive(Clone)]
+enum Target {
+ /// Bind it to `field` of `ids`.
+ Bind { ids: Vec, field: String },
+ /// Make `variable`'s value in `mode` an alias of it.
+ Alias { variable: VariableId, mode: ModeId },
+}
+
+/// The open variable picker: what it does, the variable type, where it opens.
#[derive(Clone)]
struct Picker {
- ids: Vec,
- field: String,
+ target: Target,
kind: VariableType,
at: Pos2,
}
@@ -36,7 +45,14 @@ pub fn bound_name(doc: &Document, ids: &[NodeId], field: &str) -> Option
/// Open the picker for `field` of `ids` at `at`.
pub fn open(ui: &Ui, ids: &[NodeId], field: &str, kind: VariableType, at: Pos2) {
- ui.data_mut(|d| d.insert_temp(picker_id(), Picker { ids: ids.to_vec(), field: field.to_string(), kind, at }));
+ let target = Target::Bind { ids: ids.to_vec(), field: field.to_string() };
+ ui.data_mut(|d| d.insert_temp(picker_id(), Picker { target, kind, at }));
+}
+
+/// Open the picker at `at` to alias `variable`'s value in `mode` to another variable.
+pub fn open_alias(ui: &Ui, variable: &str, mode: &str, kind: VariableType, at: Pos2) {
+ let target = Target::Alias { variable: variable.to_string(), mode: mode.to_string() };
+ ui.data_mut(|d| d.insert_temp(picker_id(), Picker { target, kind, at }));
}
/// Open the picker for `field` of `ids` under the pointer (from a section header button).
@@ -89,7 +105,16 @@ pub fn bound_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], field: &str, kin
/// The picker popover (call once per frame).
pub fn picker(app: &mut FigApp, ctx: &egui::Context) {
let Some(p) = ctx.data(|d| d.get_temp::(picker_id())) else { return };
- let Ok(doc) = app.session.doc().cloned() else { return };
+ let Ok(doc) = app.session.ds().map(|d| d.doc.clone()) else { return };
+ // Bindings offer variables scoped to the property; aliases, any that wouldn't loop.
+ let offered = |v: &figcraft_doc::Variable| match &p.target {
+ Target::Bind { ids, field } => {
+ ids.first().and_then(|id| figcraft_engine::cmd::variables::field_scope(&doc, *id, field)).is_none_or(|scope| v.in_scope(scope))
+ }
+ Target::Alias { variable, .. } => {
+ doc.variables.get(variable).is_some_and(|own| figcraft_engine::cmd::variables::alias_target(&doc.variables, own, &v.id).is_ok())
+ }
+ };
let t = Tokens::get(ctx);
let query_id = Id::new("variable_picker_query");
let mut query: String = ctx.data(|d| d.get_temp(query_id)).unwrap_or_default();
@@ -115,7 +140,7 @@ pub fn picker(app: &mut FigApp, ctx: &egui::Context) {
.variables
.variables
.iter()
- .filter(|v| v.collection == c.id && v.kind == p.kind && (q.is_empty() || v.name.to_lowercase().contains(&q)))
+ .filter(|v| v.collection == c.id && v.kind == p.kind && (q.is_empty() || v.name.to_lowercase().contains(&q)) && offered(v))
.collect();
if vars.is_empty() {
continue;
@@ -158,9 +183,12 @@ pub fn picker(app: &mut FigApp, ctx: &egui::Context) {
VariableType::String => "string",
VariableType::Boolean => "boolean",
};
- ui.label(
- egui::RichText::new(format!("No {kind} variables yet. Create them in Local variables.")).font(regular(11.0)).color(t.menu_dim),
- );
+ let msg = if doc.variables.variables.iter().any(|v| v.kind == p.kind) {
+ format!("No {kind} variables fit here.")
+ } else {
+ format!("No {kind} variables yet. Create them in Local variables.")
+ };
+ ui.label(egui::RichText::new(msg).font(regular(11.0)).color(t.menu_dim));
}
});
});
@@ -176,7 +204,10 @@ pub fn picker(app: &mut FigApp, ctx: &egui::Context) {
close = true;
}
if let Some(var) = chosen {
- app.run("variable.bind", json!({"ids": p.ids, "field": p.field, "variable": var}));
+ match &p.target {
+ Target::Bind { ids, field } => app.run("variable.bind", json!({"ids": ids, "field": field, "variable": var})),
+ Target::Alias { variable, mode } => app.run("variable.set", json!({"variable": variable, "mode": mode, "value": {"alias": var}})),
+ };
close = true;
}
if close {
diff --git a/crates/ui-egui/src/variables_panel.rs b/crates/ui-egui/src/variables_panel.rs
new file mode 100644
index 0000000..1d0260e
--- /dev/null
+++ b/crates/ui-egui/src/variables_panel.rs
@@ -0,0 +1,580 @@
+//! The Variables editor: collections on the left; the selected collection's variables on the
+//! right as a table with one value column per mode. Double-click a name to rename it, right-click
+//! for more; a value's variable button turns it into an alias of another variable. Everything goes
+//! through `variable.*` commands, so it's undoable and the same over MCP.
+
+use egui::{Align2, Context, CornerRadius, Id, Rect, Sense, Stroke, StrokeKind, Ui, UiBuilder, pos2, vec2};
+use figcraft_doc::{Collection, Variable, VariableType, VariableValue, Variables, scopes_for};
+use serde_json::{Value, json};
+
+use crate::FigApp;
+use crate::colorpicker::PickerTarget;
+use crate::icons::{self, Icon};
+use crate::right::menu_item;
+use crate::theme::{Tokens, regular, semibold};
+use crate::widgets::{self, FieldEdit, Lead};
+
+const SIDEBAR_W: f32 = 200.0;
+const NAME_W: f32 = 240.0;
+const MODE_W: f32 = 200.0;
+const ROW_H: f32 = 36.0;
+const TYPES: [VariableType; 4] = [VariableType::Color, VariableType::Number, VariableType::String, VariableType::Boolean];
+
+/// What's being renamed inline.
+#[derive(Clone, Debug, PartialEq, Hash)]
+enum Rename {
+ Collection(String),
+ /// (collection, mode)
+ Mode(String, String),
+ Variable(String),
+}
+
+/// The open editor's state (`UiState::variables`; None = closed).
+#[derive(Clone, Default)]
+pub struct Editor {
+ /// The selected collection's id.
+ collection: Option,
+ query: String,
+ rename: Option,
+ /// The variable whose details (name, description, scopes) are open.
+ details: Option,
+}
+
+impl Editor {
+ /// An editor showing `collection` (id or name; the first one when None or unknown).
+ pub fn on(collection: Option) -> Self {
+ Editor { collection, ..Default::default() }
+ }
+}
+
+/// Show the editor while it's open (call once per frame).
+pub fn show(app: &mut FigApp, ctx: &Context) {
+ let Some(mut st) = app.ui.variables.take() else { return };
+ let Ok(doc) = app.session.ds().map(|d| d.doc.clone()) else { return };
+ let vars = &doc.variables;
+ st.collection = st.collection.as_ref().and_then(|c| vars.collection(c)).or_else(|| vars.collections.first()).map(|c| c.id.clone());
+ let t = Tokens::get(ctx);
+ let screen = ctx.content_rect();
+ let size = vec2(880.0_f32.min(screen.width() - 80.0).max(480.0), 480.0_f32.min(screen.height() - 120.0).max(240.0));
+ let mut open = true;
+ egui::Window::new("Local variables")
+ .id(Id::new("variables_window"))
+ .title_bar(false)
+ .collapsible(false)
+ .resizable(false)
+ .fixed_size(size)
+ .pivot(Align2::CENTER_CENTER)
+ .default_pos(screen.center())
+ .frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(0)))
+ .show(ctx, |ui| {
+ ui.set_min_size(size);
+ ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
+ header(ui, &mut open);
+ let h = (size.y - 44.0).max(160.0);
+ let top_down = egui::Layout::top_down(egui::Align::Min);
+ ui.horizontal_top(|ui| {
+ ui.allocate_ui_with_layout(vec2(SIDEBAR_W, h), top_down, |ui| sidebar(app, ui, vars, &mut st));
+ let (line, _) = ui.allocate_exact_size(vec2(1.0, h), Sense::hover());
+ ui.painter().rect_filled(line, CornerRadius::ZERO, t.border);
+ ui.allocate_ui_with_layout(vec2(size.x - SIDEBAR_W - 1.0, h), top_down, |ui| table(app, ui, vars, &mut st));
+ });
+ });
+ details(app, ctx, vars, &mut st);
+ app.ui.variables = open.then_some(st);
+}
+
+fn header(ui: &mut Ui, open: &mut bool) {
+ let t = Tokens::get(ui.ctx());
+ let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::hover());
+ ui.painter().text(pos2(r.min.x + 16.0, r.center().y), Align2::LEFT_CENTER, "Local variables", semibold(13.0), t.text);
+ if small_button(ui, Id::new("vars_close"), Rect::from_center_size(pos2(r.max.x - 22.0, r.center().y), vec2(24.0, 24.0)), Icon::Close, "Close") {
+ *open = false;
+ }
+ ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.border));
+}
+
+/// A square icon button painted at `rect`; true when clicked.
+fn small_button(ui: &mut Ui, id: Id, rect: Rect, icon: Icon, tip: &str) -> bool {
+ let t = Tokens::get(ui.ctx());
+ let resp = ui.interact(rect, id, Sense::click()).on_hover_text(tip);
+ if resp.hovered() {
+ ui.painter().rect_filled(rect, CornerRadius::same(5), t.hover);
+ }
+ icons::paint(ui.painter(), rect.shrink(5.0), icon, t.icon);
+ resp.clicked()
+}
+
+fn rename_id(target: &Rename) -> Id {
+ Id::new(("var_rename", target))
+}
+
+fn start_rename(ui: &Ui, st: &mut Editor, target: Rename) {
+ ui.memory_mut(|m| m.request_focus(rename_id(&target).with("te")));
+ st.rename = Some(target);
+}
+
+/// The inline editor of a rename in progress: the new name once committed. The rename ends when
+/// the editor closes.
+fn rename_field(ui: &mut Ui, st: &mut Editor, target: &Rename, value: &str, rect: Rect, font: egui::FontId) -> Option {
+ let id = rename_id(target);
+ let out = widgets::text_field_inline(ui, id, value, rect, font);
+ if out.is_some() || !ui.memory(|m| m.has_focus(id.with("te"))) {
+ st.rename = None;
+ }
+ out
+}
+
+/// The first of `base`, `base 2`, `base 3`… that `taken` doesn't hold.
+fn unique(base: &str, taken: impl Fn(&str) -> bool) -> String {
+ (1..10_000).map(|k| if k == 1 { base.to_string() } else { format!("{base} {k}") }).find(|n| !taken(n)).unwrap_or_else(|| base.to_string())
+}
+
+fn sidebar(app: &mut FigApp, ui: &mut Ui, vars: &Variables, st: &mut Editor) {
+ let t = Tokens::get(ui.ctx());
+ let (hr, _) = ui.allocate_exact_size(vec2(SIDEBAR_W, 40.0), Sense::hover());
+ ui.painter().text(pos2(hr.min.x + 16.0, hr.center().y), Align2::LEFT_CENTER, "Collections", semibold(11.0), t.text_secondary);
+ if small_button(
+ ui,
+ Id::new("vars_add_coll"),
+ Rect::from_center_size(pos2(hr.max.x - 20.0, hr.center().y), vec2(24.0, 24.0)),
+ Icon::Plus,
+ "Create collection",
+ ) {
+ create_collection(app, ui, vars, st);
+ }
+ egui::ScrollArea::vertical().id_salt("vars_collections").auto_shrink([false, false]).show(ui, |ui| {
+ for c in &vars.collections {
+ let (r, resp) = ui.allocate_exact_size(vec2(SIDEBAR_W, 30.0), Sense::click());
+ let r = r.shrink2(vec2(8.0, 1.0));
+ let selected = st.collection.as_deref() == Some(c.id.as_str());
+ if selected {
+ ui.painter().rect_filled(r, CornerRadius::same(5), t.selected_row);
+ } else if resp.hovered() {
+ ui.painter().rect_filled(r, CornerRadius::same(5), t.hover);
+ }
+ let target = Rename::Collection(c.id.clone());
+ let count = vars.variables.iter().filter(|v| v.collection == c.id).count();
+ ui.painter().text(pos2(r.max.x - 8.0, r.center().y), Align2::RIGHT_CENTER, count.to_string(), regular(11.0), t.text_tertiary);
+ let name_r = Rect::from_min_max(pos2(r.min.x + 8.0, r.min.y + 4.0), pos2(r.max.x - 32.0, r.max.y - 4.0));
+ if st.rename.as_ref() == Some(&target) {
+ if let Some(name) = rename_field(ui, st, &target, &c.name, name_r, regular(11.0)) {
+ app.run("variable.renameCollection", json!({"collection": c.id, "name": name}));
+ }
+ } else {
+ let font = if selected { semibold(11.0) } else { regular(11.0) };
+ ui.painter().with_clip_rect(name_r).text(name_r.left_center(), Align2::LEFT_CENTER, &c.name, font, t.text);
+ }
+ if resp.clicked() {
+ st.collection = Some(c.id.clone());
+ }
+ if resp.double_clicked() {
+ start_rename(ui, st, target.clone());
+ }
+ resp.context_menu(|ui| {
+ if menu_item(ui, "Rename", "") {
+ start_rename(ui, st, target.clone());
+ ui.close();
+ }
+ if menu_item(ui, "Delete collection", "") {
+ app.run("variable.deleteCollection", json!({"collection": c.id}));
+ ui.close();
+ }
+ });
+ }
+ });
+}
+
+fn create_collection(app: &mut FigApp, ui: &Ui, vars: &Variables, st: &mut Editor) {
+ let name = unique("Collection", |n| vars.collections.iter().any(|c| c.name == n));
+ if let Some(id) =
+ app.run("variable.createCollection", json!({"name": name, "modes": ["Mode 1"]})).and_then(|r| r["id"].as_str().map(str::to_string))
+ {
+ st.collection = Some(id.clone());
+ start_rename(ui, st, Rename::Collection(id));
+ }
+}
+
+/// The group part of a variable name ("space/md" → "space"; "" when ungrouped).
+fn group_of(name: &str) -> &str {
+ name.rsplit_once('/').map(|(g, _)| g).unwrap_or("")
+}
+
+fn table(app: &mut FigApp, ui: &mut Ui, vars: &Variables, st: &mut Editor) {
+ let t = Tokens::get(ui.ctx());
+ let Some(coll) = st.collection.as_ref().and_then(|c| vars.collection(c)).cloned() else {
+ ui.add_space(24.0);
+ ui.horizontal(|ui| {
+ ui.add_space(24.0);
+ ui.vertical(|ui| {
+ widgets::label(
+ ui,
+ "Variables store reusable values (colours, numbers, strings, booleans) with modes such as Light and Dark.",
+ t.text_secondary,
+ );
+ ui.add_space(12.0);
+ if widgets::button(ui, "Create collection", true, true).clicked() {
+ create_collection(app, ui, vars, st);
+ }
+ });
+ });
+ return;
+ };
+ // Search.
+ let (sr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 40.0), Sense::hover());
+ let field = Rect::from_min_size(pos2(sr.min.x + 16.0, sr.center().y - 12.0), vec2(220.0, 24.0));
+ ui.painter().rect_filled(field, CornerRadius::same(5), t.field);
+ icons::paint(ui.painter(), Rect::from_center_size(pos2(field.min.x + 13.0, field.center().y), vec2(12.0, 12.0)), Icon::Search, t.icon_secondary);
+ let mut child = ui.new_child(
+ UiBuilder::new().max_rect(Rect::from_min_max(pos2(field.min.x + 24.0, field.min.y + 4.0), pos2(field.max.x - 4.0, field.max.y - 4.0))),
+ );
+ child.add(
+ egui::TextEdit::singleline(&mut st.query)
+ .frame(egui::Frame::NONE)
+ .hint_text("Search")
+ .font(regular(11.0))
+ .vertical_align(egui::Align::Center),
+ );
+ let q = st.query.trim().to_lowercase();
+ let mut list: Vec<&Variable> =
+ vars.variables.iter().filter(|v| v.collection == coll.id && (q.is_empty() || v.name.to_lowercase().contains(&q))).collect();
+ list.sort_by(|a, b| group_of(&a.name).cmp(group_of(&b.name)));
+ let width = (NAME_W + coll.modes.len() as f32 * MODE_W + 40.0).max(ui.available_width());
+ egui::ScrollArea::both().id_salt("vars_table").auto_shrink([false, false]).show(ui, |ui| {
+ ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
+ header_row(app, ui, &coll, st, width);
+ let mut group: Option<&str> = None;
+ for v in &list {
+ let g = group_of(&v.name);
+ if group != Some(g) {
+ group = Some(g);
+ if !g.is_empty() {
+ let (r, _) = ui.allocate_exact_size(vec2(width, 28.0), Sense::hover());
+ ui.painter().text(pos2(r.min.x + 16.0, r.center().y + 2.0), Align2::LEFT_CENTER, g, semibold(11.0), t.text_secondary);
+ }
+ }
+ var_row(app, ui, vars, &coll, v, st, width);
+ }
+ if list.is_empty() && !q.is_empty() {
+ let (r, _) = ui.allocate_exact_size(vec2(width, ROW_H), Sense::hover());
+ ui.painter().text(pos2(r.min.x + 16.0, r.center().y), Align2::LEFT_CENTER, "No matching variables", regular(11.0), t.text_secondary);
+ }
+ create_row(app, ui, vars, &coll, st);
+ });
+}
+
+fn header_row(app: &mut FigApp, ui: &mut Ui, coll: &Collection, st: &mut Editor, width: f32) {
+ let t = Tokens::get(ui.ctx());
+ let (r, _) = ui.allocate_exact_size(vec2(width, 32.0), Sense::hover());
+ ui.painter().text(pos2(r.min.x + 16.0, r.center().y), Align2::LEFT_CENTER, "Name", regular(11.0), t.text_secondary);
+ for (i, m) in coll.modes.iter().enumerate() {
+ let x = r.min.x + NAME_W + i as f32 * MODE_W;
+ let cell = Rect::from_min_max(pos2(x + 4.0, r.min.y + 4.0), pos2(x + MODE_W - 8.0, r.max.y - 4.0));
+ let target = Rename::Mode(coll.id.clone(), m.id.clone());
+ if st.rename.as_ref() == Some(&target) {
+ if let Some(name) = rename_field(ui, st, &target, &m.name, cell.shrink2(vec2(4.0, 2.0)), regular(11.0)) {
+ app.run("variable.renameMode", json!({"collection": coll.id, "mode": m.id, "name": name}));
+ }
+ continue;
+ }
+ let resp = ui.interact(cell, Id::new(("vars_mode", &m.id)), Sense::click());
+ if resp.hovered() {
+ ui.painter().rect_filled(cell, CornerRadius::same(4), t.hover);
+ }
+ ui.painter().with_clip_rect(cell).text(
+ pos2(cell.min.x + 4.0, cell.center().y),
+ Align2::LEFT_CENTER,
+ &m.name,
+ regular(11.0),
+ t.text_secondary,
+ );
+ if resp.double_clicked() {
+ start_rename(ui, st, target.clone());
+ }
+ resp.context_menu(|ui| {
+ if menu_item(ui, "Rename mode", "") {
+ start_rename(ui, st, target.clone());
+ ui.close();
+ }
+ if coll.modes.len() > 1 && menu_item(ui, "Delete mode", "") {
+ app.run("variable.removeMode", json!({"collection": coll.id, "mode": m.id}));
+ ui.close();
+ }
+ });
+ }
+ let x = r.min.x + NAME_W + coll.modes.len() as f32 * MODE_W;
+ if small_button(ui, Id::new("vars_add_mode"), Rect::from_center_size(pos2(x + 14.0, r.center().y), vec2(24.0, 24.0)), Icon::Plus, "Add mode") {
+ let name = unique("Mode", |n| coll.modes.iter().any(|m| m.name == n));
+ if let Some(id) = app.run("variable.addMode", json!({"collection": coll.id, "name": name})).and_then(|r| r["id"].as_str().map(str::to_string))
+ {
+ start_rename(ui, st, Rename::Mode(coll.id.clone(), id));
+ }
+ }
+ ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.border));
+}
+
+fn var_row(app: &mut FigApp, ui: &mut Ui, vars: &Variables, coll: &Collection, v: &Variable, st: &mut Editor, width: f32) {
+ let t = Tokens::get(ui.ctx());
+ let (r, resp) = ui.allocate_exact_size(vec2(width, ROW_H), Sense::click());
+ if resp.hovered() || st.details.as_deref() == Some(v.id.as_str()) {
+ ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover);
+ }
+ kind_glyph(ui, vars, v, coll, Rect::from_center_size(pos2(r.min.x + 24.0, r.center().y), vec2(14.0, 14.0)));
+ let name_r = Rect::from_min_max(pos2(r.min.x + 40.0, r.min.y + 8.0), pos2(r.min.x + NAME_W - 8.0, r.max.y - 8.0));
+ let target = Rename::Variable(v.id.clone());
+ if st.rename.as_ref() == Some(&target) {
+ if let Some(name) = rename_field(ui, st, &target, &v.name, name_r, regular(11.0)) {
+ app.run("variable.update", json!({"variable": v.id, "name": name}));
+ }
+ } else {
+ // Grouped names show their last part under the group's header.
+ let short = v.name.rsplit('/').next().unwrap_or(&v.name);
+ ui.painter().with_clip_rect(name_r).text(name_r.left_center(), Align2::LEFT_CENTER, short, regular(11.0), t.text);
+ }
+ if resp.double_clicked() && ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| p.x < name_r.max.x) {
+ start_rename(ui, st, target.clone());
+ }
+ for (i, m) in coll.modes.iter().enumerate() {
+ let x = r.min.x + NAME_W + i as f32 * MODE_W;
+ value_cell(
+ app,
+ ui,
+ vars,
+ v,
+ coll,
+ &m.id,
+ Rect::from_min_max(pos2(x + 4.0, r.center().y - 12.0), pos2(x + MODE_W - 8.0, r.center().y + 12.0)),
+ );
+ }
+ resp.context_menu(|ui| {
+ if menu_item(ui, "Edit variable", "") {
+ st.details = Some(v.id.clone());
+ ui.close();
+ }
+ if menu_item(ui, "Rename", "") {
+ start_rename(ui, st, target.clone());
+ ui.close();
+ }
+ if menu_item(ui, "Duplicate", "") {
+ app.run("variable.duplicate", json!({"variable": v.id}));
+ ui.close();
+ }
+ if menu_item(ui, "Delete variable", "") {
+ app.run("variable.delete", json!({"variable": v.id}));
+ ui.close();
+ }
+ });
+ ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.border));
+}
+
+/// The type marker before a variable's name: its colour (default mode), or a type glyph.
+fn kind_glyph(ui: &Ui, vars: &Variables, v: &Variable, coll: &Collection, rect: Rect) {
+ let t = Tokens::get(ui.ctx());
+ let p = ui.painter();
+ match v.kind {
+ VariableType::Color => {
+ let modes = coll.modes.first().map(|m| [(coll.id.clone(), m.id.clone())].into_iter().collect()).unwrap_or_default();
+ if let Some(VariableValue::Color(c)) = vars.resolve(&v.id, &modes) {
+ widgets::paint_swatch(p, rect, &figcraft_color::Paint::solid(c));
+ }
+ }
+ VariableType::Number => {
+ p.text(rect.center(), Align2::CENTER_CENTER, "#", semibold(12.0), t.icon_secondary);
+ }
+ VariableType::String => icons::paint(p, rect, Icon::Text, t.icon_secondary),
+ VariableType::Boolean => {
+ let track = Rect::from_center_size(rect.center(), vec2(14.0, 8.0));
+ p.rect_stroke(track, CornerRadius::same(4), Stroke::new(1.2, t.icon_secondary), StrokeKind::Inside);
+ p.circle_filled(pos2(track.max.x - 4.0, track.center().y), 2.0, t.icon_secondary);
+ }
+ }
+}
+
+/// `val` as a `variable.set` value.
+fn value_json(val: &VariableValue) -> Value {
+ match val {
+ VariableValue::Color(c) => json!(c.to_css_hex()),
+ VariableValue::Number(x) => json!(x),
+ VariableValue::String(s) => json!(s),
+ VariableValue::Boolean(b) => json!(b),
+ VariableValue::Alias(a) => json!({"alias": a}),
+ }
+}
+
+fn set(app: &mut FigApp, v: &Variable, mode: &str, value: Value) {
+ app.run("variable.set", json!({"variable": v.id, "mode": mode, "value": value}));
+}
+
+/// One value cell: an editor for the value, or the aliased variable's chip; the variable button
+/// on the right makes it an alias.
+fn value_cell(app: &mut FigApp, ui: &mut Ui, vars: &Variables, v: &Variable, coll: &Collection, mode: &str, rect: Rect) {
+ let t = Tokens::get(ui.ctx());
+ let Some(val) = v.values.get(mode) else { return };
+ let id = Id::new(("var_cell", &v.id, mode));
+ // The value editor leaves room for the alias button.
+ let body = Rect::from_min_max(rect.min, pos2(rect.max.x - 26.0, rect.max.y));
+ let alias_btn = Rect::from_center_size(pos2(rect.max.x - 11.0, rect.center().y), vec2(22.0, 22.0));
+ let hovered = ui.rect_contains_pointer(rect.expand2(vec2(4.0, 6.0)));
+ match val {
+ VariableValue::Alias(target) => {
+ let full = vars.get(target).map(|x| x.name.as_str()).unwrap_or("Missing variable");
+ let resp = ui.interact(body, id.with("alias"), Sense::click()).on_hover_text("Change alias");
+ ui.painter().rect_filled(body, CornerRadius::same(5), t.field);
+ let chip = body.shrink2(vec2(3.0, 3.0));
+ ui.painter().rect_filled(chip, CornerRadius::same(4), t.hover);
+ ui.painter().rect_stroke(
+ chip,
+ CornerRadius::same(4),
+ Stroke::new(1.0, if resp.hovered() { t.border_strong } else { t.border }),
+ StrokeKind::Inside,
+ );
+ icons::paint(
+ ui.painter(),
+ Rect::from_center_size(pos2(chip.min.x + 11.0, chip.center().y), vec2(12.0, 12.0)),
+ Icon::Variable,
+ t.icon_secondary,
+ );
+ let text_r = Rect::from_min_max(pos2(chip.min.x + 21.0, chip.min.y), pos2(chip.max.x - 4.0, chip.max.y));
+ ui.painter().with_clip_rect(text_r).text(text_r.left_center(), Align2::LEFT_CENTER, full, regular(11.0), t.text);
+ if resp.clicked() {
+ crate::variable_ui::open_alias(ui, &v.id, mode, v.kind, body.left_bottom());
+ }
+ // Detach: keep the value it resolves to now.
+ if hovered && small_button(ui, id.with("detach"), alias_btn, Icon::Detach, "Detach alias") {
+ let modes = [(coll.id.clone(), mode.to_string())].into_iter().collect();
+ if let Some(resolved) = vars.resolve(&v.id, &modes) {
+ set(app, v, mode, value_json(&resolved));
+ }
+ }
+ return;
+ }
+ VariableValue::Color(c) => {
+ ui.painter().rect_filled(body, CornerRadius::same(5), t.field);
+ let sw = Rect::from_center_size(pos2(body.min.x + 13.0, body.center().y), vec2(14.0, 14.0));
+ widgets::paint_swatch(ui.painter(), sw, &figcraft_color::Paint::solid(*c));
+ if ui.interact(sw.expand(3.0), id.with("swatch"), Sense::click()).on_hover_text("Edit colour").clicked() {
+ app.ui.picker = Some(PickerTarget::Variable { id: v.id.clone(), mode: mode.to_string() });
+ }
+ let hex_r = Rect::from_min_max(pos2(sw.max.x + 8.0, body.min.y + 4.0), pos2(body.max.x - 44.0, body.max.y - 4.0));
+ if let Some(h) = widgets::text_field_inline(ui, id.with("hex"), &c.to_hex(), hex_r, regular(11.0))
+ && let Some(nc) = figcraft_color::Color::from_hex(&h)
+ {
+ // Six digits keep the alpha; eight set it.
+ let nc = if h.trim().trim_start_matches('#').len() == 8 { nc } else { nc.with_alpha(c.a) };
+ set(app, v, mode, json!(nc.to_css_hex()));
+ }
+ let pct = format!("{}%", (c.a * 100.0).round());
+ ui.painter().text(pos2(body.max.x - 8.0, body.center().y), Align2::RIGHT_CENTER, pct, regular(11.0), t.text_secondary);
+ }
+ VariableValue::Number(x) => {
+ let mut child = ui.new_child(UiBuilder::new().max_rect(body));
+ match widgets::number_field(&mut child, id.with("num"), Lead::None, Some(*x), "", body.width(), 1.0) {
+ Some(FieldEdit::Set(n)) => set(app, v, mode, json!(n)),
+ Some(FieldEdit::Scrub(n, done)) => {
+ crate::typography::live(app, "Edit variable", "variable.set", json!({"variable": v.id, "mode": mode, "value": n}), done)
+ }
+ None => {}
+ }
+ }
+ VariableValue::String(s) => {
+ let mut child = ui.new_child(UiBuilder::new().max_rect(body));
+ if let Some(n) = widgets::text_field(&mut child, id.with("str"), s, body.width(), "") {
+ set(app, v, mode, json!(n));
+ }
+ }
+ VariableValue::Boolean(b) => {
+ let mut child = ui.new_child(UiBuilder::new().max_rect(body.shrink2(vec2(6.0, 0.0))));
+ if widgets::checkbox(&mut child, *b, if *b { "True" } else { "False" }) {
+ set(app, v, mode, json!(!b));
+ }
+ }
+ }
+ if hovered && small_button(ui, id.with("mkalias"), alias_btn, Icon::Variable, "Create alias") {
+ crate::variable_ui::open_alias(ui, &v.id, mode, v.kind, body.left_bottom());
+ }
+}
+
+fn create_row(app: &mut FigApp, ui: &mut Ui, vars: &Variables, coll: &Collection, st: &mut Editor) {
+ ui.add_space(8.0);
+ ui.horizontal(|ui| {
+ ui.add_space(12.0);
+ let resp = widgets::button(ui, "+ Create variable", false, true);
+ egui::Popup::menu(&resp).show(|ui| {
+ for kind in TYPES {
+ let label = match kind {
+ VariableType::Color => "Color",
+ VariableType::Number => "Number",
+ VariableType::String => "String",
+ VariableType::Boolean => "Boolean",
+ };
+ if menu_item(ui, label, "") {
+ let name = unique(label, |n| vars.variables.iter().any(|v| v.collection == coll.id && v.name == n));
+ let params = json!({"collection": coll.id, "name": name, "type": kind.id()});
+ if let Some(id) = app.run("variable.create", params).and_then(|r| r["id"].as_str().map(str::to_string)) {
+ st.query.clear();
+ start_rename(ui, st, Rename::Variable(id));
+ }
+ ui.close();
+ }
+ }
+ });
+ });
+ ui.add_space(12.0);
+}
+
+/// The details of one variable: name, description and where pickers offer it.
+fn details(app: &mut FigApp, ctx: &Context, vars: &Variables, st: &mut Editor) {
+ let Some(v) = st.details.as_ref().and_then(|id| vars.get(id)).cloned() else {
+ st.details = None;
+ return;
+ };
+ let t = Tokens::get(ctx);
+ let mut open = true;
+ egui::Window::new("Edit variable")
+ .id(Id::new("variable_details"))
+ .open(&mut open)
+ .collapsible(false)
+ .resizable(false)
+ .default_width(280.0)
+ .frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(16)))
+ .show(ctx, |ui| {
+ ui.set_width(260.0);
+ widgets::label(ui, "Name", t.text_secondary);
+ if let Some(n) = widgets::text_field(ui, Id::new(("var_detail_name", &v.id)), &v.name, 260.0, "") {
+ app.run("variable.update", json!({"variable": v.id, "name": n}));
+ }
+ ui.add_space(6.0);
+ widgets::label(ui, "Description", t.text_secondary);
+ if let Some(d) = widgets::text_field(ui, Id::new(("var_detail_desc", &v.id)), &v.description, 260.0, "What it's for") {
+ app.run("variable.update", json!({"variable": v.id, "description": d}));
+ }
+ let scopes = scopes_for(v.kind);
+ if scopes.is_empty() {
+ return;
+ }
+ ui.add_space(10.0);
+ widgets::label(ui, "Scoping: where pickers offer it", t.text_secondary);
+ // "All" off hides it everywhere; on, from everywhere it fits.
+ if widgets::checkbox(ui, v.scopes.is_none(), "All supported properties") {
+ app.run("variable.update", json!({"variable": v.id, "scopes": if v.scopes.is_none() { json!([]) } else { Value::Null }}));
+ }
+ let on: Vec<&str> = scopes.iter().map(|(id, _)| *id).filter(|id| v.in_scope(id)).collect();
+ for (sid, label) in scopes {
+ let checked = on.contains(sid);
+ ui.horizontal(|ui| {
+ ui.add_space(16.0);
+ if widgets::checkbox(ui, checked, label) {
+ let mut list = on.clone();
+ if checked {
+ list.retain(|x| x != sid);
+ } else {
+ list.push(sid);
+ }
+ app.run("variable.update", json!({"variable": v.id, "scopes": list}));
+ }
+ });
+ }
+ });
+ if !open {
+ st.details = None;
+ }
+}
diff --git a/docs/control-protocol.md b/docs/control-protocol.md
index 79b88f5..1241993 100644
--- a/docs/control-protocol.md
+++ b/docs/control-protocol.md
@@ -88,11 +88,12 @@ text tool).
Invoke a menu item exactly like clicking it (this may open a dialog). Returns `{ok, lastError}`.
-### `ui.set {rightTab?, leftTab?, theme?, home?, palette?, mainMenu?, colorPicker?, layerFilter?, collapseLayers?}`
+### `ui.set {rightTab?, leftTab?, theme?, home?, palette?, mainMenu?, colorPicker?, variables?, layerFilter?, collapseLayers?}`
Switch panels and modes: `rightTab` `design|prototype|inspect`, `leftTab` `file|assets`, `theme`,
`home` (bool), `palette` (bool: the Actions palette), `mainMenu` (bool), `colorPicker`
-(`{fills?, index?}`: open the colour picker on the selection's paint; `null` closes it), `layerFilter` (string:
+(`{fills?, index?}`: open the colour picker on the selection's paint; `null` closes it), `variables` (bool,
+or a collection id or name to show: the Variables editor), `layerFilter` (string:
open the layers search with that filter; `null` closes it), `collapseLayers` (true: collapse the
layers tree). Returns `ui.inspect`.
diff --git a/docs/mcp.md b/docs/mcp.md
index cd590b0..32d28c0 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -95,7 +95,7 @@ Or in a project's `.mcp.json`:
| Tool | What it does |
|---|---|
| `list_commands {filter?}` | Every command: id, label, menu, shortcut, undoable, params doc. |
-| `run_command {command, params}` | Run any command (e.g. `align.left`, `layout.add`, `component.create`, `instance.place` (drop an instance at a page point into the frame or auto layout slot under it), `component.exposeInstance` / `instance.exposed` (nested instance properties on instances), `inspect.code` (CSS, SwiftUI or Jetpack Compose for a layer), `style.create`, `variable.bind`, `proto.interaction.add`). |
+| `run_command {command, params}` | Run any command (e.g. `align.left`, `layout.add`, `component.create`, `instance.place` (drop an instance at a page point into the frame or auto layout slot under it), `component.exposeInstance` / `instance.exposed` (nested instance properties on instances), `inspect.code` (CSS, SwiftUI or Jetpack Compose for a layer), `style.create`, `variable.bind`, `variable.update` (rename, describe, scope), `variable.set` with `{alias}`, `variable.renameMode` / `variable.removeMode`, `variable.deleteCollection`, `proto.interaction.add`). |
| `batch {commands: [{command, params}], label?}` | Several commands as one undo step (`command.batch`); all-or-nothing. |
| `create_node {type, x, y, width, height, parent, index, name, text, path, props}` | Create a frame, rectangle, ellipse, polygon, star, line, arrow, text, vector, section, component or slice; returns `{id, node}`. |
| `set_properties {ids, props}` | `node.set` on layers (geometry, fills, strokes, effects, corner radius, auto layout, sizing, constraints, masks (`isMask`, `maskType: alpha\|vector\|luminance`), handoff status (`devStatus: none\|readyForDev\|completed`), text…); returns the updated layers. |