diff --git a/ROADMAP.md b/ROADMAP.md index 4f4435d..c14901b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -77,8 +77,14 @@ dimension measured side by side. canvas; fill menu in the properties panel; drops from the Assets panel and the canvas land in slots); nested overrides kept when the main moves or swaps a nested instance, and on nested instances copied out of an instance (re-linked to their own main); canvas drags can no longer - pull layers out of an instance. - *Left:* library variables. 2–4 h. + pull layers out of an instance; library variables (publishing includes the file's variable + collections with their modes, aliases and scopes, versioned per variable and collection, without + variables hidden from publishing; files using the library see its collections read-only under + the library's name in the Variables editor and the variable pickers; binding, aliasing or setting + a library collection's mode on a frame copies the variables in as read-only linked copies, so the + file renders without the library; library components keep their variable bindings and modes; + "Library updates available" covers changed variables and bound layers follow). + *Left:* a mode picker for explicit modes in the Design panel (engine and MCP only so far). 1–2 h. 5. **Prototyping depth**: *landed:* on-canvas connections with drag-to-connect, interaction editor, flows, smart animate, overlays, scroll/fixed/sticky, change-to and set-variable in the presenter. Custom bezier and custom spring easings with editor fields; device frames (phone, tablet, @@ -120,7 +126,7 @@ dimension measured side by side. ### Where we're going - **Next:** the fidelity pass (7) and hardening (9: zoomed-out renders and save/load at 50k layers, - a real-file corpus); variable width strokes (in progress); library variables. + a real-file corpus); variable width strokes (in progress). - **Then:** interop depth (6: PDF export, .fig best effort), plugins and collaboration (8). - **Alpha:** release builds on every push to `release` (packaging in progress), demo files, docs. diff --git a/crates/doc/src/colors_tests.rs b/crates/doc/src/colors_tests.rs index ea15650..96c2d69 100644 --- a/crates/doc/src/colors_tests.rs +++ b/crates/doc/src/colors_tests.rs @@ -50,6 +50,7 @@ fn colourful() -> (Document, NodeId) { values: BTreeMap::from([("m1".to_string(), VariableValue::Color(RED))]), description: String::new(), scopes: None, + hidden_from_publishing: false, }); d.document_colors.push(RED); (d, r) diff --git a/crates/doc/src/library.rs b/crates/doc/src/library.rs index 07fe07b..def1e12 100644 --- a/crates/doc/src/library.rs +++ b/crates/doc/src/library.rs @@ -1,18 +1,20 @@ -//! Libraries: a file's published components and styles, and the libraries other files use. +//! Libraries: a file's published components, styles and variables, and the libraries other files +//! use. //! //! **Publishing** records a [`PublishedLibrary`] on the document: a [`LibraryManifest`] (id, -//! version, the components, component sets and styles it offers, each stamped with the library -//! version it last changed in) and a snapshot document holding just those assets and what they -//! depend on. The format crate stores both next to `document.json`, so files using the library +//! version, the components, component sets, styles, variable collections and variables it offers, +//! each stamped with the library version it last changed in) and a snapshot document holding just +//! those assets and what they depend on. The format crate stores both next to `document.json`, so files using the library //! only ever see what was published, never later unpublished edits. //! //! **Using** a library: a document lists the library files it has enabled ([`LibraryLink`]). //! Placing a library component copies it (with the components and styles it uses) into the //! document's *library store*: a hidden page-like root that is not in [`Document::pages`]. The //! copies are read-only "remote" main components, so their instances behave like any other -//! instance. [`ImportedComponent`] / [`ImportedStyle`] records map each copy to its library asset -//! and version; updating re-imports a copy in place, keeping its layer ids (and with them the -//! overrides of its instances). +//! instance. Library styles, variables and collections are copied into the document's styles and +//! variables as linked, read-only copies. [`ImportedComponent`] / [`ImportedRecord`] records map +//! each copy to its library asset and version; updating re-imports a copy in place, keeping its +//! ids (and with them the overrides of instances and the bindings of layers). use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; @@ -20,7 +22,7 @@ use std::sync::Arc; use figcraft_color::{Paint, PaintKind}; use serde::{Deserialize, Serialize}; -use crate::{Document, Node, NodeId, NodeKind, StyleKind}; +use crate::{CollectionId, Document, Node, NodeId, NodeKind, StyleKind, VariableId}; /// Name of the library store's root (never shown; older readers list it as a page). pub const STORE_NAME: &str = "Library components"; @@ -42,6 +44,10 @@ pub struct LibraryManifest { pub components: Vec, /// Styles in the snapshot: the published ones and those the components use. pub styles: Vec, + /// Variable collections in the snapshot: the published ones and those variables in it use. + pub collections: Vec, + /// Variables in the snapshot: the published ones and those they alias or components bind. + pub variables: Vec, } impl LibraryManifest { @@ -52,6 +58,14 @@ impl LibraryManifest { pub fn style(&self, id: &str) -> Option<&LibraryStyle> { self.styles.iter().find(|s| s.id == id) } + + pub fn collection(&self, id: &str) -> Option<&LibraryCollection> { + self.collections.iter().find(|c| c.id == id) + } + + pub fn variable(&self, id: &str) -> Option<&LibraryVariable> { + self.variables.iter().find(|v| v.id == id) + } } /// A component (or component set) root in a library snapshot. @@ -85,6 +99,35 @@ pub struct LibraryStyle { pub published: bool, } +/// A variable collection in a library snapshot. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct LibraryCollection { + pub id: CollectionId, + pub name: String, + /// The library version its name or modes last changed in. + pub version: u64, + /// Offered to files using the library; `false` for collections only other assets use. + pub published: bool, +} + +/// A variable in a library snapshot. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct LibraryVariable { + pub id: VariableId, + pub name: String, + /// Its collection's id in the snapshot. + pub collection: CollectionId, + /// color | number | string | boolean + #[serde(rename = "type")] + pub kind: String, + pub version: u64, + /// Offered to files using the library; `false` for variables hidden from publishing that + /// published variables alias or published components bind. + pub published: bool, +} + /// A document's published library: the manifest and the snapshot it describes (with its images). #[derive(Clone, Debug)] pub struct PublishedLibrary { @@ -130,18 +173,22 @@ pub struct ImportedComponent { pub ids: BTreeMap, } -/// A library style copied into the document's styles (a linked, read-only remote style). +/// A library style, variable or variable collection copied into the document (a linked, +/// read-only remote copy). #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] -pub struct ImportedStyle { +pub struct ImportedRecord { pub library: String, - /// The style's id in the library. + /// Its id in the library. pub key: String, - /// The local style's id. + /// The local copy's id. pub local: String, pub version: u64, } +/// A library style copied into the document's styles. +pub type ImportedStyle = ImportedRecord; + /// A document's library data: enabled libraries and the assets copied from them. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] @@ -151,11 +198,22 @@ pub struct Libraries { pub store: Option, pub components: Vec, pub styles: Vec, + /// Library variable collections copied into the document's variables. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub collections: Vec, + /// Library variables copied into the document's variables. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub variables: Vec, } impl Libraries { pub fn is_empty(&self) -> bool { - self.enabled.is_empty() && self.store.is_none() && self.components.is_empty() && self.styles.is_empty() + self.enabled.is_empty() + && self.store.is_none() + && self.components.is_empty() + && self.styles.is_empty() + && self.collections.is_empty() + && self.variables.is_empty() } /// Serde helper: leave the field out of files that use no libraries. @@ -176,6 +234,16 @@ impl Libraries { pub fn style_import(&self, local: &str) -> Option<&ImportedStyle> { self.styles.iter().find(|s| s.local == local) } + + /// The import record of the local (remote) variable `local`. + pub fn variable_import(&self, local: &str) -> Option<&ImportedRecord> { + self.variables.iter().find(|s| s.local == local) + } + + /// The import record of the local (remote) variable collection `local`. + pub fn collection_import(&self, local: &str) -> Option<&ImportedRecord> { + self.collections.iter().find(|s| s.local == local) + } } impl Document { diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index 3e5b1fc..88c59a8 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -133,6 +133,10 @@ pub struct Variable { /// supported property, empty = none (hidden from pickers). #[serde(default, skip_serializing_if = "Option::is_none")] pub scopes: Option>, + /// Left out when the file is published as a library (unless a published variable or component + /// uses it). + #[serde(default, rename = "hiddenFromPublishing", skip_serializing_if = "std::ops::Not::not")] + pub hidden_from_publishing: bool, } impl Variable { diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 3cfd8b4..d20d1df 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -216,6 +216,7 @@ fn variables_resolve_modes_and_aliases() { values: vals, description: String::new(), scopes: None, + hidden_from_publishing: false, }); let mut alias = std::collections::BTreeMap::new(); alias.insert("light".to_string(), VariableValue::Alias("bg".into())); @@ -227,6 +228,7 @@ fn variables_resolve_modes_and_aliases() { values: alias, description: String::new(), scopes: None, + hidden_from_publishing: false, }); let mut modes = std::collections::BTreeMap::new(); assert_eq!(v.resolve("surface", &modes), Some(VariableValue::Color(Color::WHITE))); @@ -243,6 +245,7 @@ fn variables_resolve_modes_and_aliases() { values: cyc, description: String::new(), scopes: None, + hidden_from_publishing: false, }); assert_eq!(v.resolve("loop", &std::collections::BTreeMap::new()), None); } diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index b43dda2..983ced2 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -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 { 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 { 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 { 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)> { 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, 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 { + 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 { + 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 { + 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 { let cmd = "library.publish"; let doc = s.doc()?; - let (comps_all, styles_all) = publishable(doc); - let pick = |key: &str| -> Result<(Vec, Vec)> { - 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 { + 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 { 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::>()); 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::>(), "styles": m.styles.iter().filter(|x| x.published).map(|x| json!({"id": x.id, "name": x.name, "type": x.kind, "version": x.version})).collect::>(), + "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::>(), + "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::>(), }) } @@ -162,14 +278,11 @@ fn list(s: &mut Session, p: &Value) -> Result { 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 { 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 { }); 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::>(), - "styles": all_s.iter().filter_map(|x| doc.style(x).map(|st| json!({"id": st.id, "name": st.name, "selected": styles.contains(x)}))).collect::>(), + "components": all.components.iter().filter_map(|c| doc.node(*c).map(|n| json!({"id": c, "name": n.name, "selected": def.components.contains(c)}))).collect::>(), + "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::>(), + "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::>(), }); } Ok(res) @@ -253,19 +367,24 @@ fn updates(s: &mut Session, _p: &Value) -> Result { 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| { + list.iter().map(|r| json!({"id": r.local, "key": r.key, "name": name(&r.key)})).collect::>() + }; 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::>(), - "styles": styles.iter().map(|r| json!({"id": r.local, "key": r.key, "name": lib.manifest.style(&r.key).map(|x| x.name.clone())})).collect::>(), + "name": m.name, + "version": m.version, + "components": p.components.iter().map(|r| json!({"id": r.root, "key": r.key, "name": name(r)})).collect::>(), + "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 { .collect::>()?, 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 { } 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 { @@ -332,12 +459,6 @@ fn apply_style(s: &mut Session, p: &Value) -> Result { .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})) } diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index 97d414a..c49d64c 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -147,49 +147,9 @@ pub fn specs() -> Vec { "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 { + 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 { + 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 { 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 { 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 { +pub fn var_set(s: &mut Session, p: &Value) -> Result { let cmd = "variable.set"; let v = req_string(cmd, p, "variable")?; let doc = s.doc_mut()?; diff --git a/crates/engine/src/cmd/variables.rs b/crates/engine/src/cmd/variables.rs index 1278b88..eeb2833 100644 --- a/crates/engine/src/cmd/variables.rs +++ b/crates/engine/src/cmd/variables.rs @@ -15,7 +15,7 @@ pub fn specs() -> Vec { 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 { if let Some(sc) = scopes { slot.scopes = sc; } + if let Some(h) = boolean(p, "hiddenFromPublishing") { + slot.hidden_from_publishing = h; + } ok() } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 5d1eaeb..973674d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 789ac44..a916955 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -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 { 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, Vec) { - 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 { + 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, + pub styles: Vec, + pub collections: Vec, +} + +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, Vec) { - 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, 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) -> BTreeSet { + let mut out = BTreeSet::new(); + let mut stack: Vec = 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 { + 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, BTreeSet) { + 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, seed: u64) -> Result { +/// 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, seed: u64) -> Result { 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 = 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) { } } +/// 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 { + 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, 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, update: bool) -> BTreeMap { @@ -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, + pub collections: BTreeMap, +} + +/// 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, + collections: &BTreeSet, + 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, styles: Option<&BTreeMap>) { +/// 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, styles: Option<&BTreeMap>, 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, 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 = 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, Vec) { - 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, + pub styles: Vec, + pub collections: Vec, + pub variables: Vec, } -/// 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 = 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) -> Vec { + 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 { + let p = pending(doc, lib); + if !p.components.is_empty() { + let keys: Vec = 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::>(); + 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::>(); + let used = alias_closure(&doc.variables, seeds); + let unused: BTreeSet = + 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 = + doc.variables.variables.iter().map(|v| v.collection.clone()).chain(doc.nodes().flat_map(|n| n.var_modes.keys()).cloned()).collect(); + let unused: BTreeSet = + 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 { + 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) -> 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) -> Result<()> { let mut remote: BTreeMap = 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 = 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 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(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)> { + 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 { + 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 { + 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; diff --git a/crates/engine/src/library_variables_tests.rs b/crates/engine/src/library_variables_tests.rs new file mode 100644 index 0000000..4bbaad5 --- /dev/null +++ b/crates/engine/src/library_variables_tests.rs @@ -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()); +} diff --git a/crates/format/src/library.rs b/crates/format/src/library.rs index 6305606..ae4b980 100644 --- a/crates/format/src/library.rs +++ b/crates/format/src/library.rs @@ -87,6 +87,8 @@ pub(crate) fn read(z: &mut zip::ZipArchive, own: &BTreeMap { values, description: text(v, "description").map(|s| s.chars().take(MAX_STRING).collect()).unwrap_or_default(), scopes: scopes.flatten(), + hidden_from_publishing: v.get("hiddenFromPublishing").and_then(Value::as_bool).unwrap_or(false), }) } diff --git a/crates/ui-egui/src/assets_panel.rs b/crates/ui-egui/src/assets_panel.rs index b70a87a..47b7b1d 100644 --- a/crates/ui-egui/src/assets_panel.rs +++ b/crates/ui-egui/src/assets_panel.rs @@ -168,8 +168,7 @@ fn build_listing(app: &FigApp, doc: &Document, q: &str, key: (u64, String, u64)) let mut sec = Section { id: l.id.clone(), name: l.name.clone(), doc: None, error: None, tiles: Vec::new(), styles: Vec::new() }; match app.session.libraries.cached(&l.path) { Some(Ok(lib)) if lib.manifest.id == l.id => { - let (c, s) = library::pending(doc, lib); - updates += c.len() + s.len(); + updates += library::pending(doc, lib).count(); let m = &lib.manifest; sec.name = m.name.clone(); sec.tiles = m @@ -628,14 +627,13 @@ fn publish_form(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { None => format!("{} · not published", doc.name), }; hint(ui, &status); - let (comps, styles) = library::publishable(doc); - if comps.is_empty() && styles.is_empty() { - hint(ui, "Create components or styles to publish them as a library."); + let all = library::publishable(doc); + if all.is_empty() { + hint(ui, "Create components, styles or variables to publish them as a library."); return; } - let (def_c, def_s) = library::default_selection(doc); - let mut picked_c = Vec::new(); - let mut picked_s = Vec::new(); + let def = library::default_selection(doc); + let mut include: Vec = Vec::new(); let mut check = |ui: &mut Ui, key: String, default: bool, label: &str| -> bool { let on = form.picked.get(&key).copied().unwrap_or(default); let mut clicked = false; @@ -649,21 +647,30 @@ fn publish_form(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { } on }; - if !comps.is_empty() { + if !all.components.is_empty() { crate::widgets::sublabels(ui, "Components", None); - for c in &comps { + for c in &all.components { let name = doc.node(*c).map(|n| n.name.as_str()).unwrap_or_default(); - if check(ui, format!("c{}", c.0), def_c.contains(c), name) { - picked_c.push(json!(c.0)); + if check(ui, format!("c{}", c.0), def.components.contains(c), name) { + include.push(json!(c.0)); } } } - if !styles.is_empty() { + if !all.styles.is_empty() { crate::widgets::sublabels(ui, "Styles", None); - for s in &styles { + for s in &all.styles { let name = doc.style(s).map(|x| x.name.as_str()).unwrap_or_default(); - if check(ui, format!("s{s}"), def_s.contains(s), name) { - picked_s.push(json!(s)); + if check(ui, format!("s{s}"), def.styles.contains(s), name) { + include.push(json!(s)); + } + } + } + if !all.collections.is_empty() { + crate::widgets::sublabels(ui, "Variable collections", None); + for c in &all.collections { + let name = doc.variables.collection(c).map(|x| x.name.as_str()).unwrap_or_default(); + if check(ui, format!("v{c}"), def.collections.contains(c), name) { + include.push(json!(c)); } } } @@ -676,7 +683,7 @@ fn publish_form(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { } }); ui.add_space(8.0); - let any = !picked_c.is_empty() || !picked_s.is_empty(); + let any = !include.is_empty(); let mut publish = false; ui.horizontal(|ui| { ui.add_space(PAD); @@ -684,7 +691,6 @@ fn publish_form(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { }); ui.add_space(12.0); if publish { - let include: Vec = picked_c.into_iter().chain(picked_s).collect(); let description = form.description.clone().unwrap_or(description); if let Some(r) = app.run("library.publish", json!({"description": description, "include": include})) { form = PublishForm::default(); @@ -713,9 +719,11 @@ fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { let (r, _) = ui.allocate_exact_size(vec2(w, 44.0), Sense::hover()); let status = match app.session.libraries.cached(&l.path) { Some(Ok(lib)) if lib.manifest.id == l.id => { - let n = lib.manifest.components.iter().filter(|a| a.published).count(); - let s = lib.manifest.styles.iter().filter(|a| a.published).count(); - (format!("Version {} · {n} components · {s} styles", lib.manifest.version), t.text_secondary) + let m = &lib.manifest; + let n = m.components.iter().filter(|a| a.published).count(); + let s = m.styles.iter().filter(|a| a.published).count(); + let v = m.variables.iter().filter(|a| a.published).count(); + (format!("Version {} · {n} components · {s} styles · {v} variables", m.version), t.text_secondary) } Some(Ok(_)) => ("The file now holds another library".into(), t.danger), Some(Err(e)) => (e.to_string(), t.danger), diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index e8d5385..ce76228 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -238,10 +238,18 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul ids: app.session.selection(), }); } - // The Variables editor: true / a collection (id or name) opens it, false closes it. + // The Variables editor: true / a collection (id or name) / {library, collection?} (an + // enabled library's, by id or name) opens it, false closes it. match p.get("variables") { Some(Value::Bool(open)) => app.ui.variables = open.then(Default::default), Some(Value::String(c)) => app.ui.variables = Some(crate::variables_panel::Editor::on(Some(c.clone()))), + Some(v @ Value::Object(_)) => { + let text = |k: &str| v.get(k).and_then(Value::as_str).map(str::to_string); + app.ui.variables = Some(match text("library") { + Some(lib) => crate::variables_panel::Editor::in_library(lib, text("collection")), + None => crate::variables_panel::Editor::on(text("collection")), + }); + } _ => {} } // Layers panel search: a string opens it with that filter, null closes it. diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index 642e267..7f7e024 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -297,7 +297,7 @@ fn page_section(app: &mut FigApp, ui: &mut Ui, doc: &Document) { }); widgets::separator(ui); // Local variables & styles. - let vars = doc.variables.variables.len(); + let vars = doc.variables.variables.iter().filter(|v| !figcraft_engine::library::is_remote_variable(doc, &v.id)).count(); widgets::section_header(ui, "Local variables", &[], vars == 0); row(ui, 32.0, |ui| { let r = widgets::dropdown(ui, &format!("Open variables ({vars})"), 200.0, Some(Icon::Styles)); diff --git a/crates/ui-egui/src/style_picker.rs b/crates/ui-egui/src/style_picker.rs index eb64ed7..e421972 100644 --- a/crates/ui-egui/src/style_picker.rs +++ b/crates/ui-egui/src/style_picker.rs @@ -3,11 +3,11 @@ //! `/`-separated names (folders collapse); a list or, for colour styles, a grid of swatches. Click //! applies a style; its edit button (or a right-click) edits the name, description and values; `+` //! creates a style from the selection's current values; the detach button unlinks it. Colour slots -//! also list colour variables to bind. +//! also list colour variables to bind (this file's, then each enabled library's). use egui::{Align2, Color32, CornerRadius, Id, Rect, Response, Sense, Stroke, Ui, pos2, vec2}; use figcraft_color::Paint; -use figcraft_doc::{Document, GridKind, Node, NodeId, Style, StyleKind, VariableType, VariableValue}; +use figcraft_doc::{Document, GridKind, Node, NodeId, Style, StyleKind, VariableType}; use serde_json::json; use crate::FigApp; @@ -183,7 +183,7 @@ pub fn chip(app: &mut FigApp, ui: &mut Ui, doc: &Document, ids: &[NodeId], slot: /// What the user asked the picker to do this frame. enum Act { Apply(String), - Bind(String), + Bind(crate::variable_ui::Pick), Create, Detach, Edit(String), @@ -352,20 +352,9 @@ fn listing(app: &FigApp, ui: &mut Ui, doc: &Document, p: &mut Picker, act: &mut } groups.sort_by_key(|(f, _)| !f.is_empty()); let editable = |id: &str| !figcraft_engine::library::is_remote_style(doc, id); - let modes = std::collections::BTreeMap::new(); - let colors: Vec<(&figcraft_doc::Variable, figcraft_color::Color)> = if slot.paints() { - doc.variables - .variables - .iter() - .filter(|v| v.kind == VariableType::Color && matches(&v.name)) - .filter_map(|v| match doc.variables.resolve(&v.id, &modes) { - Some(VariableValue::Color(c)) => Some((v, c)), - _ => None, - }) - .collect() - } else { - Vec::new() - }; + let libs = if slot.paints() { crate::variable_ui::libraries(app, doc) } else { Vec::new() }; + let colors = + if slot.paints() { crate::variable_ui::groups(doc, &libs, |_, v| v.kind == VariableType::Color && matches(&v.name)) } else { Vec::new() }; egui::ScrollArea::vertical().id_salt("style_picker_scroll").max_height(320.0).show(ui, |ui| { ui.spacing_mut().item_spacing.y = 0.0; if groups.is_empty() { @@ -403,15 +392,13 @@ fn listing(app: &FigApp, ui: &mut Ui, doc: &Document, p: &mut Picker, act: &mut if !colors.is_empty() { ui.add_space(6.0); caption(ui, "Color variables"); - for (v, c) in &colors { - let (r, resp) = ui.allocate_exact_size(vec2(W, ROW_H), Sense::click()); - if resp.hovered() { - ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover); - } - widgets::paint_swatch(ui.painter(), Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(14.0, 14.0)), &Paint::solid(*c)); - ui.painter().text(pos2(r.min.x + 26.0, r.center().y), Align2::LEFT_CENTER, &v.name, regular(11.0), t.menu_text); - if resp.clicked() { - *act = Some(Act::Bind(v.id.clone())); + } + for (lib, c, vars) in &colors { + crate::variable_ui::group_caption(ui, *lib, c); + let all = lib.map_or(&doc.variables, |l| &l.doc.variables); + for v in vars { + if crate::variable_ui::variable_row(ui, all, v, W) { + *act = Some(Act::Bind(crate::variable_ui::Pick { library: lib.map(|l| l.manifest.id.clone()), id: v.id.clone() })); } } } @@ -583,8 +570,8 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { app.run("style.apply", q); close = true; } - Some(Act::Bind(variable)) => { - app.run("variable.bind", json!({"ids": ids, "field": slot.key(), "variable": variable})); + Some(Act::Bind(pick)) => { + crate::variable_ui::bind(app, &ids, slot.key(), &pick); close = true; } Some(Act::Detach) => { diff --git a/crates/ui-egui/src/variable_ui.rs b/crates/ui-egui/src/variable_ui.rs index 1414cff..d4d0a1c 100644 --- a/crates/ui-egui/src/variable_ui.rs +++ b/crates/ui-egui/src/variable_ui.rs @@ -1,10 +1,14 @@ //! Binding variables to properties from the Design panel: number fields show a variable button on //! hover and the bound variable's name once bound; colour fills bind through the style picker. The -//! picker popover lists the document's variables of the matching type (and scope), by collection; -//! the Variables editor uses it to pick alias targets too. +//! picker popover lists the document's variables of the matching type (and scope), by collection, +//! then those of each enabled library (picking one copies it in, linked and read-only); the +//! Variables editor uses it to pick alias targets too. + +use std::sync::Arc; use egui::{Align2, Color32, CornerRadius, Id, Pos2, Rect, Sense, Stroke, Ui, pos2, vec2}; -use figcraft_doc::{Document, ModeId, NodeId, VariableId, VariableType}; +use figcraft_doc::{Collection, Document, ModeId, NodeId, PublishedLibrary, Variable, VariableId, VariableType, VariableValue, Variables}; +use figcraft_engine::library; use serde_json::json; use crate::FigApp; @@ -43,6 +47,86 @@ pub fn bound_name(doc: &Document, ids: &[NodeId], field: &str) -> Option doc.variables.get(&first).map(|v| v.name.clone()) } +/// A variable a picker offers: one of this file's, or a published one of an enabled library. +#[derive(Clone, Debug, PartialEq)] +pub struct Pick { + /// The library's id; None for this file's variables. + pub library: Option, + /// The variable's id (in the library for a library variable). + pub id: VariableId, +} + +/// Bind `field` of `ids` to `pick` (a library variable is copied in first). +pub fn bind(app: &mut FigApp, ids: &[NodeId], field: &str, pick: &Pick) { + match &pick.library { + Some(lib) => app.run("library.bindVariable", json!({"library": lib, "variable": pick.id, "ids": ids, "field": field})), + None => app.run("variable.bind", json!({"ids": ids, "field": field, "variable": pick.id})), + }; +} + +/// The enabled libraries this session has read, for pickers and the Variables editor. +pub fn libraries(app: &FigApp, doc: &Document) -> Vec> { + library::read_libraries(&app.session.libraries, doc).map(|(_, lib)| lib.clone()).collect() +} + +/// A variable group a picker lists: its library (None: this file), its collection and the +/// variables offered. +pub type Group<'a> = (Option<&'a PublishedLibrary>, &'a Collection, Vec<&'a Variable>); + +/// Every group a picker lists, with the variables passing `keep`: this file's collections first +/// (never library copies), then each library's published ones. Empty groups are left out. +pub fn groups<'a>( + doc: &'a Document, + libs: &'a [Arc], + keep: impl Fn(Option<&PublishedLibrary>, &Variable) -> bool, +) -> Vec> { + let mut out: Vec> = Vec::new(); + let local: Vec<&Variable> = doc.variables.variables.iter().filter(|v| !library::is_remote_variable(doc, &v.id)).collect(); + for c in library::local_collections(doc) { + out.push((None, c, local.iter().copied().filter(|v| v.collection == c.id && keep(None, v)).collect())); + } + for lib in libs { + let offered: Vec<&Variable> = library::published_variables(lib).collect(); + for c in library::published_collections(lib) { + out.push((Some(lib.as_ref()), c, offered.iter().copied().filter(|v| v.collection == c.id && keep(Some(lib), v)).collect())); + } + } + out.retain(|g| !g.2.is_empty()); + out +} + +/// A picker row: the variable's swatch (colours) or icon, its name and (other types) its value in +/// the default modes; true when clicked. +pub fn variable_row(ui: &mut Ui, vars: &Variables, v: &Variable, width: f32) -> bool { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(width, 26.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover); + } + let value = vars.resolve(&v.id, &std::collections::BTreeMap::new()); + let lead = Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(14.0, 14.0)); + match &value { + Some(VariableValue::Color(c)) => widgets::paint_swatch(ui.painter(), lead, &figcraft_color::Paint::solid(*c)), + _ => icons::paint(ui.painter(), lead.shrink(1.0), Icon::Variable, t.menu_text), + } + let name_r = Rect::from_min_max(pos2(r.min.x + 26.0, r.min.y), pos2(r.max.x - 6.0, r.max.y)); + if let Some(val) = value.filter(|v| !matches!(v, VariableValue::Color(_))) { + ui.painter().text(pos2(r.max.x - 6.0, r.center().y), Align2::RIGHT_CENTER, val.display(), regular(11.0), t.menu_dim); + } + ui.painter().with_clip_rect(name_r).text(name_r.left_center(), Align2::LEFT_CENTER, &v.name, regular(11.0), t.menu_text); + resp.clicked() +} + +/// A group's caption: the collection's name, after its library's name for library variables. +pub fn group_caption(ui: &mut Ui, lib: Option<&PublishedLibrary>, coll: &Collection) { + let t = Tokens::get(ui.ctx()); + let text = match lib { + Some(l) => format!("{} · {}", l.manifest.name, coll.name), + None => coll.name.clone(), + }; + ui.label(egui::RichText::new(text).font(regular(10.0)).color(t.menu_dim)); +} + /// Open the picker for `field` of `ids` at `at`. pub fn open(ui: &Ui, ids: &[NodeId], field: &str, kind: VariableType, at: Pos2) { let target = Target::Bind { ids: ids.to_vec(), field: field.to_string() }; @@ -133,20 +217,25 @@ pub fn bound_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], field: &str, kin pub fn picker(app: &mut FigApp, ctx: &egui::Context) { let Some(p) = ctx.data(|d| d.get_temp::(picker_id())) else { return }; let Ok(doc) = app.session.ds().map(|d| d.doc.clone()) else { return }; - // Bindings offer variables scoped to the property; aliases, any that wouldn't loop. - let offered = |v: &figcraft_doc::Variable| match &p.target { - Target::Bind { ids, field } => { - ids.first().and_then(|id| figcraft_engine::cmd::variables::field_scope(&doc, *id, field)).is_none_or(|scope| v.in_scope(scope)) - } - Target::Alias { variable, .. } => { - doc.variables.get(variable).is_some_and(|own| figcraft_engine::cmd::variables::alias_target(&doc.variables, own, &v.id).is_ok()) - } - }; + let libs = libraries(app, &doc); let t = Tokens::get(ctx); let query_id = Id::new("variable_picker_query"); let mut query: String = ctx.data(|d| d.get_temp(query_id)).unwrap_or_default(); let q = query.trim().to_lowercase(); - let mut chosen: Option = None; + // Bindings offer variables scoped to the property; aliases, any that wouldn't loop (library + // variables never lead back to this file's). + let (scope, own) = match &p.target { + Target::Bind { ids, field } => (ids.first().and_then(|id| figcraft_engine::cmd::variables::field_scope(&doc, *id, field)), None), + Target::Alias { variable, .. } => (None, doc.variables.get(variable)), + }; + let keep = |lib: Option<&PublishedLibrary>, v: &Variable| { + v.kind == p.kind + && (q.is_empty() || v.name.to_lowercase().contains(&q)) + && scope.is_none_or(|s| v.in_scope(s)) + && (lib.is_some() || own.is_none_or(|o| figcraft_engine::cmd::variables::alias_target(&doc.variables, o, &v.id).is_ok())) + }; + let groups = groups(&doc, &libs, keep); + let mut chosen: Option = None; let mut close = false; let area = egui::Area::new(Id::new("variable_picker_area")).order(egui::Order::Foreground).fixed_pos(p.at + vec2(0.0, 4.0)).show(ctx, |ui| { egui::Frame::NONE.fill(t.menu).corner_radius(CornerRadius::same(10)).inner_margin(egui::Margin::same(8)).show(ui, |ui| { @@ -160,57 +249,25 @@ pub fn picker(app: &mut FigApp, ctx: &egui::Context) { r.request_focus(); } ui.add_space(4.0); - let mut any = false; egui::ScrollArea::vertical().max_height(280.0).show(ui, |ui| { - for c in &doc.variables.collections { - let vars: Vec<_> = doc - .variables - .variables - .iter() - .filter(|v| v.collection == c.id && v.kind == p.kind && (q.is_empty() || v.name.to_lowercase().contains(&q)) && offered(v)) - .collect(); - if vars.is_empty() { - continue; - } - any = true; - ui.label(egui::RichText::new(&c.name).font(regular(10.0)).color(t.menu_dim)); + for (lib, c, vars) in &groups { + group_caption(ui, *lib, c); + let all = lib.map_or(&doc.variables, |l| &l.doc.variables); for v in vars { - let (r, resp) = ui.allocate_exact_size(vec2(224.0, 26.0), Sense::click()); - if resp.hovered() { - ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover); - } - let modes = std::collections::BTreeMap::new(); - let value = doc.variables.resolve(&v.id, &modes); - match &value { - Some(figcraft_doc::VariableValue::Color(c)) => { - let sw = Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(14.0, 14.0)); - widgets::paint_swatch(ui.painter(), sw, &figcraft_color::Paint::solid(*c)); - } - _ => icons::paint( - ui.painter(), - Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(12.0, 12.0)), - Icon::Variable, - t.menu_text, - ), - } - ui.painter().text(pos2(r.min.x + 26.0, r.center().y), Align2::LEFT_CENTER, &v.name, regular(11.0), t.menu_text); - if let Some(val) = value.filter(|v| !matches!(v, figcraft_doc::VariableValue::Color(_))) { - ui.painter().text(pos2(r.max.x - 6.0, r.center().y), Align2::RIGHT_CENTER, val.display(), regular(11.0), t.menu_dim); - } - if resp.clicked() { - chosen = Some(v.id.clone()); + if variable_row(ui, all, v, 224.0) { + chosen = Some(Pick { library: lib.map(|l| l.manifest.id.clone()), id: v.id.clone() }); } } } }); - if !any { + if groups.is_empty() { let kind = match p.kind { VariableType::Color => "colour", VariableType::Number => "number", VariableType::String => "string", VariableType::Boolean => "boolean", }; - let msg = if doc.variables.variables.iter().any(|v| v.kind == p.kind) { + let msg = if groups_any_kind(&doc, &libs, p.kind) { format!("No {kind} variables fit here.") } else { format!("No {kind} variables yet. Create them in Local variables.") @@ -230,11 +287,16 @@ pub fn picker(app: &mut FigApp, ctx: &egui::Context) { if ctx.input(|i| i.key_pressed(egui::Key::Escape)) || clicked_outside { close = true; } - if let Some(var) = chosen { - match &p.target { - Target::Bind { ids, field } => app.run("variable.bind", json!({"ids": ids, "field": field, "variable": var})), - Target::Alias { variable, mode } => app.run("variable.set", json!({"variable": variable, "mode": mode, "value": {"alias": var}})), - }; + if let Some(pick) = chosen { + match (&p.target, &pick.library) { + (Target::Bind { ids, field }, _) => bind(app, ids, field, &pick), + (Target::Alias { variable, mode }, None) => { + app.run("variable.set", json!({"variable": variable, "mode": mode, "value": {"alias": pick.id}})); + } + (Target::Alias { variable, mode }, Some(lib)) => { + app.run("library.aliasVariable", json!({"library": lib, "alias": pick.id, "variable": variable, "mode": mode})); + } + } close = true; } if close { @@ -246,3 +308,8 @@ pub fn picker(app: &mut FigApp, ctx: &egui::Context) { ctx.data_mut(|d| d.insert_temp(query_id, query)); } } + +/// Whether this file or a library offers any variable of `kind`. +fn groups_any_kind(doc: &Document, libs: &[Arc], kind: VariableType) -> bool { + !groups(doc, libs, |_, v| v.kind == kind).is_empty() +} diff --git a/crates/ui-egui/src/variables_panel.rs b/crates/ui-egui/src/variables_panel.rs index 83ce158..0f5fb88 100644 --- a/crates/ui-egui/src/variables_panel.rs +++ b/crates/ui-egui/src/variables_panel.rs @@ -1,10 +1,12 @@ -//! The Variables editor: collections on the left; the selected collection's variables on the -//! right as a table with one value column per mode. Double-click a name to rename it, right-click -//! for more; a value's variable button turns it into an alias of another variable. Everything goes -//! through `variable.*` commands, so it's undoable and the same over MCP. +//! The Variables editor: collections on the left (this file's, then each enabled library's, +//! read-only under the library's name); the selected collection's variables on the right as a +//! table with one value column per mode. Double-click a name to rename it, right-click for more; a +//! value's variable button turns it into an alias of another variable (a library one too). +//! Everything goes through `variable.*` commands, so it's undoable and the same over MCP. use egui::{Align2, Context, CornerRadius, Id, Rect, Sense, Stroke, StrokeKind, Ui, UiBuilder, pos2, vec2}; -use figcraft_doc::{Collection, Variable, VariableType, VariableValue, Variables, scopes_for}; +use figcraft_doc::{Collection, Document, PublishedLibrary, Variable, VariableType, VariableValue, Variables, scopes_for}; +use figcraft_engine::library; use serde_json::{Value, json}; use crate::FigApp; @@ -34,6 +36,8 @@ enum Rename { /// The open editor's state (`UiState::variables`; None = closed). #[derive(Clone, Default)] pub struct Editor { + /// The library whose collection is shown (id or name); None for this file's. + library: Option, /// The selected collection's id. collection: Option, query: String, @@ -49,14 +53,89 @@ impl Editor { pub fn on(collection: Option) -> Self { Editor { collection, ..Default::default() } } + + /// An editor showing a collection of enabled library `library` (id or name). + pub fn in_library(library: String, collection: Option) -> Self { + Editor { library: Some(library), collection, ..Default::default() } + } +} + +/// What the editor lists for one source: this file's variables (editable, never library copies) +/// or an enabled library's published ones (read-only). +struct View<'a> { + vars: &'a Variables, + /// The library's (id, name); None for this file. + library: Option<(&'a str, &'a str)>, + collections: Vec<&'a Collection>, + variables: Vec<&'a Variable>, +} + +impl<'a> View<'a> { + fn local(doc: &'a Document) -> Self { + View { + vars: &doc.variables, + library: None, + collections: library::local_collections(doc).collect(), + variables: doc.variables.variables.iter().filter(|v| !library::is_remote_variable(doc, &v.id)).collect(), + } + } + + fn of(lib: &'a PublishedLibrary) -> Self { + View { + vars: &lib.doc.variables, + library: Some((&lib.manifest.id, &lib.manifest.name)), + collections: library::published_collections(lib).collect(), + variables: library::published_variables(lib).collect(), + } + } + + fn id(&self) -> Option<&'a str> { + self.library.map(|l| l.0) + } + + fn editable(&self) -> bool { + self.library.is_none() + } + + /// Whether the editor's `library` (id or name) names this view. + fn is(&self, library: Option<&str>) -> bool { + match (self.library, library) { + (None, None) => true, + (Some((id, name)), Some(l)) => l == id || l == name, + _ => false, + } + } + + /// Collection `c` (id or name). + fn collection(&self, c: &str) -> Option<&'a Collection> { + self.collections.iter().copied().find(|x| x.id == c).or_else(|| self.collections.iter().copied().find(|x| x.name == c)) + } + + fn in_collection(&self, c: &str) -> impl Iterator { + self.variables.iter().copied().filter(move |v| v.collection == c) + } } /// Show the editor while it's open (call once per frame). pub fn show(app: &mut FigApp, ctx: &Context) { let Some(mut st) = app.ui.variables.take() else { return }; let Ok(doc) = app.session.ds().map(|d| d.doc.clone()) else { return }; - let vars = &doc.variables; - st.collection = st.collection.as_ref().and_then(|c| vars.collection(c)).or_else(|| vars.collections.first()).map(|c| c.id.clone()); + let libs = crate::variable_ui::libraries(app, &doc); + let views: Vec = std::iter::once(View::local(&doc)).chain(libs.iter().map(|l| View::of(l))).collect(); + // The selected collection (its source's first when none is), else this file's first. + let found = views.iter().find(|v| v.is(st.library.as_deref())).and_then(|v| { + let c = match st.collection.as_deref() { + Some(c) => v.collection(c), + None => v.collections.first().copied(), + }; + Some((v, c?)) + }); + let Some((view, coll)) = found.map(|(v, c)| (v, Some(c))).or_else(|| views.first().map(|v| (v, v.collections.first().copied()))) else { + app.ui.variables = Some(st); + return; + }; + st.library = view.id().map(str::to_string); + st.collection = coll.map(|c| c.id.clone()); let t = Tokens::get(ctx); let screen = ctx.content_rect(); let size = vec2(880.0_f32.min(screen.width() - 80.0).max(480.0), 480.0_f32.min(screen.height() - 120.0).max(240.0)); @@ -77,13 +156,13 @@ pub fn show(app: &mut FigApp, ctx: &Context) { let h = (size.y - 44.0).max(160.0); let top_down = egui::Layout::top_down(egui::Align::Min); ui.horizontal_top(|ui| { - ui.allocate_ui_with_layout(vec2(SIDEBAR_W, h), top_down, |ui| sidebar(app, ui, vars, &mut st)); + ui.allocate_ui_with_layout(vec2(SIDEBAR_W, h), top_down, |ui| sidebar(app, ui, &views, &doc.variables, &mut st)); let (line, _) = ui.allocate_exact_size(vec2(1.0, h), Sense::hover()); ui.painter().rect_filled(line, CornerRadius::ZERO, t.border); - ui.allocate_ui_with_layout(vec2(size.x - SIDEBAR_W - 1.0, h), top_down, |ui| table(app, ui, vars, &mut st)); + ui.allocate_ui_with_layout(vec2(size.x - SIDEBAR_W - 1.0, h), top_down, |ui| table(app, ui, view, &mut st)); }); }); - details(app, ctx, vars, &mut st); + details(app, ctx, &doc.variables, &mut st); app.ui.variables = open.then_some(st); } @@ -133,7 +212,7 @@ fn unique(base: &str, taken: impl Fn(&str) -> bool) -> String { (1..10_000).map(|k| if k == 1 { base.to_string() } else { format!("{base} {k}") }).find(|n| !taken(n)).unwrap_or_else(|| base.to_string()) } -fn sidebar(app: &mut FigApp, ui: &mut Ui, vars: &Variables, st: &mut Editor) { +fn sidebar(app: &mut FigApp, ui: &mut Ui, views: &[View], local: &Variables, st: &mut Editor) { let t = Tokens::get(ui.ctx()); let (hr, _) = ui.allocate_exact_size(vec2(SIDEBAR_W, 40.0), Sense::hover()); ui.painter().text(pos2(hr.min.x + 16.0, hr.center().y), Align2::LEFT_CENTER, "Collections", semibold(11.0), t.text_secondary); @@ -144,56 +223,85 @@ fn sidebar(app: &mut FigApp, ui: &mut Ui, vars: &Variables, st: &mut Editor) { Icon::Plus, "Create collection", ) { - create_collection(app, ui, vars, st); + create_collection(app, ui, local, st); } egui::ScrollArea::vertical().id_salt("vars_collections").auto_shrink([false, false]).show(ui, |ui| { - for c in &vars.collections { - let (r, resp) = ui.allocate_exact_size(vec2(SIDEBAR_W, 30.0), Sense::click()); - let r = r.shrink2(vec2(8.0, 1.0)); - let selected = st.collection.as_deref() == Some(c.id.as_str()); - if selected { - ui.painter().rect_filled(r, CornerRadius::same(5), t.selected_row); - } else if resp.hovered() { - ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); + for view in views { + if let Some((_, name)) = view.library + && !view.collections.is_empty() + { + library_title(ui, name); } - let target = Rename::Collection(c.id.clone()); - let count = vars.variables.iter().filter(|v| v.collection == c.id).count(); - ui.painter().text(pos2(r.max.x - 8.0, r.center().y), Align2::RIGHT_CENTER, count.to_string(), regular(11.0), t.text_tertiary); - let name_r = Rect::from_min_max(pos2(r.min.x + 8.0, r.min.y + 4.0), pos2(r.max.x - 32.0, r.max.y - 4.0)); - if st.rename.as_ref() == Some(&target) { - if let Some(name) = rename_field(ui, st, &target, &c.name, name_r, regular(11.0)) { - app.run("variable.renameCollection", json!({"collection": c.id, "name": name})); - } - } else { - let font = if selected { semibold(11.0) } else { regular(11.0) }; - ui.painter().with_clip_rect(name_r).text(name_r.left_center(), Align2::LEFT_CENTER, &c.name, font, t.text); - } - if resp.clicked() { - if !selected { - st.group = None; - } - st.collection = Some(c.id.clone()); - } - if resp.double_clicked() { - start_rename(ui, st, target.clone()); - } - resp.context_menu(|ui| { - if menu_item(ui, "Rename", "") { - start_rename(ui, st, target.clone()); - ui.close(); - } - if menu_item(ui, "Delete collection", "") { - app.run("variable.deleteCollection", json!({"collection": c.id})); - ui.close(); - } - }); - if selected { - group_rows(app, ui, vars, c, st); + for c in &view.collections { + collection_row(app, ui, view, c, st); } } }); } +/// A library's heading in the sidebar: its collections follow, read-only. +fn library_title(ui: &mut Ui, name: &str) { + let t = Tokens::get(ui.ctx()); + ui.add_space(8.0); + let (r, resp) = ui.allocate_exact_size(vec2(SIDEBAR_W, 26.0), Sense::hover()); + icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 22.0, r.center().y), vec2(12.0, 12.0)), Icon::Library, t.icon_secondary); + let name_r = Rect::from_min_max(pos2(r.min.x + 34.0, r.min.y), pos2(r.max.x - 8.0, r.max.y)); + ui.painter().with_clip_rect(name_r).text(name_r.left_center(), Align2::LEFT_CENTER, name, semibold(11.0), t.text_secondary); + resp.on_hover_text("Library: its variables are read-only here; edit them in the library file and publish it"); +} + +/// One collection in the sidebar (with its groups while selected). +fn collection_row(app: &mut FigApp, ui: &mut Ui, view: &View, c: &Collection, st: &mut Editor) { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(SIDEBAR_W, 30.0), Sense::click()); + let r = r.shrink2(vec2(8.0, 1.0)); + let selected = view.is(st.library.as_deref()) && st.collection.as_deref() == Some(c.id.as_str()); + if selected { + ui.painter().rect_filled(r, CornerRadius::same(5), t.selected_row); + } else if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); + } + let target = Rename::Collection(c.id.clone()); + let count = view.in_collection(&c.id).count(); + ui.painter().text(pos2(r.max.x - 8.0, r.center().y), Align2::RIGHT_CENTER, count.to_string(), regular(11.0), t.text_tertiary); + let indent = if view.editable() { 8.0 } else { 26.0 }; + let name_r = Rect::from_min_max(pos2(r.min.x + indent, r.min.y + 4.0), pos2(r.max.x - 32.0, r.max.y - 4.0)); + if view.editable() && st.rename.as_ref() == Some(&target) { + if let Some(name) = rename_field(ui, st, &target, &c.name, name_r, regular(11.0)) { + app.run("variable.renameCollection", json!({"collection": c.id, "name": name})); + } + } else { + let font = if selected { semibold(11.0) } else { regular(11.0) }; + ui.painter().with_clip_rect(name_r).text(name_r.left_center(), Align2::LEFT_CENTER, &c.name, font, t.text); + } + if resp.clicked() { + if !selected { + st.group = None; + st.query.clear(); + } + st.library = view.id().map(str::to_string); + st.collection = Some(c.id.clone()); + } + if view.editable() { + if resp.double_clicked() { + start_rename(ui, st, target.clone()); + } + resp.context_menu(|ui| { + if menu_item(ui, "Rename", "") { + start_rename(ui, st, target.clone()); + ui.close(); + } + if menu_item(ui, "Delete collection", "") { + app.run("variable.deleteCollection", json!({"collection": c.id})); + ui.close(); + } + }); + } + if selected { + group_rows(app, ui, view, c, st); + } +} + /// Every group path in `names` with its ancestors ("a/b/c" → "a", "a/b"), sorted. fn groups<'a>(names: impl Iterator) -> Vec { let mut out: Vec = Vec::new(); @@ -211,10 +319,11 @@ fn groups<'a>(names: impl Iterator) -> Vec { } /// "All variables" and the collection's groups (indented by depth): picking one filters the -/// table; groups can be renamed (every variable in them follows) or deleted. -fn group_rows(app: &mut FigApp, ui: &mut Ui, vars: &Variables, coll: &Collection, st: &mut Editor) { +/// table; this file's groups can be renamed (every variable in them follows) or deleted. +fn group_rows(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, st: &mut Editor) { let t = Tokens::get(ui.ctx()); - let names: Vec<&str> = vars.variables.iter().filter(|v| v.collection == coll.id).map(|v| v.name.as_str()).collect(); + let members: Vec<&Variable> = view.in_collection(&coll.id).collect(); + let names: Vec<&str> = members.iter().map(|v| v.name.as_str()).collect(); let list = groups(names.iter().copied()); if list.is_empty() { return; @@ -243,9 +352,9 @@ fn group_rows(app: &mut FigApp, ui: &mut Ui, vars: &Variables, coll: &Collection continue; }; let target = Rename::Group(coll.id.clone(), g.clone()); - if st.rename.as_ref() == Some(&target) { + if view.editable() && st.rename.as_ref() == Some(&target) { if let Some(new) = rename_field(ui, st, &target, label, name_r, regular(11.0)) { - rename_group(app, vars, coll, g, &new, st); + rename_group(app, &members, g, &new, st); } } else { ui.painter().with_clip_rect(name_r).text(name_r.left_center(), Align2::LEFT_CENTER, label, regular(11.0), t.text); @@ -253,6 +362,9 @@ fn group_rows(app: &mut FigApp, ui: &mut Ui, vars: &Variables, coll: &Collection if resp.clicked() { st.group = Some(g.clone()); } + if !view.editable() { + continue; + } if resp.double_clicked() { start_rename(ui, st, target.clone()); } @@ -262,10 +374,9 @@ fn group_rows(app: &mut FigApp, ui: &mut Ui, vars: &Variables, coll: &Collection ui.close(); } if menu_item(ui, "Delete group", "") { - let steps: Vec = vars - .variables + let steps: Vec = members .iter() - .filter(|v| v.collection == coll.id && v.name.starts_with(&format!("{g}/"))) + .filter(|v| v.name.starts_with(&format!("{g}/"))) .map(|v| json!({"command": "variable.delete", "params": {"variable": v.id}})) .collect(); app.run("command.batch", json!({"label": "Delete group", "commands": steps})); @@ -278,8 +389,9 @@ fn group_rows(app: &mut FigApp, ui: &mut Ui, vars: &Variables, coll: &Collection } } -/// Rename the last part of group `g` to `new`: every variable under it moves along (one undo step). -fn rename_group(app: &mut FigApp, vars: &Variables, coll: &Collection, g: &str, new: &str, st: &mut Editor) { +/// Rename the last part of group `g` of a collection (its variables `members`) to `new`: every +/// variable under it moves along (one undo step). +fn rename_group(app: &mut FigApp, members: &[&Variable], g: &str, new: &str, st: &mut Editor) { let new = new.trim().replace('/', "-"); if new.is_empty() { return; @@ -289,10 +401,9 @@ fn rename_group(app: &mut FigApp, vars: &Variables, coll: &Collection, g: &str, None => new, }; let prefix = format!("{g}/"); - let steps: Vec = vars - .variables + let steps: Vec = members .iter() - .filter(|v| v.collection == coll.id && v.name.starts_with(&prefix)) + .filter(|v| v.name.starts_with(&prefix)) .map(|v| json!({"command": "variable.update", "params": {"variable": v.id, "name": format!("{renamed}/{}", &v.name[prefix.len()..])}})) .collect(); if app.run("command.batch", json!({"label": "Rename group", "commands": steps})).is_some() @@ -307,6 +418,7 @@ fn create_collection(app: &mut FigApp, ui: &Ui, vars: &Variables, st: &mut Edito if let Some(id) = app.run("variable.createCollection", json!({"name": name, "modes": ["Mode 1"]})).and_then(|r| r["id"].as_str().map(str::to_string)) { + st.library = None; st.collection = Some(id.clone()); start_rename(ui, st, Rename::Collection(id)); } @@ -317,9 +429,14 @@ fn group_of(name: &str) -> &str { name.rsplit_once('/').map(|(g, _)| g).unwrap_or("") } -fn table(app: &mut FigApp, ui: &mut Ui, vars: &Variables, st: &mut Editor) { +fn table(app: &mut FigApp, ui: &mut Ui, view: &View, st: &mut Editor) { let t = Tokens::get(ui.ctx()); - let Some(coll) = st.collection.as_ref().and_then(|c| vars.collection(c)).cloned() else { + // A sidebar click this frame picked another source: shown next frame. + if !view.is(st.library.as_deref()) { + ui.ctx().request_repaint(); + return; + } + let Some(coll) = st.collection.as_deref().and_then(|c| view.collection(c)) else { ui.add_space(24.0); ui.horizontal(|ui| { ui.add_space(24.0); @@ -331,7 +448,7 @@ fn table(app: &mut FigApp, ui: &mut Ui, vars: &Variables, st: &mut Editor) { ); ui.add_space(12.0); if widgets::button(ui, "Create collection", true, true).clicked() { - create_collection(app, ui, vars, st); + create_collection(app, ui, view.vars, st); } }); }); @@ -352,15 +469,20 @@ fn table(app: &mut FigApp, ui: &mut Ui, vars: &Variables, st: &mut Editor) { .font(regular(11.0)) .vertical_align(egui::Align::Center), ); + if let Some((_, name)) = view.library { + let note = Rect::from_min_max(pos2(field.max.x + 16.0, sr.min.y), pos2(sr.max.x - 16.0, sr.max.y)); + let text = format!("From the library \"{name}\": read-only"); + ui.painter().with_clip_rect(note).text(note.right_center(), Align2::RIGHT_CENTER, text, regular(11.0), t.text_secondary); + } let q = st.query.trim().to_lowercase(); let in_group = |v: &Variable| st.group.as_ref().is_none_or(|g| v.name.starts_with(&format!("{g}/"))); let mut list: Vec<&Variable> = - vars.variables.iter().filter(|v| v.collection == coll.id && in_group(v) && (q.is_empty() || v.name.to_lowercase().contains(&q))).collect(); + view.in_collection(&coll.id).filter(|v| in_group(v) && (q.is_empty() || v.name.to_lowercase().contains(&q))).collect(); list.sort_by(|a, b| group_of(&a.name).cmp(group_of(&b.name))); let width = (NAME_W + coll.modes.len() as f32 * MODE_W + 40.0).max(ui.available_width()); egui::ScrollArea::both().id_salt("vars_table").auto_shrink([false, false]).show(ui, |ui| { ui.spacing_mut().item_spacing = vec2(0.0, 0.0); - header_row(app, ui, &coll, st, width); + header_row(app, ui, view, coll, st, width); let mut group: Option<&str> = None; for v in &list { let g = group_of(&v.name); @@ -371,17 +493,19 @@ fn table(app: &mut FigApp, ui: &mut Ui, vars: &Variables, st: &mut Editor) { ui.painter().text(pos2(r.min.x + 16.0, r.center().y + 2.0), Align2::LEFT_CENTER, g, semibold(11.0), t.text_secondary); } } - var_row(app, ui, vars, &coll, v, st, width); + var_row(app, ui, view, coll, v, st, width); } if list.is_empty() && !q.is_empty() { let (r, _) = ui.allocate_exact_size(vec2(width, ROW_H), Sense::hover()); ui.painter().text(pos2(r.min.x + 16.0, r.center().y), Align2::LEFT_CENTER, "No matching variables", regular(11.0), t.text_secondary); } - create_row(app, ui, vars, &coll, st); + if view.editable() { + create_row(app, ui, view, coll, st); + } }); } -fn header_row(app: &mut FigApp, ui: &mut Ui, coll: &Collection, st: &mut Editor, width: f32) { +fn header_row(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, st: &mut Editor, width: f32) { let t = Tokens::get(ui.ctx()); let (r, _) = ui.allocate_exact_size(vec2(width, 32.0), Sense::hover()); ui.painter().text(pos2(r.min.x + 16.0, r.center().y), Align2::LEFT_CENTER, "Name", regular(11.0), t.text_secondary); @@ -389,23 +513,22 @@ fn header_row(app: &mut FigApp, ui: &mut Ui, coll: &Collection, st: &mut Editor, let x = r.min.x + NAME_W + i as f32 * MODE_W; let cell = Rect::from_min_max(pos2(x + 4.0, r.min.y + 4.0), pos2(x + MODE_W - 8.0, r.max.y - 4.0)); let target = Rename::Mode(coll.id.clone(), m.id.clone()); - if st.rename.as_ref() == Some(&target) { + if view.editable() && st.rename.as_ref() == Some(&target) { if let Some(name) = rename_field(ui, st, &target, &m.name, cell.shrink2(vec2(4.0, 2.0)), regular(11.0)) { app.run("variable.renameMode", json!({"collection": coll.id, "mode": m.id, "name": name})); } continue; } + let name_pos = pos2(cell.min.x + 4.0, cell.center().y); + if !view.editable() { + ui.painter().with_clip_rect(cell).text(name_pos, Align2::LEFT_CENTER, &m.name, regular(11.0), t.text_secondary); + continue; + } let resp = ui.interact(cell, Id::new(("vars_mode", &m.id)), Sense::click()); if resp.hovered() { ui.painter().rect_filled(cell, CornerRadius::same(4), t.hover); } - ui.painter().with_clip_rect(cell).text( - pos2(cell.min.x + 4.0, cell.center().y), - Align2::LEFT_CENTER, - &m.name, - regular(11.0), - t.text_secondary, - ); + ui.painter().with_clip_rect(cell).text(name_pos, Align2::LEFT_CENTER, &m.name, regular(11.0), t.text_secondary); if resp.double_clicked() { start_rename(ui, st, target.clone()); } @@ -421,7 +544,9 @@ fn header_row(app: &mut FigApp, ui: &mut Ui, coll: &Collection, st: &mut Editor, }); } let x = r.min.x + NAME_W + coll.modes.len() as f32 * MODE_W; - if small_button(ui, Id::new("vars_add_mode"), Rect::from_center_size(pos2(x + 14.0, r.center().y), vec2(24.0, 24.0)), Icon::Plus, "Add mode") { + if view.editable() + && small_button(ui, Id::new("vars_add_mode"), Rect::from_center_size(pos2(x + 14.0, r.center().y), vec2(24.0, 24.0)), Icon::Plus, "Add mode") + { let name = unique("Mode", |n| coll.modes.iter().any(|m| m.name == n)); if let Some(id) = app.run("variable.addMode", json!({"collection": coll.id, "name": name})).and_then(|r| r["id"].as_str().map(str::to_string)) { @@ -431,16 +556,17 @@ fn header_row(app: &mut FigApp, ui: &mut Ui, coll: &Collection, st: &mut Editor, ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.border)); } -fn var_row(app: &mut FigApp, ui: &mut Ui, vars: &Variables, coll: &Collection, v: &Variable, st: &mut Editor, width: f32) { +fn var_row(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, v: &Variable, st: &mut Editor, width: f32) { let t = Tokens::get(ui.ctx()); + let editable = view.editable(); let (r, resp) = ui.allocate_exact_size(vec2(width, ROW_H), Sense::click()); - if resp.hovered() || st.details.as_deref() == Some(v.id.as_str()) { + if resp.hovered() || (editable && st.details.as_deref() == Some(v.id.as_str())) { ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover); } - kind_glyph(ui, vars, v, coll, Rect::from_center_size(pos2(r.min.x + 24.0, r.center().y), vec2(14.0, 14.0))); + kind_glyph(ui, view.vars, v, coll, Rect::from_center_size(pos2(r.min.x + 24.0, r.center().y), vec2(14.0, 14.0))); let name_r = Rect::from_min_max(pos2(r.min.x + 40.0, r.min.y + 8.0), pos2(r.min.x + NAME_W - 8.0, r.max.y - 8.0)); let target = Rename::Variable(v.id.clone()); - if st.rename.as_ref() == Some(&target) { + if editable && st.rename.as_ref() == Some(&target) { if let Some(name) = rename_field(ui, st, &target, &v.name, name_r, regular(11.0)) { app.run("variable.update", json!({"variable": v.id, "name": name})); } @@ -449,39 +575,34 @@ fn var_row(app: &mut FigApp, ui: &mut Ui, vars: &Variables, coll: &Collection, v let short = v.name.rsplit('/').next().unwrap_or(&v.name); ui.painter().with_clip_rect(name_r).text(name_r.left_center(), Align2::LEFT_CENTER, short, regular(11.0), t.text); } - if resp.double_clicked() && ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| p.x < name_r.max.x) { - start_rename(ui, st, target.clone()); - } for (i, m) in coll.modes.iter().enumerate() { let x = r.min.x + NAME_W + i as f32 * MODE_W; - value_cell( - app, - ui, - vars, - v, - coll, - &m.id, - Rect::from_min_max(pos2(x + 4.0, r.center().y - 12.0), pos2(x + MODE_W - 8.0, r.center().y + 12.0)), - ); + let cell = Rect::from_min_max(pos2(x + 4.0, r.center().y - 12.0), pos2(x + MODE_W - 8.0, r.center().y + 12.0)); + value_cell(app, ui, view, v, coll, &m.id, cell); } - resp.context_menu(|ui| { - if menu_item(ui, "Edit variable", "") { - st.details = Some(v.id.clone()); - ui.close(); - } - if menu_item(ui, "Rename", "") { + if editable { + if resp.double_clicked() && ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| p.x < name_r.max.x) { start_rename(ui, st, target.clone()); - ui.close(); } - if menu_item(ui, "Duplicate", "") { - app.run("variable.duplicate", json!({"variable": v.id})); - ui.close(); - } - if menu_item(ui, "Delete variable", "") { - app.run("variable.delete", json!({"variable": v.id})); - ui.close(); - } - }); + resp.context_menu(|ui| { + if menu_item(ui, "Edit variable", "") { + st.details = Some(v.id.clone()); + ui.close(); + } + if menu_item(ui, "Rename", "") { + start_rename(ui, st, target.clone()); + ui.close(); + } + if menu_item(ui, "Duplicate", "") { + app.run("variable.duplicate", json!({"variable": v.id})); + ui.close(); + } + if menu_item(ui, "Delete variable", "") { + app.run("variable.delete", json!({"variable": v.id})); + ui.close(); + } + }); + } ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.border)); } @@ -524,48 +645,50 @@ fn set(app: &mut FigApp, v: &Variable, mode: &str, value: Value) { } /// One value cell: an editor for the value, or the aliased variable's chip; the variable button -/// on the right makes it an alias. -fn value_cell(app: &mut FigApp, ui: &mut Ui, vars: &Variables, v: &Variable, coll: &Collection, mode: &str, rect: Rect) { +/// on the right makes it an alias. A library's values only show. +fn value_cell(app: &mut FigApp, ui: &mut Ui, view: &View, v: &Variable, coll: &Collection, mode: &str, rect: Rect) { let t = Tokens::get(ui.ctx()); let Some(val) = v.values.get(mode) else { return }; + let editable = view.editable(); let id = Id::new(("var_cell", &v.id, mode)); // The value editor leaves room for the alias button. let body = Rect::from_min_max(rect.min, pos2(rect.max.x - 26.0, rect.max.y)); let alias_btn = Rect::from_center_size(pos2(rect.max.x - 11.0, rect.center().y), vec2(22.0, 22.0)); - let hovered = ui.rect_contains_pointer(rect.expand2(vec2(4.0, 6.0))); - match val { - VariableValue::Alias(target) => { - let full = vars.get(target).map(|x| x.name.as_str()).unwrap_or("Missing variable"); - let resp = ui.interact(body, id.with("alias"), Sense::click()).on_hover_text("Change alias"); - ui.painter().rect_filled(body, CornerRadius::same(5), t.field); - let chip = body.shrink2(vec2(3.0, 3.0)); - ui.painter().rect_filled(chip, CornerRadius::same(4), t.hover); - ui.painter().rect_stroke( - chip, - CornerRadius::same(4), - Stroke::new(1.0, if resp.hovered() { t.border_strong } else { t.border }), - StrokeKind::Inside, - ); - icons::paint( - ui.painter(), - Rect::from_center_size(pos2(chip.min.x + 11.0, chip.center().y), vec2(12.0, 12.0)), - Icon::Variable, - t.icon_secondary, - ); - let text_r = Rect::from_min_max(pos2(chip.min.x + 21.0, chip.min.y), pos2(chip.max.x - 4.0, chip.max.y)); - ui.painter().with_clip_rect(text_r).text(text_r.left_center(), Align2::LEFT_CENTER, full, regular(11.0), t.text); - if resp.clicked() { - crate::variable_ui::open_alias(ui, &v.id, mode, v.kind, body.left_bottom()); - } - // Detach: keep the value it resolves to now. - if hovered && small_button(ui, id.with("detach"), alias_btn, Icon::Detach, "Detach alias") { - let modes = [(coll.id.clone(), mode.to_string())].into_iter().collect(); - if let Some(resolved) = vars.resolve(&v.id, &modes) { - set(app, v, mode, value_json(&resolved)); - } - } - return; + let hovered = editable && ui.rect_contains_pointer(rect.expand2(vec2(4.0, 6.0))); + if let VariableValue::Alias(target) = val { + let full = view.vars.get(target).map(|x| x.name.as_str()).unwrap_or("Missing variable"); + let resp = ui.interact(body, id.with("alias"), if editable { Sense::click() } else { Sense::hover() }); + let resp = if editable { resp.on_hover_text("Change alias") } else { resp }; + ui.painter().rect_filled(body, CornerRadius::same(5), t.field); + let chip = body.shrink2(vec2(3.0, 3.0)); + ui.painter().rect_filled(chip, CornerRadius::same(4), t.hover); + let border = if editable && resp.hovered() { t.border_strong } else { t.border }; + ui.painter().rect_stroke(chip, CornerRadius::same(4), Stroke::new(1.0, border), StrokeKind::Inside); + icons::paint( + ui.painter(), + Rect::from_center_size(pos2(chip.min.x + 11.0, chip.center().y), vec2(12.0, 12.0)), + Icon::Variable, + t.icon_secondary, + ); + let text_r = Rect::from_min_max(pos2(chip.min.x + 21.0, chip.min.y), pos2(chip.max.x - 4.0, chip.max.y)); + ui.painter().with_clip_rect(text_r).text(text_r.left_center(), Align2::LEFT_CENTER, full, regular(11.0), t.text); + if resp.clicked() { + crate::variable_ui::open_alias(ui, &v.id, mode, v.kind, body.left_bottom()); } + // Detach: keep the value it resolves to now. + if hovered && small_button(ui, id.with("detach"), alias_btn, Icon::Detach, "Detach alias") { + let modes = [(coll.id.clone(), mode.to_string())].into_iter().collect(); + if let Some(resolved) = view.vars.resolve(&v.id, &modes) { + set(app, v, mode, value_json(&resolved)); + } + } + return; + } + if !editable { + read_only_value(ui, val, body); + return; + } + match val { VariableValue::Color(c) => { ui.painter().rect_filled(body, CornerRadius::same(5), t.field); let sw = Rect::from_center_size(pos2(body.min.x + 13.0, body.center().y), vec2(14.0, 14.0)); @@ -581,8 +704,7 @@ fn value_cell(app: &mut FigApp, ui: &mut Ui, vars: &Variables, v: &Variable, col let nc = if h.trim().trim_start_matches('#').len() == 8 { nc } else { nc.with_alpha(c.a) }; set(app, v, mode, json!(nc.to_css_hex())); } - let pct = format!("{}%", (c.a * 100.0).round()); - ui.painter().text(pos2(body.max.x - 8.0, body.center().y), Align2::RIGHT_CENTER, pct, regular(11.0), t.text_secondary); + alpha_text(ui, c, body); } VariableValue::Number(x) => { let mut child = ui.new_child(UiBuilder::new().max_rect(body)); @@ -606,13 +728,40 @@ fn value_cell(app: &mut FigApp, ui: &mut Ui, vars: &Variables, v: &Variable, col set(app, v, mode, json!(!b)); } } + VariableValue::Alias(_) => {} } if hovered && small_button(ui, id.with("mkalias"), alias_btn, Icon::Variable, "Create alias") { crate::variable_ui::open_alias(ui, &v.id, mode, v.kind, body.left_bottom()); } } -fn create_row(app: &mut FigApp, ui: &mut Ui, vars: &Variables, coll: &Collection, st: &mut Editor) { +/// A colour's alpha as a percentage at the right end of `body`. +fn alpha_text(ui: &Ui, c: &figcraft_color::Color, body: Rect) { + let t = Tokens::get(ui.ctx()); + let pct = format!("{}%", (c.a * 100.0).round()); + ui.painter().text(pos2(body.max.x - 8.0, body.center().y), Align2::RIGHT_CENTER, pct, regular(11.0), t.text_secondary); +} + +/// A value that only shows (a library's): its swatch and hex for colours, else its text. +fn read_only_value(ui: &Ui, val: &VariableValue, body: Rect) { + let t = Tokens::get(ui.ctx()); + ui.painter().rect_filled(body, CornerRadius::same(5), t.field); + let mut x = body.min.x + 8.0; + if let VariableValue::Color(c) = val { + let sw = Rect::from_center_size(pos2(body.min.x + 13.0, body.center().y), vec2(14.0, 14.0)); + widgets::paint_swatch(ui.painter(), sw, &figcraft_color::Paint::solid(*c)); + x = sw.max.x + 8.0; + alpha_text(ui, c, body); + } + let text = match val { + VariableValue::Boolean(b) => (if *b { "True" } else { "False" }).to_string(), + other => other.display(), + }; + let text_r = Rect::from_min_max(pos2(x, body.min.y), pos2(body.max.x - 44.0, body.max.y)); + ui.painter().with_clip_rect(text_r).text(text_r.left_center(), Align2::LEFT_CENTER, text, regular(11.0), t.text); +} + +fn create_row(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, st: &mut Editor) { ui.add_space(8.0); ui.horizontal(|ui| { ui.add_space(12.0); @@ -626,7 +775,7 @@ fn create_row(app: &mut FigApp, ui: &mut Ui, vars: &Variables, coll: &Collection VariableType::Boolean => "Boolean", }; if menu_item(ui, label, "") { - let name = unique(label, |n| vars.variables.iter().any(|v| v.collection == coll.id && v.name == n)); + let name = unique(label, |n| view.in_collection(&coll.id).any(|v| v.name == n)); let params = json!({"collection": coll.id, "name": name, "type": kind.id()}); if let Some(id) = app.run("variable.create", params).and_then(|r| r["id"].as_str().map(str::to_string)) { st.query.clear(); @@ -640,9 +789,10 @@ fn create_row(app: &mut FigApp, ui: &mut Ui, vars: &Variables, coll: &Collection ui.add_space(12.0); } -/// The details of one variable: name, description and where pickers offer it. +/// The details of one of this file's variables: name, description, where pickers offer it and +/// whether publishing the file as a library leaves it out. fn details(app: &mut FigApp, ctx: &Context, vars: &Variables, st: &mut Editor) { - let Some(v) = st.details.as_ref().and_then(|id| vars.get(id)).cloned() else { + let Some(v) = st.details.as_ref().filter(|_| st.library.is_none()).and_then(|id| vars.get(id)).cloned() else { st.details = None; return; }; @@ -666,6 +816,10 @@ fn details(app: &mut FigApp, ctx: &Context, vars: &Variables, st: &mut Editor) { if let Some(d) = widgets::text_field(ui, Id::new(("var_detail_desc", &v.id)), &v.description, 260.0, "What it's for") { app.run("variable.update", json!({"variable": v.id, "description": d})); } + ui.add_space(6.0); + if widgets::checkbox(ui, v.hidden_from_publishing, "Hide from publishing") { + app.run("variable.update", json!({"variable": v.id, "hiddenFromPublishing": !v.hidden_from_publishing})); + } let scopes = scopes_for(v.kind); if scopes.is_empty() { return; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 41c6beb..dc29918 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -99,7 +99,8 @@ Invoke a menu item exactly like clicking it (this may open a dialog). Returns `{ Switch panels and modes: `rightTab` `design|prototype|inspect`, `leftTab` `file|assets`, `theme`, `home` (bool), `palette` (bool: the Actions palette), `mainMenu` (bool), `colorPicker` (`{fills?, index?}`: open the colour picker on the selection's paint; `null` closes it), `variables` (bool, -or a collection id or name to show: the Variables editor), `layerFilter` (string: +or a collection id or name to show, or `{library, collection?}` for an enabled library's collection: the +Variables editor), `layerFilter` (string: open the layers search with that filter; `null` closes it), `collapseLayers` (true: collapse the layers tree), `exportPreview` (bool: the Design panel Export section's preview), `preview` (true or a frame id: open the prototype preview pane like `proto.preview`; false or `null` closes it), diff --git a/docs/mcp.md b/docs/mcp.md index 2c3bc61..59313d0 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -166,14 +166,17 @@ accounts, nothing on the network; the browser build has no file system, so there read library files return an error). - **Publish** (in the library file): `library.publish {description?, include?, exclude?}` publishes - the file's components, component sets and styles as a new version (by default everything except - names starting with `.` or `_`, or what the last publish offered; `include`/`exclude` take - component ids and style ids or names). Components and styles they use come along, unoffered. Each - asset keeps the version it last changed in; publishing with no changes is refused. Save the file - (`file.save`) so other files see the new version. + the file's components, component sets, styles and variable collections as a new version (by + default everything except names starting with `.` or `_`, or what the last publish offered; + `include`/`exclude` take component ids and style or collection ids or names). A collection comes + with its variables (values per mode, aliases, scopes), except those hidden from publishing + (`variable.update {variable, hiddenFromPublishing: true}`). Components, styles and variables that + published assets use (a component's bound variables, a variable's alias targets) come along, + unoffered. Each asset keeps the version it last changed in; publishing with no changes is refused. + Save the file (`file.save`) so other files see the new version. - **Enable**: `library.add {path}` / `library.remove {id}` (id, name or path) / `library.list - {assets?}` (versions, pending updates, errors; with `assets: true` each library's components and - styles, and what this file can publish). Libraries are read read-only with size limits, and read + {assets?}` (versions, pending updates, errors; with `assets: true` each library's components, + styles, variable collections (with their modes) and variables, and what this file can publish). Libraries are read read-only with size limits, and read again when the file changes. - **Use**: `library.place {library, component, x?, y?}` (a component, set or variant by id or name; page point, default the view's centre) copies the component, with the components and styles it @@ -181,10 +184,19 @@ read library files return an error). {library, component}` only copies it (then `instance.create`, `instance.swap`…). `library.applyStyle {library, style, ids?, target?}` copies a style in (linked, read-only) and applies it. Instances of library components behave like any instance: overrides, properties, - variants, swap, reset, detach. Editing the copies (or library styles) is refused; edit the library - file and publish. -- **Update**: `library.updates` lists the components and styles with a newer published version; - `library.acceptUpdates {ids? (library ids)}` pulls them in, keeping instance overrides. + variants, swap, reset, detach. Library components keep their variable bindings and explicit modes + (bound to copies of the library's variables). +- **Library variables**: `library.bindVariable {library, variable, field, ids?}` copies a published + variable in (linked, read-only, with the variables it aliases and its collection) and binds it + like `variable.bind`; `library.aliasVariable {library, alias, variable, mode?}` makes a local + variable's value an alias of a library variable; `library.setVariableMode {library, collection, + mode, ids?}` sets a library collection's mode on frames (the page when no ids are given); + `library.importVariable {library, variable}` only copies it (then `variable.bind`, `variable.set + {value: {alias}}`). The copies render without the library file. Editing the copies (library + components, styles, variables or collections) is refused; edit the library file and publish. +- **Update**: `library.updates` lists the components, styles, variable collections and variables + with a newer published version; `library.acceptUpdates {ids? (library ids)}` pulls them in, + keeping instance overrides; bound layers, aliases and explicit modes follow the updated values. ### Slots diff --git a/docs/parity.md b/docs/parity.md index a0a030a..11fc885 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -1,6 +1,6 @@ # Parity report -Generated by `cargo xtask parity`: FigCraft commands registered for the incumbent's main menu and toolbar (381 commands in the engine). +Generated by `cargo xtask parity`: FigCraft commands registered for the incumbent's main menu and toolbar (389 commands in the engine). **197 / 202 menu items (97.5%)**