mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 13:56:54 -06:00
Merge main into #1615
This commit is contained in:
@@ -103,10 +103,12 @@ impl Expr {
|
||||
Expr::Value(VariableValue::Alias(id)) => return vars.resolve(id, modes),
|
||||
Expr::Value(VariableValue::Number(n)) => return n.is_finite().then_some(VariableValue::Number(*n)),
|
||||
Expr::Value(v) => return Some(v.clone()),
|
||||
Expr::Mode { collection } => return vars.collection(collection)?.current_mode(modes).map(|m| VariableValue::String(m.name.clone())),
|
||||
Expr::Mode { collection } => {
|
||||
return vars.collection_by_id(collection)?.current_mode(modes).map(|m| VariableValue::String(m.name.clone()));
|
||||
}
|
||||
Expr::InMode { variable, mode } => {
|
||||
let v = vars.get(variable)?;
|
||||
let c = vars.collection(&v.collection)?;
|
||||
let v = vars.get_by_id(variable)?;
|
||||
let c = vars.collection_by_id(&v.collection)?;
|
||||
let mut at = modes.clone();
|
||||
at.insert(c.id.clone(), c.find_mode(mode)?.id.clone());
|
||||
return vars.resolve(&v.id, &at);
|
||||
@@ -143,20 +145,22 @@ impl Expr {
|
||||
return "…".into();
|
||||
}
|
||||
match self {
|
||||
Expr::Value(VariableValue::Alias(id)) => vars.get(id).map_or_else(|| "(missing variable)".into(), |v| var_ref(vars, v)),
|
||||
Expr::Value(VariableValue::Alias(id)) => vars.get_by_id(id).map_or_else(|| "(missing variable)".into(), |v| var_ref(vars, v)),
|
||||
Expr::Value(VariableValue::String(s)) => quote(s),
|
||||
// Exact numbers and `#` colours, so the text reads back as the same expression.
|
||||
Expr::Value(VariableValue::Number(n)) => format!("{n}"),
|
||||
Expr::Value(VariableValue::Color(c)) => c.to_css_hex(),
|
||||
Expr::Value(v) => v.display(),
|
||||
Expr::Mode { collection } => {
|
||||
let name = vars.collection(collection).map_or("(missing collection)", |c| c.name.as_str());
|
||||
let name = vars.collection_by_id(collection).map_or("(missing collection)", |c| c.name.as_str());
|
||||
format!("mode({})", name_arg(name))
|
||||
}
|
||||
Expr::InMode { variable, mode } => {
|
||||
let var = vars.get(variable);
|
||||
let mode =
|
||||
var.and_then(|v| vars.collection(&v.collection)).and_then(|c| c.find_mode(mode)).map_or("(missing mode)", |m| m.name.as_str());
|
||||
let var = vars.get_by_id(variable);
|
||||
let mode = var
|
||||
.and_then(|v| vars.collection_by_id(&v.collection))
|
||||
.and_then(|c| c.find_mode(mode))
|
||||
.map_or("(missing mode)", |m| m.name.as_str());
|
||||
format!("{}[{}]", Expr::var(variable.clone()).display_at(vars, depth + 1), name_arg(mode))
|
||||
}
|
||||
Expr::Op { op, args } => {
|
||||
|
||||
@@ -138,3 +138,37 @@ fn lookups_evaluate_in_their_mode_and_modes_follow_the_layer() {
|
||||
assert_eq!(mode_of("gone").eval(&vars, &light), None);
|
||||
assert_eq!(in_mode("n", "dark").variables(), ["n"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_keep_expression_evaluation_display_and_types() {
|
||||
let mut vars = more_vars();
|
||||
let mut shadow = vars.get_by_id("s").unwrap().clone();
|
||||
shadow.id = "shadow".into();
|
||||
shadow.name = "n".into();
|
||||
vars.variables.insert(0, shadow);
|
||||
let mut shadow = vars.collection("cs").unwrap().clone();
|
||||
shadow.id = "shadow".into();
|
||||
shadow.name = "c".into();
|
||||
vars.collections.insert(0, shadow);
|
||||
let modes = BTreeMap::new();
|
||||
for e in [Expr::var("n"), in_mode("n", "dark")] {
|
||||
assert_eq!(e.type_of(&vars), Ok(VariableType::Number));
|
||||
assert!(e.display(&vars).starts_with("n-name"));
|
||||
}
|
||||
assert_eq!(Expr::var("n").eval(&vars, &modes), Some(VariableValue::Number(3.0)));
|
||||
assert_eq!(in_mode("n", "dark").eval(&vars, &modes), Some(VariableValue::Number(7.0)));
|
||||
assert_eq!(Expr::parse("n-name[Dark]", &vars), Ok(in_mode("n", "dark")));
|
||||
assert_eq!(mode_of("c").eval(&vars, &modes), Some(VariableValue::String("Light".into())));
|
||||
assert_eq!(mode_of("c").display(&vars), "mode(Theme)");
|
||||
|
||||
vars.variables.retain(|v| v.id != "n");
|
||||
for e in [Expr::var("n"), in_mode("n", "dark")] {
|
||||
assert_eq!(e.eval(&vars, &modes), None);
|
||||
assert_eq!(e.type_of(&vars), Err("No variable `n`".into()));
|
||||
assert!(e.display(&vars).contains("missing variable"));
|
||||
}
|
||||
vars.collections.retain(|c| c.id != "c");
|
||||
assert_eq!(mode_of("c").eval(&vars, &modes), None);
|
||||
assert_eq!(mode_of("c").type_of(&vars), Err("No variable collection `c`".into()));
|
||||
assert_eq!(mode_of("c").display(&vars), "mode(\"(missing collection)\")");
|
||||
}
|
||||
|
||||
@@ -88,15 +88,15 @@ impl Expr {
|
||||
return Err("The expression is nested too deeply".into());
|
||||
}
|
||||
let (op, args) = match self {
|
||||
Expr::Value(VariableValue::Alias(id)) => return vars.get(id).map(|v| v.kind).ok_or_else(|| format!("No variable `{id}`")),
|
||||
Expr::Value(VariableValue::Alias(id)) => return vars.get_by_id(id).map(|v| v.kind).ok_or_else(|| format!("No variable `{id}`")),
|
||||
Expr::Value(VariableValue::Number(n)) if !n.is_finite() => return Err("Numbers must be finite".into()),
|
||||
Expr::Value(v) => return v.type_of().ok_or_else(|| "Not a value".to_string()),
|
||||
Expr::Mode { collection } => {
|
||||
return vars.collection(collection).map(|_| T::String).ok_or_else(|| format!("No variable collection `{collection}`"));
|
||||
return vars.collection_by_id(collection).map(|_| T::String).ok_or_else(|| format!("No variable collection `{collection}`"));
|
||||
}
|
||||
Expr::InMode { variable, mode } => {
|
||||
let v = vars.get(variable).ok_or_else(|| format!("No variable `{variable}`"))?;
|
||||
let c = vars.collection(&v.collection).ok_or_else(|| format!("No variable collection `{}`", v.collection))?;
|
||||
let v = vars.get_by_id(variable).ok_or_else(|| format!("No variable `{variable}`"))?;
|
||||
let c = vars.collection_by_id(&v.collection).ok_or_else(|| format!("No variable collection `{}`", v.collection))?;
|
||||
return c.find_mode(mode).map(|_| v.kind).ok_or_else(|| format!("{} has no mode `{mode}`", c.name));
|
||||
}
|
||||
Expr::Op { op, args } => (*op, args),
|
||||
@@ -501,7 +501,7 @@ impl Parser<'_> {
|
||||
self.i += 1;
|
||||
let start = self.i;
|
||||
let name = self.argument(']', "mode")?;
|
||||
let coll = self.vars.get(&var).and_then(|v| self.vars.collection(&v.collection));
|
||||
let coll = self.vars.get_by_id(&var).and_then(|v| self.vars.collection_by_id(&v.collection));
|
||||
match coll.and_then(|c| c.find_mode(&name)) {
|
||||
Some(m) => Ok(Expr::InMode { variable: var, mode: m.id.clone() }),
|
||||
None => {
|
||||
|
||||
@@ -278,13 +278,18 @@ impl Variables {
|
||||
self.collections.iter().find(|c| c.id == id || c.name == id)
|
||||
}
|
||||
|
||||
/// Look up a stored collection reference by exact id, without matching collection names.
|
||||
pub fn collection_by_id(&self, id: &str) -> Option<&Collection> {
|
||||
self.collections.iter().find(|c| c.id == id)
|
||||
}
|
||||
|
||||
/// Resolve `id` in `modes` (collection → chosen mode; default mode otherwise), following aliases
|
||||
/// (bounded, cycles return None).
|
||||
pub fn resolve(&self, id: &str, modes: &BTreeMap<CollectionId, ModeId>) -> Option<VariableValue> {
|
||||
let mut cur = id.to_string();
|
||||
for _ in 0..32 {
|
||||
let var = self.get_by_id(&cur)?;
|
||||
let coll = self.collection(&var.collection)?;
|
||||
let coll = self.collection_by_id(&var.collection)?;
|
||||
let mode = modes.get(&coll.id).filter(|m| var.values.contains_key(*m)).or_else(|| coll.default_mode())?;
|
||||
match var.values.get(mode).or_else(|| var.values.values().next())? {
|
||||
VariableValue::Alias(next) => cur = next.clone(),
|
||||
|
||||
@@ -708,3 +708,17 @@ fn two_segment_closed_loop_rounded_corners() {
|
||||
let path = net.fill_path();
|
||||
assert!(!path.elements().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_resolve_only_their_exact_collection() {
|
||||
let mut vars = crate::proto_expr_tests::vars();
|
||||
// The wrong collection's modes force the old resolver's first-value fallback (dark).
|
||||
let mut shadow = vars.collections[0].clone();
|
||||
shadow.id = "shadow".into();
|
||||
shadow.name = "c".into();
|
||||
shadow.modes = vec![Mode { id: "other".into(), name: "Other".into() }];
|
||||
vars.collections.insert(0, shadow);
|
||||
assert_eq!(vars.resolve("n", &Default::default()), Some(VariableValue::Number(3.0)));
|
||||
vars.collections.retain(|c| c.id != "c");
|
||||
assert_eq!(vars.resolve("n", &Default::default()), None);
|
||||
}
|
||||
|
||||
@@ -240,7 +240,10 @@ pub fn default_selection(doc: &Document, prev: Option<&LibraryManifest>) -> Sele
|
||||
.collections
|
||||
.into_iter()
|
||||
.filter(|c| {
|
||||
pick(prev.and_then(|m| m.collection(c)).map(|x| (x.published, x.name.as_str())), doc.variables.collection(c).map(|x| x.name.as_str()))
|
||||
pick(
|
||||
prev.and_then(|m| m.collection(c)).map(|x| (x.published, x.name.as_str())),
|
||||
doc.variables.collection_by_id(c).map(|x| x.name.as_str()),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
@@ -251,7 +254,7 @@ fn alias_closure(vars: &Variables, seeds: impl IntoIterator<Item = VariableId>)
|
||||
let mut out = BTreeSet::new();
|
||||
let mut stack: Vec<VariableId> = seeds.into_iter().collect();
|
||||
while let Some(cur) = stack.pop() {
|
||||
let Some(v) = vars.get(&cur) else { continue };
|
||||
let Some(v) = vars.get_by_id(&cur) else { continue };
|
||||
if out.len() >= MAX_ASSETS || !out.insert(v.id.clone()) {
|
||||
continue;
|
||||
}
|
||||
@@ -702,7 +705,7 @@ pub fn import_variables(
|
||||
) -> VariableMap {
|
||||
let (lid, m, src) = (lib.manifest.id.as_str(), &lib.manifest, &lib.doc.variables);
|
||||
let vars = alias_closure(src, keys.iter().cloned());
|
||||
let colls: BTreeSet<&CollectionId> = collections.iter().chain(vars.iter().filter_map(|v| src.get(v)).map(|v| &v.collection)).collect();
|
||||
let colls: BTreeSet<&CollectionId> = collections.iter().chain(vars.iter().filter_map(|v| src.get_by_id(v)).map(|v| &v.collection)).collect();
|
||||
let mut out = VariableMap::default();
|
||||
// Collections first: the variables name theirs.
|
||||
for key in colls {
|
||||
@@ -758,7 +761,7 @@ pub fn import_variables(
|
||||
let val = match val {
|
||||
// An alias to a variable the library doesn't have resolves to nothing: dropped
|
||||
// (never pointed at a variable of this file).
|
||||
VariableValue::Alias(a) => match src.get(a).and_then(|t| out.variables.get(&t.id)) {
|
||||
VariableValue::Alias(a) => match src.get_by_id(a).and_then(|t| out.variables.get(&t.id)) {
|
||||
Some(l) => VariableValue::Alias(l.clone()),
|
||||
None => continue,
|
||||
},
|
||||
@@ -955,7 +958,7 @@ pub fn pending(doc: &Document, lib: &PublishedLibrary) -> Pending {
|
||||
.filter(|r| r.library == m.id)
|
||||
.map(|r| r.key.as_str())
|
||||
.collect();
|
||||
let copied = |key: &str| libs.variables.iter().any(|r| r.library == m.id && r.key == key && vars.get(&r.local).is_some());
|
||||
let copied = |key: &str| libs.variables.iter().any(|r| r.library == m.id && r.key == key && vars.get_by_id(&r.local).is_some());
|
||||
variables.extend(published_variables(lib).filter(|v| extended.contains(v.collection.as_str()) && !copied(&v.id)).map(|v| ImportedRecord {
|
||||
library: m.id.clone(),
|
||||
key: v.id.clone(),
|
||||
|
||||
@@ -149,7 +149,7 @@ pub fn swap_library(doc: &mut Document, from: &str, to: &PublishedLibrary) -> Re
|
||||
out.styles = styles.len();
|
||||
|
||||
// Variables: by collection and variable name and type; collections and modes by name.
|
||||
let coll_name = |d: &Document, id: &str| d.variables.collection(id).map(|c| c.name.clone()).unwrap_or_default();
|
||||
let coll_name = |d: &Document, id: &str| d.variables.collection_by_id(id).map(|c| c.name.clone()).unwrap_or_default();
|
||||
// Variables the document's own layers bind or its own variables alias.
|
||||
let remote: BTreeSet<String> = doc.libraries.variables.iter().map(|r| r.local.clone()).collect();
|
||||
let aliases = doc.variables.variables.iter().filter(|v| !remote.contains(&v.id)).flat_map(|v| v.values.values()).filter_map(|x| match x {
|
||||
@@ -161,7 +161,7 @@ pub fn swap_library(doc: &mut Document, from: &str, to: &PublishedLibrary) -> Re
|
||||
let lib_coll = |name: &str| to.doc.variables.collections.iter().find(|c| c.name == name && m.collection(&c.id).is_some_and(|x| x.published));
|
||||
let mut var_keys: BTreeMap<String, String> = BTreeMap::new();
|
||||
for r in &var_recs {
|
||||
let Some(v) = doc.variables.get(&r.local) else { continue };
|
||||
let Some(v) = doc.variables.get_by_id(&r.local) else { continue };
|
||||
let cname = coll_name(doc, &v.collection);
|
||||
let found = lib_coll(&cname).and_then(|c| {
|
||||
to.doc
|
||||
@@ -189,10 +189,10 @@ pub fn swap_library(doc: &mut Document, from: &str, to: &PublishedLibrary) -> Re
|
||||
// A's collection → (B's, A's mode id → B's mode of the same name).
|
||||
let mut colls: BTreeMap<String, (String, BTreeMap<String, String>)> = BTreeMap::new();
|
||||
for (a, k) in &coll_keys {
|
||||
let (Some(b), Some(ac)) = (vmap.collections.get(k), doc.variables.collection(a)) else { continue };
|
||||
let (Some(b), Some(ac)) = (vmap.collections.get(k), doc.variables.collection_by_id(a)) else { continue };
|
||||
let modes = doc
|
||||
.variables
|
||||
.collection(b)
|
||||
.collection_by_id(b)
|
||||
.map(|bc| {
|
||||
ac.modes.iter().filter_map(|am| bc.modes.iter().find(|bm| bm.name == am.name).map(|bm| (am.id.clone(), bm.id.clone()))).collect()
|
||||
})
|
||||
|
||||
@@ -114,3 +114,35 @@ fn swap_moves_instances_styles_and_variables_by_name() {
|
||||
assert!(s.execute("library.swap", &json!({"from": b, "to": b})).is_err());
|
||||
assert!(s.execute("library.swap", &json!({"from": a, "to": b})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_keep_library_swap_matches_on_the_real_copies() {
|
||||
let dir = Scratch(std::env::temp_dir().join(format!("figcraft-libswap-ids-{}", std::process::id())));
|
||||
std::fs::create_dir_all(&dir.0).unwrap();
|
||||
let (pa, pb) = (dir.0.join("a.figcraft").to_string_lossy().into_owned(), dir.0.join("b.figcraft").to_string_lossy().into_owned());
|
||||
let a = library(&pa, "#0000ff", &[]);
|
||||
let b = library(&pb, "#00ff00", &[]);
|
||||
let mut s = Session::new();
|
||||
let shadow_collection = ex(&mut s, "variable.createCollection", json!({"name": "Shadow"}));
|
||||
let shadow = ex(&mut s, "variable.create", json!({"collection": "Shadow", "name": "shadow", "type": "color", "value": "#ff0000"}));
|
||||
ex(&mut s, "library.add", json!({"path": pa}));
|
||||
ex(&mut s, "library.add", json!({"path": pb}));
|
||||
let dot = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})));
|
||||
ex(&mut s, "library.bindVariable", json!({"library": a, "variable": "accent", "field": "fill", "ids": [dot]}));
|
||||
ex(&mut s, "library.setVariableMode", json!({"library": a, "collection": "Theme", "mode": "Dark", "ids": [dot]}));
|
||||
let doc = s.doc().unwrap();
|
||||
let target = doc.libraries.variables[0].local.clone();
|
||||
let collection = doc.libraries.collections[0].local.clone();
|
||||
ex(&mut s, "variable.update", json!({"variable": shadow["id"], "name": target}));
|
||||
ex(&mut s, "variable.renameCollection", json!({"collection": shadow_collection["id"], "name": collection}));
|
||||
let result = ex(&mut s, "library.swap", json!({"from": a, "to": b}));
|
||||
assert_eq!(result["variables"], 1);
|
||||
assert_eq!(result["unmatched"], json!([]));
|
||||
let doc = s.doc().unwrap();
|
||||
let node = doc.node(dot).unwrap();
|
||||
assert_eq!(node.fills[0].solid_color().unwrap().to_hex(), "00FF00");
|
||||
assert!(!node.bound.is_empty());
|
||||
assert!(!node.var_modes.is_empty());
|
||||
assert!(node.bound.values().all(|v| doc.libraries.variable_import(v).is_some_and(|r| r.library == b)));
|
||||
assert!(node.var_modes.keys().all(|c| doc.libraries.collection_import(c).is_some_and(|r| r.library == b)));
|
||||
}
|
||||
|
||||
@@ -245,7 +245,7 @@ fn alias(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let kind = doc.variables.get(&own).filter(|v| !is_remote_variable(doc, &v.id)).map(|v| v.kind);
|
||||
let kind = kind.ok_or_else(|| bad(cmd, format!("no local variable `{own}`")))?;
|
||||
let (lib, key) = variable_arg(s, cmd, p, "alias")?;
|
||||
if lib.doc.variables.get(&key).is_some_and(|v| v.kind != kind) {
|
||||
if lib.doc.variables.get_by_id(&key).is_some_and(|v| v.kind != kind) {
|
||||
return Err(bad(cmd, "the library variable has another type"));
|
||||
}
|
||||
let local = import_variable(s, &lib, &key)?;
|
||||
@@ -474,7 +474,8 @@ fn swap_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn assets_json(lib: &PublishedLibrary) -> Value {
|
||||
let m = &lib.manifest;
|
||||
let modes = |c: &str| lib.doc.variables.collection(c).map(|c| c.modes.iter().map(|x| json!({"id": x.id, "name": x.name})).collect::<Vec<_>>());
|
||||
let modes =
|
||||
|c: &str| lib.doc.variables.collection_by_id(c).map(|c| c.modes.iter().map(|x| json!({"id": x.id, "name": x.name})).collect::<Vec<_>>());
|
||||
json!({
|
||||
"components": m.components.iter().filter(|a| a.published).map(|a| json!({"id": a.id, "name": a.name, "set": a.set, "version": a.version})).collect::<Vec<_>>(),
|
||||
"styles": m.styles.iter().filter(|x| x.published).map(|x| json!({"id": x.id, "name": x.name, "type": x.kind, "version": x.version})).collect::<Vec<_>>(),
|
||||
@@ -520,7 +521,7 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
res["publishable"] = json!({
|
||||
"components": all.components.iter().filter_map(|c| doc.node(*c).map(|n| json!({"id": c, "name": n.name, "selected": def.components.contains(c)}))).collect::<Vec<_>>(),
|
||||
"styles": all.styles.iter().filter_map(|x| doc.style(x).map(|st| json!({"id": st.id, "name": st.name, "selected": def.styles.contains(x)}))).collect::<Vec<_>>(),
|
||||
"collections": all.collections.iter().filter_map(|x| doc.variables.collection(x).map(|c| json!({"id": c.id, "name": c.name, "selected": def.collections.contains(x)}))).collect::<Vec<_>>(),
|
||||
"collections": all.collections.iter().filter_map(|x| doc.variables.collection_by_id(x).map(|c| json!({"id": c.id, "name": c.name, "selected": def.collections.contains(x)}))).collect::<Vec<_>>(),
|
||||
});
|
||||
}
|
||||
Ok(res)
|
||||
|
||||
@@ -308,40 +308,6 @@ pub(crate) fn check_action_dests(cmd: &str, doc: &Document, actions: &[Action])
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Variables, collections and modes that actions name by name, stored by id.
|
||||
fn canonical_ids(doc: &Document, it: &mut Interaction) {
|
||||
canonical_actions(&doc.variables, &mut it.actions);
|
||||
}
|
||||
|
||||
/// Variables (also those expressions read), collections and modes that `actions` name by name,
|
||||
/// stored by id.
|
||||
pub(crate) fn canonical_actions(vars: &Variables, actions: &mut [Action]) {
|
||||
walk_mut(actions, &mut |a| match a {
|
||||
Action::SetVariable { variable, expr, .. } => {
|
||||
if let Some(v) = vars.get(variable) {
|
||||
*variable = v.id.clone();
|
||||
}
|
||||
if let Some(e) = expr {
|
||||
canonical_expr(vars, e, 0);
|
||||
}
|
||||
}
|
||||
Action::SetVariableMode { collection, mode } => {
|
||||
if let Some(c) = vars.collection(collection) {
|
||||
if let Some(m) = c.find_mode(mode) {
|
||||
*mode = m.id.clone();
|
||||
}
|
||||
*collection = c.id.clone();
|
||||
}
|
||||
}
|
||||
Action::Conditional { blocks } => {
|
||||
for e in blocks.iter_mut().filter_map(|b| b.condition.as_mut()) {
|
||||
canonical_expr(vars, e, 0);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
}
|
||||
|
||||
/// Variables, collections and modes an expression names by name, stored by id.
|
||||
pub(crate) fn canonical_expr(vars: &Variables, e: &mut Expr, depth: usize) {
|
||||
match e {
|
||||
@@ -357,7 +323,7 @@ pub(crate) fn canonical_expr(vars: &Variables, e: &mut Expr, depth: usize) {
|
||||
}
|
||||
Expr::InMode { variable, mode } => {
|
||||
if let Some(v) = vars.get(variable) {
|
||||
if let Some(m) = vars.collection(&v.collection).and_then(|c| c.find_mode(mode)) {
|
||||
if let Some(m) = vars.collection_by_id(&v.collection).and_then(|c| c.find_mode(mode)) {
|
||||
*mode = m.id.clone();
|
||||
}
|
||||
*variable = v.id.clone();
|
||||
@@ -425,7 +391,6 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut it = it;
|
||||
it.delay_ms = delay(cmd, p)?.unwrap_or(0);
|
||||
settle(&mut it);
|
||||
canonical_ids(s.doc()?, &mut it);
|
||||
check_dests(cmd, s, &it)?;
|
||||
let mut index = 0;
|
||||
for i in req_targets(cmd, s, p)? {
|
||||
@@ -484,7 +449,6 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if it.actions.is_empty() {
|
||||
return Err(bad(cmd, "an interaction needs an action"));
|
||||
}
|
||||
canonical_ids(s.doc()?, &mut it);
|
||||
check_dests(cmd, s, &it)?;
|
||||
if let Some(slot) = s.doc_mut()?.get_mut(i)?.interactions.get_mut(k) {
|
||||
*slot = it;
|
||||
|
||||
@@ -159,15 +159,73 @@ pub fn default_condition(vars: &Variables) -> Expr {
|
||||
}
|
||||
}
|
||||
|
||||
/// `p` with every `expression` text in it read: on a set variable action it becomes `expr`
|
||||
/// (checked against the variable), elsewhere (conditional blocks) the `condition` (checked to give
|
||||
/// true or false).
|
||||
/// `p` with incoming names stored as ids and every `expression` text read: on a set variable
|
||||
/// action it becomes `expr` (checked against the variable), elsewhere (conditional blocks) the
|
||||
/// `condition` (checked to give true or false). Parsed text already holds ids, so it is never
|
||||
/// canonicalized again. Existing actions that a command leaves alone never pass through here.
|
||||
pub fn with_expressions(cmd: &str, vars: &Variables, p: &Value) -> Result<Value> {
|
||||
let mut v = p.clone();
|
||||
canonical_inputs(cmd, vars, &mut v, 0)?;
|
||||
resolve_expressions(cmd, vars, &mut v, 0)?;
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// Visit input actions and conditional blocks, never literal values or parsed expressions.
|
||||
fn canonical_inputs(cmd: &str, vars: &Variables, v: &mut Value, depth: usize) -> Result<()> {
|
||||
if depth > 4 * MAX_ACTION_DEPTH {
|
||||
return Err(bad(cmd, "actions are nested too deeply"));
|
||||
}
|
||||
match v {
|
||||
Value::Array(a) => a.iter_mut().try_for_each(|x| canonical_inputs(cmd, vars, x, depth + 1)),
|
||||
Value::Object(o) => {
|
||||
canonical_action_names(vars, o);
|
||||
canonical_json_exprs(cmd, vars, o)?;
|
||||
for key in ["action", "actions", "interaction", "blocks"] {
|
||||
if let Some(child) = o.get_mut(key) {
|
||||
canonical_inputs(cmd, vars, child, depth + 1)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Only command input accepts names: settle action targets before reading text expressions.
|
||||
fn canonical_action_names(vars: &Variables, o: &mut serde_json::Map<String, Value>) {
|
||||
match o.get("type").and_then(Value::as_str) {
|
||||
Some("setVariable") => {
|
||||
if let Some(v) = o.get("variable").and_then(Value::as_str).and_then(|n| vars.get(n)) {
|
||||
o.insert("variable".into(), json!(v.id));
|
||||
}
|
||||
}
|
||||
Some("setVariableMode") => {
|
||||
if let Some(c) = o.get("collection").and_then(Value::as_str).and_then(|n| vars.collection(n)) {
|
||||
if let Some(m) = o.get("mode").and_then(Value::as_str).and_then(|n| c.find_mode(n)) {
|
||||
o.insert("mode".into(), json!(m.id));
|
||||
}
|
||||
o.insert("collection".into(), json!(c.id));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON expressions accept names too; text replaces them and is parsed separately.
|
||||
fn canonical_json_exprs(cmd: &str, vars: &Variables, o: &mut serde_json::Map<String, Value>) -> Result<()> {
|
||||
if o.get("expression").is_some_and(Value::is_string) {
|
||||
return Ok(());
|
||||
}
|
||||
for key in ["expr", "condition"] {
|
||||
if let Some(value) = o.get_mut(key).filter(|v| !v.is_null()) {
|
||||
let mut e: Expr = serde_json::from_value(value.clone()).map_err(|e| bad(cmd, format!("{key}: {e}")))?;
|
||||
super::proto::canonical_expr(vars, &mut e, 0);
|
||||
*value = expr_json(cmd, &e)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_expressions(cmd: &str, vars: &Variables, v: &mut Value, depth: usize) -> Result<()> {
|
||||
if depth > 4 * MAX_ACTION_DEPTH {
|
||||
return Err(bad(cmd, "actions are nested too deeply"));
|
||||
@@ -181,7 +239,7 @@ fn resolve_expressions(cmd: &str, vars: &Variables, v: &mut Value, depth: usize)
|
||||
let var = o
|
||||
.get("variable")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|n| vars.get(n))
|
||||
.and_then(|n| vars.get_by_id(n))
|
||||
.ok_or_else(|| bad(cmd, "setVariable: no such `variable`"))?;
|
||||
e.check_value(vars, var.kind).map_err(|m| bad(cmd, m))?;
|
||||
o.insert("expr".into(), expr_json(cmd, &e)?);
|
||||
@@ -208,7 +266,7 @@ fn check_action(cmd: &str, vars: &Variables, a: &Action) -> Result<()> {
|
||||
for x in walk(std::slice::from_ref(a)) {
|
||||
match x {
|
||||
Action::SetVariable { variable, expr, .. } => {
|
||||
let v = vars.get(variable).ok_or_else(|| bad(cmd, format!("no variable `{variable}`")))?;
|
||||
let v = vars.get_by_id(variable).ok_or_else(|| bad(cmd, format!("no variable `{variable}`")))?;
|
||||
if let Some(e) = expr {
|
||||
e.check_value(vars, v.kind).map_err(|m| bad(cmd, m))?;
|
||||
}
|
||||
@@ -231,8 +289,7 @@ fn check_action(cmd: &str, vars: &Variables, a: &Action) -> Result<()> {
|
||||
fn action_param(cmd: &str, s: &Session, p: &Value) -> Result<Action> {
|
||||
let doc = s.doc()?;
|
||||
let p = with_expressions(cmd, &doc.variables, p)?;
|
||||
let mut a: Action = parse(cmd, &p, "action")?.ok_or_else(|| bad(cmd, "`action` required"))?;
|
||||
super::proto::canonical_actions(&doc.variables, std::slice::from_mut(&mut a));
|
||||
let a: Action = parse(cmd, &p, "action")?.ok_or_else(|| bad(cmd, "`action` required"))?;
|
||||
check_action(cmd, &doc.variables, &a)?;
|
||||
super::proto::check_action_dests(cmd, doc, std::slice::from_ref(&a))?;
|
||||
Ok(a)
|
||||
|
||||
@@ -958,7 +958,7 @@ pub fn var_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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 coll = doc.variables.collection(&var.collection).cloned().ok_or_else(|| bad(cmd, "variable has no collection"))?;
|
||||
let coll = doc.variables.collection_by_id(&var.collection).cloned().ok_or_else(|| bad(cmd, "variable has no collection"))?;
|
||||
let mode = match string(p, "mode") {
|
||||
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"))?,
|
||||
|
||||
@@ -89,7 +89,7 @@ fn inherited(cmd: &str, what: &str) -> crate::EngineError {
|
||||
|
||||
/// 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) {
|
||||
match doc.variables.collection_by_id(collection) {
|
||||
Some(c) if c.extends.is_some() => Err(inherited(cmd, "an extended collection's modes")),
|
||||
_ => Ok(()),
|
||||
}
|
||||
@@ -304,7 +304,7 @@ pub(crate) fn forget_variables(doc: &mut Document, gone: &BTreeSet<VariableId>)
|
||||
for v in vars.variables.iter().filter(|v| !gone.contains(&v.id)) {
|
||||
for (m, val) in &v.values {
|
||||
let VariableValue::Alias(a) = val else { continue };
|
||||
let Some(t) = vars.get(a).filter(|t| gone.contains(&t.id)) else { continue };
|
||||
let Some(t) = vars.get_by_id(a).filter(|t| gone.contains(&t.id)) else { continue };
|
||||
let modes = std::collections::BTreeMap::from([(t.collection.clone(), m.clone())]);
|
||||
if let Some(r) = vars.resolve(&t.id, &modes) {
|
||||
detached.push((v.id.clone(), m.clone(), r));
|
||||
@@ -333,11 +333,11 @@ fn reset_override(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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 = var.extends.as_deref().and_then(|x| doc.variables.get_by_id(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"))?;
|
||||
let coll = doc.variables.collection_by_id(&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(),
|
||||
@@ -433,6 +433,12 @@ pub fn sync_extensions(doc: &mut Document) {
|
||||
/// 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"))?;
|
||||
alias_target_by_id(vars, var, &t.id)
|
||||
}
|
||||
|
||||
/// Check an alias whose target is already a stored or remapped id, without matching names.
|
||||
pub(crate) fn alias_target_by_id(vars: &Variables, var: &Variable, target: &str) -> std::result::Result<VariableId, String> {
|
||||
let t = vars.get_by_id(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()));
|
||||
}
|
||||
@@ -445,9 +451,9 @@ pub fn alias_target(vars: &Variables, var: &Variable, target: &str) -> std::resu
|
||||
if !seen.insert(cur.clone()) || seen.len() > 10_000 {
|
||||
continue;
|
||||
}
|
||||
for v in vars.get(&cur).map(|x| x.values.values()).into_iter().flatten() {
|
||||
for v in vars.get_by_id(&cur).map(|x| x.values.values()).into_iter().flatten() {
|
||||
if let VariableValue::Alias(a) = v
|
||||
&& let Some(next) = vars.get(a)
|
||||
&& let Some(next) = vars.get_by_id(a)
|
||||
{
|
||||
stack.push(next.id.clone());
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ fn css(doc: &Document, colls: &[&Collection], tailwind: bool) -> String {
|
||||
Some(VariableValue::Number(x)) => format!("{}px", trim_num(*x)),
|
||||
Some(VariableValue::String(t)) => format!("\"{}\"", css_safe(t)),
|
||||
Some(VariableValue::Boolean(b)) => b.to_string(),
|
||||
Some(VariableValue::Alias(a)) => match vars.get(a) {
|
||||
Some(VariableValue::Alias(a)) => match vars.get_by_id(a) {
|
||||
Some(t) => format!("var({})", name(t)),
|
||||
None => continue,
|
||||
},
|
||||
@@ -330,7 +330,7 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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)) {
|
||||
match target.and_then(|t| super::variables::alias_target_by_id(&doc.variables, var, t)) {
|
||||
Ok(t) => VariableValue::Alias(t),
|
||||
Err(e) => {
|
||||
warnings.push(format!("`{}`: {e}", v.name));
|
||||
|
||||
@@ -372,7 +372,7 @@ pub fn bound_variable<'d>(doc: &'d Document, n: &Node, field: &str) -> Option<&'
|
||||
_ => &[],
|
||||
};
|
||||
let found = if forms.is_empty() { n.bound.get(field) } else { forms.iter().find_map(|k| n.bound.get(*k)) };
|
||||
found.and_then(|v| doc.variables.get(v))
|
||||
found.and_then(|v| doc.variables.get_by_id(v))
|
||||
}
|
||||
|
||||
/// The variable bound to `field` on `n`, by name.
|
||||
|
||||
@@ -58,6 +58,25 @@ fn properties_group_layout_auto_layout_paints_and_tokens() {
|
||||
assert!(s.execute("inspect.properties", &json!({"id": 999_999})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_keep_their_dev_inspect_token() {
|
||||
let mut s = Session::new();
|
||||
s.execute("variable.createCollection", &json!({"name": "Theme"})).unwrap();
|
||||
let shadow = s.execute("variable.create", &json!({"collection": "Theme", "name": "shadow", "type": "color", "value": "#FF0000"})).unwrap();
|
||||
let surface = s.execute("variable.create", &json!({"collection": "Theme", "name": "surface", "type": "color", "value": "#0D99FF"})).unwrap();
|
||||
let r = id(s.execute("node.create", &json!({"type": "rectangle", "props": {"fill": {"$var": surface["id"]}}})).unwrap());
|
||||
s.execute("variable.update", &json!({"variable": "surface", "codeSyntax": {"WEB": "--real-surface"}})).unwrap();
|
||||
s.execute("variable.update", &json!({"variable": shadow["id"], "name": surface["id"], "codeSyntax": {"WEB": "--shadow"}})).unwrap();
|
||||
|
||||
let code = s.execute("inspect.code", &json!({"id": r, "language": "css"})).unwrap();
|
||||
assert!(code["code"].as_str().unwrap().contains("var(--real-surface"));
|
||||
assert!(!code["code"].as_str().unwrap().contains("--shadow"));
|
||||
let out = s.execute("inspect.properties", &json!({"id": r})).unwrap();
|
||||
let fills = out["sections"].as_array().unwrap().iter().find(|s| s["title"] == "Fills").unwrap();
|
||||
assert_eq!(fills["rows"][0]["color"], "#0D99FF");
|
||||
assert_eq!(fills["rows"][0]["token"], "surface");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typography_section_for_text() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -206,3 +206,26 @@ fn nested_instances_of_variants_link_to_their_mains_layers() {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert_eq!(outline(s.doc().unwrap()), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_keep_exported_binding_identity() {
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "variable.createCollection", json!({"name": "Theme"}));
|
||||
let shadow = run(&mut s, "variable.create", json!({"collection": "Theme", "name": "shadow", "type": "color", "value": "#FF0000"}));
|
||||
let surface = run(&mut s, "variable.create", json!({"collection": "Theme", "name": "surface", "type": "color", "value": "#0D99FF"}));
|
||||
let r = id(&run(&mut s, "node.create", json!({"type": "rectangle", "props": {"name": "Bound", "fill": {"$var": surface["id"]}}})));
|
||||
run(&mut s, "select.set", json!({"ids": [r]}));
|
||||
run(&mut s, "style.create", json!({"type": "paint", "name": "Surface"}));
|
||||
run(&mut s, "node.set", json!({"id": r, "props": {"name": "Styled"}}));
|
||||
run(&mut s, "node.create", json!({"type": "rectangle", "props": {"name": "Bound", "fill": {"$var": surface["id"]}}}));
|
||||
run(&mut s, "variable.update", json!({"variable": shadow["id"], "name": surface["id"]}));
|
||||
let (bytes, _) = figcraft_format::fig::export(s.doc().unwrap(), None).unwrap();
|
||||
let (back, _) = figcraft_format::fig::import(&bytes, "identity").unwrap();
|
||||
let bound = back.nodes().find(|n| n.name == "Bound").unwrap().bound.values().next().unwrap();
|
||||
assert_eq!(back.variables.get_by_id(bound).unwrap().name, "surface");
|
||||
let style = back.styles.iter().find(|s| s.name == "Surface").unwrap();
|
||||
assert!(!style.bound.is_empty());
|
||||
for bound in style.bound.values() {
|
||||
assert_eq!(back.variables.get_by_id(bound).unwrap().name, "surface");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -428,3 +428,36 @@ fn a_library_published_before_modes_were_overrides_keeps_the_modes_set_inside_ne
|
||||
healthy(&s);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_keep_library_alias_dependencies() {
|
||||
let mut s = Session::new();
|
||||
for name in ["Shadow", "Base", "Theme"] {
|
||||
ex(&mut s, "variable.createCollection", json!({"name": name}));
|
||||
}
|
||||
let shadow = ex(&mut s, "variable.create", json!({"collection": "Shadow", "name": "shadow", "type": "color", "value": "#FF0000"}));
|
||||
let surface = ex(&mut s, "variable.create", json!({"collection": "Base", "name": "surface", "type": "color", "value": "#0D99FF"}));
|
||||
let alias = ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "alias", "type": "color", "value": {"alias": surface["id"]}}));
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"name": "Chip", "x": 0, "y": 0, "width": 100, "height": 40})));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [f], "field": "fill", "variable": alias["id"]}));
|
||||
let chip = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
|
||||
ex(&mut s, "variable.update", json!({"variable": shadow["id"], "name": surface["id"]}));
|
||||
let doc = s.doc().unwrap();
|
||||
let selection = Selection { components: vec![chip], ..Default::default() };
|
||||
let lib = publish(doc, None, &selection, None, 0).unwrap();
|
||||
let target = surface["id"].as_str().unwrap();
|
||||
assert!(lib.doc.variables.get_by_id(target).is_some());
|
||||
assert!(lib.doc.variables.get_by_id(shadow["id"].as_str().unwrap()).is_none());
|
||||
assert!(lib.doc.variables.collections.iter().any(|c| c.name == "Base"));
|
||||
|
||||
// Import from a snapshot that retains both the dependency and its namesake.
|
||||
let lib = publish(doc, None, &publishable(doc), None, 1).unwrap();
|
||||
let mut dest = Document::new("consumer");
|
||||
let key = alias["id"].as_str().unwrap().to_string();
|
||||
let map = import_variables(&mut dest, &lib, &std::collections::BTreeSet::from([key.clone()]), &Default::default(), false);
|
||||
assert!(map.variables.contains_key(target));
|
||||
let copy = dest.variables.get_by_id(&map.variables[&key]).unwrap();
|
||||
assert!(!copy.values.is_empty());
|
||||
assert!(copy.values.values().all(|v| *v == VariableValue::Alias(map.variables[target].clone())));
|
||||
assert!(dest.variables.collections.iter().any(|c| c.name == "Base"));
|
||||
}
|
||||
|
||||
@@ -42,10 +42,11 @@ fn fit(kind: VariableType, v: VariableValue) -> Option<VariableValue> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set `variable` to `value`, or to `expr` evaluated in the modes of `node` (the layer whose
|
||||
/// interaction runs), in every mode, and re-apply bindings. False when nothing could be set.
|
||||
/// Set stored variable id `variable` to `value`, or to `expr` evaluated in the modes of `node`
|
||||
/// (the layer whose interaction runs), in every mode, and re-apply bindings. False when nothing
|
||||
/// could be set.
|
||||
pub fn set_variable(doc: &mut Document, node: NodeId, variable: &str, value: &Value, expr: Option<&Expr>) -> bool {
|
||||
let Some(var) = doc.variables.get(variable) else { return false };
|
||||
let Some(var) = doc.variables.get_by_id(variable) else { return false };
|
||||
let (id, kind) = (var.id.clone(), var.kind);
|
||||
let v = match expr {
|
||||
Some(e) => e.eval(&doc.variables, &doc.modes_for(node)).and_then(|v| fit(kind, v)),
|
||||
@@ -65,7 +66,14 @@ pub fn set_variable(doc: &mut Document, node: NodeId, variable: &str, value: &Va
|
||||
/// on the page's top-level frames that chose another mode of it (modes chosen deeper inside still
|
||||
/// win), then re-apply bindings. False for an unknown collection or mode.
|
||||
pub fn set_mode(doc: &mut Document, frame: NodeId, collection: &str, mode: &str) -> bool {
|
||||
let Some(c) = doc.variables.collection(collection) else { return false };
|
||||
let Some(id) = doc.variables.collection(collection).map(|c| c.id.clone()) else { return false };
|
||||
set_mode_by_id(doc, frame, &id, mode)
|
||||
}
|
||||
|
||||
/// Switch a stored collection id to `mode`, without matching another collection's name.
|
||||
/// Otherwise behaves like [`set_mode`].
|
||||
pub fn set_mode_by_id(doc: &mut Document, frame: NodeId, collection: &str, mode: &str) -> bool {
|
||||
let Some(c) = doc.variables.collection_by_id(collection) else { return false };
|
||||
let Some(mode) = c.find_mode(mode).map(|m| m.id.clone()) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -207,3 +207,140 @@ fn junk_conditional_actions_never_panic() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_set_only_the_prototype_action_target() {
|
||||
let (mut s, frame, _, _, _) = setup();
|
||||
let shadow = var_id(s.doc().unwrap(), "count");
|
||||
let target = s.execute("variable.create", &json!({"collection": "Theme", "name": "target", "type": "number", "value": 5})).unwrap();
|
||||
let target = target["id"].as_str().unwrap().to_string();
|
||||
s.execute("variable.update", &json!({"variable": shadow, "name": target})).unwrap();
|
||||
let mut doc = s.doc().unwrap().clone();
|
||||
assert!(set_variable(&mut doc, frame, &target, &json!(9), None));
|
||||
assert_eq!(doc.variables.resolve(&target, &Default::default()), Some(VariableValue::Number(9.0)));
|
||||
assert_eq!(doc.variables.resolve(&shadow, &Default::default()), Some(VariableValue::Number(0.0)));
|
||||
doc.variables.variables.retain(|v| v.id != target);
|
||||
let before = doc.clone();
|
||||
assert!(!set_variable(&mut doc, frame, &target, &json!(11), None));
|
||||
assert_eq!(doc, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_survive_unrelated_interaction_edits() {
|
||||
let (mut s, frame, _, _, _) = setup();
|
||||
let target = s.execute("variable.create", &json!({"collection": "Theme", "name": "target", "type": "number", "value": 5})).unwrap()["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
s.execute(
|
||||
"proto.interaction.add",
|
||||
&json!({"id": frame, "actions": [
|
||||
{"type": "back"},
|
||||
{"type": "setVariable", "variable": "target", "expression": "target + 1"},
|
||||
{"type": "setVariableMode", "collection": "Theme", "mode": "Dark"}
|
||||
]}),
|
||||
)
|
||||
.unwrap();
|
||||
let theme = s.doc().unwrap().variables.collections[0].id.clone();
|
||||
s.execute("variable.createCollection", &json!({"name": "shadow", "modes": ["Light", "Dark"]})).unwrap();
|
||||
s.execute("variable.renameCollection", &json!({"collection": "shadow", "name": theme})).unwrap();
|
||||
// Put the namesake before the referenced collection, as variable lookup already does.
|
||||
s.doc_mut().unwrap().variables.collections.swap(0, 1);
|
||||
s.execute("variable.update", &json!({"variable": "count", "name": target})).unwrap();
|
||||
let actions = s.doc().unwrap().node(frame).unwrap().interactions[0].actions.clone();
|
||||
for patch in [json!({"durationMs": 450}), json!({"action": {"type": "closeOverlay"}})] {
|
||||
let mut p = json!({"id": frame, "index": 0});
|
||||
p.as_object_mut().unwrap().extend(patch.as_object().unwrap().clone());
|
||||
s.execute("proto.interaction.set", &p).unwrap();
|
||||
let it = &s.doc().unwrap().node(frame).unwrap().interactions[0];
|
||||
assert_eq!(it.duration_ms, 450);
|
||||
assert_eq!(it.actions[1..], actions[1..], "only the supplied fields change");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_from_text_are_not_resolved_twice() {
|
||||
let (mut s, frame, _, _, _) = setup();
|
||||
let target = s.execute("variable.create", &json!({"collection": "Theme", "name": "target", "type": "number", "value": 5})).unwrap()["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
s.execute("variable.set", &json!({"variable": "count", "value": 100})).unwrap();
|
||||
s.execute("variable.update", &json!({"variable": "count", "name": target})).unwrap();
|
||||
let action = json!({"type": "setVariable", "variable": "target", "expression": "target + 1"});
|
||||
s.execute("proto.interaction.add", &json!({"id": frame, "action": action})).unwrap();
|
||||
for (cmd, patch) in [
|
||||
("proto.interaction.add", json!({"action": action})),
|
||||
("proto.interaction.set", json!({"action": action})),
|
||||
("proto.interaction.set", json!({"interaction": {"actions": [action]}})),
|
||||
("proto.action.add", json!({"path": [0], "action": action})),
|
||||
("proto.action.set", json!({"path": [0], "action": action})),
|
||||
] {
|
||||
let mut p = json!({"id": frame});
|
||||
if cmd != "proto.interaction.add" {
|
||||
p["index"] = json!(0);
|
||||
}
|
||||
p.as_object_mut().unwrap().extend(patch.as_object().unwrap().clone());
|
||||
let r = s.execute(cmd, &p).unwrap();
|
||||
let index = if cmd == "proto.interaction.add" { r["index"].as_u64().unwrap() as usize } else { 0 };
|
||||
let Action::SetVariable { variable, expr: Some(expr), .. } = &s.doc().unwrap().node(frame).unwrap().interactions[index].actions[0] else {
|
||||
panic!("{cmd}: no set variable expression")
|
||||
};
|
||||
assert_eq!(variable, &target, "{cmd}");
|
||||
assert_eq!(expr.eval(&s.doc().unwrap().variables, &Default::default()), Some(VariableValue::Number(6.0)), "{cmd}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_resolve_only_incoming_action_and_expression_names() {
|
||||
let (mut s, frame, _, _, _) = setup();
|
||||
let target = s.execute("variable.create", &json!({"collection": "Theme", "name": "target", "type": "number", "value": 5})).unwrap()["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
s.execute("variable.set", &json!({"variable": "count", "value": 100})).unwrap();
|
||||
s.execute("variable.update", &json!({"variable": "count", "name": target})).unwrap();
|
||||
let value = json!({"type": "setVariable", "variable": "target"});
|
||||
s.execute(
|
||||
"proto.interaction.add",
|
||||
&json!({"id": frame, "actions": [
|
||||
{"type": "setVariable", "variable": "target", "value": null, "expr": Expr::var(&target)},
|
||||
{"type": "conditional", "blocks": [{"expression": "target == 5", "actions": [
|
||||
{"type": "setVariable", "variable": "label", "value": value}
|
||||
]}]}
|
||||
]}),
|
||||
)
|
||||
.unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
let actions = &doc.node(frame).unwrap().interactions[0].actions;
|
||||
let Action::SetVariable { variable, expr: Some(expr), .. } = &actions[0] else { panic!("no set variable") };
|
||||
assert_eq!(variable, &target);
|
||||
// A JSON expression is command input: it still accepts names, even one that resembles an id.
|
||||
assert_eq!(expr.eval(&doc.variables, &Default::default()), Some(VariableValue::Number(100.0)));
|
||||
let Action::Conditional { blocks } = &actions[1] else { panic!("no conditional") };
|
||||
assert_eq!(blocks[0].condition.as_ref().unwrap().eval(&doc.variables, &Default::default()), Some(VariableValue::Boolean(true)));
|
||||
let Action::SetVariable { value: stored, .. } = &blocks[0].actions[0] else { panic!("no nested set variable") };
|
||||
assert_eq!(stored, &value, "a literal value is not an action to canonicalize");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_switch_only_the_prototype_collection() {
|
||||
let (s, frame, _, _, r) = setup();
|
||||
let mut doc = s.doc().unwrap().clone();
|
||||
let theme = doc.variables.collections[0].clone();
|
||||
let mut shadow = theme.clone();
|
||||
shadow.id = "shadow".into();
|
||||
shadow.name = theme.id.clone();
|
||||
doc.variables.collections.insert(0, shadow);
|
||||
assert!(crate::proto_run::set_mode_by_id(&mut doc, frame, &theme.id, "Dark"));
|
||||
assert_eq!(fill_hex(&doc, r).as_deref(), Some("000000"));
|
||||
let page = doc.page_of(frame).unwrap();
|
||||
assert!(!doc.node(page).unwrap().var_modes.contains_key("shadow"));
|
||||
// The public name-accepting wrapper keeps its existing lookup behavior.
|
||||
assert!(set_mode(&mut doc, frame, &theme.id, "Light"));
|
||||
assert!(doc.node(page).unwrap().var_modes.contains_key("shadow"));
|
||||
doc.variables.collections.retain(|c| c.id != theme.id);
|
||||
let before = doc.clone();
|
||||
assert!(!crate::proto_run::set_mode_by_id(&mut doc, frame, &theme.id, "Dark"));
|
||||
assert_eq!(doc, before);
|
||||
}
|
||||
|
||||
@@ -498,3 +498,24 @@ fn a_translucent_variable_bound_to_an_empty_fill_applies_its_alpha_once() {
|
||||
assert_eq!(node(&s, r).fills[0].solid_color().unwrap().to_css_hex(), "#00FF0040");
|
||||
assert_eq!(node(&s, r).fills[0].opacity, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_cannot_hide_an_alias_cycle() {
|
||||
for link in ["a", "b"] {
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "variable.createCollection", json!({"name": "Theme"}));
|
||||
let mut ids = std::collections::BTreeMap::new();
|
||||
for name in ["shadow", "a", "b"] {
|
||||
let v = run(&mut s, "variable.create", json!({"collection": "Theme", "name": name, "type": "number", "value": 1}));
|
||||
ids.insert(name, v["id"].clone());
|
||||
}
|
||||
run(&mut s, "variable.set", json!({"variable": "b", "value": {"alias": "a"}}));
|
||||
run(&mut s, "variable.update", json!({"variable": ids["shadow"], "name": ids[link]}));
|
||||
let before = s.doc().unwrap().clone();
|
||||
let undo = s.ds().unwrap().history.undo.len();
|
||||
let error = s.execute("variable.set", &json!({"variable": "a", "value": {"alias": "b"}})).unwrap_err();
|
||||
assert!(error.to_string().contains("refer back to itself"), "{link}: {error}");
|
||||
assert_eq!(s.doc().unwrap(), &before);
|
||||
assert_eq!(s.ds().unwrap().history.undo.len(), undo);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,3 +125,66 @@ fn create_collection_refuses_a_taken_name() {
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "Theme 2"}));
|
||||
assert_eq!(s.doc().unwrap().variables.collections.len(), before + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_detach_deleted_aliases_per_mode() {
|
||||
for collection in [false, true] {
|
||||
let mut s = Session::new();
|
||||
for name in ["Base", "Theme"] {
|
||||
ex(&mut s, "variable.createCollection", json!({"name": name, "modes": ["Light", "Dark"]}));
|
||||
}
|
||||
let shadow = ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "shadow", "type": "color", "value": "#FF0000"}));
|
||||
let surface = ex(
|
||||
&mut s,
|
||||
"variable.create",
|
||||
json!({"collection": "Base", "name": "surface", "type": "color", "values": {"Light": "#0D99FF", "Dark": "#000000"}}),
|
||||
);
|
||||
// Use a different collection only when deleting the whole target collection.
|
||||
let home = if collection { "Theme" } else { "Base" };
|
||||
let alias = ex(&mut s, "variable.create", json!({"collection": home, "name": "alias", "type": "color", "value": {"alias": surface["id"]}}));
|
||||
ex(&mut s, "variable.update", json!({"variable": shadow["id"], "name": surface["id"]}));
|
||||
let aid = alias["id"].as_str().unwrap();
|
||||
let before = s.doc().unwrap().variables.get_by_id(aid).unwrap().clone();
|
||||
let expected: std::collections::BTreeMap<_, _> = before
|
||||
.values
|
||||
.keys()
|
||||
.map(|m| {
|
||||
let modes = std::collections::BTreeMap::from([(before.collection.clone(), m.clone())]);
|
||||
(m.clone(), s.doc().unwrap().variables.resolve(aid, &modes).unwrap())
|
||||
})
|
||||
.collect();
|
||||
if collection {
|
||||
ex(&mut s, "variable.deleteCollection", json!({"collection": "Base"}));
|
||||
} else {
|
||||
ex(&mut s, "variable.delete", json!({"variable": "surface"}));
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().variables.get_by_id(aid).unwrap().values, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_keep_collection_edits_on_the_resolved_collection() {
|
||||
let mut s = Session::new();
|
||||
let shadow = ex(&mut s, "variable.createCollection", json!({"name": "Shadow", "modes": ["Only"]}));
|
||||
let theme = ex(&mut s, "variable.createCollection", json!({"name": "Theme", "modes": ["Light", "Dark"]}));
|
||||
let surface = ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "surface", "type": "number", "value": 1}));
|
||||
ex(&mut s, "variable.renameCollection", json!({"collection": shadow["id"], "name": theme["id"]}));
|
||||
ex(&mut s, "variable.set", json!({"variable": "surface", "mode": "Dark", "value": 7}));
|
||||
let doc = s.doc().unwrap();
|
||||
let coll = doc.variables.collection_by_id(theme["id"].as_str().unwrap()).unwrap();
|
||||
let mode = coll.find_mode("Dark").unwrap().id.clone();
|
||||
let modes = std::collections::BTreeMap::from([(coll.id.clone(), mode)]);
|
||||
assert_eq!(doc.variables.resolve(surface["id"].as_str().unwrap(), &modes), Some(figcraft_doc::VariableValue::Number(7.0)));
|
||||
|
||||
// Mode commands resolve the user's collection argument before checking its stored id.
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let mut extended = doc.variables.collection_by_id(theme["id"].as_str().unwrap()).unwrap().clone();
|
||||
extended.id = "extended".into();
|
||||
extended.name = "Extended".into();
|
||||
extended.extends = Some(theme["id"].as_str().unwrap().into());
|
||||
doc.variables.collections.push(extended);
|
||||
doc.variables.collections[0].name = "extended".into();
|
||||
let before = s.doc().unwrap().clone();
|
||||
assert!(s.execute("variable.removeMode", &json!({"collection": "Extended", "mode": "Dark"})).is_err());
|
||||
assert_eq!(s.doc().unwrap(), &before);
|
||||
}
|
||||
|
||||
@@ -231,6 +231,39 @@ fn variables_export_and_import_round_trip() {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_keep_remapped_import_aliases() {
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "Theme"}));
|
||||
let shadow = ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "shadow", "type": "number", "value": 100}))["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let target = ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "target", "type": "number", "value": 5}))["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
ex(&mut s, "variable.update", json!({"variable": shadow, "name": target}));
|
||||
let imported = json!({"meta": {
|
||||
"variableCollections": {"c": {"id": "c", "name": "Theme", "modes": [{"modeId": "m", "name": "Mode 1"}]}},
|
||||
"variables": {
|
||||
"t": {"id": "t", "name": "target", "variableCollectionId": "c", "resolvedType": "FLOAT", "valuesByMode": {"m": 5}},
|
||||
"a": {"id": "a", "name": "alias", "variableCollectionId": "c", "resolvedType": "FLOAT",
|
||||
"valuesByMode": {"m": {"type": "VARIABLE_ALIAS", "id": "t"}}}
|
||||
}
|
||||
}});
|
||||
let result = ex(&mut s, "variable.import", json!({"variables": imported}));
|
||||
assert_eq!(result["warnings"], json!([]));
|
||||
let vars = &s.doc().unwrap().variables;
|
||||
let alias = vars.get("alias").unwrap();
|
||||
assert_eq!(alias.values.values().next(), Some(&VariableValue::Alias(target.clone())));
|
||||
assert_eq!(vars.resolve(&alias.id, &Default::default()), Some(VariableValue::Number(5.0)));
|
||||
// The command's alias argument remains name-accepting, unlike the import's remapped id.
|
||||
ex(&mut s, "variable.set", json!({"variable": "alias", "value": {"alias": target}}));
|
||||
let vars = &s.doc().unwrap().variables;
|
||||
assert_eq!(vars.resolve(&vars.get("alias").unwrap().id, &Default::default()), Some(VariableValue::Number(100.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variables_import_checks_what_it_reads() {
|
||||
let mut s = Session::new();
|
||||
@@ -555,3 +588,53 @@ fn css_export_neutralises_every_css_newline_in_strings() {
|
||||
assert!(css.contains("\"a b c\""), "{css:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_keep_exported_css_alias_names() {
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "Theme"}));
|
||||
let shadow = ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "shadow", "type": "color", "value": "#FF0000"}));
|
||||
let surface = ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "surface", "type": "color", "value": "#0D99FF"}));
|
||||
ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "alias", "type": "color", "value": {"alias": surface["id"]}}));
|
||||
ex(&mut s, "variable.update", json!({"variable": shadow["id"], "name": surface["id"], "codeSyntax": {"WEB": "--shadow"}}));
|
||||
let css = ex(&mut s, "variable.export", json!({"format": "css"}));
|
||||
assert!(css["text"].as_str().unwrap().contains("--alias: var(--surface);"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_reset_only_the_inherited_parent_and_collection() {
|
||||
for collection_shadow in [false, true] {
|
||||
let mut s = Session::new();
|
||||
let shadow_coll = ex(&mut s, "variable.createCollection", json!({"name": "Shadow"}));
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "Base", "modes": ["Light", "Dark"]}));
|
||||
let shadow = ex(&mut s, "variable.create", json!({"collection": "Shadow", "name": "shadow", "type": "number", "value": 0}));
|
||||
let surface =
|
||||
ex(&mut s, "variable.create", json!({"collection": "Base", "name": "surface", "type": "number", "values": {"Light": 4, "Dark": 8}}));
|
||||
if collection_shadow {
|
||||
ex(&mut s, "variable.renameCollection", json!({"collection": shadow_coll["id"], "name": "extended"}));
|
||||
} else {
|
||||
ex(&mut s, "variable.update", json!({"variable": shadow["id"], "name": surface["id"]}));
|
||||
}
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let parent = doc.variables.get_by_id(surface["id"].as_str().unwrap()).unwrap().clone();
|
||||
let mut coll = doc.variables.collection_by_id(&parent.collection).unwrap().clone();
|
||||
let (light, dark) = (coll.find_mode("Light").unwrap().id.clone(), coll.find_mode("Dark").unwrap().id.clone());
|
||||
coll.extends = Some(coll.id.clone());
|
||||
coll.id = "extended".into();
|
||||
coll.name = "Extended".into();
|
||||
let mut inherited = parent.clone();
|
||||
inherited.id = "inherited".into();
|
||||
inherited.name = "inherited".into();
|
||||
inherited.collection = coll.id.clone();
|
||||
inherited.extends = Some(parent.id);
|
||||
inherited.overrides = inherited.values.keys().cloned().collect();
|
||||
inherited.values.values_mut().for_each(|v| *v = VariableValue::Number(2.0));
|
||||
doc.variables.collections.push(coll);
|
||||
doc.variables.variables.push(inherited);
|
||||
ex(&mut s, "variable.resetOverride", json!({"variable": "inherited", "mode": "Dark"}));
|
||||
let result = s.doc().unwrap().variables.get_by_id("inherited").unwrap();
|
||||
assert_eq!(result.values[&light], VariableValue::Number(2.0));
|
||||
assert_eq!(result.values[&dark], VariableValue::Number(8.0));
|
||||
assert_eq!(result.overrides, std::collections::BTreeSet::from([light]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,9 +251,9 @@ impl<'d> Exporter<'d> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A reference to variable `var` (its id or name) as variable data.
|
||||
/// A reference to variable `var` (its stored id) as variable data.
|
||||
fn alias(&mut self, var: &str) -> Option<Kv<'static>> {
|
||||
let found = self.doc.variables.get(var).and_then(|v| Some((v.kind, *self.var_ids.get(v.id.as_str())?)));
|
||||
let found = self.doc.variables.get_by_id(var).and_then(|v| Some((v.kind, *self.var_ids.get(v.id.as_str())?)));
|
||||
let Some((kind, local)) = found else {
|
||||
self.warn("bindings to variables that aren't in the document were written as their values");
|
||||
return None;
|
||||
|
||||
@@ -1657,7 +1657,7 @@ impl<'a> Exporter<'a> {
|
||||
if let Some(name) = self.token_names.get(variable) {
|
||||
return Some(name.clone());
|
||||
}
|
||||
let var = self.doc.variables.get(variable)?;
|
||||
let var = self.doc.variables.get_by_id(variable)?;
|
||||
let name = if variable.starts_with("penpot:tokens:") {
|
||||
var.name.split_once('/').map_or(var.name.clone(), |(_, n)| n.to_owned())
|
||||
} else {
|
||||
|
||||
@@ -909,3 +909,27 @@ fn page_guides_reloaded_from_a_figcraft_file_are_exported() {
|
||||
let page_json = e.iter().find(|(k, _)| k.matches('/').count() == 3 && k.contains("/pages/")).unwrap().1;
|
||||
assert_eq!(page_json["guides"].as_object().unwrap().values().next().unwrap()["position"], 410);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_keep_penpot_token_identity() {
|
||||
for missing in [false, true] {
|
||||
let mut doc = Document::new("Token identity");
|
||||
doc.variables.collections.push(Collection {
|
||||
id: "c".into(),
|
||||
name: "Theme".into(),
|
||||
modes: vec![Mode { id: "m".into(), name: "Default".into() }],
|
||||
extends: None,
|
||||
});
|
||||
doc.variables.variables.push(variable("shadow", "target", "c", VariableType::Number, &[("m", VariableValue::Number(20.0))]));
|
||||
if !missing {
|
||||
doc.variables.variables.push(variable("target", "surface", "c", VariableType::Number, &[("m", VariableValue::Number(40.0))]));
|
||||
}
|
||||
let p = page(&doc);
|
||||
let r = add(&mut doc, p, NodeKind::Rectangle, "Bound", 0.0, 0.0, 40.0, 10.0);
|
||||
doc.node_mut(r).unwrap().bound.insert("width".into(), "target".into());
|
||||
let exported = entries(&export(&doc).unwrap().0);
|
||||
let width = &shape(&exported, "Bound")["appliedTokens"]["width"];
|
||||
let expected = if missing { Value::Null } else { json!("surface") };
|
||||
assert_eq!(width, &expected);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,3 +248,34 @@ fn junk_in_conditional_actions_never_panics() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_keep_imported_mode_lookup_identity() {
|
||||
for collection in [false, true] {
|
||||
let mut vars = variables_json();
|
||||
if collection {
|
||||
let mut shadow = vars["meta"]["variableCollections"][THEME].clone();
|
||||
shadow["id"] = json!("VariableCollectionId:0:0");
|
||||
shadow["name"] = json!(THEME);
|
||||
shadow["modes"] = json!([{"modeId": "other", "name": "Other"}]);
|
||||
vars["meta"]["variableCollections"]["VariableCollectionId:0:0"] = shadow;
|
||||
} else {
|
||||
let mut shadow = vars["meta"]["variables"][SPACE].clone();
|
||||
shadow["id"] = json!("VariableID:0:0");
|
||||
shadow["name"] = json!(LABEL);
|
||||
vars["meta"]["variables"]["VariableID:0:0"] = shadow;
|
||||
}
|
||||
let lookup = expr("VAR_MODE_LOOKUP", vec![var(LABEL), data("STRING", json!("1:0"))]);
|
||||
let (doc, warnings) = import_with(&file(vec![on("ON_CLICK", vec![set(LABEL, lookup)])]), Some(&vars), &no_images).unwrap();
|
||||
let actions = &button(&doc).interactions[0].actions;
|
||||
assert_eq!(
|
||||
actions[0],
|
||||
Action::SetVariable {
|
||||
variable: LABEL.into(),
|
||||
value: Value::Null,
|
||||
expr: Some(Expr::InMode { variable: LABEL.into(), mode: "1:0".into() }),
|
||||
},
|
||||
"{warnings:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -442,8 +442,8 @@ impl Ctx<'_> {
|
||||
};
|
||||
let vars = &self.doc.variables;
|
||||
let found = parts.filter(|(id, _)| self.var_kinds.contains_key(*id)).and_then(|(id, mode)| {
|
||||
let v = vars.get(id)?;
|
||||
let m = vars.collection(&v.collection)?.modes.iter().find(|m| m.id == mode)?;
|
||||
let v = vars.get_by_id(id)?;
|
||||
let m = vars.collection_by_id(&v.collection)?.modes.iter().find(|m| m.id == mode)?;
|
||||
Some(Expr::InMode { variable: v.id.clone(), mode: m.id.clone() })
|
||||
});
|
||||
if found.is_none() {
|
||||
|
||||
@@ -131,7 +131,7 @@ pub fn mode_rows<'a>(doc: &'a Document, libs: &'a [Arc<PublishedLibrary>]) -> Ve
|
||||
let (id, name) = (lib.manifest.id.as_str(), library::library_name(doc, &lib.manifest.id));
|
||||
for c in library::published_collections(lib) {
|
||||
let local = copy(id, &c.id).map(|r| r.local.as_str());
|
||||
let coll = local.and_then(|l| doc.variables.collection(l)).unwrap_or(c);
|
||||
let coll = local.and_then(|l| doc.variables.collection_by_id(l)).unwrap_or(c);
|
||||
if several(coll) {
|
||||
out.push(ModeRow { coll, library: Some((id, name)), key: Some(&c.id), local });
|
||||
}
|
||||
@@ -141,7 +141,7 @@ pub fn mode_rows<'a>(doc: &'a Document, libs: &'a [Arc<PublishedLibrary>]) -> Ve
|
||||
if out.iter().any(|x| x.local == Some(r.local.as_str())) {
|
||||
continue;
|
||||
}
|
||||
if let Some(c) = doc.variables.collection(&r.local).filter(|c| several(c)) {
|
||||
if let Some(c) = doc.variables.collection_by_id(&r.local).filter(|c| several(c)) {
|
||||
out.push(ModeRow { coll: c, library: Some((&r.library, library::library_name(doc, &r.library))), key: None, local: Some(&r.local) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,7 +232,7 @@ impl Presenter {
|
||||
Action::SetVariableMode { collection, mode } => {
|
||||
let Some(d) = self.design.as_mut() else { return false };
|
||||
let before = d.clone();
|
||||
let set = figcraft_engine::proto_run::set_mode(d, self.current, collection, mode);
|
||||
let set = figcraft_engine::proto_run::set_mode_by_id(d, self.current, collection, mode);
|
||||
if set {
|
||||
self.settle(&before);
|
||||
}
|
||||
|
||||
@@ -76,7 +76,8 @@ fn counter_then_conditional_navigation() {
|
||||
#[test]
|
||||
fn set_variable_mode_recolours_bound_layers() {
|
||||
let (mut doc, a, _, button) = setup();
|
||||
on_click(&mut doc, button, vec![Action::SetVariableMode { collection: "Theme".into(), mode: "Dark".into() }]);
|
||||
let theme = doc.variables.collection("Theme").unwrap().id.clone();
|
||||
on_click(&mut doc, button, vec![Action::SetVariableMode { collection: theme.clone(), mode: "Dark".into() }]);
|
||||
let hex = |p: &Presenter| p.doc.as_ref().and_then(|d| d.node(a)?.fills.first()?.solid_color()).map(|c| c.to_hex());
|
||||
let mut p = Presenter::new(a);
|
||||
p.sync(&Arc::new(doc), 0.0);
|
||||
@@ -85,7 +86,7 @@ fn set_variable_mode_recolours_bound_layers() {
|
||||
assert_eq!(hex(&p).as_deref(), Some("000000"));
|
||||
// An unknown mode does nothing.
|
||||
let mut d2 = p.doc.clone().unwrap();
|
||||
on_click(&mut d2, button, vec![Action::SetVariableMode { collection: "Theme".into(), mode: "Sepia".into() }]);
|
||||
on_click(&mut d2, button, vec![Action::SetVariableMode { collection: theme, mode: "Sepia".into() }]);
|
||||
let mut p2 = Presenter::new(a);
|
||||
p2.sync(&Arc::new(d2), 0.0);
|
||||
assert!(!p2.fire(button, 1, None, 1.0));
|
||||
@@ -104,3 +105,29 @@ fn deeply_nested_conditionals_stop() {
|
||||
assert!(!p.fire(button, 0, None, 1.0));
|
||||
assert_eq!(p.current, a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_keep_player_collection_identity() {
|
||||
for missing in [false, true] {
|
||||
let (mut doc, a, _, button) = setup();
|
||||
let theme = doc.variables.collections[0].clone();
|
||||
let mut shadow = theme.clone();
|
||||
shadow.id = "shadow".into();
|
||||
shadow.name = theme.id.clone();
|
||||
doc.variables.collections.insert(0, shadow);
|
||||
if missing {
|
||||
doc.variables.collections.retain(|c| c.id != theme.id);
|
||||
}
|
||||
on_click(&mut doc, button, vec![Action::SetVariableMode { collection: theme.id.clone(), mode: "Dark".into() }]);
|
||||
let mut p = Presenter::new(a);
|
||||
p.sync(&Arc::new(doc), 0.0);
|
||||
assert_eq!(p.fire(button, 0, None, 1.0), !missing);
|
||||
let played = p.doc.as_ref().unwrap();
|
||||
let modes = &played.node(played.page_of(a).unwrap()).unwrap().var_modes;
|
||||
assert!(!modes.contains_key("shadow"));
|
||||
if !missing {
|
||||
let fill = played.node(a).unwrap().fills[0].solid_color().unwrap();
|
||||
assert_eq!(fill.to_hex(), "000000");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +133,34 @@ fn brand_library(tag: &str) -> (String, String) {
|
||||
(path, lid)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_keep_library_mode_picker_rows() {
|
||||
let (path, lid) = brand_library("stored-ids");
|
||||
let mut s = Session::new();
|
||||
let f = id(&s.execute("frame.create", &json!({"width": 100, "height": 100})).unwrap());
|
||||
s.execute("variable.createCollection", &json!({"name": "shadow"})).unwrap();
|
||||
s.execute("library.add", &json!({"path": path})).unwrap();
|
||||
s.execute("library.setVariableMode", &json!({"library": lid, "collection": "Brand", "mode": "Dark", "ids": [f]})).unwrap();
|
||||
let local = s.doc().unwrap().libraries.collections[0].local.clone();
|
||||
s.execute("variable.renameCollection", &json!({"collection": "shadow", "name": local})).unwrap();
|
||||
let mut u = Ui::new(s);
|
||||
let doc = u.app.session.doc().unwrap();
|
||||
let libs = crate::variable_ui::libraries(&u.app, doc);
|
||||
// Both a read library and an unavailable one keep the actual copy's modes, not its namesake's.
|
||||
for available in [libs.as_slice(), &[]] {
|
||||
let rows = crate::variable_ui::mode_rows(doc, available);
|
||||
assert_eq!(rows.len(), 1, "{rows:?}");
|
||||
assert_eq!(rows[0].coll.id, local);
|
||||
assert_eq!(rows[0].coll.name, "Brand");
|
||||
assert_eq!(rows[0].coll.modes.len(), 2);
|
||||
assert_eq!(rows[0].local, Some(local.as_str()));
|
||||
assert_eq!(rows[0].library.unwrap().0, lid);
|
||||
}
|
||||
u.run("select.set", json!({"ids": [f]}));
|
||||
u.frames(2, Vec::new);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_collections_show_in_the_mode_picker_under_their_library() {
|
||||
let (path, lid) = brand_library("picker");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Components, variants, styles and variables parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Design systems are why teams adopt Figma. **Components: breadth ~96%, ready ~72%. Styles and
|
||||
variables: breadth ~95%, ready ~76% (estimated).** QA'd with a synthetic design system of ~300
|
||||
@@ -68,7 +68,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
|
||||
| Deleting a bound variable or its collection | done | layers keep their last value and are unbound; aliases to it take its values |
|
||||
| Code syntax per platform (Web, iOS, Android, Flutter) | done | generated CSS uses the Web name; in the variables JSON; SwiftUI/Compose/ArkUI/Flutter code doesn't name variables yet |
|
||||
| Bindings: fills, strokes, effects, opacity, padding, gaps, sizes, radius, font family/style, font size/weight, line height, letter spacing, paragraph spacing/indent, text, visibility, layout grids, instance properties | done | type-checked per field; number variables fill text content; the Design panel's font family and font style menus start with Apply variable (string variables), and a bound one shows its variable in place of the menu; a colour variable bound to a solid paint brings its whole colour, alpha included, once (the paint's opacity restarts at 100% on binding, an opacity set while bound multiplies on top; unbinding moves the alpha back into the opacity) |
|
||||
| Explicit modes per frame, library collections' modes | done | |
|
||||
| Explicit modes per frame, library collections' modes | done | stored variable and collection references resolve by exact id in export, library copies and swaps, imported aliases, alias deletion and cycle checks, Dev Inspect, the library mode picker and prototype playback; a collection or variable named like another's id no longer stands in for it there |
|
||||
| Gradient stops and variant properties bound to variables | done | a stop's binding follows it when earlier stops are removed |
|
||||
| Extended collections | done | library collections, including a library's own extended collections (chains through libraries); overrides per mode with reset; follow library updates, variables new to the library collection being an update of their own; modes inherited. Checked with synthetic libraries only ([gaps](gaps.md) #14) |
|
||||
| Variables import/export (JSON) | done | the REST local variables shape (round-trips; merges by collection, mode and variable name; `variableCollections` and `variables` read under `meta` or at the top level, a warning when no variables are found or fewer than the collections list); CSS custom properties and Dart constants (Flutter) export; a variables file named by path must be a regular file of at most 256 MiB; in the browser the export arrives as a download (import there still needs a file picker) |
|
||||
@@ -78,6 +78,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-30 | minor | M3, follow-up to #1542: stored variable and collection references use exact ids in export, library copies and swaps, imported aliases, alias deletion and cycle checks, Dev Inspect, the library mode picker, and prototype actions and expressions. Interaction edits resolve only incoming names and leave stored references alone. Command arguments still accept names as before. Tests: the `stored_variable_ids_*` tests in the doc, engine, format and ui-egui present and responsive suites. Numbers unchanged |
|
||||
| 2026-10-30 | minor | M8: the component set's Properties section fits the narrowest right panel: variant values wrap onto more lines and every + and − button stays visible. Grade unchanged. |
|
||||
| 2026-10-30 | minor | M8, #1097: library updates from a team library a file names are offered, not fetched from its server by themselves: only team libraries you added or checked are checked in the background (**Check for updates** in Assets ▸ Libraries). Numbers unchanged. |
|
||||
| 2026-10-30 | minor | M8, #1114: variables import from an explicit file path (`variable.import`, the `variables` of `file.open` and `file.import`) takes regular files of at most 256 MiB, the limit the sibling `variables.json` already had; anything else is refused before the document changes. Grade unchanged. Tests: `import_export_tests::an_explicit_variables_path_is_capped_like_the_sibling_file`, `variables_io_tests::variables_import_caps_the_file_it_reads` |
|
||||
|
||||
+3
-2
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user