M3: library variables (#60)

Publishing a library now includes the file's variable collections (modes,
aliases, scopes), versioned per variable and collection; variables hidden
from publishing stay out unless a published variable aliases them or a
published component binds them. Files using the library see its
collections read-only in the Variables editor and the variable pickers;
binding, aliasing or setting a collection's mode copies the variables in
as read-only linked copies, library components keep their bindings, and
"Library updates available" covers changed variables (bound layers follow).

New commands: library.importVariable, library.bindVariable,
library.aliasVariable, library.setVariableMode; variable.update takes
hiddenFromPublishing.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BFlat
2026-10-10 09:06:18 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 20ca885084
commit d7fd41cd13
22 changed files with 1611 additions and 493 deletions
+182 -61
View File
@@ -1,5 +1,7 @@
//! Library commands: publish this file as a library; enable, use and update other library files.
//! Library commands: publish this file as a library; enable, use and update other library files
//! (their components, styles and variables).
use std::collections::BTreeSet;
use std::sync::Arc;
use figcraft_doc::*;
@@ -22,14 +24,14 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"library.publish",
"Publish library",
"{description?, include? [component/set ids, style ids or names: exactly these], exclude? [same]} publishes this file's components, component sets and styles (by default all but names starting with . or _, or what the last publish offered) as a new library version; save the file so other files see it",
"{description?, include? [component/set ids, style ids, variable collection ids or names: exactly these], exclude? [same]} publishes this file's components, component sets, styles and variable collections (by default all but names starting with . or _, or what the last publish offered; variables hidden from publishing stay out unless something published uses them) as a new library version; save the file so other files see it",
true,
publish_cmd
),
spec!(
"library.list",
"List libraries",
"{assets? (also list each library's components and styles, and what this file can publish)} → enabled libraries (version, updates, errors) and this file's published library",
"{assets? (also list each library's components, styles, variable collections and variables, and what this file can publish)} → enabled libraries (version, updates, errors) and this file's published library",
false,
list
),
@@ -41,11 +43,17 @@ pub fn specs() -> Vec<CommandSpec> {
true,
remove
),
spec!("library.updates", "Library updates", "{} → the components and styles with a newer published version", false, updates),
spec!(
"library.updates",
"Library updates",
"{} → the components, styles, variable collections and variables with a newer published version",
false,
updates
),
spec!(
"library.acceptUpdates",
"Accept library updates",
"{ids? [library ids; all when omitted]} pulls updated components and styles in (instances keep their overrides)",
"{ids? [library ids; all when omitted]} pulls updated components, styles, variable collections and variables in (instances keep their overrides; bound layers follow)",
true,
accept
),
@@ -75,9 +83,55 @@ pub fn specs() -> Vec<CommandSpec> {
true,
apply_style
),
spec!(
"library.importVariable",
"Import library variable",
"{library, variable (id or name of a published variable)} → {variable (local, read-only linked copy to use with variable.bind, or as an alias in variable.set)}",
true,
|s, p| {
let (lib, key) = variable_arg(s, "library.importVariable", p, "variable")?;
Ok(json!({"variable": import_variable(s, &lib, &key)?}))
}
),
spec!(
"library.bindVariable",
"Bind library variable",
"{library, variable (id or name of a published variable), field (as variable.bind), ids?} copies the variable in (linked, read-only) and binds it",
true,
|s, p| {
let (lib, key) = variable_arg(s, "library.bindVariable", p, "variable")?;
let local = import_variable(s, &lib, &key)?;
super::styles::bind(s, &with(p, &["ids", "id", "field"], json!({"variable": local})))?;
Ok(json!({"variable": local}))
}
),
spec!(
"library.aliasVariable",
"Alias library variable",
"{library, alias (id or name of a published variable of the same type), variable (a local variable), mode? (default first)} copies the library variable in (linked, read-only) and makes the local variable's value an alias of it",
true,
alias
),
spec!(
"library.setVariableMode",
"Set library variable mode",
"{library, collection (id or name of a published collection), mode (id or name), ids? (frames; the page when omitted)} copies the collection in (linked, read-only) and sets its mode on the frames",
true,
set_mode
),
]
}
/// `base` with `p`'s values of `keys` (those it has).
fn with(p: &Value, keys: &[&str], mut base: Value) -> Value {
for k in keys {
if let Some(v) = get(p, k) {
base[*k] = v.clone();
}
}
base
}
/// An enabled library of the active document (`library` param), read from its file.
fn library(s: &mut Session, cmd: &str, p: &Value) -> Result<(LibraryLink, Arc<PublishedLibrary>)> {
let key = req_string(cmd, p, "library")?;
@@ -105,42 +159,101 @@ fn component_arg(s: &mut Session, cmd: &str, p: &Value) -> Result<(LibraryLink,
Ok((link, lib, key))
}
/// The `library` and a published variable named by `param` (id, else name): (library, the
/// variable's id in the library).
fn variable_arg(s: &mut Session, cmd: &str, p: &Value, param: &str) -> Result<(Arc<PublishedLibrary>, VariableId)> {
let (link, lib) = library(s, cmd, p)?;
let key = req_string(cmd, p, param)?;
let id = published_variables(&lib).find(|v| v.id == key).or_else(|| published_variables(&lib).find(|v| v.name == key)).map(|v| v.id.clone());
let id = id.ok_or_else(|| bad(cmd, format!("\"{}\" publishes no variable `{key}`", link.name)))?;
Ok((lib, id))
}
/// Copy library variable `key` in (with what it aliases); its local id.
fn import_variable(s: &mut Session, lib: &PublishedLibrary, key: &str) -> Result<VariableId> {
let map = import_variables(s.doc_mut()?, lib, &BTreeSet::from([key.to_string()]), &BTreeSet::new(), false);
map.variables.get(key).cloned().ok_or_else(|| EngineError::Other("the variable didn't import".into()))
}
fn alias(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "library.aliasVariable";
let own = req_string(cmd, p, "variable")?;
let doc = s.doc()?;
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) {
return Err(bad(cmd, "the library variable has another type"));
}
let local = import_variable(s, &lib, &key)?;
super::styles::var_set(s, &with(p, &["variable", "mode"], json!({"value": {"alias": local}})))?;
Ok(json!({"variable": local}))
}
fn set_mode(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "library.setVariableMode";
let (link, lib) = library(s, cmd, p)?;
let key = req_string(cmd, p, "collection")?;
let coll = published_collections(&lib)
.find(|c| c.id == key)
.or_else(|| published_collections(&lib).find(|c| c.name == key))
.ok_or_else(|| bad(cmd, format!("\"{}\" publishes no variable collection `{key}`", link.name)))?;
let m = req_string(cmd, p, "mode")?;
let mode = coll.modes.iter().find(|x| x.id == m || x.name == m).map(|x| x.id.clone()).ok_or_else(|| bad(cmd, "no such mode"))?;
let key = coll.id.clone();
let map = import_variables(s.doc_mut()?, &lib, &BTreeSet::new(), &BTreeSet::from([key.clone()]), false);
let local = map.collections.get(&key).cloned().ok_or_else(|| EngineError::Other("the collection didn't import".into()))?;
super::styles::set_mode(s, &with(p, &["ids", "id"], json!({"collection": local, "mode": mode})))?;
Ok(json!({"collection": local}))
}
fn publish_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "library.publish";
let doc = s.doc()?;
let (comps_all, styles_all) = publishable(doc);
let pick = |key: &str| -> Result<(Vec<NodeId>, Vec<String>)> {
let Some(v) = get(p, key) else { return Ok((Vec::new(), Vec::new())) };
let Value::Array(items) = v else { return Err(bad(cmd, format!("`{key}` must be an array of component ids and style ids or names"))) };
let (mut c, mut st) = (Vec::new(), Vec::new());
let all = publishable(doc);
let coll_named = |c: &String, t: &str| doc.variables.collections.iter().any(|x| x.id == *c && x.name == t);
let count = |s: &Selection| s.components.len() + s.styles.len() + s.collections.len();
let pick = |key: &str| -> Result<Selection> {
let mut sel = Selection::default();
let Some(v) = get(p, key) else { return Ok(sel) };
let Value::Array(items) = v else {
return Err(bad(cmd, format!("`{key}` must be an array of component ids and style or variable collection ids or names")));
};
for item in items {
let text = item.as_str().map(str::trim);
let before = (c.len(), st.len());
if let Some(t) = text.filter(|t| styles_all.iter().any(|x| x == t)) {
st.push(t.to_string());
} else if let Some(i) = id_of(item).filter(|i| comps_all.contains(i)) {
c.push(i);
let before = count(&sel);
if let Some(t) = text.filter(|t| all.styles.iter().any(|x| x == t)) {
sel.styles.push(t.to_string());
} else if let Some(t) = text.filter(|t| all.collections.iter().any(|x| x == t)) {
sel.collections.push(t.to_string());
} else if let Some(i) = id_of(item).filter(|i| all.components.contains(i)) {
sel.components.push(i);
} else if let Some(t) = text {
c.extend(comps_all.iter().filter(|i| doc.node(**i).is_some_and(|n| n.name == t)));
st.extend(styles_all.iter().filter(|x| doc.style(x).is_some_and(|y| y.name == t)).cloned());
sel.components.extend(all.components.iter().filter(|i| doc.node(**i).is_some_and(|n| n.name == t)));
sel.styles.extend(all.styles.iter().filter(|x| doc.style(x).is_some_and(|y| y.name == t)).cloned());
sel.collections.extend(all.collections.iter().filter(|c| coll_named(c, t)).cloned());
}
if (c.len(), st.len()) == before {
return Err(bad(cmd, format!("{item} is not a local component, component set or style of this file")));
if count(&sel) == before {
return Err(bad(cmd, format!("{item} is not a local component, component set, style or variable collection of this file")));
}
}
Ok((c, st))
Ok(sel)
};
let (mut comps, mut styles) = if get(p, "include").is_some() { pick("include")? } else { default_selection(doc) };
let (xc, xs) = pick("exclude")?;
comps.retain(|c| !xc.contains(c));
styles.retain(|x| !xs.contains(x));
let mut sel = if get(p, "include").is_some() { pick("include")? } else { default_selection(doc) };
let x = pick("exclude")?;
sel.components.retain(|c| !x.components.contains(c));
sel.styles.retain(|s| !x.styles.contains(s));
sel.collections.retain(|c| !x.collections.contains(c));
let description = string(p, "description").map(|d| d.trim().chars().take(MAX_DESCRIPTION).collect());
let lib = publish(doc, &comps, &styles, description, s.ds()?.uid)?;
let lib = publish(doc, &sel, description, s.ds()?.uid)?;
let m = &lib.manifest;
let out = json!({
"id": lib.manifest.id,
"version": lib.manifest.version,
"components": comps.len(),
"styles": styles.len(),
"id": m.id,
"version": m.version,
"components": sel.components.len(),
"styles": sel.styles.len(),
"collections": sel.collections.len(),
"variables": m.variables.iter().filter(|v| v.published).count(),
});
s.doc_mut()?.published = Some(Arc::new(lib));
Ok(out)
@@ -148,9 +261,12 @@ fn publish_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<_>>());
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<_>>(),
"collections": m.collections.iter().filter(|x| x.published).map(|x| json!({"id": x.id, "name": x.name, "modes": modes(&x.id), "version": x.version})).collect::<Vec<_>>(),
"variables": m.variables.iter().filter(|x| x.published).map(|x| json!({"id": x.id, "name": x.name, "type": x.kind, "collection": x.collection, "version": x.version})).collect::<Vec<_>>(),
})
}
@@ -162,14 +278,11 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
let mut e = json!({"id": l.id, "name": l.name, "path": l.path, "seenVersion": l.version});
match s.libraries.load(&l.path) {
Ok(lib) if lib.manifest.id == l.id => {
let (c, st) = pending(s.doc()?, &lib);
e["version"] = json!(lib.manifest.version);
e["description"] = json!(lib.manifest.description);
e["updates"] = json!(c.len() + st.len());
if with_assets {
let a = assets_json(&lib);
e["components"] = a["components"].clone();
e["styles"] = a["styles"].clone();
e["updates"] = json!(pending(s.doc()?, &lib).count());
if with_assets && let (Value::Object(e), Value::Object(a)) = (&mut e, assets_json(&lib)) {
e.extend(a);
}
}
Ok(lib) => e["error"] = json!(format!("the file now holds another library (\"{}\")", lib.manifest.name)),
@@ -181,7 +294,8 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
let published = doc.published.as_ref().map(|lib| {
let m = &lib.manifest;
let mut v = json!({"id": m.id, "name": m.name, "version": m.version, "description": m.description, "time": m.time,
"components": m.components.iter().filter(|a| a.published).count(), "styles": m.styles.iter().filter(|x| x.published).count()});
"components": m.components.iter().filter(|a| a.published).count(), "styles": m.styles.iter().filter(|x| x.published).count(),
"collections": m.collections.iter().filter(|x| x.published).count(), "variables": m.variables.iter().filter(|x| x.published).count()});
if with_assets {
v["assets"] = assets_json(lib);
}
@@ -189,11 +303,11 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
});
let mut res = json!({"libraries": out, "published": published});
if with_assets {
let (comps, styles) = default_selection(doc);
let (all_c, all_s) = publishable(doc);
let (def, all) = (default_selection(doc), publishable(doc));
res["publishable"] = json!({
"components": all_c.iter().filter_map(|c| doc.node(*c).map(|n| json!({"id": c, "name": n.name, "selected": comps.contains(c)}))).collect::<Vec<_>>(),
"styles": all_s.iter().filter_map(|x| doc.style(x).map(|st| json!({"id": st.id, "name": st.name, "selected": styles.contains(x)}))).collect::<Vec<_>>(),
"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<_>>(),
});
}
Ok(res)
@@ -253,19 +367,24 @@ fn updates(s: &mut Session, _p: &Value) -> Result<Value> {
continue;
}
};
let doc = s.doc()?;
let (comps, styles) = pending(doc, &lib);
if comps.is_empty() && styles.is_empty() {
let p = pending(s.doc()?, &lib);
if p.is_empty() {
continue;
}
count += comps.len() + styles.len();
let name = |r: &ImportedComponent| lib.manifest.component(r.key).map(|a| a.name.clone()).unwrap_or_default();
count += p.count();
let m = &lib.manifest;
let name = |r: &ImportedComponent| m.component(r.key).map(|a| a.name.clone()).unwrap_or_default();
let rows = |list: &[ImportedRecord], name: &dyn Fn(&str) -> Option<String>| {
list.iter().map(|r| json!({"id": r.local, "key": r.key, "name": name(&r.key)})).collect::<Vec<_>>()
};
out.push(json!({
"id": l.id,
"name": lib.manifest.name,
"version": lib.manifest.version,
"components": comps.iter().map(|r| json!({"id": r.root, "key": r.key, "name": name(r)})).collect::<Vec<_>>(),
"styles": styles.iter().map(|r| json!({"id": r.local, "key": r.key, "name": lib.manifest.style(&r.key).map(|x| x.name.clone())})).collect::<Vec<_>>(),
"name": m.name,
"version": m.version,
"components": p.components.iter().map(|r| json!({"id": r.root, "key": r.key, "name": name(r)})).collect::<Vec<_>>(),
"styles": rows(&p.styles, &|k| m.style(k).map(|x| x.name.clone())),
"collections": rows(&p.collections, &|k| m.collection(k).map(|x| x.name.clone())),
"variables": rows(&p.variables, &|k| m.variable(k).map(|x| x.name.clone())),
}));
}
Ok(json!({"count": count, "libraries": out, "errors": errors}))
@@ -286,7 +405,7 @@ fn accept(s: &mut Session, p: &Value) -> Result<Value> {
.collect::<Result<_>>()?,
None => s.doc()?.libraries.enabled.clone(),
};
let (mut comps, mut styles, mut skipped) = (0, 0, Vec::new());
let (mut done, mut skipped) = (Pending::default(), Vec::new());
for l in links {
let lib = match library(s, cmd, &json!({"library": l.id})) {
Ok((_, lib)) => lib,
@@ -296,11 +415,19 @@ fn accept(s: &mut Session, p: &Value) -> Result<Value> {
}
Err(e) => return Err(e),
};
let (c, st) = accept_updates(s.doc_mut()?, &lib)?;
comps += c;
styles += st;
let p = accept_updates(s.doc_mut()?, &lib)?;
done.components.extend(p.components);
done.styles.extend(p.styles);
done.collections.extend(p.collections);
done.variables.extend(p.variables);
}
Ok(json!({"components": comps, "styles": styles, "skipped": skipped}))
Ok(json!({
"components": done.components.len(),
"styles": done.styles.len(),
"collections": done.collections.len(),
"variables": done.variables.len(),
"skipped": skipped,
}))
}
fn place(s: &mut Session, p: &Value) -> Result<Value> {
@@ -332,12 +459,6 @@ fn apply_style(s: &mut Session, p: &Value) -> Result<Value> {
.into_values()
.next()
.ok_or_else(|| EngineError::Other("the style didn't import".into()))?;
let mut q = json!({"style": local});
for k in ["ids", "id", "target"] {
if let Some(v) = get(p, k) {
q[k] = v.clone();
}
}
super::styles::apply(s, &q)?;
super::styles::apply(s, &with(p, &["ids", "id", "target"], json!({"style": local})))?;
Ok(json!({"style": local}))
}
+49 -43
View File
@@ -147,49 +147,9 @@ pub fn specs() -> Vec<CommandSpec> {
"Bind variable",
"{ids?, field (fill|stroke|width|height|opacity (percent)|cornerRadius|gap|paddingHorizontal|characters|visible|fontSize…), variable (null unbinds)}",
true,
|s, p| {
let field = req_string("variable.bind", p, "field")?;
let var = string(p, "variable");
let ids = req_targets("variable.bind", s, p)?;
let doc = s.doc_mut()?;
if let Some(v) = &var
&& doc.variables.get(v).is_none()
{
return Err(bad("variable.bind", "no such variable"));
}
let vid = var.and_then(|v| doc.variables.get(&v).map(|x| x.id.clone()));
for i in ids {
let n = doc.get_mut(i)?;
match &vid {
Some(v) => {
n.bound.insert(field.clone(), v.clone());
}
None => {
n.bound.remove(&field);
}
}
}
ok()
}
bind
),
spec!("variable.setMode", "Set variable mode", "{ids? (frames/page), collection, mode}", true, |s, p| {
let c = req_string("variable.setMode", p, "collection")?;
let m = req_string("variable.setMode", p, "mode")?;
let doc = s.doc()?;
let coll = doc.variables.collection(&c).ok_or_else(|| bad("variable.setMode", "no such collection"))?;
let mode =
coll.modes.iter().find(|x| x.id == m || x.name == m).map(|x| x.id.clone()).ok_or_else(|| bad("variable.setMode", "no such mode"))?;
let cid = coll.id.clone();
let mut ids = targets(s, p);
if ids.is_empty() {
ids = vec![s.page()?];
}
let doc = s.doc_mut()?;
for i in ids {
doc.get_mut(i)?.var_modes.insert(cid.clone(), mode.clone());
}
ok()
}),
spec!("variable.setMode", "Set variable mode", "{ids? (frames/page), collection, mode}", true, set_mode),
spec!("variable.list", "List variables", "{}", false, |s, _| Ok(serde_json::to_value(&s.doc()?.variables).unwrap_or(Value::Null))),
]
}
@@ -371,6 +331,51 @@ pub fn propagate_style(doc: &mut Document, sid: &str) {
}
}
/// `variable.bind`: bind (or with no variable, unbind) `field` of the targets.
pub fn bind(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "variable.bind";
let field = req_string(cmd, p, "field")?;
let var = string(p, "variable");
let ids = req_targets(cmd, s, p)?;
let doc = s.doc_mut()?;
let vid = match &var {
Some(v) => Some(doc.variables.get(v).map(|x| x.id.clone()).ok_or_else(|| bad(cmd, "no such variable"))?),
None => None,
};
for i in ids {
let n = doc.get_mut(i)?;
match &vid {
Some(v) => {
n.bound.insert(field.clone(), v.clone());
}
None => {
n.bound.remove(&field);
}
}
}
ok()
}
/// `variable.setMode`: choose a collection's mode on frames (the page when none are given).
pub fn set_mode(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "variable.setMode";
let c = req_string(cmd, p, "collection")?;
let m = req_string(cmd, p, "mode")?;
let doc = s.doc()?;
let coll = doc.variables.collection(&c).ok_or_else(|| bad(cmd, "no such collection"))?;
let mode = coll.modes.iter().find(|x| x.id == m || x.name == m).map(|x| x.id.clone()).ok_or_else(|| bad(cmd, "no such mode"))?;
let cid = coll.id.clone();
let mut ids = targets(s, p);
if ids.is_empty() {
ids = vec![s.page()?];
}
let doc = s.doc_mut()?;
for i in ids {
doc.get_mut(i)?.var_modes.insert(cid.clone(), mode.clone());
}
ok()
}
fn var_value(kind: VariableType, v: &Value) -> Option<VariableValue> {
if let Some(a) = v.get("alias").and_then(Value::as_str) {
return Some(VariableValue::Alias(a.to_string()));
@@ -432,11 +437,12 @@ fn var_create(s: &mut Session, p: &Value) -> Result<Value> {
values,
description: string(p, "description").unwrap_or_default(),
scopes: None,
hidden_from_publishing: false,
});
Ok(json!({"id": id}))
}
fn var_set(s: &mut Session, p: &Value) -> Result<Value> {
pub fn var_set(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "variable.set";
let v = req_string(cmd, p, "variable")?;
let doc = s.doc_mut()?;
+4 -1
View File
@@ -15,7 +15,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"variable.update",
"Edit variable",
"{variable, name?, description?, scopes? ([scope ids]; null or [\"ALL_SCOPES\"] = every supported property, [] = hidden from pickers)}",
"{variable, name?, description?, scopes? ([scope ids]; null or [\"ALL_SCOPES\"] = every supported property, [] = hidden from pickers), hiddenFromPublishing? (left out when the file is published as a library)}",
true,
update
),
@@ -82,6 +82,9 @@ fn update(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(sc) = scopes {
slot.scopes = sc;
}
if let Some(h) = boolean(p, "hiddenFromPublishing") {
slot.hidden_from_publishing = h;
}
ok()
}
+2
View File
@@ -885,6 +885,8 @@ mod html_codegen_tests;
#[cfg(test)]
mod import_export_tests;
#[cfg(test)]
mod library_variables_tests;
#[cfg(test)]
mod mask_tests;
#[cfg(test)]
mod nested_override_tests;
+443 -95
View File
@@ -1,13 +1,15 @@
//! Local libraries (the data model is in [`figcraft_doc::library`]).
//!
//! - **Publish** ([`publish`]): the chosen components, component sets and styles, and every asset
//! and style they use, are copied into a snapshot. Each asset keeps the library version it last
//! changed in, so files using the library update only what changed.
//! - **Publish** ([`publish`]): the chosen components, component sets, styles and variable
//! collections, and every asset, style and variable they use, are copied into a snapshot. Each
//! asset keeps the library version it last changed in, so files using the library update only
//! what changed. Variables hidden from publishing stay out unless something published uses them.
//! - **Read** ([`Cache`]): library files are read read-only (only their library entries, with size
//! limits) and again whenever they change on disk. The browser has no file system: there every
//! read is a clear error.
//! - **Use** ([`import`], [`import_styles`]): assets are copied into the document's library store
//! as read-only main components and styles; [`check_read_only`] refuses other edits of them.
//! - **Use** ([`import`], [`import_styles`], [`import_variables`]): assets are copied into the
//! document's library store as read-only main components, and into its styles and variables as
//! read-only linked copies; [`check_read_only`] refuses other edits of them.
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::sync::Arc;
@@ -27,7 +29,7 @@ const MAX_CACHED: usize = 64;
/// Most assets one import or publish walks (with what they use).
const MAX_ASSETS: usize = 100_000;
/// Why library edits are refused.
pub const READ_ONLY: &str = "library components and styles can't be edited in this file: edit them in their library file and publish it";
pub const READ_ONLY: &str = "library components, styles and variables can't be edited in this file: edit them in their library file and publish it";
#[cfg(target_arch = "wasm32")]
const NO_FILES: &str = "libraries need the desktop app: the browser has no file system";
@@ -135,32 +137,104 @@ pub fn local_styles(doc: &Document) -> impl Iterator<Item = &Style> {
doc.styles.iter().filter(|s| !is_remote_style(doc, &s.id))
}
/// What a file can publish: its top-level local components and sets, and its local styles.
pub fn publishable(doc: &Document) -> (Vec<NodeId>, Vec<String>) {
let comps = local_components(doc).into_iter().filter(|c| asset_root(doc, *c) == Some(*c)).collect();
(comps, local_styles(doc).map(|s| s.id.clone()).collect())
/// Whether variable `id` was copied from a library.
pub fn is_remote_variable(doc: &Document, id: &str) -> bool {
doc.libraries.variable_import(id).is_some()
}
/// Whether variable collection `id` was copied from a library.
pub fn is_remote_collection(doc: &Document, id: &str) -> bool {
doc.libraries.collection_import(id).is_some()
}
/// Local variable collections (not library copies).
pub fn local_collections(doc: &Document) -> impl Iterator<Item = &Collection> {
doc.variables.collections.iter().filter(|c| !is_remote_collection(doc, &c.id))
}
/// What a publish offers: asset roots, style ids and variable collection ids.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Selection {
pub components: Vec<NodeId>,
pub styles: Vec<String>,
pub collections: Vec<CollectionId>,
}
impl Selection {
pub fn is_empty(&self) -> bool {
self.components.is_empty() && self.styles.is_empty() && self.collections.is_empty()
}
}
/// What a file can publish: its top-level local components and sets, its local styles and its
/// local variable collections.
pub fn publishable(doc: &Document) -> Selection {
Selection {
components: local_components(doc).into_iter().filter(|c| asset_root(doc, *c) == Some(*c)).collect(),
styles: local_styles(doc).map(|s| s.id.clone()).collect(),
collections: local_collections(doc).map(|c| c.id.clone()).collect(),
}
}
/// What a publish offers when nothing is chosen: the assets offered last time plus new ones, never
/// assets named with a leading `.` or `_`.
pub fn default_selection(doc: &Document) -> (Vec<NodeId>, Vec<String>) {
let (comps, styles) = publishable(doc);
pub fn default_selection(doc: &Document) -> Selection {
let all = publishable(doc);
let prev = doc.published.as_ref().map(|p| &p.manifest);
let comps = comps
.into_iter()
.filter(|c| {
let offered = prev.and_then(|m| m.component(*c)).map(|a| a.published);
offered.unwrap_or_else(|| doc.node(*c).is_some_and(|n| !hidden_name(&n.name)))
})
.collect();
let styles = styles
.into_iter()
.filter(|s| {
let offered = prev.and_then(|m| m.style(s)).map(|x| x.published);
offered.unwrap_or_else(|| doc.style(s).is_some_and(|x| !hidden_name(&x.name)))
})
.collect();
(comps, styles)
let pick = |offered: Option<bool>, name: Option<&str>| offered.unwrap_or_else(|| name.is_some_and(|n| !hidden_name(n)));
Selection {
components: all
.components
.into_iter()
.filter(|c| pick(prev.and_then(|m| m.component(*c)).map(|a| a.published), doc.node(*c).map(|n| n.name.as_str())))
.collect(),
styles: all
.styles
.into_iter()
.filter(|s| pick(prev.and_then(|m| m.style(s)).map(|x| x.published), doc.style(s).map(|x| x.name.as_str())))
.collect(),
collections: all
.collections
.into_iter()
.filter(|c| pick(prev.and_then(|m| m.collection(c)).map(|x| x.published), doc.variables.collection(c).map(|x| x.name.as_str())))
.collect(),
}
}
/// `seeds` (variable ids) and every variable they alias, transitively, by id.
fn alias_closure(vars: &Variables, seeds: impl IntoIterator<Item = VariableId>) -> BTreeSet<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 };
if out.len() >= MAX_ASSETS || !out.insert(v.id.clone()) {
continue;
}
for val in v.values.values() {
if let VariableValue::Alias(a) = val {
stack.push(a.clone());
}
}
}
out
}
/// The variables a layer's prototype actions set.
fn set_variables(n: &Node) -> impl Iterator<Item = &VariableId> {
n.interactions.iter().flat_map(|it| &it.actions).filter_map(|a| match a {
Action::SetVariable { variable, .. } => Some(variable),
_ => None,
})
}
/// Variables the layers under `roots` bind, and the collections they choose modes of.
fn used_variables(doc: &Document, roots: &[NodeId]) -> (BTreeSet<VariableId>, BTreeSet<CollectionId>) {
let (mut vars, mut colls) = (BTreeSet::new(), BTreeSet::new());
for n in roots.iter().flat_map(|r| doc.subtree(*r)).filter_map(|i| doc.node(i)) {
vars.extend(n.bound.values().cloned());
colls.extend(n.var_modes.keys().cloned());
}
(vars, colls)
}
/// Whether asset `root` is the same in two snapshots (its position on the page aside).
@@ -181,13 +255,15 @@ fn new_library_id(doc: &Document, seed: u64) -> String {
h.get(..16).map(str::to_string).unwrap_or(h)
}
/// Publish `components` (asset roots) and `styles` of `doc` as a new library version. `seed` keeps
/// a first publish's new id unique. Refused when there is nothing to publish or nothing changed.
pub fn publish(doc: &Document, components: &[NodeId], styles: &[String], description: Option<String>, seed: u64) -> Result<PublishedLibrary> {
/// Publish `sel` (asset roots, styles and variable collections) of `doc` as a new library version.
/// `seed` keeps a first publish's new id unique. Refused when there is nothing to publish or
/// nothing changed.
pub fn publish(doc: &Document, sel: &Selection, description: Option<String>, seed: u64) -> Result<PublishedLibrary> {
let cmd = "library.publish";
if components.is_empty() && styles.is_empty() {
return Err(disabled(cmd, "nothing to publish: the file has no components or styles to offer"));
if sel.is_empty() {
return Err(disabled(cmd, "nothing to publish: the file has no components, styles or variables to offer"));
}
let (components, styles) = (&sel.components, &sel.styles);
let units = closure(doc, components);
// The snapshot keeps the file's ids, on a page whose id none of them uses.
let mut snap = Document::new(doc.name.clone());
@@ -213,6 +289,28 @@ pub fn publish(doc: &Document, components: &[NodeId], styles: &[String], descrip
let mut keep: BTreeSet<String> = linked_styles(doc, &units);
keep.extend(styles.iter().cloned());
snap.styles = doc.styles.iter().filter(|s| keep.contains(&s.id)).cloned().collect();
// Variables: the published collections' (but those hidden from publishing), those the assets
// bind, and what all of them alias.
let offered_colls: BTreeSet<&str> = sel.collections.iter().map(String::as_str).collect();
let offered_vars: BTreeSet<&str> = doc
.variables
.variables
.iter()
.filter(|v| offered_colls.contains(v.collection.as_str()) && !v.hidden_from_publishing)
.map(|v| v.id.as_str())
.collect();
let (bound, mode_colls) = used_variables(doc, &units);
let var_ids = alias_closure(&doc.variables, offered_vars.iter().map(|v| v.to_string()).chain(bound));
snap.variables.variables = doc.variables.variables.iter().filter(|v| var_ids.contains(&v.id)).cloned().collect();
let coll_ids: BTreeSet<&str> = snap
.variables
.variables
.iter()
.map(|v| v.collection.as_str())
.chain(offered_colls.iter().copied())
.chain(mode_colls.iter().map(String::as_str))
.collect();
snap.variables.collections = doc.variables.collections.iter().filter(|c| coll_ids.contains(c.id.as_str())).cloned().collect();
for h in used_images(&snap) {
if let Some(img) = doc.images.get(&h) {
snap.images.insert(h, img.clone());
@@ -251,6 +349,39 @@ pub fn publish(doc: &Document, components: &[NodeId], styles: &[String], descrip
}
})
.collect();
let old_colls: HashMap<&str, &Collection> =
prev.map(|p| p.doc.variables.collections.iter().map(|c| (c.id.as_str(), c)).collect()).unwrap_or_default();
let collections = snap
.variables
.collections
.iter()
.map(|c| {
let kept = prev.and_then(|p| p.manifest.collection(&c.id)).filter(|_| old_colls.get(c.id.as_str()) == Some(&c)).map(|x| x.version);
LibraryCollection {
id: c.id.clone(),
name: c.name.clone(),
version: kept.unwrap_or(version),
published: offered_colls.contains(c.id.as_str()),
}
})
.collect();
let old_vars: HashMap<&str, &Variable> = prev.map(|p| p.doc.variables.variables.iter().map(|v| (v.id.as_str(), v)).collect()).unwrap_or_default();
let variables = snap
.variables
.variables
.iter()
.map(|v| {
let kept = prev.and_then(|p| p.manifest.variable(&v.id)).filter(|_| old_vars.get(v.id.as_str()) == Some(&v)).map(|x| x.version);
LibraryVariable {
id: v.id.clone(),
name: v.name.clone(),
collection: v.collection.clone(),
kind: v.kind.id().into(),
version: kept.unwrap_or(version),
published: offered_vars.contains(v.id.as_str()),
}
})
.collect();
let manifest = LibraryManifest {
id: prev.map(|p| p.manifest.id.clone()).unwrap_or_else(|| new_library_id(doc, seed)),
name: doc.name.clone(),
@@ -259,6 +390,8 @@ pub fn publish(doc: &Document, components: &[NodeId], styles: &[String], descrip
time: crate::cmd::comment::now_secs(),
components,
styles,
collections,
variables,
};
if let Some(p) = prev
&& (LibraryManifest { version: p.manifest.version, time: p.manifest.time, ..manifest.clone() }) == p.manifest
@@ -379,6 +512,25 @@ fn copy_images(doc: &mut Document, src: &Document, hashes: &BTreeSet<String>) {
}
}
/// The record of library `lid`'s `key` among `records` whose copy `exists`.
fn record_of(records: &[ImportedRecord], lid: &str, key: &str, exists: impl Fn(&str) -> bool) -> Option<ImportedRecord> {
records.iter().find(|r| r.library == lid && r.key == key && exists(&r.local)).cloned()
}
/// Add or replace the record of `rec`'s library asset.
fn set_record(records: &mut Vec<ImportedRecord>, rec: ImportedRecord) {
match records.iter_mut().find(|r| r.library == rec.library && r.key == rec.key) {
Some(r) => *r = rec,
None => records.push(rec),
}
}
/// A local id for a copy of library `lid`'s `key`: `lid:key`, numbered when `taken`.
fn local_key(lid: &str, key: &str, taken: impl Fn(&str) -> bool) -> String {
let base = format!("{lid}:{key}");
(1..10_000).map(|k| if k == 1 { base.clone() } else { format!("{base}#{k}") }).find(|l| !taken(l)).unwrap_or(base)
}
/// Copy library styles `keys` into `doc` as linked remote styles (with `update`, copies older than
/// the library's are refreshed). Returns library style id → local id.
pub fn import_styles(doc: &mut Document, lib: &PublishedLibrary, keys: &BTreeSet<String>, update: bool) -> BTreeMap<String, String> {
@@ -388,32 +540,21 @@ pub fn import_styles(doc: &mut Document, lib: &PublishedLibrary, keys: &BTreeSet
for key in keys {
let Some(style) = lib.doc.styles.iter().find(|s| s.id == *key) else { continue };
let version = lib.manifest.style(key).map_or(lib.manifest.version, |s| s.version);
let record = doc.libraries.styles.iter().find(|r| r.library == lid && r.key == *key && doc.styles.iter().any(|s| s.id == r.local)).cloned();
let local = match record {
let local = match record_of(&doc.libraries.styles, &lid, key, |l| doc.styles.iter().any(|s| s.id == l)) {
Some(r) => {
if update
&& version > r.version
&& let Some(slot) = doc.styles.iter_mut().find(|s| s.id == r.local)
{
*slot = Style { id: r.local.clone(), ..style.clone() };
if let Some(rec) = doc.libraries_mut().styles.iter_mut().find(|x| x.local == r.local) {
rec.version = version;
}
set_record(&mut doc.libraries_mut().styles, ImportedStyle { version, ..r.clone() });
}
r.local
}
None => {
let base = format!("{lid}:{key}");
let mut local = base.clone();
let mut k = 1;
while doc.styles.iter().any(|s| s.id == local) && k < 10_000 {
k += 1;
local = format!("{base}#{k}");
}
let local = local_key(&lid, key, |l| doc.styles.iter().any(|s| s.id == l));
doc.styles.push(Style { id: local.clone(), ..style.clone() });
let libs = doc.libraries_mut();
libs.styles.retain(|r| !(r.library == lid && r.key == *key));
libs.styles.push(ImportedStyle { library: lid.clone(), key: key.clone(), local: local.clone(), version });
set_record(&mut doc.libraries_mut().styles, ImportedStyle { library: lid.clone(), key: key.clone(), local: local.clone(), version });
local
}
};
@@ -426,11 +567,105 @@ pub fn import_styles(doc: &mut Document, lib: &PublishedLibrary, keys: &BTreeSet
out
}
/// Library variable and collection ids → the ids of their local copies.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct VariableMap {
pub variables: BTreeMap<VariableId, VariableId>,
pub collections: BTreeMap<CollectionId, CollectionId>,
}
/// Copy library variables `keys` (with every variable they alias) and collections `collections`
/// (with those of the variables) into `doc` as linked, read-only copies. Copies the document
/// already has are kept, or with `update` refreshed in place (same ids, so bindings, aliases and
/// explicit modes follow) when the library has a newer version.
pub fn import_variables(
doc: &mut Document,
lib: &PublishedLibrary,
keys: &BTreeSet<VariableId>,
collections: &BTreeSet<CollectionId>,
update: bool,
) -> 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 mut out = VariableMap::default();
// Collections first: the variables name theirs.
for key in colls {
let Some(c) = src.collections.iter().find(|c| c.id == *key) else { continue };
let version = m.collection(key).map_or(m.version, |x| x.version);
let exists = |d: &Document, l: &str| d.variables.collections.iter().any(|x| x.id == l);
let local = match record_of(&doc.libraries.collections, lid, key, |l| exists(doc, l)) {
Some(r) => {
if update
&& version > r.version
&& let Some(slot) = doc.variables.collections.iter_mut().find(|x| x.id == r.local)
{
*slot = Collection { id: r.local.clone(), ..c.clone() };
set_record(&mut doc.libraries_mut().collections, ImportedRecord { version, ..r.clone() });
}
r.local
}
None => {
let local = local_key(lid, key, |l| exists(doc, l));
doc.variables.collections.push(Collection { id: local.clone(), ..c.clone() });
let rec = ImportedRecord { library: lid.to_string(), key: key.clone(), local: local.clone(), version };
set_record(&mut doc.libraries_mut().collections, rec);
local
}
};
out.collections.insert(key.clone(), local);
}
// Local ids first (the copies alias each other), then the values.
let mut write: Vec<(&Variable, VariableId, u64, bool)> = Vec::new();
for key in &vars {
let Some(v) = src.variables.iter().find(|x| x.id == *key).filter(|v| out.collections.contains_key(&v.collection)) else { continue };
let version = m.variable(key).map_or(m.version, |x| x.version);
let local = match record_of(&doc.libraries.variables, lid, key, |l| doc.variables.variables.iter().any(|x| x.id == l)) {
Some(r) => {
if update && version > r.version {
write.push((v, r.local.clone(), version, false));
}
r.local
}
None => {
let taken = |l: &str| doc.variables.variables.iter().any(|x| x.id == l) || out.variables.values().any(|x| x == l);
let local = local_key(lid, key, taken);
write.push((v, local.clone(), version, true));
local
}
};
out.variables.insert(key.clone(), local);
}
for (v, local, version, new) in write {
let Some(collection) = out.collections.get(&v.collection).cloned() else { continue };
let mut values = BTreeMap::new();
for (mode, val) in &v.values {
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)) {
Some(l) => VariableValue::Alias(l.clone()),
None => continue,
},
other => other.clone(),
};
values.insert(mode.clone(), val);
}
let copy = Variable { id: local.clone(), collection, values, ..v.clone() };
match doc.variables.variables.iter_mut().find(|x| x.id == local).filter(|_| !new) {
Some(slot) => *slot = copy,
None => doc.variables.variables.push(copy),
}
set_record(&mut doc.libraries_mut().variables, ImportedRecord { library: lid.to_string(), key: v.id.clone(), local, version });
}
out
}
/// Point a layer copied from `src` at its new ids. [`Document::paste_nodes`] maps ids, children,
/// parents, instance mains and prototype destinations; this maps the rest: mirrored layers,
/// instance swap properties and (with `styles`) style links. Variable bindings (not imported) are
/// dropped with their values kept; references `map` doesn't know are dropped.
fn localize(n: &mut Node, src: &Document, map: &BTreeMap<NodeId, NodeId>, styles: Option<&BTreeMap<String, String>>) {
/// instance swap properties and (with `styles` and `vars`) style links, variable bindings and
/// explicit modes; references the maps don't know are dropped (bindings with their values kept).
fn localize(n: &mut Node, src: &Document, map: &BTreeMap<NodeId, NodeId>, styles: Option<&BTreeMap<String, String>>, vars: Option<&VariableMap>) {
n.source = n.source.and_then(|s| map.get(&s).copied());
let swaps = match &n.kind {
NodeKind::Instance { component, .. } if !map.contains_key(component) => {
@@ -475,8 +710,10 @@ fn localize(n: &mut Node, src: &Document, map: &BTreeMap<NodeId, NodeId>, styles
*r = r.take().and_then(|s| styles.get(&s).cloned());
}
}
n.bound.clear();
n.var_modes.clear();
if let Some(vars) = vars {
n.bound = std::mem::take(&mut n.bound).into_iter().filter_map(|(f, v)| vars.variables.get(&v).map(|l| (f, l.clone()))).collect();
n.var_modes = std::mem::take(&mut n.var_modes).into_iter().filter_map(|(c, m)| vars.collections.get(&c).map(|l| (l.clone(), m))).collect();
}
n.plugin_data.remove("figcraft.thumbnail");
for it in &mut n.interactions {
it.actions.retain(|a| !matches!(a, Action::SetVariable { .. }) && a.dest().is_none_or(|d| map.contains_key(&d)));
@@ -524,6 +761,8 @@ pub fn import(doc: &mut Document, lib: &PublishedLibrary, keys: &[NodeId], updat
}
let roots_written: Vec<NodeId> = write.iter().map(|(u, _)| *u).collect();
let styles = import_styles(doc, lib, &linked_styles(src, &roots_written), update);
let (bound, mode_colls) = used_variables(src, &roots_written);
let vars = import_variables(doc, lib, &bound, &mode_colls, update);
for (u, _) in &write {
if let Some(r) = records.get(u)
&& doc.contains(r.root)
@@ -536,7 +775,7 @@ pub fn import(doc: &mut Document, lib: &PublishedLibrary, keys: &[NodeId], updat
let mut nodes = Vec::with_capacity(sub.len());
for id in sub {
let Some(mut n) = src.node(*id).cloned() else { continue };
localize(&mut n, src, &map, Some(&styles));
localize(&mut n, src, &map, Some(&styles), Some(&vars));
node_images(&n, &mut hashes);
nodes.push(n);
}
@@ -556,36 +795,61 @@ pub fn import(doc: &mut Document, lib: &PublishedLibrary, keys: &[NodeId], updat
Ok(map)
}
/// Pending updates of `doc`'s copies of library `lib`: (component records, style records).
pub fn pending(doc: &Document, lib: &PublishedLibrary) -> (Vec<ImportedComponent>, Vec<ImportedStyle>) {
let m = &lib.manifest;
let comps = doc
.libraries
.components
.iter()
.filter(|r| r.library == m.id && doc.contains(r.root) && m.component(r.key).is_some_and(|a| a.version > r.version))
.cloned()
.collect();
let styles = doc
.libraries
.styles
.iter()
.filter(|r| r.library == m.id && doc.style(&r.local).is_some() && m.style(&r.key).is_some_and(|s| s.version > r.version))
.cloned()
.collect();
(comps, styles)
/// A document's copies of one library's assets (the records), e.g. those with pending updates.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Pending {
pub components: Vec<ImportedComponent>,
pub styles: Vec<ImportedRecord>,
pub collections: Vec<ImportedRecord>,
pub variables: Vec<ImportedRecord>,
}
/// Pull library `lib`'s updates of the copies `doc` has (instances keep their overrides).
/// Returns how many components and styles changed.
pub fn accept_updates(doc: &mut Document, lib: &PublishedLibrary) -> Result<(usize, usize)> {
let (comps, styles) = pending(doc, lib);
if !comps.is_empty() {
let keys: Vec<NodeId> = comps.iter().map(|r| r.key).collect();
impl Pending {
pub fn count(&self) -> usize {
self.components.len() + self.styles.len() + self.collections.len() + self.variables.len()
}
pub fn is_empty(&self) -> bool {
self.count() == 0
}
}
/// The records of library `lid` among `records` whose copy `exists` and whose library `version` is
/// newer.
fn stale(records: &[ImportedRecord], lid: &str, exists: impl Fn(&str) -> bool, version: impl Fn(&str) -> Option<u64>) -> Vec<ImportedRecord> {
records.iter().filter(|r| r.library == lid && exists(&r.local) && version(&r.key).is_some_and(|v| v > r.version)).cloned().collect()
}
/// Pending updates of `doc`'s copies of library `lib`.
pub fn pending(doc: &Document, lib: &PublishedLibrary) -> Pending {
let (m, libs, vars) = (&lib.manifest, &doc.libraries, &doc.variables);
Pending {
components: libs
.components
.iter()
.filter(|r| r.library == m.id && doc.contains(r.root) && m.component(r.key).is_some_and(|a| a.version > r.version))
.cloned()
.collect(),
styles: stale(&libs.styles, &m.id, |l| doc.styles.iter().any(|s| s.id == l), |k| m.style(k).map(|x| x.version)),
collections: stale(&libs.collections, &m.id, |l| vars.collections.iter().any(|c| c.id == l), |k| m.collection(k).map(|x| x.version)),
variables: stale(&libs.variables, &m.id, |l| vars.variables.iter().any(|v| v.id == l), |k| m.variable(k).map(|x| x.version)),
}
}
/// Pull library `lib`'s updates of the copies `doc` has (instances keep their overrides, bound
/// layers re-resolve). Returns what was updated.
pub fn accept_updates(doc: &mut Document, lib: &PublishedLibrary) -> Result<Pending> {
let p = pending(doc, lib);
if !p.components.is_empty() {
let keys: Vec<NodeId> = p.components.iter().map(|r| r.key).collect();
import(doc, lib, &keys, true)?;
}
if !styles.is_empty() {
import_styles(doc, lib, &styles.iter().map(|r| r.key.clone()).collect(), true);
if !p.styles.is_empty() {
import_styles(doc, lib, &p.styles.iter().map(|r| r.key.clone()).collect(), true);
}
if !p.variables.is_empty() || !p.collections.is_empty() {
let keys = |list: &[ImportedRecord]| list.iter().map(|r| r.key.clone()).collect::<BTreeSet<_>>();
import_variables(doc, lib, &keys(&p.variables), &keys(&p.collections), true);
}
let m = &lib.manifest;
if let Some(l) = doc.libraries_mut().enabled.iter_mut().find(|l| l.id == m.id)
@@ -595,10 +859,11 @@ pub fn accept_updates(doc: &mut Document, lib: &PublishedLibrary) -> Result<(usi
l.name = m.name.clone();
}
prune(doc, &m.id)?;
Ok((comps.len(), styles.len()))
Ok(p)
}
/// Drop copies of `library`'s components and styles that nothing in the document uses any more.
/// Drop copies of `library`'s components, styles, variables and collections that nothing in the
/// document uses any more.
pub fn prune(doc: &mut Document, library: &str) -> Result<()> {
for _ in 0..MAX_ASSETS {
// Which copies instances outside them use.
@@ -630,16 +895,68 @@ pub fn prune(doc: &mut Document, library: &str) -> Result<()> {
doc.styles.retain(|s| !unused.contains(&s.id));
doc.libraries_mut().styles.retain(|r| !unused.contains(&r.local));
}
// Variables layers bind or set, and local variables alias (with what those alias).
let seeds = doc
.nodes()
.flat_map(|n| n.bound.values().chain(set_variables(n)))
.cloned()
.chain(doc.variables.variables.iter().filter(|v| !is_remote_variable(doc, &v.id)).flat_map(|v| v.values.values()).filter_map(|x| match x {
VariableValue::Alias(a) => Some(a.clone()),
_ => None,
}))
.collect::<Vec<_>>();
let used = alias_closure(&doc.variables, seeds);
let unused: BTreeSet<String> =
doc.libraries.variables.iter().filter(|r| r.library == library && !used.contains(&r.local)).map(|r| r.local.clone()).collect();
if !unused.is_empty() {
doc.variables.variables.retain(|v| !unused.contains(&v.id));
doc.libraries_mut().variables.retain(|r| !unused.contains(&r.local));
}
// Collections the remaining variables are in, or layers choose a mode of.
let used: BTreeSet<String> =
doc.variables.variables.iter().map(|v| v.collection.clone()).chain(doc.nodes().flat_map(|n| n.var_modes.keys()).cloned()).collect();
let unused: BTreeSet<String> =
doc.libraries.collections.iter().filter(|r| r.library == library && !used.contains(&r.local)).map(|r| r.local.clone()).collect();
if !unused.is_empty() {
doc.variables.collections.retain(|c| !unused.contains(&c.id));
doc.libraries_mut().collections.retain(|r| !unused.contains(&r.local));
}
Ok(())
}
/// `theirs` records of copies `exists` in this document that `mine` doesn't list yet.
fn new_records(mine: &[ImportedRecord], theirs: &[ImportedRecord], exists: impl Fn(&str) -> bool) -> Vec<ImportedRecord> {
theirs.iter().filter(|s| exists(&s.local) && !mine.iter().any(|x| x.library == s.library && x.key == s.key)).cloned().collect()
}
/// When pages of `other` are appended to `doc` (`map`: their ids → the pasted ids): the library
/// copies they use come along (shared with the copies `doc` has of the same assets), with their
/// records and library links, and the pasted layers point at them.
/// records and library links, and the pasted layers point at them. Styles and variables are
/// appended by id beforehand: this records the copies among them.
pub fn merge_store(doc: &mut Document, other: &Document, map: &BTreeMap<NodeId, NodeId>) -> Result<()> {
if other.library_store().is_none() {
if other.libraries.is_empty() {
return Ok(());
}
if other.library_store().is_some() {
merge_components(doc, other, map)?;
}
let styles = new_records(&doc.libraries.styles, &other.libraries.styles, |l| doc.styles.iter().any(|s| s.id == l));
let colls = new_records(&doc.libraries.collections, &other.libraries.collections, |l| doc.variables.collections.iter().any(|c| c.id == l));
let vars = new_records(&doc.libraries.variables, &other.libraries.variables, |l| doc.variables.variables.iter().any(|v| v.id == l));
let libs = doc.libraries_mut();
libs.styles.extend(styles);
libs.collections.extend(colls);
libs.variables.extend(vars);
for l in &other.libraries.enabled {
if libs.link(&l.id).is_none() {
libs.enabled.push(l.clone());
}
}
Ok(())
}
/// The component copies of [`merge_store`].
fn merge_components(doc: &mut Document, other: &Document, map: &BTreeMap<NodeId, NodeId>) -> Result<()> {
let mut remote: BTreeMap<NodeId, NodeId> = BTreeMap::new();
let mut fresh = Vec::new();
for r in other.libraries.components.iter().filter(|r| other.contains(r.root)) {
@@ -658,7 +975,7 @@ pub fn merge_store(doc: &mut Document, other: &Document, map: &BTreeMap<NodeId,
for r in &fresh {
let mut nodes: Vec<Node> = other.subtree(r.root).into_iter().filter_map(|i| other.node(i).cloned()).collect();
for n in &mut nodes {
localize(n, other, &remote, None);
localize(n, other, &remote, None, None);
}
let Some(root) = remote.get(&r.root).copied() else { continue };
doc.paste_nodes(&nodes, &remote, r.root, store, None)?;
@@ -685,17 +1002,6 @@ pub fn merge_store(doc: &mut Document, other: &Document, map: &BTreeMap<NodeId,
n.source = source;
}
}
let libs = doc.libraries_mut();
for s in &other.libraries.styles {
if !libs.styles.iter().any(|x| x.library == s.library && x.key == s.key) {
libs.styles.push(s.clone());
}
}
for l in &other.libraries.enabled {
if libs.link(&l.id).is_none() {
libs.enabled.push(l.clone());
}
}
Ok(())
}
@@ -705,6 +1011,9 @@ pub fn check_read_only(cmd: &str, before: &Document, after: &Document) -> std::r
if cmd.starts_with("library.") || cmd == "file.import" {
return Ok(());
}
if !variables_unchanged(before, after) {
return Err(READ_ONLY.into());
}
let Some(store) = after.library_store().or_else(|| before.library_store()) else { return Ok(()) };
let touched = |d: &Document, id: NodeId| d.contains(id) && d.page_of(id) == Some(store);
if after.dirty().iter().any(|id| touched(before, *id) || touched(after, *id)) {
@@ -713,6 +1022,25 @@ pub fn check_read_only(cmd: &str, before: &Document, after: &Document) -> std::r
Ok(())
}
/// Whether the library variables and collections copied into `before` are the same in `after`,
/// and no other variable joined one of those collections. A document whose library data was
/// replaced (undo, redo, a restored version) brings its own copies.
fn variables_unchanged(before: &Document, after: &Document) -> bool {
let libs = &before.libraries;
if (libs.variables.is_empty() && libs.collections.is_empty()) || !Arc::ptr_eq(libs, &after.libraries) || before.variables == after.variables {
return true;
}
let (b, a) = (&before.variables, &after.variables);
fn by_id<T>(list: &[T], id: impl Fn(&T) -> &str) -> HashMap<&str, &T> {
list.iter().map(|x| (id(x), x)).collect()
}
let (bv, av) = (by_id(&b.variables, |x| &x.id), by_id(&a.variables, |x| &x.id));
let (bc, ac) = (by_id(&b.collections, |x| &x.id), by_id(&a.collections, |x| &x.id));
libs.variables.iter().all(|r| bv.get(r.local.as_str()) == av.get(r.local.as_str()))
&& libs.collections.iter().all(|r| bc.get(r.local.as_str()) == ac.get(r.local.as_str()))
&& a.variables.iter().all(|v| libs.collection_import(&v.collection).is_none() || after.libraries.variable_import(&v.id).is_some())
}
/// The library a remote layer was copied from (its id), if any.
pub fn library_of(doc: &Document, id: NodeId) -> Option<&str> {
if !doc.is_remote(id) {
@@ -727,6 +1055,26 @@ pub fn library_name<'a>(doc: &'a Document, id: &'a str) -> &'a str {
doc.libraries.link(id).map_or(id, |l| l.name.as_str())
}
/// The enabled libraries of `doc` that `cache` has read (and that still hold the linked library).
pub fn read_libraries<'a>(cache: &'a Cache, doc: &'a Document) -> impl Iterator<Item = (&'a LibraryLink, &'a Arc<PublishedLibrary>)> {
doc.libraries.enabled.iter().filter_map(|l| match cache.cached(&l.path) {
Some(Ok(lib)) if lib.manifest.id == l.id => Some((l, lib)),
_ => None,
})
}
/// The variable collections library `lib` offers.
pub fn published_collections(lib: &PublishedLibrary) -> impl Iterator<Item = &Collection> {
let m = &lib.manifest;
lib.doc.variables.collections.iter().filter(|c| m.collection(&c.id).is_some_and(|x| x.published))
}
/// The variables library `lib` offers (not those hidden from publishing).
pub fn published_variables(lib: &PublishedLibrary) -> impl Iterator<Item = &Variable> {
let offered: BTreeSet<&str> = lib.manifest.variables.iter().filter(|v| v.published).map(|v| v.id.as_str()).collect();
lib.doc.variables.variables.iter().filter(move |v| offered.contains(v.id.as_str()))
}
#[cfg(test)]
#[path = "library_tests.rs"]
mod tests;
@@ -0,0 +1,316 @@
//! Library variables: publishing collections (hidden variables, versions), binding, aliasing and
//! explicit modes from a library, read-only copies, updates, components that bind variables, and
//! files that bring copies along.
use figcraft_doc::*;
use serde_json::{Value, json};
use crate::Session;
use crate::library::*;
use crate::params::id_of;
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
match s.execute(cmd, &p) {
Ok(v) => v,
Err(e) => panic!("{cmd} {p}: {e}"),
}
}
fn idv(v: &Value) -> NodeId {
id_of(&v["id"]).unwrap()
}
fn fill(s: &Session, id: NodeId) -> String {
s.doc().unwrap().node(id).unwrap().fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()).unwrap_or_default()
}
fn healthy(s: &Session) {
let doc = s.doc().unwrap();
assert!(doc.validate().is_empty(), "{:?}", doc.validate());
}
/// A scratch directory for one test.
fn dir(name: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("figcraft-libvars-{}-{name}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
d
}
fn path_str(p: &std::path::Path) -> String {
p.to_string_lossy().into_owned()
}
/// A library file's variables: "Theme" (Light/Dark) with `primary` (blue/white), `radius` (4),
/// `accent` aliasing `base` (hidden from publishing), and a private "_Scratch" collection.
fn theme(s: &mut Session) {
ex(s, "variable.createCollection", json!({"name": "Theme", "modes": ["Light", "Dark"]}));
ex(s, "variable.create", json!({"collection": "Theme", "name": "primary", "type": "color", "values": {"Light": "#0000ff", "Dark": "#ffffff"}}));
ex(s, "variable.create", json!({"collection": "Theme", "name": "radius", "type": "number", "value": 4}));
ex(s, "variable.create", json!({"collection": "Theme", "name": "base", "type": "color", "value": "#00ff00"}));
ex(s, "variable.update", json!({"variable": "base", "hiddenFromPublishing": true}));
ex(s, "variable.create", json!({"collection": "Theme", "name": "accent", "type": "color", "value": "#000000"}));
ex(s, "variable.set", json!({"variable": "accent", "value": {"alias": "base"}}));
ex(s, "variable.createCollection", json!({"name": "_Scratch"}));
ex(s, "variable.create", json!({"collection": "_Scratch", "name": "tmp", "type": "number", "value": 1}));
}
/// Publish the active document and save it to `path`; returns the library id.
fn publish_to(s: &mut Session, path: &str) -> String {
let r = ex(s, "library.publish", json!({}));
ex(s, "file.saveAs", json!({"path": path}));
r["id"].as_str().unwrap().to_string()
}
fn republish(lib: &mut Session) {
ex(lib, "library.publish", json!({}));
ex(lib, "file.save", json!({}));
}
/// The local copy of library variable `name`.
fn copy_of(s: &Session, name: &str) -> Variable {
let doc = s.doc().unwrap();
doc.variables.variables.iter().find(|v| v.name == name && is_remote_variable(doc, &v.id)).cloned().unwrap()
}
#[test]
fn publishing_collections_hides_and_versions_variables() {
let mut s = Session::new();
theme(&mut s);
let r = ex(&mut s, "library.publish", json!({}));
assert_eq!((r["collections"].as_u64(), r["variables"].as_u64()), (Some(1), Some(3)), "{r}");
let lib = s.doc().unwrap().published.clone().unwrap();
let m = &lib.manifest;
let var = |name: &str| m.variables.iter().find(|v| v.name == name).cloned();
assert!(m.collections.iter().all(|c| c.name != "_Scratch"), "names starting with _ stay private");
assert!(var("primary").is_some_and(|v| v.published && v.version == 1 && v.kind == "color"));
// A variable hidden from publishing stays out of the offer, but comes along for the alias.
assert!(var("base").is_some_and(|v| !v.published));
assert!(var("tmp").is_none());
assert_eq!(lib.doc.variables.variables.len(), 4);
// Saved and read back, the manifest and snapshot are the same.
let bytes = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap();
let read = figcraft_format::read_library(std::io::Cursor::new(&bytes)).unwrap().unwrap();
assert_eq!(read.manifest, *m);
assert_eq!(read.doc.variables, lib.doc.variables);
// Nothing changed: refused. A change bumps only the variable it touches.
assert!(s.execute("library.publish", &json!({})).unwrap_err().to_string().contains("no changes"));
ex(&mut s, "variable.set", json!({"variable": "radius", "value": 8}));
assert_eq!(ex(&mut s, "library.publish", json!({}))["version"], 2);
let m = s.doc().unwrap().published.clone().unwrap().manifest.clone();
let version = |name: &str| m.variables.iter().find(|v| v.name == name).map(|v| v.version);
assert_eq!((version("radius"), version("primary")), (Some(2), Some(1)));
assert_eq!(m.collections.first().map(|c| c.version), Some(1), "the collection itself didn't change");
// A new mode changes the collection.
ex(&mut s, "variable.addMode", json!({"collection": "Theme", "name": "Dim"}));
ex(&mut s, "library.publish", json!({}));
let m = s.doc().unwrap().published.clone().unwrap().manifest.clone();
assert_eq!(m.collections.first().map(|c| c.version), Some(3));
// Hiding a published variable is a change; collections can be included and excluded by name.
ex(&mut s, "variable.update", json!({"variable": "radius", "hiddenFromPublishing": true}));
let r = ex(&mut s, "library.publish", json!({}));
assert_eq!(r["variables"], 2);
let r = ex(&mut s, "library.publish", json!({"include": ["_Scratch"]}));
assert_eq!((r["collections"].as_u64(), r["variables"].as_u64()), (Some(1), Some(1)));
ex(&mut s, "library.publish", json!({"include": ["Theme", "_Scratch"]}));
let r = ex(&mut s, "library.publish", json!({"exclude": ["_Scratch"]}));
assert_eq!(r["collections"], 1, "the default follows the last publish");
let list = ex(&mut s, "library.list", json!({"assets": true}));
assert_eq!(list["publishable"]["collections"].as_array().map(Vec::len), Some(2), "{list}");
// Variables alone can be published; nothing at all can't.
let mut empty = Session::new();
assert!(empty.execute("library.publish", &json!({})).is_err());
}
#[test]
fn bind_alias_mode_read_only_and_updates() {
let d = dir("flow");
let lib_path = path_str(&d.join("theme.figcraft"));
let mut lib = Session::new();
theme(&mut lib);
let lid = publish_to(&mut lib, &lib_path);
let mut s = Session::new();
let added = ex(&mut s, "library.add", json!({"path": lib_path}));
assert_eq!(added["variables"].as_array().map(Vec::len), Some(3), "{added}");
assert_eq!(added["collections"][0]["modes"].as_array().map(Vec::len), Some(2));
let frame = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 200})));
let inner = idv(&ex(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20, "parent": frame})));
let outer = idv(&ex(&mut s, "shape.rectangle", json!({"x": 300, "y": 0, "width": 20, "height": 20})));
// Binding copies the variable in (linked, read-only) and the layers take its value.
let r = ex(&mut s, "library.bindVariable", json!({"library": lid, "variable": "primary", "field": "fill", "ids": [inner, outer]}));
let local = r["variable"].as_str().unwrap().to_string();
assert_eq!((fill(&s, inner), fill(&s, outer)), ("0000FF".into(), "0000FF".into()));
ex(&mut s, "library.bindVariable", json!({"library": lid, "variable": "radius", "field": "cornerRadius", "ids": [outer]}));
// Undo and redo (as commands, like the menus) take a copy back and bring it again.
ex(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc().unwrap().variables.variables.len(), 1);
ex(&mut s, "edit.redo", json!({}));
assert_eq!(s.doc().unwrap().variables.variables.len(), 2);
{
let doc = s.doc().unwrap();
assert!(is_remote_variable(doc, &local));
assert_eq!(doc.variables.collections.len(), 1, "one copy of the collection for both");
assert!(local_collections(doc).next().is_none(), "library copies aren't local collections");
assert!(publishable(doc).collections.is_empty());
}
// Binding again reuses the copy; hidden or unknown variables can't be bound.
ex(&mut s, "library.bindVariable", json!({"library": lid, "variable": "primary", "field": "fill", "ids": [outer]}));
assert_eq!(s.doc().unwrap().variables.variables.len(), 2);
for v in ["base", "tmp", "nope"] {
assert!(s.execute("library.bindVariable", &json!({"library": lid, "variable": v, "field": "fill", "ids": [outer]})).is_err(), "{v}");
}
// Explicit mode: the frame shows the library collection's Dark mode.
let r = ex(&mut s, "library.setVariableMode", json!({"library": lid, "collection": "Theme", "mode": "Dark", "ids": [frame]}));
assert_eq!(r["collection"].as_str(), Some(s.doc().unwrap().variables.collections[0].id.as_str()));
assert_eq!((fill(&s, inner), fill(&s, outer)), ("FFFFFF".into(), "0000FF".into()));
assert!(s.execute("library.setVariableMode", &json!({"library": lid, "collection": "Theme", "mode": "Nope"})).is_err());
assert!(s.execute("library.setVariableMode", &json!({"library": lid, "collection": "_Scratch", "mode": "Mode 1"})).is_err());
// A local variable aliases a library one (copied in with the hidden variable it aliases).
ex(&mut s, "variable.createCollection", json!({"name": "App"}));
ex(&mut s, "variable.create", json!({"collection": "App", "name": "brand", "type": "color", "value": "#123456"}));
ex(&mut s, "variable.create", json!({"collection": "App", "name": "gap", "type": "number", "value": 2}));
ex(&mut s, "library.aliasVariable", json!({"library": lid, "alias": "accent", "variable": "brand"}));
let modes = Default::default();
assert!(matches!(s.doc().unwrap().variables.resolve("brand", &modes), Some(VariableValue::Color(c)) if c.to_hex() == "00FF00"));
assert!(s.execute("library.aliasVariable", &json!({"library": lid, "alias": "primary", "variable": "gap"})).is_err(), "types differ");
assert!(s.execute("library.aliasVariable", &json!({"library": lid, "alias": "primary", "variable": local})).is_err(), "copies are read-only");
// The copies are read-only.
let coll = copy_of(&s, "primary").collection;
for (cmd, p) in [
("variable.set", json!({"variable": local, "value": "#ff0000"})),
("variable.update", json!({"variable": local, "name": "mine"})),
("variable.delete", json!({"variable": local})),
("variable.duplicate", json!({"variable": local})),
("variable.create", json!({"collection": coll, "name": "extra", "type": "number", "value": 1})),
("variable.addMode", json!({"collection": coll, "name": "Mine"})),
("variable.renameMode", json!({"collection": coll, "mode": "Dark", "name": "Night"})),
("variable.renameCollection", json!({"collection": coll, "name": "Mine"})),
("variable.deleteCollection", json!({"collection": coll})),
] {
let e = s.execute(cmd, &p).unwrap_err();
assert!(e.to_string().contains("can't be edited"), "{cmd}: {e}");
}
// Local edits still work.
ex(&mut s, "variable.set", json!({"variable": "gap", "value": 3}));
healthy(&s);
// The library changes its variables and publishes again.
ex(&mut lib, "variable.set", json!({"variable": "primary", "mode": "Light", "value": "#ff0000"}));
ex(&mut lib, "variable.set", json!({"variable": "primary", "mode": "Dark", "value": "#222222"}));
ex(&mut lib, "variable.set", json!({"variable": "base", "value": "#00ffff"}));
ex(&mut lib, "variable.renameMode", json!({"collection": "Theme", "mode": "Dark", "name": "Night"}));
republish(&mut lib);
let u = ex(&mut s, "library.updates", json!({}));
assert_eq!(u["count"], 3, "primary, base and the collection: {u}");
assert_eq!(u["libraries"][0]["variables"].as_array().map(Vec::len), Some(2));
let r = ex(&mut s, "library.acceptUpdates", json!({}));
assert_eq!((r["variables"].as_u64(), r["collections"].as_u64()), (Some(2), Some(1)), "{r}");
// Same copies (same ids), new values: the bound layers and the alias follow.
assert_eq!(copy_of(&s, "primary").id, local);
assert_eq!((fill(&s, inner), fill(&s, outer)), ("222222".into(), "FF0000".into()));
assert!(matches!(s.doc().unwrap().variables.resolve("brand", &modes), Some(VariableValue::Color(c)) if c.to_hex() == "00FFFF"));
assert_eq!(s.doc().unwrap().variables.collections[0].modes[1].name, "Night");
assert_eq!(ex(&mut s, "library.updates", json!({}))["count"], 0);
// Undo takes the update back.
ex(&mut s, "edit.undo", json!({}));
assert_eq!(fill(&s, outer), "0000FF");
ex(&mut s, "edit.redo", json!({}));
healthy(&s);
// Saved and reopened, the copies, records and bindings stay; the file renders without the library.
let user_path = path_str(&d.join("app.figcraft"));
ex(&mut s, "file.saveAs", json!({"path": user_path}));
std::fs::remove_file(&lib_path).unwrap();
let mut t = Session::empty();
ex(&mut t, "file.open", json!({"path": user_path}));
assert_eq!(t.doc().unwrap().libraries, s.doc().unwrap().libraries);
assert_eq!(fill(&t, outer), "FF0000");
assert!(t.execute("library.bindVariable", &json!({"library": lid, "variable": "primary", "field": "fill", "ids": [outer]})).is_err());
// Removing the library keeps the copies in use and drops the rest.
ex(&mut s, "variable.bind", json!({"ids": [outer], "field": "cornerRadius", "variable": null}));
ex(&mut s, "library.remove", json!({"id": lid}));
let doc = s.doc().unwrap();
let names: Vec<&str> = doc.variables.variables.iter().filter(|v| is_remote_variable(doc, &v.id)).map(|v| v.name.as_str()).collect();
assert_eq!(names.len(), 3, "primary (bound), accent and base (aliased): {names:?}");
assert!(!names.contains(&"radius"));
assert_eq!(doc.libraries.collections.len(), 1);
healthy(&s);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn library_components_keep_their_variable_bindings() {
let d = dir("components");
let lib_path = path_str(&d.join("kit.figcraft"));
let mut lib = Session::new();
theme(&mut lib);
let f = idv(&ex(&mut lib, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 40, "name": "Chip"})));
ex(&mut lib, "variable.bind", json!({"ids": [f], "field": "fill", "variable": "primary"}));
ex(&mut lib, "variable.bind", json!({"ids": [f], "field": "cornerRadius", "variable": "tmp"}));
ex(&mut lib, "variable.setMode", json!({"ids": [f], "collection": "Theme", "mode": "Dark"}));
let chip = idv(&ex(&mut lib, "component.create", json!({"ids": [f]})));
let lid = publish_to(&mut lib, &lib_path);
// The private collection's variable comes along (not offered) for the component.
let m = lib.doc().unwrap().published.clone().unwrap().manifest.clone();
assert!(m.variables.iter().any(|v| v.name == "tmp" && !v.published));
let mut s = Session::new();
ex(&mut s, "library.add", json!({"path": lib_path}));
let inst = idv(&ex(&mut s, "library.place", json!({"library": lid, "component": "Chip", "x": 0, "y": 0})));
assert_eq!(fill(&s, inst), "FFFFFF", "the component's own mode comes along");
{
let doc = s.doc().unwrap();
let node = doc.node(inst).unwrap();
let bound = node.bound.get("fill").unwrap();
assert!(is_remote_variable(doc, bound), "bound to the copy, not to a variable of the file");
assert!(node.var_modes.keys().all(|c| is_remote_collection(doc, c)));
assert_eq!(doc.libraries.variables.len(), 2);
}
// A value of a mode the component doesn't show: only the variable changes.
ex(&mut lib, "variable.set", json!({"variable": "primary", "mode": "Light", "value": "#444444"}));
republish(&mut lib);
let version = |lib: &Session| lib.doc().unwrap().published.as_ref().and_then(|p| p.manifest.component(chip)).map(|a| a.version);
assert_eq!(version(&lib), Some(1), "the component didn't change");
assert_eq!(ex(&mut s, "library.updates", json!({}))["count"], 1);
ex(&mut s, "library.acceptUpdates", json!({}));
assert_eq!(fill(&s, inst), "FFFFFF");
let light = copy_of(&s, "primary").values.values().next().cloned();
assert!(matches!(light, Some(VariableValue::Color(c)) if c.to_hex() == "444444"));
// The value it shows: the component (its resolved fill) and the variable update, the instance follows.
ex(&mut lib, "variable.set", json!({"variable": "primary", "mode": "Dark", "value": "#333333"}));
republish(&mut lib);
assert_eq!(version(&lib), Some(3));
assert_eq!(ex(&mut s, "library.updates", json!({}))["count"], 2);
ex(&mut s, "library.acceptUpdates", json!({}));
assert_eq!(fill(&s, inst), "333333");
healthy(&s);
// Another file importing these pages brings the copies and their records along.
let user_path = path_str(&d.join("user.figcraft"));
ex(&mut s, "file.saveAs", json!({"path": user_path}));
let mut t = Session::new();
ex(&mut t, "file.import", json!({"path": user_path}));
let doc = t.doc().unwrap();
assert_eq!(doc.libraries.variables.len(), 2);
assert_eq!(doc.libraries.collections.len(), 2);
assert!(local_collections(doc).next().is_none());
healthy(&t);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn library_variable_commands_fail_cleanly() {
let mut s = Session::new();
for cmd in ["library.importVariable", "library.bindVariable", "library.aliasVariable", "library.setVariableMode"] {
for p in [json!({}), json!({"library": 1}), json!({"library": "x", "variable": [], "alias": null, "collection": {}, "mode": 3})] {
assert!(s.execute(cmd, &p).is_err(), "{cmd} {p}");
}
}
assert!(s.doc().unwrap().libraries.is_empty());
}