mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 02:07:09 -06:00
- A visibility variable could hide an object but never show it again: dataset application, capture and binding refused any object that wasn't visible (`is_editable`), so a row that hid an object left it hidden for good and a capture after hiding it in the Layers panel didn't record it. They now use `Document::variable_target`, which ignores the object's own visibility (locked art and art on hidden or locked layers are still skipped). - Renaming a variable renamed the active dataset when the two shared a name, leaving it pointing at a dataset that doesn't exist. - `variable.delete` and `dataset.delete` without `names` act on the highlighted variable and the active dataset, so Window > Variables > Delete Variable / Delete Data Set no longer fail with "missing `names`". - `ids` is checked like other commands' explicit targets (a non-list no longer falls back to the selection, bad ids are errors), names are trimmed and capped at 255 characters, and the bind checks are shared by Bind and Make Dynamic. - Make Dynamic's fallback name no longer becomes " 2" for unnamed art. - `dataset.list` reads the art once for every row's drift check, and `Variables::prune` looks names up in a set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
208 lines
8.7 KiB
Rust
208 lines
8.7 KiB
Rust
//! Variables (data merge): named values bound to objects, with datasets that apply
|
|
//! them all at once. One template document produces many variants (badges, price
|
|
//! lists, localized copies) by selecting a dataset instead of editing each object.
|
|
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use super::{Document, NodeId, NodeKind};
|
|
|
|
/// What a variable drives.
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum VariableKind {
|
|
/// A type object's characters.
|
|
#[default]
|
|
Text,
|
|
/// Whether bound objects show.
|
|
Visibility,
|
|
}
|
|
|
|
impl VariableKind {
|
|
/// Parse the `kind` of `variable.define`: `text` or `visibility` (`visible` is accepted
|
|
/// as the same thing, as the panel and the docs call it both).
|
|
pub fn parse(s: &str) -> Option<Self> {
|
|
match s.to_ascii_lowercase().as_str() {
|
|
"text" => Some(Self::Text),
|
|
"visibility" | "visible" => Some(Self::Visibility),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// The kind's own name, as `variable.define` takes it and `variable.list` reports it.
|
|
pub fn label(self) -> &'static str {
|
|
match self {
|
|
Self::Text => "text",
|
|
Self::Visibility => "visibility",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One named variable.
|
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
|
pub struct Variable {
|
|
pub name: String,
|
|
pub kind: VariableKind,
|
|
}
|
|
|
|
/// One value a dataset gives a variable.
|
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum DataValue {
|
|
Text(String),
|
|
Visible(bool),
|
|
}
|
|
|
|
impl DataValue {
|
|
/// Whether this value fits the variable's kind (a dataset may carry values for
|
|
/// variables of either kind; each binding takes what fits it).
|
|
pub fn fits(&self, kind: VariableKind) -> bool {
|
|
matches!((self, kind), (Self::Text(_), VariableKind::Text) | (Self::Visible(_), VariableKind::Visibility))
|
|
}
|
|
}
|
|
|
|
/// One named row of values, applied with `dataset.select`.
|
|
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
|
pub struct DataSet {
|
|
pub name: String,
|
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
|
pub values: BTreeMap<String, DataValue>,
|
|
}
|
|
|
|
impl DataSet {
|
|
/// Whether the art, as [`Document::captured_values`] read it, still holds what this row
|
|
/// says. A value the art can't report (its object is locked or gone) isn't drift: the row
|
|
/// keeps it, and applying it again would skip it too.
|
|
pub fn matches(&self, current: &BTreeMap<String, DataValue>) -> bool {
|
|
self.values.iter().all(|(k, v)| current.get(k).is_none_or(|c| c == v))
|
|
}
|
|
}
|
|
|
|
/// The variable state of a document: definitions, datasets, bindings
|
|
/// (object id → the variables bound to it) and which dataset is active.
|
|
///
|
|
/// An object may be driven by several variables — the reference app binds the selected object
|
|
/// to the selected variable, one click at a time, and never says a second binding replaces the
|
|
/// first — so a binding is a set. `dataset.select` applies them in the order the variables are
|
|
/// defined, which is what makes two text variables on one object deterministic: the last one
|
|
/// wins, and `applied` counts both.
|
|
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
|
pub struct Variables {
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub variables: Vec<Variable>,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub datasets: Vec<DataSet>,
|
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
|
pub bindings: BTreeMap<NodeId, BTreeSet<String>>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub active_dataset: Option<String>,
|
|
}
|
|
|
|
impl Variables {
|
|
/// The variable `name`, if the document defines it.
|
|
pub fn variable(&self, name: &str) -> Option<&Variable> {
|
|
self.variables.iter().find(|v| v.name == name)
|
|
}
|
|
|
|
/// The dataset `name`, if the document has it.
|
|
pub fn dataset(&self, name: &str) -> Option<&DataSet> {
|
|
self.datasets.iter().find(|d| d.name == name)
|
|
}
|
|
|
|
/// Bind `id` to the variable `name`. True when it was not bound to it already.
|
|
pub fn bind(&mut self, id: NodeId, name: &str) -> bool {
|
|
self.bindings.entry(id).or_default().insert(name.to_string())
|
|
}
|
|
|
|
/// Drop `id`'s binding to `name` (and the binding itself when that was its last variable).
|
|
/// True when there was one.
|
|
pub fn unbind(&mut self, id: NodeId, name: &str) -> bool {
|
|
let Some(bound) = self.bindings.get_mut(&id) else { return false };
|
|
let dropped = bound.remove(name);
|
|
if bound.is_empty() {
|
|
self.bindings.remove(&id);
|
|
}
|
|
dropped
|
|
}
|
|
|
|
/// The objects bound to the variable `name`, in document order.
|
|
pub fn objects_of(&self, name: &str) -> Vec<NodeId> {
|
|
let mut ids: Vec<NodeId> = self.bindings.iter().filter(|(_, v)| v.contains(name)).map(|(id, _)| *id).collect();
|
|
ids.sort_unstable();
|
|
ids
|
|
}
|
|
|
|
/// Drop the variables `names`, the dataset values that name them and the bindings to
|
|
/// them. Datasets named here are left alone: their names are their own namespace (the
|
|
/// active one is dropped by whoever deletes the dataset).
|
|
pub fn prune(&mut self, names: &[String]) -> usize {
|
|
// A set, so a long list of names costs a lookup per value rather than a scan.
|
|
let names: BTreeSet<&str> = names.iter().map(String::as_str).collect();
|
|
let before = self.variables.len();
|
|
self.variables.retain(|v| !names.contains(v.name.as_str()));
|
|
for dataset in &mut self.datasets {
|
|
dataset.values.retain(|k, _| !names.contains(k.as_str()));
|
|
}
|
|
for bound in self.bindings.values_mut() {
|
|
bound.retain(|v| !names.contains(v.as_str()));
|
|
}
|
|
self.bindings.retain(|_, v| !v.is_empty());
|
|
before - self.variables.len()
|
|
}
|
|
}
|
|
|
|
impl Document {
|
|
/// Forget the bindings of objects that are no longer in the document (after every edit),
|
|
/// like [`Document::prune_assets`] does for Asset Export's. A binding outliving its object
|
|
/// would count as a skip in every dataset application and grow the saved file forever.
|
|
pub fn prune_variable_bindings(&mut self) {
|
|
if self.variables.bindings.keys().all(|id| self.node(*id).is_some()) {
|
|
return;
|
|
}
|
|
let mut bindings = std::mem::take(&mut self.variables.bindings);
|
|
bindings.retain(|id, _| self.node(*id).is_some());
|
|
self.variables.bindings = bindings;
|
|
}
|
|
|
|
/// Whether a variable may read and rewrite the object `id`: it is in the document, neither
|
|
/// it nor anything above it is locked, and nothing above it is hidden. Its own visibility
|
|
/// doesn't count, as that is what a visibility variable drives: a row that hid it must be
|
|
/// able to show it again, and a capture must record it hidden.
|
|
pub fn variable_target(&self, id: NodeId) -> bool {
|
|
self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| !n.locked && (n.visible || *i == id))))
|
|
}
|
|
|
|
/// What the object `id` holds for the variable of `kind`: its characters, or whether it
|
|
/// shows. `None` when the object is gone, is not of that kind, or is locked or on a hidden
|
|
/// or locked layer (the document's value is whatever it was left at).
|
|
pub fn variable_value(&self, id: NodeId, kind: VariableKind) -> Option<DataValue> {
|
|
if !self.variable_target(id) {
|
|
return None;
|
|
}
|
|
let node = self.node(id)?;
|
|
match (kind, &node.kind) {
|
|
(VariableKind::Text, NodeKind::Text(t)) => Some(DataValue::Text(t.plain_text())),
|
|
(VariableKind::Visibility, _) => Some(DataValue::Visible(node.visible)),
|
|
(VariableKind::Text, _) => None,
|
|
}
|
|
}
|
|
|
|
/// The values a dataset captured right now: what each bound object holds, in the order the
|
|
/// variables are defined. `dataset.capture` writes these into a new row and
|
|
/// `dataset.update` into the active one; the Variables panel compares the active row with
|
|
/// them to tell whether the art still matches what it says.
|
|
pub fn captured_values(&self) -> BTreeMap<String, DataValue> {
|
|
let mut out = BTreeMap::new();
|
|
for v in &self.variables.variables {
|
|
// An object driven by several variables of the same kind reads from the first one
|
|
// bound, so a capture is one value per variable and applies back the same way.
|
|
let value = self.variables.objects_of(&v.name).into_iter().find_map(|id| self.variable_value(id, v.kind));
|
|
if let Some(value) = value {
|
|
out.insert(v.name.clone(), value);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
}
|