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