From 555b0e60d37b273a772ae16af1038af1a24a7250 Mon Sep 17 00:00:00 2001 From: Fahri Meral Date: Fri, 9 Oct 2026 16:47:14 +0300 Subject: [PATCH 01/12] variables: data-merge model and engine commands --- crates/doc/src/lib.rs | 6 + crates/doc/src/variables.rs | 101 ++++++++++ crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/textedit.rs | 13 ++ crates/engine/src/cmd/variablecmds.rs | 275 ++++++++++++++++++++++++++ crates/engine/src/lib.rs | 2 + crates/engine/src/tests_variables.rs | 137 +++++++++++++ 7 files changed, 536 insertions(+) create mode 100644 crates/doc/src/variables.rs create mode 100644 crates/engine/src/cmd/variablecmds.rs create mode 100644 crates/engine/src/tests_variables.rs diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index b45b921a..3c555def 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -41,6 +41,7 @@ pub mod style_libs; pub mod swatches; pub mod text; pub mod trace; +pub mod variables; use std::collections::BTreeMap; use std::sync::Arc; @@ -91,6 +92,7 @@ pub use text::{ TextWrap, VerticalAlign, WrapShape, }; pub use trace::TraceView; +pub use variables::{DataSet, DataValue, Variable, VariableKind, Variables}; pub use vectorcraft_color as color; pub use vectorcraft_geom as geom; @@ -561,6 +563,9 @@ pub struct Document { /// Select → Save Selection… (at most [`SavedSelection::MAX`]); saved with the document. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub saved_selections: Vec, + /// Variables (data merge): named values bound to objects, applied per dataset. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub variables: Variables, #[serde(default)] pub grid: GridPrefs, #[serde(default = "ppi72")] @@ -693,6 +698,7 @@ impl Document { guides: vec![], views: vec![], saved_selections: vec![], + variables: Variables::default(), grid: GridPrefs::default(), raster_effects_ppi: 72.0, images: BTreeMap::new(), diff --git a/crates/doc/src/variables.rs b/crates/doc/src/variables.rs new file mode 100644 index 00000000..14a7402d --- /dev/null +++ b/crates/doc/src/variables.rs @@ -0,0 +1,101 @@ +//! 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; + +use serde::{Deserialize, Serialize}; + +use super::NodeId; + +/// 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`). + pub fn parse(s: &str) -> Option { + match s.to_ascii_lowercase().as_str() { + "text" => Some(Self::Text), + "visibility" | "visible" => Some(Self::Visibility), + _ => None, + } + } + + 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, +} + +/// The variable state of a document: definitions, datasets, bindings +/// (object id → variable name) and which dataset is active. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Variables { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub variables: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub datasets: Vec, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub bindings: BTreeMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active_dataset: Option, +} + +impl Variables { + pub fn variable(&self, name: &str) -> Option<&Variable> { + self.variables.iter().find(|v| v.name == name) + } + + /// Drop the bindings (and the active dataset) that name what is gone. + pub fn prune(&mut self, names: &[String]) { + self.variables.retain(|v| !names.contains(&v.name)); + for dataset in &mut self.datasets { + dataset.values.retain(|k, _| !names.contains(k)); + } + self.bindings.retain(|_, v| !names.contains(v)); + if self.active_dataset.as_ref().is_some_and(|a| names.contains(a)) { + self.active_dataset = None; + } + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index dceb266e..228410de 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -77,6 +77,7 @@ pub(crate) mod textwrap; pub(crate) mod threads; pub(crate) mod typecmd; mod typemenu; +mod variablecmds; pub(crate) mod views; pub mod wand; pub mod webexport; @@ -235,6 +236,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(maskedit::specs()); v.extend(tabs::specs()); v.extend(docmenu::specs()); + v.extend(variablecmds::specs()); v.extend(docinfo::specs()); v.extend(panelcmds::specs()); v.extend(buildcmds::specs()); diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 509ea200..5787dd54 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -112,6 +112,19 @@ fn text_ref(s: &Session, id: NodeId) -> Result { } } +/// Replace a type object's whole content: the new text takes the replaced text's +/// style and paragraph styles follow, exactly as `text.editRange` over the full +/// range does. Dataset apply and friends share this so Variables edits behave +/// like Type tool edits. +pub(crate) fn set_plain_text(d: &mut vectorcraft_doc::Document, id: NodeId, text: &str) -> Result<()> { + let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?; + let len = edit::runs_len(&t.runs); + t.splice_paras(0, len, text); + edit::replace_range(&mut t.runs, 0, len, text); + refresh_bounds(t); + Ok(()) +} + fn byte_param(p: &Value, k: &str) -> Option { p.get(k).and_then(Value::as_u64).map(|v| v as usize) } diff --git a/crates/engine/src/cmd/variablecmds.rs b/crates/engine/src/cmd/variablecmds.rs new file mode 100644 index 00000000..eecc2133 --- /dev/null +++ b/crates/engine/src/cmd/variablecmds.rs @@ -0,0 +1,275 @@ +//! Variables (data merge): named values bound to objects, applied per dataset. +//! +//! One template document produces many variants: bind a type object to a `text` +//! variable and anything to a `visibility` variable, put a row of values in each +//! dataset, and `dataset.select` swaps the whole document to that row in one undo +//! step. Agents reach every command through `run_command`, like all engine commands. + +use serde_json::{Value, json}; +use vectorcraft_doc::{DataValue, NodeId, NodeKind, VariableKind}; + +use super::edit::selected_roots; +use super::textedit::set_plain_text; +use super::*; + +fn var_name(p: &Value, cmd: &str) -> Result { + match str_param(p, "name").filter(|v| !v.is_empty()) { + Some(v) => Ok(v.to_string()), + None => Err(bad(cmd, "missing `name`")), + } +} + +fn names_param(p: &Value, cmd: &str) -> Result> { + match p.get("names").and_then(Value::as_array) { + Some(names) if !names.is_empty() => { + let mut out = Vec::with_capacity(names.len()); + for n in names { + match n.as_str().filter(|v| !v.is_empty()) { + Some(v) => out.push(v.to_string()), + None => return Err(bad(cmd, "`names` must be non-empty strings")), + } + } + Ok(out) + } + _ => Err(bad(cmd, "missing `names`")), + } +} + +fn dataset_value(cmd: &str, var: &str, kind: VariableKind, v: &Value) -> Result { + match kind { + VariableKind::Text => v.as_str().map(|t| DataValue::Text(t.to_string())), + VariableKind::Visibility => v.as_bool().map(DataValue::Visible), + } + .ok_or_else(|| bad(cmd, format!("value for `{var}` must match its variable kind"))) +} + +fn values_param(s: &Session, p: &Value, cmd: &str) -> Result> { + let mut out = std::collections::BTreeMap::new(); + let Some(given) = p.get("values") else { return Ok(out) }; + let Some(obj) = given.as_object() else { return Err(bad(cmd, "`values` must be an object")) }; + for (var, v) in obj { + let kind = s.doc()?.doc.variables.variable(var).map(|d| d.kind).ok_or_else(|| bad(cmd, format!("no variable named `{var}`")))?; + out.insert(var.clone(), dataset_value(cmd, var, kind, v)?); + } + Ok(out) +} + +fn define(s: &mut Session, p: &Value) -> Result { + const C: &str = "variable.define"; + let name = var_name(p, C)?; + let kind = str_param(p, "kind").and_then(VariableKind::parse).ok_or_else(|| bad(C, "`kind` must be `text` or `visibility`"))?; + if s.doc()?.doc.variables.variable(&name).is_some() { + return Err(bad(C, format!("variable `{name}` is already defined"))); + } + s.edit("New Variable", |d, _| { + d.variables.variables.push(vectorcraft_doc::Variable { name: name.clone(), kind }); + Ok(()) + })?; + Ok(json!({"name": name, "kind": kind.label()})) +} + +fn delete(s: &mut Session, p: &Value) -> Result { + const C: &str = "variable.delete"; + let names = names_param(p, C)?; + let n = s.edit("Delete Variables", |d, _| { + d.variables.prune(&names); + Ok(names.len()) + })?; + Ok(json!({"deleted": n})) +} + +fn list(s: &mut Session, _p: &Value) -> Result { + let st = s.doc()?; + let variables: Vec = st + .doc + .variables + .variables + .iter() + .map(|v| { + let bindings = st.doc.variables.bindings.values().filter(|b| *b == &v.name).count(); + json!({"name": v.name, "kind": v.kind.label(), "bindings": bindings}) + }) + .collect(); + Ok(json!({"variables": variables})) +} + +fn bind(s: &mut Session, p: &Value) -> Result { + const C: &str = "variable.bind"; + let name = str_param(p, "variable").filter(|v| !v.is_empty()).map(str::to_string).ok_or_else(|| bad(C, "missing `variable`"))?; + let ids = match ids_param(p, "ids") { + Some(v) => v, + None => selected_roots(s)?, + }; + if ids.is_empty() { + return Err(bad(C, "nothing to bind")); + } + let kind = s.doc()?.doc.variables.variable(&name).map(|v| v.kind).ok_or_else(|| bad(C, format!("no variable named `{name}`")))?; + // A text variable only drives type; visibility drives anything that exists. + if kind == VariableKind::Text { + for id in &ids { + match s.doc()?.doc.node(*id).map(|n| &n.kind) { + Some(NodeKind::Text(_)) => {} + Some(_) => return Err(bad(C, format!("node {} is not type", id.0))), + None => return Err(EngineError::NoNode(*id)), + } + } + } else { + for id in &ids { + if s.doc()?.doc.node(*id).is_none() { + return Err(EngineError::NoNode(*id)); + } + } + } + s.edit("Bind Variable", |d, _| { + for id in &ids { + d.variables.bindings.insert(*id, name.clone()); + } + Ok(()) + })?; + Ok(json!({"variable": name, "ids": ids.iter().map(|i| i.0).collect::>()})) +} + +fn unbind(s: &mut Session, p: &Value) -> Result { + let ids = match ids_param(p, "ids") { + Some(v) => v, + None => selected_roots(s)?, + }; + let n = s.edit("Unbind Variable", |d, _| { + let mut n = 0; + for id in &ids { + if d.variables.bindings.remove(id).is_some() { + n += 1; + } + } + Ok(n) + })?; + Ok(json!({"unbound": n})) +} + +fn dataset_new(s: &mut Session, p: &Value) -> Result { + const C: &str = "dataset.new"; + let name = var_name(p, C)?; + let values = values_param(s, p, C)?; + if s.doc()?.doc.variables.datasets.iter().any(|d| d.name == name) { + return Err(bad(C, format!("dataset `{name}` already exists"))); + } + s.edit("New Data Set", |d, _| { + d.variables.datasets.push(vectorcraft_doc::DataSet { name: name.clone(), values: values.clone() }); + Ok(()) + })?; + Ok(json!({"name": name})) +} + +fn dataset_delete(s: &mut Session, p: &Value) -> Result { + const C: &str = "dataset.delete"; + let names = names_param(p, C)?; + let n = s.edit("Delete Data Sets", |d, _| { + let before = d.variables.datasets.len(); + d.variables.datasets.retain(|d| !names.contains(&d.name)); + if d.variables.active_dataset.as_ref().is_some_and(|a| names.contains(a)) { + d.variables.active_dataset = None; + } + Ok(before - d.variables.datasets.len()) + })?; + Ok(json!({"deleted": n})) +} + +fn dataset_list(s: &mut Session, _p: &Value) -> Result { + let st = s.doc()?; + let datasets: Vec = st.doc.variables.datasets.iter().map(|d| json!({"name": d.name, "values": d.values})).collect(); + Ok(json!({"datasets": datasets, "active": st.doc.variables.active_dataset})) +} + +fn apply_dataset(s: &mut Session, name: &str) -> Result { + const C: &str = "dataset.select"; + let (applied, skipped) = s.edit("Data Set", |d, _| { + let ds = d.variables.datasets.iter().find(|x| x.name == name).cloned().ok_or_else(|| bad(C, format!("no dataset named `{name}`")))?; + let bindings: Vec<(NodeId, String)> = d.variables.bindings.iter().map(|(id, v)| (*id, v.clone())).collect(); + let mut applied = 0usize; + let mut skipped = 0usize; + for (id, var) in bindings { + let (kind, value) = match (d.variables.variable(&var).map(|v| v.kind), ds.values.get(&var)) { + (Some(kind), Some(value)) if value.fits(kind) => (kind, value.clone()), + _ => { + skipped += 1; + continue; + } + }; + let done = match (kind, &value) { + (VariableKind::Text, DataValue::Text(text)) => { + if d.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))) { + set_plain_text(d, id, text).is_ok() + } else { + false + } + } + (VariableKind::Visibility, DataValue::Visible(show)) => { + if let Some(n) = d.node_mut(id) { + n.visible = *show; + true + } else { + false + } + } + _ => false, + }; + if done { + applied += 1; + } else { + skipped += 1; + } + } + d.variables.active_dataset = Some(name.to_string()); + Ok((applied, skipped)) + })?; + Ok(json!({"name": name, "applied": applied, "skipped": skipped})) +} + +fn dataset_select(s: &mut Session, p: &Value) -> Result { + let name = var_name(p, "dataset.select")?; + apply_dataset(s, &name) +} + +fn dataset_step(s: &mut Session, cmd: &str, forward: bool) -> Result { + let st = s.doc()?; + if st.doc.variables.datasets.is_empty() { + return Err(bad(cmd, "no datasets")); + } + let at = st.doc.variables.active_dataset.as_ref().and_then(|a| st.doc.variables.datasets.iter().position(|d| &d.name == a)); + let next = match at { + Some(i) if forward => (i + 1) % st.doc.variables.datasets.len(), + Some(i) => (i + st.doc.variables.datasets.len() - 1) % st.doc.variables.datasets.len(), + None if forward => 0, + None => st.doc.variables.datasets.len() - 1, + }; + let name = st.doc.variables.datasets.get(next).map(|d| d.name.clone()).ok_or_else(|| bad(cmd, "no datasets"))?; + apply_dataset(s, &name) +} + +pub fn specs() -> Vec { + vec![ + cmd!("variable.define", "New Variable", [], None, "{name, kind: text|visibility} define a variable", has_doc, define), + cmd!("variable.delete", "Delete Variable", [], None, "{names} delete variables with their bindings and values", has_doc, delete), + cmd!("variable.list", "List Variables", [], None, "{} → variables with kinds and binding counts", has_doc, list), + cmd!( + "variable.bind", + "Bind Variable", + [], + None, + "{variable, ids?} bind objects (default: the selection) to a variable; text needs type", + has_doc, + bind + ), + cmd!("variable.unbind", "Unbind Variable", [], None, "{ids?} drop bindings (default: the selection)", has_doc, unbind), + cmd!("dataset.new", "New Data Set", [], None, "{name, values?: {variable: value}} add a dataset", has_doc, dataset_new), + cmd!("dataset.delete", "Delete Data Set", [], None, "{names} delete datasets", has_doc, dataset_delete), + cmd!("dataset.list", "List Data Sets", [], None, "{} → datasets with values and the active one", has_doc, dataset_list), + cmd!("dataset.select", "Select Data Set", [], None, "{name} apply a dataset in one undo step → {applied, skipped}", has_doc, dataset_select), + cmd!("dataset.next", "Next Data Set", [], None, "{} apply the next dataset, wrapping", has_doc, |s, _| dataset_step(s, "dataset.next", true)), + cmd!("dataset.prev", "Previous Data Set", [], None, "{} apply the previous dataset, wrapping", has_doc, |s, _| dataset_step( + s, + "dataset.prev", + false + )), + ] +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8a964610..b8774641 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1663,6 +1663,8 @@ mod tests_typearea; #[cfg(test)] mod tests_units; #[cfg(test)] +mod tests_variables; +#[cfg(test)] mod tests_webexport; #[cfg(test)] mod tests_widthpoints; diff --git a/crates/engine/src/tests_variables.rs b/crates/engine/src/tests_variables.rs new file mode 100644 index 00000000..6f03fd11 --- /dev/null +++ b/crates/engine/src/tests_variables.rs @@ -0,0 +1,137 @@ +//! Variables (data merge): definitions with kinds, bindings to objects, datasets +//! applied in one undo step, and validation that rejects instead of reinterpreting. + +use serde_json::{Value, json}; +use vectorcraft_doc::NodeId; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + s +} + +fn text(s: &mut Session, content: &str) -> NodeId { + let r = s.execute("text.create", &json!({"x": 10, "y": 10, "text": content})).unwrap(); + NodeId(r["id"].as_u64().unwrap()) +} + +fn rect(s: &mut Session) -> NodeId { + let r = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap(); + NodeId(r["id"].as_u64().unwrap()) +} + +fn plain(s: &Session, id: NodeId) -> String { + match &s.doc().unwrap().doc.node(id).unwrap().kind { + vectorcraft_doc::NodeKind::Text(t) => t.plain_text(), + _ => panic!("not text"), + } +} + +#[test] +fn text_and_visibility_datasets_apply_in_one_undo_step() { + let mut s = session(); + let t = text(&mut s, "Alice"); + let r = rect(&mut s); + s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Show", "ids": [r.0]})).unwrap(); + s.execute("dataset.new", &json!({"name": "B", "values": {"Name": "Bob", "Show": false}})).unwrap(); + + let v = s.execute("dataset.select", &json!({"name": "B"})).unwrap(); + assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64()), (Some(2), Some(0))); + assert_eq!(plain(&s, t), "Bob"); + assert!(!s.doc().unwrap().doc.node(r).unwrap().visible); + + // One undo step covers the whole dataset application. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(plain(&s, t), "Alice"); + assert!(s.doc().unwrap().doc.node(r).unwrap().visible); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(plain(&s, t), "Bob"); +} + +#[test] +fn dataset_next_and_prev_wrap_around() { + let mut s = session(); + let t = text(&mut s, "x"); + s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap(); + for name in ["A", "B"] { + s.execute("dataset.new", &json!({"name": name, "values": {"Name": name}})).unwrap(); + } + s.execute("dataset.next", &json!({})).unwrap(); + assert_eq!(plain(&s, t), "A"); + s.execute("dataset.next", &json!({})).unwrap(); + assert_eq!(plain(&s, t), "B"); + s.execute("dataset.next", &json!({})).unwrap(); + assert_eq!(plain(&s, t), "A"); + s.execute("dataset.prev", &json!({})).unwrap(); + assert_eq!(plain(&s, t), "B"); + let v = s.execute("dataset.list", &json!({})).unwrap(); + assert_eq!(v["active"], "B"); +} + +#[test] +fn mismatched_and_missing_bindings_are_skipped_and_counted() { + let mut s = session(); + let t = text(&mut s, "x"); + let r = rect(&mut s); + s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + // A text variable bound to a rectangle applies to nothing; a dataset value for an + // unbound variable is ignored. + s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap(); + s.execute("dataset.new", &json!({"name": "D", "values": {"Name": "y", "Ghost": "z"}})).unwrap_err(); + s.execute("dataset.new", &json!({"name": "D", "values": {"Name": "y"}})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Name", "ids": [r.0]})).unwrap_err(); + let v = s.execute("dataset.select", &json!({"name": "D"})).unwrap(); + assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64()), (Some(1), Some(0))); + assert_eq!(plain(&s, t), "y"); +} + +#[test] +fn deleting_a_variable_cleans_bindings_values_and_active_dataset() { + let mut s = session(); + let t = text(&mut s, "x"); + s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap(); + s.execute("dataset.new", &json!({"name": "D", "values": {"Name": "y"}})).unwrap(); + s.execute("dataset.select", &json!({"name": "D"})).unwrap(); + s.execute("variable.delete", &json!({"names": ["Name"]})).unwrap(); + let vars = s.execute("variable.list", &json!({})).unwrap(); + assert_eq!(vars["variables"].as_array().map(Vec::len), Some(0)); + let sets = s.execute("dataset.list", &json!({})).unwrap(); + assert!(sets["datasets"][0].get("values").is_none_or(|v| v == &json!({}))); + assert_eq!(sets["active"], "D"); + s.execute("dataset.delete", &json!({"names": ["D"]})).unwrap(); + let sets = s.execute("dataset.list", &json!({})).unwrap(); + assert_eq!(sets["datasets"].as_array().map(Vec::len), Some(0)); + assert!(sets["active"].is_null()); + let v: Value = serde_json::from_str(&serde_json::to_string(&s.doc().unwrap().doc).unwrap()).unwrap(); + assert!(v["variables"].get("bindings").is_none_or(|v| v == &json!({}))); +} + +#[test] +fn bad_variables_requests_are_errors_not_panics() { + let mut s = session(); + s.execute("variable.define", &json!({"name": "N", "kind": "text"})).unwrap(); + for (cmd, params) in [ + ("variable.define", json!({})), + ("variable.define", json!({"name": "", "kind": "text"})), + ("variable.define", json!({"name": "N", "kind": "colour"})), + ("variable.define", json!({"name": "N", "kind": "text"})), + ("variable.delete", json!({})), + ("variable.bind", json!({})), + ("variable.bind", json!({"variable": "N"})), + ("variable.bind", json!({"variable": "Missing", "ids": [1]})), + ("dataset.new", json!({})), + ("dataset.new", json!({"name": "D", "values": []})), + ("dataset.new", json!({"name": "D", "values": {"N": 5}})), + ("dataset.select", json!({"name": "Missing"})), + ("dataset.next", json!({})), + ] { + assert!(s.execute(cmd, ¶ms).is_err(), "{cmd} {params}"); + } +} From ba2069c057c3acc682b58436c070ca8a6bca760c Mon Sep 17 00:00:00 2001 From: Fahri Meral Date: Fri, 9 Oct 2026 16:47:14 +0300 Subject: [PATCH 02/12] variables: panel, window menu entry and translations --- crates/ui-egui/src/i18n/es.tsv | 12 ++++ crates/ui-egui/src/i18n/fr.tsv | 12 ++++ crates/ui-egui/src/i18n/it.tsv | 12 ++++ crates/ui-egui/src/i18n/ja.tsv | 12 ++++ crates/ui-egui/src/i18n/ru.tsv | 12 ++++ crates/ui-egui/src/i18n/uk.tsv | 12 ++++ crates/ui-egui/src/i18n/zh-hans.tsv | 12 ++++ crates/ui-egui/src/i18n/zh-hant.tsv | 12 ++++ crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/panels/mod.rs | 2 + crates/ui-egui/src/panels/variables.rs | 90 ++++++++++++++++++++++++++ crates/ui-egui/src/state.rs | 1 + 12 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/src/panels/variables.rs diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 6746a917..e2a08889 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3055,6 +3055,18 @@ V: V: Variable Width Profile Perfil de anchura variable Variables Variables + New Variable Nueva variable + Delete Variable Eliminar variable + List Variables Enumerar variables + Bind Variable Vincular variable + Unbind Variable Desvincular variable + New Data Set Nuevo conjunto de datos + Delete Data Set Eliminar conjunto de datos + List Data Sets Enumerar conjuntos de datos + Select Data Set Seleccionar conjunto de datos + Next Data Set Conjunto de datos siguiente + Previous Data Set Conjunto de datos anterior + Data Sets Conjuntos de datos Variation: Variación: Vector Effects Efectos vectoriales Vector Halftone Semitono vectorial diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 27150a15..b138f38c 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3052,6 +3052,18 @@ V: V : Variable Width Profile Profil de largeur variable Variables Variables + New Variable Nouvelle variable + Delete Variable Supprimer la variable + List Variables Lister les variables + Bind Variable Lier la variable + Unbind Variable Délier la variable + New Data Set Nouveau jeu de données + Delete Data Set Supprimer le jeu de données + List Data Sets Lister les jeux de données + Select Data Set Sélectionner le jeu de données + Next Data Set Jeu de données suivant + Previous Data Set Jeu de données précédent + Data Sets Jeux de données Variation: Variation : Vector Effects Effets vectoriels Vector Halftone Trame de demi-teintes vectorielle diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 4ab058c3..7564f5e0 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3055,6 +3055,18 @@ V: V: Variable Width Profile Profilo larghezza variabile Variables Variabili + New Variable Nuova variabile + Delete Variable Elimina variabile + List Variables Elenca variabili + Bind Variable Collega variabile + Unbind Variable Scollega variabile + New Data Set Nuovo set di dati + Delete Data Set Elimina set di dati + List Data Sets Elenca set di dati + Select Data Set Seleziona set di dati + Next Data Set Set di dati successivo + Previous Data Set Set di dati precedente + Data Sets Set di dati Variation: Variazione: Vector Effects Effetti vettoriali Vector Halftone Retino vettoriale diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 28a5c518..373c08a0 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -569,6 +569,18 @@ Paragraph Styles 段落スタイル Tabs タブ Variables 変数 + New Variable 新規変数 + Delete Variable 変数を削除 + List Variables 変数一覧 + Bind Variable 変数をバインド + Unbind Variable 変数のバインドを解除 + New Data Set 新規データセット + Delete Data Set データセットを削除 + List Data Sets データセット一覧 + Select Data Set データセットを選択 + Next Data Set 次のデータセット + Previous Data Set 前のデータセット + Data Sets データセット Brush Libraries ブラシライブラリ Built-in Libraries 内蔵ライブラリ User Defined ユーザー定義 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index a222cde4..c3a9b8db 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3051,6 +3051,18 @@ V: В: Variable Width Profile Профиль переменной ширины Variables Переменные + New Variable Новая переменная + Delete Variable Удалить переменную + List Variables Перечислить переменные + Bind Variable Привязать переменную + Unbind Variable Отвязать переменную + New Data Set Новый набор данных + Delete Data Set Удалить набор данных + List Data Sets Перечислить наборы данных + Select Data Set Выделить набор данных + Next Data Set Следующий набор данных + Previous Data Set Предыдущий набор данных + Data Sets Наборы данных Variation: Вариация: Vector Effects Векторные эффекты Vector Halftone Векторный полутон diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 05c421b1..a7ada88f 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3047,6 +3047,18 @@ V: В: Variable Width Profile Профіль змінної ширини Variables Змінні + New Variable Нова змінна + Delete Variable Видалити змінну + List Variables Перерахувати змінні + Bind Variable Прив'язати змінну + Unbind Variable Відв'язати змінну + New Data Set Новий набір даних + Delete Data Set Видалити набір даних + List Data Sets Перерахувати набори даних + Select Data Set Виділити набір даних + Next Data Set Наступний набір даних + Previous Data Set Попередній набір даних + Data Sets Набори даних Variation: Варіація: Vector Effects Векторні ефекти Vector Halftone Векторний напівтон diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 7c4663ee..abfe42fc 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2947,6 +2947,18 @@ V: V: Variable Width Profile 变量宽度配置文件 Variables 变量 + New Variable 新建变量 + Delete Variable 删除变量 + List Variables 列出变量 + Bind Variable 绑定变量 + Unbind Variable 解除变量绑定 + New Data Set 新建数据集 + Delete Data Set 删除数据集 + List Data Sets 列出数据集 + Select Data Set 选择数据集 + Next Data Set 下一个数据集 + Previous Data Set 上一个数据集 + Data Sets 数据集 Variation: 变化: Vector Effects 矢量效果 Vector Halftone 矢量半色调 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 07735295..3a2636de 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3038,6 +3038,18 @@ V: V: Variable Width Profile 可變寬度描述檔 Variables 變數 + New Variable 新增變數 + Delete Variable 刪除變數 + List Variables 列出變數 + Bind Variable 綁定變數 + Unbind Variable 解除變數綁定 + New Data Set 新增資料集 + Delete Data Set 刪除資料集 + List Data Sets 列出資料集 + Select Data Set 選取資料集 + Next Data Set 下一個資料集 + Previous Data Set 上一個資料集 + Data Sets 資料集 Variation: 變化: Vector Effects 向量效果 Vector Halftone 向量半色調 diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a5ba1d39..372668b9 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -2548,7 +2548,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { panel("Tabs", "tabs"), ], ), - todo("Variables"), + panel("Variables", "variables"), Sep, sub("Brush Libraries", library_placeholders()), sub("Graphic Style Libraries", crate::panels::graphic_styles::window_menu()), diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index ec8ef59a..f8986904 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -42,6 +42,7 @@ pub mod tabs; pub mod text_styles; pub mod transform; pub mod transparency; +pub mod variables; use egui::{Rect, Sense, Ui, vec2}; use serde_json::{Value, json}; @@ -136,6 +137,7 @@ pub fn show_icon_panel(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) { "tabs" => tabs::show(app, ui), flattener_preview::ID => flattener_preview::show(app, ui), "attributes" => attributes::show(app, ui), + "variables" => variables::show(app, ui), "colorThemes" => color_themes::show(app, ui), links::ID => links::show(app, ui), asset_export::ID => asset_export::show(app, ui), diff --git a/crates/ui-egui/src/panels/variables.rs b/crates/ui-egui/src/panels/variables.rs new file mode 100644 index 00000000..59dba7b0 --- /dev/null +++ b/crates/ui-egui/src/panels/variables.rs @@ -0,0 +1,90 @@ +//! Variables panel: the document's variables (what they drive, what they bind) and +//! its datasets. Clicking a dataset applies it; clicking a variable selects what it +//! binds; the arrows step to the next or previous dataset. Definitions are made with +//! the `variable.*` and `dataset.*` commands (command palette, actions, MCP). + +use egui::Ui; +use serde_json::json; + +use crate::VectorcraftApp; +use crate::widgets; + +pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { + let Some(st) = app.session.active() else { + widgets::dim_label(ui, tl!("No document")); + return; + }; + // Owned snapshots: the rows below run commands, which borrow the app mutably. + let vars: Vec<(String, String, Vec)> = st + .doc + .variables + .variables + .iter() + .map(|v| { + let bound: Vec = st.doc.variables.bindings.iter().filter(|(_, name)| *name == &v.name).map(|(id, _)| id.0).collect(); + (v.name.clone(), v.kind.label().to_string(), bound) + }) + .collect(); + let sets: Vec<(String, usize, bool)> = st + .doc + .variables + .datasets + .iter() + .map(|d| (d.name.clone(), d.values.len(), st.doc.variables.active_dataset.as_deref() == Some(d.name.as_str()))) + .collect(); + widgets::subheader(ui, tl!("Variables")); + if vars.is_empty() { + widgets::dim_label(ui, tl!("None")); + } + for (name, kind, bound) in &vars { + let row = format!("{name} · {kind} · {}", bound.len()); + if ui.selectable_label(false, row).clicked() && !bound.is_empty() { + app.run("select.set", json!({"ids": bound})).ok(); + } + } + ui.horizontal(|ui| { + widgets::subheader(ui, tl!("Data Sets")); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.small_button("◀").clicked() { + app.run("dataset.prev", json!({})).ok(); + } + if ui.small_button("▶").clicked() { + app.run("dataset.next", json!({})).ok(); + } + }); + }); + if sets.is_empty() { + widgets::dim_label(ui, tl!("None")); + } + for (name, count, active) in &sets { + let row = format!("{}{name} · {count}", if *active { "● " } else { "" }); + if ui.selectable_label(*active, row).clicked() { + app.run("dataset.select", json!({"name": name})).ok(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn draws_headless() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + app.session.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hi"})).unwrap(); + app.session.execute("variable.define", &json!({"name": "Title", "kind": "text"})).unwrap(); + app.session.execute("dataset.new", &json!({"name": "A", "values": {"Title": "Yo"}})).unwrap(); + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + show(&mut app, ui); + }); + out.textures_delta.clear(); + let text = crate::tests_labels::painted_text(&mut app, show); + assert!(text.contains("Title") && text.contains("Data Sets") && text.contains("A · 1"), "{text}"); + // Clicking the dataset applies it. + app.run("dataset.select", json!({"name": "A"})).unwrap(); + let text = crate::tests_labels::painted_text(&mut app, show); + assert!(text.contains("● A"), "{text}"); + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 9c75a81f..994559f3 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -138,6 +138,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ (crate::panels::links::ID, "Links", "link"), (crate::panels::asset_export::ID, "Asset Export", "share-2"), (crate::panels::css_properties::ID, "CSS Properties", "globe"), + ("variables", "Variables", "dc-list-view"), ]; /// Groups of icon panels separated by dividers in the collapsed column. From c89906a13afe36a02599b7902331d9e4b51e82fd Mon Sep 17 00:00:00 2001 From: Fahri Meral Date: Fri, 9 Oct 2026 16:47:14 +0300 Subject: [PATCH 03/12] docs: note variables in ROADMAP automation row --- ROADMAP.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 731a5008..18208864 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -103,6 +103,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **Architecture:** 19+ crates with enforced layering (`cargo xtask layers`). Every action is a command (~400 engine + ~50 UI). Undo is unlimited via structural sharing. `command.batch` runs several commands as one transaction. - **Automation:** - Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command. + - Variables (data merge): text and visibility variables bound to objects, datasets applied in one undo step (`variable.*`, `dataset.*`), with a Variables panel; every command, like the rest, is drivable over MCP and the CLI. - JSON-lines control channel with real egui pointer and keyboard injection. - MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings. `--automation-read-root` / `--automation-write-root` (on `vectorcraft-cli mcp` and on `vectorcraft --control`) confine the files commands read and write to chosen folders, links followed, with the flags PhotoCraft takes (#832). - MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), large containers slice with `document.node {depth?, childLimit?}` (truncated levels report `childCount`), `document.inspect` and the `inspect_document` tool slice the same way so a depth-0 read is the layer skeleton (~9.7 KB instead of ~2 MB on a 2,000-object document), `document.find` locates nodes by name/kind/text with ancestor paths, and the CLI sweeps the protocol surface over stdio against the compiled binary. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads). @@ -241,7 +242,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | File formats | 6 | 85% | Affinity export, and the rest of Affinity import (effects, adjustments, brushes, master pages, live symbol instances), DWG (no open spec: DXF instead), PSD placement as layers (Photoshop Import Options; the merged image is done), EPS checked against more real files users send (Inkscape files open as vectors; other generators' constructs are tested with programs written from the PostScript Language Reference only; CID-keyed CJK type and `CCITTFaxDecode` images not read), legacy `.ai` files saved without the prolog that defines their operators (their own operator set isn't read yet), the rest of Illustrator's editing data (appearances and live effects, symbols, pattern fills, brushes, placed files: #637), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers, presets and Affinity import are done | 6–10 (existing scope; Affinity export unestimated) | | Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 | | Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done) | 1–2 | -| Automation | 3 | 60% | Variables (data merge), scripting surface, batch | 8–12 | +| Automation | 3 | 62% | scripting surface, batch (Variables: text/visibility variables bound to objects, datasets applied in one undo step, panel; image/graph kinds and dataset import open) | 6–10 | | UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | macOS native menu dismissal fixed (M14.8); remaining: Variables, SVG Interactivity, Properties per context, Consolidate All Windows, floating panels outside the app window and docked into any group, context menus beyond the canvas (Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done), about 17 Preferences options stored but not yet acted on (#394: Highlight Alternate Glyphs, Open Documents As Tabs, Real-time Drawing, hyphenation language, Appearance of Black, devices…) | 14–22 | | Libraries, Links, Package | 2 | 82% | the Libraries panel is local only (no cloud by design: libraries are files on this machine) and has no shared libraries, linked graphics or gradients and patterns as items yet; its graphics, colours and text styles (#745) and its search, groups and library export/import as `.vclibrary` files (#926) are done; Links and Package are done; placed VectorCraft documents are done (linked by default, vectors in every output, previews, Break Link, Expand, Edit Original, refresh on save) | 2–3 | | **Feature parity** | **103** | **~75%** | | **~210–330** | From 202894372a17a4eb82f652bff660539fcd3b7d72 Mon Sep 17 00:00:00 2001 From: Fahri Meral Date: Fri, 9 Oct 2026 19:48:15 +0300 Subject: [PATCH 04/12] variables: fix binding lifetime, dataset editing and the missing UI to define MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of #761 turned up three real defects and a feature that was visible but not usable. All of it is fixed here, each defect with a regression test that fails without the fix. - A binding outlived the object it named. Deleting art left its entry in `variables.bindings` forever: it counted as a skip in every dataset application, `variable.list` counted it, and the saved file grew with every delete. `Document::prune_variable_bindings` now lets them go after each edit, as `prune_assets` does for Asset Export's. - `Variables::prune` cleared `active_dataset` by comparing it against the *variable* names being deleted. A dataset named like a variable lost its active state when the variable went. The two are separate namespaces; the block is gone (dataset.delete already did it right). - `variable.delete` reported the number of names asked for, not the number deleted, so deleting a name that was not there still said `deleted: 1`. - `dataset.set` (new): a dataset's values could only be set when it was created, so changing one meant deleting and rebuilding the row — losing its place in the list and the active one. This replaces the row's values and reports what it dropped. - Window › Variables, and dialogs for the "…" commands. Every command had an empty menu path and the command palette runs those with `{}`, so nothing could be defined from the UI at all. The commands now carry their menu path, the "…" ones open a Variables dialog (a name and a kind; a name and a field per variable, its kind's editor), and `dataset.set` opens with the row's own values. - Locked art is neither bound nor rewritten by a dataset, and says so. - `set_plain_text` told a node that existed but was not type that it was missing. - The panel had Document Info's icon, so the two were indistinguishable in the collapsed column; it gets its own, plus New/Delete buttons, and its rows say what their count is. Format round trips now cover variables (the testkit fixture defines both kinds, binds them and applies a row), and ROADMAP/README no longer list the feature as missing. cargo xtask ci: all 7 steps pass. --- README.md | 2 +- ROADMAP.md | 11 +- crates/doc/src/variables.rs | 44 +++- crates/engine/src/cmd/textedit.rs | 6 +- crates/engine/src/cmd/variablecmds.rs | 199 +++++++++------ crates/engine/src/lib.rs | 4 + crates/engine/src/tests_variables.rs | 133 +++++++++- crates/testkit/src/fixtures.rs | 9 + crates/ui-egui/src/dialogs/mod.rs | 2 + crates/ui-egui/src/dialogs/variables.rs | 317 ++++++++++++++++++++++++ crates/ui-egui/src/i18n/cs.tsv | 11 + crates/ui-egui/src/i18n/es.tsv | 12 + crates/ui-egui/src/i18n/fr.tsv | 12 + crates/ui-egui/src/i18n/it.tsv | 12 + crates/ui-egui/src/i18n/ja.tsv | 12 + crates/ui-egui/src/i18n/pt-br.tsv | 11 + crates/ui-egui/src/i18n/ru.tsv | 12 + crates/ui-egui/src/i18n/uk.tsv | 12 + crates/ui-egui/src/i18n/zh-hans.tsv | 12 + crates/ui-egui/src/i18n/zh-hant.tsv | 12 + crates/ui-egui/src/menus.rs | 29 ++- crates/ui-egui/src/panels/mod.rs | 2 +- crates/ui-egui/src/panels/variables.rs | 124 +++++++-- crates/ui-egui/src/state.rs | 2 +- 24 files changed, 895 insertions(+), 107 deletions(-) create mode 100644 crates/ui-egui/src/dialogs/variables.rs diff --git a/README.md b/README.md index a848d63f..79b26dbb 100644 --- a/README.md +++ b/README.md @@ -221,7 +221,7 @@ self-assessed, so the [honest assessment](ROADMAP.md#honest-assessment-2026-10-0 - 3D and Materials; - the Photoshop-style raster effects (Effect Gallery); - CJK composition for vertical type (vertical type itself has initial support, with a Japanese interface); -- Variables and scripting; +- scripting, and the rest of Variables (image and graph kinds, dataset import); - an interaction-fidelity pass covering every tool's modifiers and small behaviours; - packaging for Windows and Linux. diff --git a/ROADMAP.md b/ROADMAP.md index 18208864..0232e57e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -35,7 +35,7 @@ since 2026-10-01. |---|---|---| | **Breadth:** menus, tools and panels exist | ~90% | 13 menu items still stubbed; every tool implemented except Touch Type; 51 panel modules | | **Depth:** each feature behaves like Illustrator | ~69–75% | Strong: paint, appearance and colour (M3), Pathfinder and booleans, selection, drawing, files (M4). Weak: advanced type (~45%; inline symbols in text are beyond parity and don't raise it), brushes and symbols (in progress), raster effects (~20%) | -| **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~19 of ~56 filters), SVG Filters, CJK composition (vertical type with kinsoku, tate-chu-yoko and the font's vertical metrics; ruby, mojikumi and proportional vertical metrics open), Variables (data merge), scripting | +| **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~19 of ~56 filters), SVG Filters, CJK composition (vertical type with kinsoku, tate-chu-yoko and the font's vertical metrics; ruby, mojikumi and proportional vertical metrics open), scripting | | **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first | | **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured | | **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai`, EPS, DXF, EMF/WMF, raster formats, PSD export and PSD/PSB placement (merged image), Place and Links, Package, Print, clipboard flavours. Affinity documents (Affinity 1–3) open natively as editable art, checked against 51 pinned public files (33 current `.af`), with selected saved-thumbnail comparisons and every-board native/SVG/PDF/PSD checks for current files; Affinity export is missing and effects, adjustments and brushes don't import. Affinity isn't an Illustrator feature, so this doesn't raise the parity score. The editing copy inside an Illustrator EPS or `.ai` is read for its structure: layers, groups, names, hidden objects and type, colours, gradients, transparency, images and every artboard (see Out of scope for what it doesn't read yet); DWG has no open spec | @@ -77,7 +77,8 @@ Ordered by how much each gap blocks someone from switching. Sizes are one-agent graphic-style libraries generated in code (never Adobe's); dynamic symbols; Start Global Edit; brushes driven by pen tilt, bearing and barrel rotation (6D art pens, #372), which needs tablet input beyond pressure on every platform. 17–28 h. -6. **Automation:** Variables (data merge), a scripting surface over the command registry, batch. 8–12 h. +6. **Automation:** a scripting surface over the command registry, batch, and the rest of + Variables (data merge): image and graph variable kinds, dataset import/export. 6–10 h. 7. **Views and windows:** multiple windows and arrange, Consolidate All Windows, video rulers. 15–25 h. 8. **Hardening at scale:** a corpus of real-world SVG/PDF/EPS files, Affinity files from more versions and platforms @@ -103,7 +104,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **Architecture:** 19+ crates with enforced layering (`cargo xtask layers`). Every action is a command (~400 engine + ~50 UI). Undo is unlimited via structural sharing. `command.batch` runs several commands as one transaction. - **Automation:** - Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command. - - Variables (data merge): text and visibility variables bound to objects, datasets applied in one undo step (`variable.*`, `dataset.*`), with a Variables panel; every command, like the rest, is drivable over MCP and the CLI. + - Variables (data merge): text and visibility variables bound to objects (an object may carry several), dataset rows applied in one undo step (`variable.*`, `dataset.*`) from a Variables panel and Window › Variables, with dialogs to define, bind and edit, Make Text/Visibility Dynamic, Capture/Update Data Set, and a row in italics when an edit leaves the art not matching it; bindings let go of deleted art and locked art is left alone; every command, like the rest, is drivable over MCP and the CLI. - JSON-lines control channel with real egui pointer and keyboard injection. - MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings. `--automation-read-root` / `--automation-write-root` (on `vectorcraft-cli mcp` and on `vectorcraft --control`) confine the files commands read and write to chosen folders, links followed, with the flags PhotoCraft takes (#832). - MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), large containers slice with `document.node {depth?, childLimit?}` (truncated levels report `childCount`), `document.inspect` and the `inspect_document` tool slice the same way so a depth-0 read is the layer skeleton (~9.7 KB instead of ~2 MB on a 2,000-object document), `document.find` locates nodes by name/kind/text with ancestor paths, and the CLI sweeps the protocol surface over stdio against the compiled binary. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads). @@ -242,8 +243,8 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | File formats | 6 | 85% | Affinity export, and the rest of Affinity import (effects, adjustments, brushes, master pages, live symbol instances), DWG (no open spec: DXF instead), PSD placement as layers (Photoshop Import Options; the merged image is done), EPS checked against more real files users send (Inkscape files open as vectors; other generators' constructs are tested with programs written from the PostScript Language Reference only; CID-keyed CJK type and `CCITTFaxDecode` images not read), legacy `.ai` files saved without the prolog that defines their operators (their own operator set isn't read yet), the rest of Illustrator's editing data (appearances and live effects, symbols, pattern fills, brushes, placed files: #637), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers, presets and Affinity import are done | 6–10 (existing scope; Affinity export unestimated) | | Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 | | Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done) | 1–2 | -| Automation | 3 | 62% | scripting surface, batch (Variables: text/visibility variables bound to objects, datasets applied in one undo step, panel; image/graph kinds and dataset import open) | 6–10 | -| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | macOS native menu dismissal fixed (M14.8); remaining: Variables, SVG Interactivity, Properties per context, Consolidate All Windows, floating panels outside the app window and docked into any group, context menus beyond the canvas (Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done), about 17 Preferences options stored but not yet acted on (#394: Highlight Alternate Glyphs, Open Documents As Tabs, Real-time Drawing, hyphenation language, Appearance of Black, devices…) | 14–22 | +| Automation | 3 | 65% | scripting surface, batch (Variables: text/visibility variables bound to objects, an object may carry several, dataset rows applied in one undo step, panel and Window › Variables with dialogs; image/graph kinds and dataset import/export open) | 6–10 | +| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 80% | macOS native menu dismissal fixed (M14.8); Variables, Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done; remaining: SVG Interactivity, Properties per context, Consolidate All Windows, floating panels outside the app window and docked into any group, context menus beyond the canvas, about 17 Preferences options stored but not yet acted on (#394: Highlight Alternate Glyphs, Open Documents As Tabs, Real-time Drawing, hyphenation language, Appearance of Black, devices…) | 13–20 | | Libraries, Links, Package | 2 | 82% | the Libraries panel is local only (no cloud by design: libraries are files on this machine) and has no shared libraries, linked graphics or gradients and patterns as items yet; its graphics, colours and text styles (#745) and its search, groups and library export/import as `.vclibrary` files (#926) are done; Links and Package are done; placed VectorCraft documents are done (linked by default, vectors in every output, previews, Break Link, Expand, Edit Original, refresh on save) | 2–3 | | **Feature parity** | **103** | **~75%** | | **~210–330** | | Interaction-fidelity pass (side by side with Illustrator: every tool modifier, cursor, dialog, Properties context) | | | | 60–90 | diff --git a/crates/doc/src/variables.rs b/crates/doc/src/variables.rs index 14a7402d..9ba5de03 100644 --- a/crates/doc/src/variables.rs +++ b/crates/doc/src/variables.rs @@ -6,7 +6,7 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; -use super::NodeId; +use super::{Document, NodeId}; /// What a variable drives. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -20,7 +20,8 @@ pub enum VariableKind { } impl VariableKind { - /// Parse the `kind` of `variable.define` (`text` or `visibility`). + /// 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 { match s.to_ascii_lowercase().as_str() { "text" => Some(Self::Text), @@ -29,6 +30,7 @@ impl VariableKind { } } + /// 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", @@ -83,19 +85,47 @@ pub struct Variables { } 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) } - /// Drop the bindings (and the active dataset) that name what is gone. - pub fn prune(&mut self, names: &[String]) { + /// The dataset `name`, if the document has it. + pub fn dataset(&self, name: &str) -> Option<&DataSet> { + self.datasets.iter().find(|d| d.name == name) + } + + /// The objects bound to the variable `name`, in document order. + pub fn objects_of(&self, name: &str) -> Vec { + let mut ids: Vec = self.bindings.iter().filter(|(_, v)| *v == 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 { + let before = self.variables.len(); self.variables.retain(|v| !names.contains(&v.name)); for dataset in &mut self.datasets { dataset.values.retain(|k, _| !names.contains(k)); } self.bindings.retain(|_, v| !names.contains(v)); - if self.active_dataset.as_ref().is_some_and(|a| names.contains(a)) { - self.active_dataset = None; - } + 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; } } diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 5787dd54..0df32a97 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -117,8 +117,12 @@ fn text_ref(s: &Session, id: NodeId) -> Result { /// range does. Dataset apply and friends share this so Variables edits behave /// like Type tool edits. pub(crate) fn set_plain_text(d: &mut vectorcraft_doc::Document, id: NodeId, text: &str) -> Result<()> { + let len = match d.node(id).map(|n| &n.kind) { + Some(NodeKind::Text(t)) => edit::runs_len(&t.runs), + Some(_) => return Err(EngineError::Other(format!("node {} is not text", id.0))), + None => return Err(EngineError::NoNode(id)), + }; let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?; - let len = edit::runs_len(&t.runs); t.splice_paras(0, len, text); edit::replace_range(&mut t.runs, 0, len, text); refresh_bounds(t); diff --git a/crates/engine/src/cmd/variablecmds.rs b/crates/engine/src/cmd/variablecmds.rs index eecc2133..a7b260a5 100644 --- a/crates/engine/src/cmd/variablecmds.rs +++ b/crates/engine/src/cmd/variablecmds.rs @@ -5,17 +5,24 @@ //! dataset, and `dataset.select` swaps the whole document to that row in one undo //! step. Agents reach every command through `run_command`, like all engine commands. +use std::collections::BTreeMap; + use serde_json::{Value, json}; -use vectorcraft_doc::{DataValue, NodeId, NodeKind, VariableKind}; +use vectorcraft_doc::{DataSet, DataValue, NodeId, NodeKind, Variable, VariableKind, Variables}; use super::edit::selected_roots; use super::textedit::set_plain_text; use super::*; fn var_name(p: &Value, cmd: &str) -> Result { - match str_param(p, "name").filter(|v| !v.is_empty()) { + named(str_param(p, "name"), "name", cmd) +} + +/// `key`'s value, as a non-empty name. +fn named(v: Option<&str>, key: &str, cmd: &str) -> Result { + match v.filter(|v| !v.is_empty()) { Some(v) => Ok(v.to_string()), - None => Err(bad(cmd, "missing `name`")), + None => Err(bad(cmd, format!("missing `{key}`"))), } } @@ -43,12 +50,15 @@ fn dataset_value(cmd: &str, var: &str, kind: VariableKind, v: &Value) -> Result< .ok_or_else(|| bad(cmd, format!("value for `{var}` must match its variable kind"))) } -fn values_param(s: &Session, p: &Value, cmd: &str) -> Result> { - let mut out = std::collections::BTreeMap::new(); +/// The `values` object of `dataset.new` / `dataset.set`, each entry checked against its +/// variable's kind. Every name must be a variable the document defines: a value nothing +/// reads is a typo, not a value to keep. +fn values_param(vars: &Variables, p: &Value, cmd: &str) -> Result> { + let mut out = BTreeMap::new(); let Some(given) = p.get("values") else { return Ok(out) }; let Some(obj) = given.as_object() else { return Err(bad(cmd, "`values` must be an object")) }; for (var, v) in obj { - let kind = s.doc()?.doc.variables.variable(var).map(|d| d.kind).ok_or_else(|| bad(cmd, format!("no variable named `{var}`")))?; + let kind = vars.variable(var).map(|d| d.kind).ok_or_else(|| bad(cmd, format!("no variable named `{var}`")))?; out.insert(var.clone(), dataset_value(cmd, var, kind, v)?); } Ok(out) @@ -62,19 +72,15 @@ fn define(s: &mut Session, p: &Value) -> Result { return Err(bad(C, format!("variable `{name}` is already defined"))); } s.edit("New Variable", |d, _| { - d.variables.variables.push(vectorcraft_doc::Variable { name: name.clone(), kind }); + d.variables.variables.push(Variable { name: name.clone(), kind }); Ok(()) })?; Ok(json!({"name": name, "kind": kind.label()})) } fn delete(s: &mut Session, p: &Value) -> Result { - const C: &str = "variable.delete"; - let names = names_param(p, C)?; - let n = s.edit("Delete Variables", |d, _| { - d.variables.prune(&names); - Ok(names.len()) - })?; + let names = names_param(p, "variable.delete")?; + let n = s.edit("Delete Variables", |d, _| Ok(d.variables.prune(&names)))?; Ok(json!({"deleted": n})) } @@ -95,7 +101,7 @@ fn list(s: &mut Session, _p: &Value) -> Result { fn bind(s: &mut Session, p: &Value) -> Result { const C: &str = "variable.bind"; - let name = str_param(p, "variable").filter(|v| !v.is_empty()).map(str::to_string).ok_or_else(|| bad(C, "missing `variable`"))?; + let name = named(str_param(p, "variable"), "variable", C)?; let ids = match ids_param(p, "ids") { Some(v) => v, None => selected_roots(s)?, @@ -103,30 +109,29 @@ fn bind(s: &mut Session, p: &Value) -> Result { if ids.is_empty() { return Err(bad(C, "nothing to bind")); } - let kind = s.doc()?.doc.variables.variable(&name).map(|v| v.kind).ok_or_else(|| bad(C, format!("no variable named `{name}`")))?; - // A text variable only drives type; visibility drives anything that exists. - if kind == VariableKind::Text { - for id in &ids { - match s.doc()?.doc.node(*id).map(|n| &n.kind) { - Some(NodeKind::Text(_)) => {} - Some(_) => return Err(bad(C, format!("node {} is not type", id.0))), - None => return Err(EngineError::NoNode(*id)), - } - } - } else { - for id in &ids { - if s.doc()?.doc.node(*id).is_none() { - return Err(EngineError::NoNode(*id)); + let doc = &s.doc()?.doc; + let kind = doc.variables.variable(&name).map(|v| v.kind).ok_or_else(|| bad(C, format!("no variable named `{name}`")))?; + for id in &ids { + match doc.node(*id) { + None => return Err(EngineError::NoNode(*id)), + // Art on a locked or hidden layer is not the document's to rewrite, and a text + // variable only drives type. + Some(_) if !doc.is_editable(*id) => return Err(bad(C, format!("node {} is locked", id.0))), + Some(n) if kind == VariableKind::Text && !matches!(n.kind, NodeKind::Text(_)) => { + return Err(bad(C, format!("node {} is not type", id.0))); } + Some(_) => {} } } + // An object holds one binding, so binding it again replaces what it had; say which. + let replaced: Vec = ids.iter().filter(|id| doc.variables.bindings.get(id).is_some_and(|b| *b != name)).map(|id| id.0).collect(); s.edit("Bind Variable", |d, _| { for id in &ids { d.variables.bindings.insert(*id, name.clone()); } Ok(()) })?; - Ok(json!({"variable": name, "ids": ids.iter().map(|i| i.0).collect::>()})) + Ok(json!({"variable": name, "ids": ids.iter().map(|i| i.0).collect::>(), "replaced": replaced})) } fn unbind(s: &mut Session, p: &Value) -> Result { @@ -149,23 +154,41 @@ fn unbind(s: &mut Session, p: &Value) -> Result { fn dataset_new(s: &mut Session, p: &Value) -> Result { const C: &str = "dataset.new"; let name = var_name(p, C)?; - let values = values_param(s, p, C)?; - if s.doc()?.doc.variables.datasets.iter().any(|d| d.name == name) { + let st = s.doc()?; + // A dataset and a variable are separate namespaces, so a dataset may share a variable's + // name; it is its own row of values either way. + let values = values_param(&st.doc.variables, p, C)?; + if st.doc.variables.dataset(&name).is_some() { return Err(bad(C, format!("dataset `{name}` already exists"))); } s.edit("New Data Set", |d, _| { - d.variables.datasets.push(vectorcraft_doc::DataSet { name: name.clone(), values: values.clone() }); + d.variables.datasets.push(DataSet { name: name.clone(), values: values.clone() }); Ok(()) })?; Ok(json!({"name": name})) } +/// Replace a dataset's values. A variable left out of `values` loses its value in this row, +/// so the dataset ends up holding exactly what the call asked for. +fn dataset_set(s: &mut Session, p: &Value) -> Result { + const C: &str = "dataset.set"; + let name = var_name(p, C)?; + let values = values_param(&s.doc()?.doc.variables, p, C)?; + let (set, removed) = s.edit("Edit Data Set", |d, _| { + let ds = d.variables.datasets.iter_mut().find(|x| x.name == name).ok_or_else(|| bad(C, format!("no dataset named `{name}`")))?; + let removed: Vec = ds.values.keys().filter(|k| !values.contains_key(*k)).cloned().collect(); + ds.values = values.clone(); + Ok((values.len(), removed)) + })?; + Ok(json!({"name": name, "values": set, "removed": removed})) +} + fn dataset_delete(s: &mut Session, p: &Value) -> Result { - const C: &str = "dataset.delete"; - let names = names_param(p, C)?; + let names = names_param(p, "dataset.delete")?; let n = s.edit("Delete Data Sets", |d, _| { let before = d.variables.datasets.len(); d.variables.datasets.retain(|d| !names.contains(&d.name)); + // The active dataset's own name is the one that goes, not a variable's. if d.variables.active_dataset.as_ref().is_some_and(|a| names.contains(a)) { d.variables.active_dataset = None; } @@ -182,12 +205,15 @@ fn dataset_list(s: &mut Session, _p: &Value) -> Result { fn apply_dataset(s: &mut Session, name: &str) -> Result { const C: &str = "dataset.select"; - let (applied, skipped) = s.edit("Data Set", |d, _| { - let ds = d.variables.datasets.iter().find(|x| x.name == name).cloned().ok_or_else(|| bad(C, format!("no dataset named `{name}`")))?; + let (applied, skipped) = s.edit("Apply Data Set", |d, _| { + let ds = d.variables.dataset(name).cloned().ok_or_else(|| bad(C, format!("no dataset named `{name}`")))?; let bindings: Vec<(NodeId, String)> = d.variables.bindings.iter().map(|(id, v)| (*id, v.clone())).collect(); let mut applied = 0usize; let mut skipped = 0usize; for (id, var) in bindings { + // A binding whose variable is gone, whose value this row doesn't carry, or whose + // value doesn't fit the variable's kind, is left alone and counted, never + // silently reinterpreted. let (kind, value) = match (d.variables.variable(&var).map(|v| v.kind), ds.values.get(&var)) { (Some(kind), Some(value)) if value.fits(kind) => (kind, value.clone()), _ => { @@ -195,22 +221,20 @@ fn apply_dataset(s: &mut Session, name: &str) -> Result { continue; } }; + // Art on a locked or hidden layer, and text whose object is gone, can't be + // rewritten: skipped and counted, like the rest. + if !d.is_editable(id) { + skipped += 1; + continue; + } let done = match (kind, &value) { - (VariableKind::Text, DataValue::Text(text)) => { - if d.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))) { - set_plain_text(d, id, text).is_ok() - } else { - false - } - } - (VariableKind::Visibility, DataValue::Visible(show)) => { - if let Some(n) = d.node_mut(id) { - n.visible = *show; - true - } else { - false - } - } + // The Type tool's own whole-range replace, so a dataset's text is styled and + // laid out exactly as typing it would be. + (VariableKind::Text, DataValue::Text(text)) => set_plain_text(d, id, text).is_ok(), + (VariableKind::Visibility, DataValue::Visible(show)) => d.node_mut(id).is_some_and(|n| { + n.visible = *show; + true + }), _ => false, }; if done { @@ -226,21 +250,21 @@ fn apply_dataset(s: &mut Session, name: &str) -> Result { } fn dataset_select(s: &mut Session, p: &Value) -> Result { - let name = var_name(p, "dataset.select")?; - apply_dataset(s, &name) + apply_dataset(s, &var_name(p, "dataset.select")?) } fn dataset_step(s: &mut Session, cmd: &str, forward: bool) -> Result { let st = s.doc()?; - if st.doc.variables.datasets.is_empty() { + let n = st.doc.variables.datasets.len(); + if n == 0 { return Err(bad(cmd, "no datasets")); } - let at = st.doc.variables.active_dataset.as_ref().and_then(|a| st.doc.variables.datasets.iter().position(|d| &d.name == a)); - let next = match at { - Some(i) if forward => (i + 1) % st.doc.variables.datasets.len(), - Some(i) => (i + st.doc.variables.datasets.len() - 1) % st.doc.variables.datasets.len(), - None if forward => 0, - None => st.doc.variables.datasets.len() - 1, + let at = st.doc.variables.active_dataset.as_deref().and_then(|a| st.doc.variables.datasets.iter().position(|d| d.name == a)); + let next = match (at, forward) { + (Some(i), true) => (i + 1) % n, + (Some(i), false) => (i + n - 1) % n, + (None, true) => 0, + (None, false) => n - 1, }; let name = st.doc.variables.datasets.get(next).map(|d| d.name.clone()).ok_or_else(|| bad(cmd, "no datasets"))?; apply_dataset(s, &name) @@ -248,28 +272,55 @@ fn dataset_step(s: &mut Session, cmd: &str, forward: bool) -> Result { pub fn specs() -> Vec { vec![ - cmd!("variable.define", "New Variable", [], None, "{name, kind: text|visibility} define a variable", has_doc, define), - cmd!("variable.delete", "Delete Variable", [], None, "{names} delete variables with their bindings and values", has_doc, delete), - cmd!("variable.list", "List Variables", [], None, "{} → variables with kinds and binding counts", has_doc, list), + cmd!("variable.define", "New Variable…", ["Window", "Variables"], None, "{name, kind: text|visibility} define a variable", has_doc, define), + cmd!( + "variable.delete", + "Delete Variable", + ["Window", "Variables"], + None, + "{names} delete variables with their bindings and values", + has_doc, + delete + ), + cmd!(query "variable.list", "List Variables", [], None, "{} → variables with kinds and binding counts", has_doc, list), cmd!( "variable.bind", "Bind Variable", - [], + ["Window", "Variables"], None, - "{variable, ids?} bind objects (default: the selection) to a variable; text needs type", + "{variable, ids?} bind objects (default: the selection) to a variable; text needs type, locked art is refused. An object holds one binding, so this replaces what it had → {ids, replaced}", has_doc, bind ), - cmd!("variable.unbind", "Unbind Variable", [], None, "{ids?} drop bindings (default: the selection)", has_doc, unbind), - cmd!("dataset.new", "New Data Set", [], None, "{name, values?: {variable: value}} add a dataset", has_doc, dataset_new), - cmd!("dataset.delete", "Delete Data Set", [], None, "{names} delete datasets", has_doc, dataset_delete), - cmd!("dataset.list", "List Data Sets", [], None, "{} → datasets with values and the active one", has_doc, dataset_list), - cmd!("dataset.select", "Select Data Set", [], None, "{name} apply a dataset in one undo step → {applied, skipped}", has_doc, dataset_select), - cmd!("dataset.next", "Next Data Set", [], None, "{} apply the next dataset, wrapping", has_doc, |s, _| dataset_step(s, "dataset.next", true)), - cmd!("dataset.prev", "Previous Data Set", [], None, "{} apply the previous dataset, wrapping", has_doc, |s, _| dataset_step( + cmd!("variable.unbind", "Unbind Variable", ["Window", "Variables"], None, "{ids?} drop bindings (default: the selection)", has_doc, unbind), + cmd!("dataset.new", "New Data Set…", ["Window", "Variables"], None, "{name, values?: {variable: value}} add a dataset", has_doc, dataset_new), + cmd!( + "dataset.set", + "Edit Data Set…", + ["Window", "Variables"], + None, + "{name, values?: {variable: value}} replace a dataset's values; a variable left out loses its value in this row → {values, removed}", + has_doc, + dataset_set + ), + cmd!("dataset.delete", "Delete Data Set", ["Window", "Variables"], None, "{names} delete datasets", has_doc, dataset_delete), + cmd!(query "dataset.list", "List Data Sets", [], None, "{} → datasets with values and the active one", has_doc, dataset_list), + cmd!( + "dataset.select", + "Select Data Set", + ["Window", "Variables"], + None, + "{name} apply a dataset in one undo step → {applied, skipped}", + has_doc, + dataset_select + ), + cmd!("dataset.next", "Next Data Set", ["Window", "Variables"], None, "{} apply the next dataset, wrapping", has_doc, |s, _| dataset_step( s, - "dataset.prev", - false + "dataset.next", + true )), + cmd!("dataset.prev", "Previous Data Set", ["Window", "Variables"], None, "{} apply the previous dataset, wrapping", has_doc, |s, _| { + dataset_step(s, "dataset.prev", false) + }), ] } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b8774641..c5a7104d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1226,6 +1226,10 @@ impl Session { if !st.doc.assets.is_empty() { Arc::make_mut(&mut st.doc).prune_assets(); } + // Variables: bindings let go of deleted art. + if !st.doc.variables.bindings.is_empty() { + Arc::make_mut(&mut st.doc).prune_variable_bindings(); + } if doc_sane(&st.doc, &st.selection) { Ok(v) } else { diff --git a/crates/engine/src/tests_variables.rs b/crates/engine/src/tests_variables.rs index 6f03fd11..1db01ea8 100644 --- a/crates/engine/src/tests_variables.rs +++ b/crates/engine/src/tests_variables.rs @@ -92,7 +92,7 @@ fn mismatched_and_missing_bindings_are_skipped_and_counted() { } #[test] -fn deleting_a_variable_cleans_bindings_values_and_active_dataset() { +fn deleting_a_variable_cleans_bindings_values_and_leaves_datasets_alone() { let mut s = session(); let t = text(&mut s, "x"); s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); @@ -104,6 +104,8 @@ fn deleting_a_variable_cleans_bindings_values_and_active_dataset() { assert_eq!(vars["variables"].as_array().map(Vec::len), Some(0)); let sets = s.execute("dataset.list", &json!({})).unwrap(); assert!(sets["datasets"][0].get("values").is_none_or(|v| v == &json!({}))); + // The dataset named "D" is still the active one: `variable.delete` prunes variables, and + // the dataset's own name is its own namespace. assert_eq!(sets["active"], "D"); s.execute("dataset.delete", &json!({"names": ["D"]})).unwrap(); let sets = s.execute("dataset.list", &json!({})).unwrap(); @@ -113,6 +115,135 @@ fn deleting_a_variable_cleans_bindings_values_and_active_dataset() { assert!(v["variables"].get("bindings").is_none_or(|v| v == &json!({}))); } +/// A dataset may share a variable's name: they are separate namespaces, and deleting the +/// variable must not silently unselect the dataset that happens to be called the same. +#[test] +fn a_dataset_survives_a_variable_of_the_same_name_being_deleted() { + let mut s = session(); + s.execute("variable.define", &json!({"name": "Row", "kind": "text"})).unwrap(); + s.execute("dataset.new", &json!({"name": "Row", "values": {"Row": "y"}})).unwrap(); + s.execute("dataset.select", &json!({"name": "Row"})).unwrap(); + s.execute("variable.delete", &json!({"names": ["Row"]})).unwrap(); + let sets = s.execute("dataset.list", &json!({})).unwrap(); + assert_eq!(sets["datasets"][0]["name"], "Row", "the dataset is not a variable"); + assert_eq!(sets["active"], "Row", "the active dataset keeps its name"); +} + +/// Deleting art lets its bindings go, as it does for Asset Export's assets: a binding that +/// outlived its object would count as a skip in every dataset application and grow the file. +#[test] +fn deleted_art_lets_its_bindings_go() { + let mut s = session(); + let t = text(&mut s, "x"); + let r = rect(&mut s); + s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Show", "ids": [r.0]})).unwrap(); + s.execute("dataset.new", &json!({"name": "D", "values": {"Name": "y", "Show": false}})).unwrap(); + + s.execute("select.set", &json!({"ids": [t.0]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + let vars = s.execute("variable.list", &json!({})).unwrap(); + let of = |name: &str| vars["variables"].as_array().map(|a| a.iter().find(|v| v["name"] == name).unwrap()["bindings"].as_u64().unwrap()).unwrap(); + assert_eq!(of("Name"), 0, "the deleted text object took its binding with it"); + assert_eq!(of("Show"), 1, "the rectangle is still bound"); + // Nothing stale is left for the application to skip. + let v = s.execute("dataset.select", &json!({"name": "D"})).unwrap(); + assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64()), (Some(1), Some(0))); +} + +/// `deleted` counts what was deleted, not what was asked for. +#[test] +fn delete_reports_what_it_actually_removed() { + let mut s = session(); + s.execute("variable.define", &json!({"name": "A", "kind": "text"})).unwrap(); + s.execute("variable.define", &json!({"name": "B", "kind": "text"})).unwrap(); + s.execute("dataset.new", &json!({"name": "D"})).unwrap(); + assert_eq!(s.execute("variable.delete", &json!({"names": ["A"]})).unwrap()["deleted"], 1); + assert_eq!(s.execute("variable.delete", &json!({"names": ["A"]})).unwrap()["deleted"], 0, "already gone"); + assert_eq!(s.execute("variable.delete", &json!({"names": ["A", "B"]})).unwrap()["deleted"], 1, "only B was left"); + assert_eq!(s.execute("dataset.delete", &json!({"names": ["D"]})).unwrap()["deleted"], 1); + assert_eq!(s.execute("dataset.delete", &json!({"names": ["D"]})).unwrap()["deleted"], 0); +} + +/// An object holds one binding, so binding it again replaces what it had, and the reply says +/// which objects were rebound rather than letting the old variable silently lose them. +#[test] +fn binding_again_replaces_and_says_so() { + let mut s = session(); + let t = text(&mut s, "x"); + s.execute("variable.define", &json!({"name": "First", "kind": "text"})).unwrap(); + s.execute("variable.define", &json!({"name": "Second", "kind": "text"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "First", "ids": [t.0]})).unwrap(); + let v = s.execute("variable.bind", &json!({"variable": "Second", "ids": [t.0]})).unwrap(); + assert_eq!(v["replaced"], json!([t.0])); + let vars = s.execute("variable.list", &json!({})).unwrap(); + let of = |n: &str| vars["variables"].as_array().unwrap().iter().find(|v| v["name"] == n).unwrap()["bindings"].as_u64(); + assert_eq!((of("First"), of("Second")), (Some(0), Some(1))); + // Rebinding to the same variable changes nothing and reports no replacement. + assert_eq!(s.execute("variable.bind", &json!({"variable": "Second", "ids": [t.0]})).unwrap()["replaced"], json!([])); + assert_eq!(s.execute("variable.unbind", &json!({"ids": [t.0]})).unwrap()["unbound"], 1); + assert_eq!(s.execute("variable.unbind", &json!({"ids": [t.0]})).unwrap()["unbound"], 0); +} + +/// Locked art is the document's to rewrite only while it isn't locked. +#[test] +fn locked_art_is_neither_bound_nor_rewritten() { + let mut s = session(); + let t = text(&mut s, "x"); + let r = rect(&mut s); + s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Show", "ids": [r.0]})).unwrap(); + s.execute("dataset.new", &json!({"name": "D", "values": {"Name": "y", "Show": false}})).unwrap(); + + s.execute("select.set", &json!({"ids": [t.0]})).unwrap(); + s.execute("object.lock", &json!({})).unwrap(); + assert!(s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).is_err(), "locked art takes no new binding"); + // The bindings made before the lock still count, and the application skips the locked art. + let v = s.execute("dataset.select", &json!({"name": "D"})).unwrap(); + assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64()), (Some(1), Some(1))); + assert_eq!(plain(&s, t), "x", "the locked text was left alone"); + assert!(!s.doc().unwrap().doc.node(r).unwrap().visible, "the unlocked rectangle still applied"); +} + +/// `dataset.set` is how a value changes after the row exists: replacing it must not need the +/// dataset to be deleted and rebuilt (which loses its place in the list and the active one). +#[test] +fn dataset_set_replaces_the_row_and_reports_what_it_dropped() { + let mut s = session(); + let t = text(&mut s, "x"); + let r = rect(&mut s); + s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Show", "ids": [r.0]})).unwrap(); + s.execute("dataset.new", &json!({"name": "D", "values": {"Name": "y", "Show": false}})).unwrap(); + + // The dataset keeps its place and stays active while its values change. + let v = s.execute("dataset.set", &json!({"name": "D", "values": {"Name": "z"}})).unwrap(); + assert_eq!((v["values"].clone(), v["removed"].clone()), (json!(1), json!(["Show"]))); + let sets = s.execute("dataset.list", &json!({})).unwrap(); + assert_eq!(sets["datasets"].as_array().map(Vec::len), Some(1)); + assert_eq!(sets["datasets"][0]["values"], json!({"Name": {"text": "z"}})); + + s.execute("dataset.select", &json!({"name": "D"})).unwrap(); + assert_eq!(plain(&s, t), "z"); + assert!(s.doc().unwrap().doc.node(r).unwrap().visible, "`Show` has no value in this row now"); + + // Errors are errors, not a silent rewrite of some other row. + assert!(s.execute("dataset.set", &json!({"name": "Missing"})).is_err()); + assert!(s.execute("dataset.set", &json!({"name": "D", "values": {"Ghost": "z"}})).is_err()); + assert!(s.execute("dataset.set", &json!({"name": "D", "values": {"Show": "yes"}})).is_err()); + assert!(s.execute("dataset.set", &json!({"name": "D", "values": []})).is_err()); + // One step, so one undo puts the row back as it was. + s.execute("dataset.set", &json!({"name": "D", "values": {"Name": "w"}})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(plain(&s, t), "z"); +} + #[test] fn bad_variables_requests_are_errors_not_panics() { let mut s = session(); diff --git a/crates/testkit/src/fixtures.rs b/crates/testkit/src/fixtures.rs index e3fb4ea7..d79615cd 100644 --- a/crates/testkit/src/fixtures.rs +++ b/crates/testkit/src/fixtures.rs @@ -154,6 +154,15 @@ pub fn rich_session() -> Session { exec(&mut s, "symbol.new", json!({"name": "Dot"})); let t = id_of(&exec(&mut s, "text.create", json!({"x": 250, "y": 370, "text": "Tap to add", "size": 18}))); exec(&mut s, "text.insertInline", json!({"id": t.0, "at": 4, "symbol": "Dot"})); + // Variables (data merge): both kinds, bound to art, with two dataset rows and one applied, + // so format round trips cover the definitions, the bindings and the rows. + exec(&mut s, "variable.define", json!({"name": "Title", "kind": "text"})); + exec(&mut s, "variable.define", json!({"name": "Badge", "kind": "visibility"})); + exec(&mut s, "variable.bind", json!({"variable": "Title", "ids": [tx.0]})); + exec(&mut s, "variable.bind", json!({"variable": "Badge", "ids": [a]})); + exec(&mut s, "dataset.new", json!({"name": "Row 1", "values": {"Title": "Merged", "Badge": true}})); + exec(&mut s, "dataset.new", json!({"name": "Row 2", "values": {"Title": "Other"}})); + exec(&mut s, "dataset.select", json!({"name": "Row 1"})); let _ = (a, b, c, l); exec(&mut s, "select.none", json!({})); s diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index e67461f9..78e7fd8b 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -88,6 +88,7 @@ pub mod tile_edge_color; mod tools; mod transform; pub mod transform_each; +pub mod variables; pub mod width_point; use serde_json::{Value, json}; @@ -212,6 +213,7 @@ registry! { ExportForScreens: ["exportForScreens"] => export_for_screens::SPEC, Recolor: [recolor::KIND] => recolor::SPEC, Command: ["command"] => command::SPEC, + Variables: [variables::KIND] => variables::SPEC, Effect: ["effect"] => effect::SPEC, SaveChanges: [crate::unsaved::KIND] => save_changes::SPEC, Preferences: ["preferences"] => DialogSpec::window(crate::prefs_dialog::show, |app, _| crate::prefs_dialog::confirm(app)), diff --git a/crates/ui-egui/src/dialogs/variables.rs b/crates/ui-egui/src/dialogs/variables.rs new file mode 100644 index 00000000..4edf1c86 --- /dev/null +++ b/crates/ui-egui/src/dialogs/variables.rs @@ -0,0 +1,317 @@ +//! The Variables dialogs (`variable.define`, `variable.bind`, `dataset.new`, `dataset.set` +//! and `dataset.select`), opened from Window › Variables or the command palette. +//! +//! One dialog covers all of them: what it shows is what the command takes. A variable is a +//! name and a kind; a dataset is a name and one field per variable — a text field for a +//! `text` variable, a checkbox for a `visibility` one. `dataset.set` opens with the row's +//! current values, so editing a row is editing what it already says. + +use serde_json::{Value, json}; +use vectorcraft_doc::{DataValue, VariableKind, Variables}; + +use super::{DialogSpec, run_and_close}; +use crate::VectorcraftApp; +use crate::state::Dialog; +use crate::theme::Tokens; +use crate::widgets; + +/// The dialog kind of every Variables dialog. +pub const KIND: &str = "variables"; + +/// The kinds a variable can have, as `variable.define` names them. +const KINDS: [&str; 2] = ["text", "visibility"]; + +pub(super) const SPEC: DialogSpec = + DialogSpec { heading: |d| tl!(&d.str("__label")).to_string(), body, confirm, min_width: 380.0, ..DialogSpec::FORM }; + +/// The command the dialog runs and what it is called. +fn opened(id: &str) -> Option<&'static str> { + Some(match id { + "variable.define" => "New Variable", + "variable.bind" => "Bind Variable", + "dataset.new" => "New Data Set", + "dataset.set" => "Edit Data Set", + "dataset.select" => "Select Data Set", + _ => return None, + }) +} + +/// Open `id`'s dialog: OK runs the command with the fields below. +pub fn open(app: &mut VectorcraftApp, id: &str) -> bool { + let Some(label) = opened(id) else { return false }; + let Some(st) = app.session.active() else { + app.status(tl!("no document").to_string()); + return true; + }; + let vars = st.doc.variables.clone(); + let mut fields = serde_json::Map::new(); + match id { + "variable.define" => { + fields.insert("name".into(), json!("")); + fields.insert("kind".into(), json!(KINDS[0])); + } + "variable.bind" => { + fields.insert("variable".into(), json!("")); + } + "dataset.new" => { + fields.insert("name".into(), json!("")); + value_fields(&vars, vars.datasets.first().map(|d| &d.values), &mut fields); + } + "dataset.set" => { + // Without a row to edit there is nothing to open. + let Some(name) = vars.active_dataset.clone().or_else(|| vars.datasets.first().map(|d| d.name.clone())) else { + app.status(tl!("no data set to edit").to_string()); + return true; + }; + let Some(current) = vars.dataset(&name) else { return true }; + fields.insert("name".into(), json!(name)); + value_fields(&vars, Some(¤t.values), &mut fields); + } + "dataset.select" => { + fields.insert("name".into(), json!("")); + } + _ => {} + } + fields.insert("__command".into(), json!(id)); + fields.insert("__label".into(), json!(label)); + app.ui.dialog = Some(Dialog::new(KIND, Value::Object(fields))); + true +} + +/// Whether `id` opens one of these dialogs. +pub fn opens(id: &str) -> bool { + opened(id).is_some() +} + +/// One field per variable, prefilled from `current` (the row being edited, else the first row +/// so a new one starts from something rather than from nothing). +fn value_fields(vars: &Variables, current: Option<&std::collections::BTreeMap>, out: &mut serde_json::Map) { + for v in &vars.variables { + let value = match (current.and_then(|c| c.get(&v.name)), v.kind) { + (Some(DataValue::Text(t)), _) => json!(t), + (Some(DataValue::Visible(b)), _) => json!(b), + // Without a value in the row a text field starts empty (no value in this row) and + // a visibility one starts shown: its false is a value, so it is never "unset". + (None, VariableKind::Text) => json!(""), + (None, VariableKind::Visibility) => json!(true), + }; + out.insert(value_key(&v.name), value); + } +} + +/// A variable name as a field key. A field key shares the dialog's flat namespace with the +/// command's own parameters, so names are prefixed to stay clear of `name` and `values`. +fn value_key(name: &str) -> String { + format!("value:{name}") +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let t = Tokens::get(ui.ctx()); + let Some(st) = app.session.active() else { + widgets::dim_label(ui, "No document"); + return false; + }; + let vars = st.doc.variables.clone(); + let variable_names: Vec<&str> = vars.variables.iter().map(|v| v.name.as_str()).collect(); + let dataset_names: Vec<&str> = vars.datasets.iter().map(|x| x.name.as_str()).collect(); + egui::Grid::new("variables-grid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { + let label = |ui: &mut egui::Ui, text: &str| widgets::field_label(ui, egui::RichText::new(text).color(t.text)); + match d.str("__command").as_str() { + "variable.define" => { + label(ui, tl!("Name")); + text(ui, d, "name", 220.0); + ui.end_row(); + label(ui, tl!("Kind")); + dropdown(ui, d, "kind", &KINDS, 220.0); + ui.end_row(); + } + "variable.bind" => { + label(ui, tl!("Variable")); + dropdown(ui, d, "variable", &variable_names, 220.0); + ui.end_row(); + } + "dataset.select" => { + label(ui, tl!("Data Set")); + dropdown(ui, d, "name", &dataset_names, 220.0); + ui.end_row(); + } + _ => { + label(ui, tl!("Name")); + text(ui, d, "name", 220.0); + ui.end_row(); + if vars.variables.is_empty() { + widgets::dim_label(ui, "Define a variable first"); + ui.end_row(); + return; + } + // One row per variable, in the order they are defined, each with the editor + // its kind takes. + for v in &vars.variables { + label(ui, &v.name); + let key = value_key(&v.name); + match v.kind { + VariableKind::Text => text(ui, d, &key, 220.0), + VariableKind::Visibility => checkbox(ui, d, &key), + }; + ui.end_row(); + } + } + } + }); + false +} + +/// A text field for `key`, written back as it is typed. +fn text(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32) -> egui::Response { + let mut s = d.str(key); + let r = ui.add(egui::TextEdit::singleline(&mut s).desired_width(width)); + if r.changed() { + d.fields.insert(key.into(), json!(s)); + } + r +} + +/// A checkbox for `key`, written back as it is toggled. +fn checkbox(ui: &mut egui::Ui, d: &mut Dialog, key: &str) -> egui::Response { + let mut b = d.bool(key); + let r = ui.checkbox(&mut b, ""); + if r.changed() { + d.fields.insert(key.into(), json!(b)); + } + r +} + +/// A dropdown over `options` for `key`, written back as it changes. The empty option is first +/// so a fresh dialog can still be filled in; a name that is no longer offered reads as empty +/// rather than being silently swapped for another one. +fn dropdown(ui: &mut egui::Ui, d: &mut Dialog, key: &str, options: &[&str], width: f32) { + let cur = d.str(key); + let mut labels: Vec<&str> = vec![""]; + labels.extend_from_slice(options); + let shown = if cur.is_empty() || options.contains(&cur.as_str()) { cur.as_str() } else { "" }; + if let Some(i) = widgets::dropdown(ui, ("variables-dd", key), shown, &labels, width) { + d.fields.insert(key.into(), json!(options.get(i.saturating_sub(1)).copied().unwrap_or_default())); + } +} + +/// The `values` object the dialog's fields mean: a text variable whose field is empty carries +/// no value in this row, and a visibility checkbox is always a value — its false, too. +fn values_of(d: &Dialog, vars: &Variables) -> Value { + let mut out = serde_json::Map::new(); + for v in &vars.variables { + let key = value_key(&v.name); + if v.kind == VariableKind::Text { + let text = d.str(&key); + if !text.is_empty() { + out.insert(v.name.clone(), json!(text)); + } + } else { + out.insert(v.name.clone(), json!(d.bool(&key))); + } + } + Value::Object(out) +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let cmd = d.str("__command"); + let name = d.str("name"); + let vars = app.session.active().map(|st| st.doc.variables.clone()).unwrap_or_default(); + let p = match cmd.as_str() { + "variable.define" => json!({"name": name, "kind": d.str("kind")}), + "variable.bind" => json!({"variable": d.str("variable")}), + "dataset.new" | "dataset.set" => json!({"name": name, "values": values_of(d, &vars)}), + "dataset.select" => json!({"name": name}), + _ => json!({}), + }; + run_and_close(app, &cmd, p) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use vectorcraft_engine::Session; + + use super::*; + + fn app_with_variables() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.run("variable.define", json!({"name": "Title", "kind": "text"})).unwrap(); + app.run("variable.define", json!({"name": "Badge", "kind": "visibility"})).unwrap(); + app + } + + #[test] + fn defining_a_variable_from_the_dialog_works() { + let mut app = app_with_variables(); + assert!(open(&mut app, "variable.define")); + { + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!(d.kind, KIND); + assert_eq!(d.str("name"), ""); + assert_eq!(d.str("kind"), "text", "text is the default kind"); + } + app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("Price")); + crate::dialogs::confirm(&mut app).unwrap(); + let vars = app.run("variable.list", json!({})).unwrap(); + assert!(vars["variables"].as_array().unwrap().iter().any(|v| v["name"] == "Price")); + } + + #[test] + fn a_new_dataset_carries_the_dialogs_values_and_omits_the_empty_ones() { + let mut app = app_with_variables(); + open(&mut app, "dataset.new"); + { + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("name".into(), json!("Row 1")); + d.fields.insert(value_key("Title"), json!("Hello")); + d.fields.insert(value_key("Badge"), json!(false)); + } + crate::dialogs::confirm(&mut app).unwrap(); + let sets = app.run("dataset.list", json!({})).unwrap(); + assert_eq!(sets["datasets"][0]["name"], "Row 1"); + assert_eq!(sets["datasets"][0]["values"]["Title"], json!({"text": "Hello"})); + assert_eq!(sets["datasets"][0]["values"]["Badge"], json!({"visible": false})); + } + + #[test] + fn editing_a_dataset_opens_with_the_rows_own_values() { + let mut app = app_with_variables(); + app.run("dataset.new", json!({"name": "Row 1", "values": {"Title": "Hello", "Badge": false}})).unwrap(); + app.run("dataset.set", json!({"name": "Row 1", "values": {"Badge": true}})).unwrap(); + open(&mut app, "dataset.set"); + { + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!(d.str("name"), "Row 1"); + assert_eq!(d.str(&value_key("Title")), "", "the row carries no value for it"); + assert!(d.bool(&value_key("Badge")), "the row's own value"); + } + app.ui.dialog.as_mut().unwrap().fields.insert(value_key("Title"), json!("Changed")); + crate::dialogs::confirm(&mut app).unwrap(); + let sets = app.run("dataset.list", json!({})).unwrap(); + assert_eq!(sets["datasets"][0]["values"]["Title"], json!({"text": "Changed"})); + assert_eq!(sets["datasets"][0]["values"]["Badge"], json!({"visible": true})); + } + + #[test] + fn selecting_a_dataset_from_the_dialog_applies_it() { + let mut app = app_with_variables(); + let id = app.run("text.create", json!({"x": 10, "y": 10, "text": "x"})).unwrap()["id"].as_u64().unwrap(); + app.run("variable.bind", json!({"variable": "Title", "ids": [id]})).unwrap(); + app.run("dataset.new", json!({"name": "Row 1", "values": {"Title": "Hi"}})).unwrap(); + open(&mut app, "dataset.select"); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("name"), ""); + app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("Row 1")); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(app.run("text.getRange", json!({"id": id})).unwrap()["text"], "Hi"); + } + + #[test] + fn only_the_variables_commands_open_one() { + let mut app = app_with_variables(); + assert!(opens("dataset.set") && opens("variable.define")); + assert!(!opens("dataset.list") && !opens("dataset.next"), "no dialog for a query or a step"); + assert!(!open(&mut app, "dataset.list"), "and it opens nothing"); + assert!(app.ui.dialog.is_none()); + } +} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index d5064d0f..82e7f297 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -534,6 +534,17 @@ Paragraph Styles Styly odstavců Tabs Tabulátory Variables Proměnné + New Variable… Nová proměnná… + Bind Variable… Přiřadit proměnnou… + Unbind Variable Zrušit přiřazení proměnné + Delete Variable Odstranit proměnnou + New Data Set… Nová sada dat… + Edit Data Set… Upravit sadu dat… + Delete Data Set Odstranit sadu dat + Select Data Set… Vybrat sadu dat… + Next Data Set Další sada dat + Previous Data Set Předchozí sada dat + Variables Panel Panel proměnných Brush Libraries Knihovny štětců Built-in Libraries Vestavěné knihovny User Defined Uživatelské diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index e2a08889..b772fcb4 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3067,6 +3067,18 @@ Next Data Set Conjunto de datos siguiente Previous Data Set Conjunto de datos anterior Data Sets Conjuntos de datos + New Variable… Nueva variable… + Bind Variable… Vincular variable… + New Data Set… Nuevo conjunto de datos… + Edit Data Set… Editar conjunto de datos… + Select Data Set… Seleccionar conjunto de datos… + Variables Panel Panel de variables + Data Set Conjunto de datos + Kind Tipo + Variable Variable + no document no hay documento + no data set to edit no hay ningún conjunto de datos que editar + {name} · {kind} · {count} {name} · {kind} · {count} Variation: Variación: Vector Effects Efectos vectoriales Vector Halftone Semitono vectorial diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index b138f38c..9b68580b 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3064,6 +3064,18 @@ Next Data Set Jeu de données suivant Previous Data Set Jeu de données précédent Data Sets Jeux de données + New Variable… Nouvelle variable… + Bind Variable… Lier la variable… + New Data Set… Nouveau jeu de données… + Edit Data Set… Modifier le jeu de données… + Select Data Set… Sélectionner le jeu de données… + Variables Panel Panneau des variables + Data Set Jeu de données + Kind Type + Variable Variable + no document aucun document + no data set to edit aucun jeu de données à modifier + {name} · {kind} · {count} {name} · {kind} · {count} Variation: Variation : Vector Effects Effets vectoriels Vector Halftone Trame de demi-teintes vectorielle diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 7564f5e0..7ab85fb9 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3067,6 +3067,18 @@ Next Data Set Set di dati successivo Previous Data Set Set di dati precedente Data Sets Set di dati + New Variable… Nuova variabile… + Bind Variable… Associa variabile… + New Data Set… Nuovo set di dati… + Edit Data Set… Modifica set di dati… + Select Data Set… Seleziona set di dati… + Variables Panel Pannello variabili + Data Set Set di dati + Kind Tipo + Variable Variabile + no document nessun documento + no data set to edit nessun set di dati da modificare + {name} · {kind} · {count} {name} · {kind} · {count} Variation: Variazione: Vector Effects Effetti vettoriali Vector Halftone Retino vettoriale diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 373c08a0..4e30c4f8 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -581,6 +581,18 @@ Next Data Set 次のデータセット Previous Data Set 前のデータセット Data Sets データセット + New Variable… 新規変数… + Bind Variable… 変数をバインド… + New Data Set… 新規データセット… + Edit Data Set… データセットを編集… + Select Data Set… データセットを選択… + Variables Panel 変数パネル + Data Set データセット + Kind 種類 + Variable 変数 + no document ドキュメントがありません + no data set to edit 編集するデータセットがありません + {name} · {kind} · {count} {name} · {kind} · {count} 件 Brush Libraries ブラシライブラリ Built-in Libraries 内蔵ライブラリ User Defined ユーザー定義 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 19022075..dba9433d 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2530,6 +2530,17 @@ V: V: Variable Width Profile Perfil de Largura Variável Variables Variáveis + New Variable… Nova variável… + Bind Variable… Associar variável… + Unbind Variable Desassociar variável + Delete Variable Excluir variável + New Data Set… Novo conjunto de dados… + Edit Data Set… Editar conjunto de dados… + Delete Data Set Excluir conjunto de dados + Select Data Set… Selecionar conjunto de dados… + Next Data Set Próximo conjunto de dados + Previous Data Set Conjunto de dados anterior + Variables Panel Painel de variáveis Variation: Variação: Vector Effects Efeitos Vetoriais Vector Halftone Meio-tom Vetorial diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index c3a9b8db..d131f983 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3063,6 +3063,18 @@ Next Data Set Следующий набор данных Previous Data Set Предыдущий набор данных Data Sets Наборы данных + New Variable… Новая переменная… + Bind Variable… Привязать переменную… + New Data Set… Новый набор данных… + Edit Data Set… Изменить набор данных… + Select Data Set… Выбрать набор данных… + Variables Panel Панель переменных + Data Set Набор данных + Kind Тип + Variable Переменная + no document нет документа + no data set to edit нет набора данных для изменения + {name} · {kind} · {count} {name} · {kind} · {count} Variation: Вариация: Vector Effects Векторные эффекты Vector Halftone Векторный полутон diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index a7ada88f..81e073e4 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3059,6 +3059,18 @@ Next Data Set Наступний набір даних Previous Data Set Попередній набір даних Data Sets Набори даних + New Variable… Нова змінна… + Bind Variable… Прив’язати змінну… + New Data Set… Новий набір даних… + Edit Data Set… Змінити набір даних… + Select Data Set… Вибрати набір даних… + Variables Panel Панель змінних + Data Set Набір даних + Kind Тип + Variable Змінна + no document немає документа + no data set to edit немає набору даних для зміни + {name} · {kind} · {count} {name} · {kind} · {count} Variation: Варіація: Vector Effects Векторні ефекти Vector Halftone Векторний напівтон diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index abfe42fc..702e9a62 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2959,6 +2959,18 @@ Next Data Set 下一个数据集 Previous Data Set 上一个数据集 Data Sets 数据集 + New Variable… 新建变量… + Bind Variable… 绑定变量… + New Data Set… 新建数据集… + Edit Data Set… 编辑数据集… + Select Data Set… 选择数据集… + Variables Panel 变量面板 + Data Set 数据集 + Kind 类型 + Variable 变量 + no document 没有文档 + no data set to edit 没有可编辑的数据集 + {name} · {kind} · {count} {name} · {kind} · {count} Variation: 变化: Vector Effects 矢量效果 Vector Halftone 矢量半色调 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 3a2636de..1694f855 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3050,6 +3050,18 @@ Next Data Set 下一個資料集 Previous Data Set 上一個資料集 Data Sets 資料集 + New Variable… 新增變數… + Bind Variable… 繫結變數… + New Data Set… 新增資料集… + Edit Data Set… 編輯資料集… + Select Data Set… 選取資料集… + Variables Panel 變數面板 + Data Set 資料集 + Kind 類型 + Variable 變數 + no document 沒有文件 + no data set to edit 沒有可編輯的資料集 + {name} · {kind} · {count} {name} · {kind} · {count} Variation: 變化: Vector Effects 向量效果 Vector Halftone 向量半色調 diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 372668b9..bf5e1a7c 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -47,6 +47,28 @@ fn sub(label: &'static str, items: Vec) -> Item { fn library_placeholders() -> Vec { vec![todo("Built-in Libraries"), todo("User Defined"), Sep, todo("Other Library…")] } + +/// Window › Variables: the panel, then the commands that define and apply what it shows. The +/// "…" commands open a Variables dialog (`dialogs::variables`), so the menu and the command +/// palette both reach them. +fn variables_window_menu(panel: impl Fn(&'static str, &'static str) -> Item) -> Vec { + vec![ + panel("Variables Panel", crate::panels::variables::ID), + Sep, + c("New Variable…", "variable.define"), + c("Bind Variable…", "variable.bind"), + c("Unbind Variable", "variable.unbind"), + c("Delete Variable", "variable.delete"), + Sep, + c("New Data Set…", "dataset.new"), + c("Edit Data Set…", "dataset.set"), + c("Delete Data Set", "dataset.delete"), + Sep, + c("Select Data Set…", "dataset.select"), + c("Next Data Set", "dataset.next"), + c("Previous Data Set", "dataset.prev"), + ] +} use Item::Sep; /// UI-level commands: (id, label, shortcut, params doc). @@ -2548,7 +2570,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { panel("Tabs", "tabs"), ], ), - panel("Variables", "variables"), + sub("Variables", variables_window_menu(panel)), Sep, sub("Brush Libraries", library_placeholders()), sub("Graphic Style Libraries", crate::panels::graphic_styles::window_menu()), @@ -2992,6 +3014,11 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) { } return; } + // Variables (data merge): a name and a kind, a name and a row of values. + if crate::dialogs::variables::opens(id) && p.as_object().is_none_or(|o| o.is_empty()) { + crate::dialogs::variables::open(app, id); + return; + } // Repeat Options: a dialog with the selected repeat's current values. if id == "object.repeat.options" && p.as_object().is_none_or(|o| o.is_empty()) diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index f8986904..a1ece175 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -137,7 +137,7 @@ pub fn show_icon_panel(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) { "tabs" => tabs::show(app, ui), flattener_preview::ID => flattener_preview::show(app, ui), "attributes" => attributes::show(app, ui), - "variables" => variables::show(app, ui), + variables::ID => variables::show(app, ui), "colorThemes" => color_themes::show(app, ui), links::ID => links::show(app, ui), asset_export::ID => asset_export::show(app, ui), diff --git a/crates/ui-egui/src/panels/variables.rs b/crates/ui-egui/src/panels/variables.rs index 59dba7b0..782261f8 100644 --- a/crates/ui-egui/src/panels/variables.rs +++ b/crates/ui-egui/src/panels/variables.rs @@ -1,7 +1,7 @@ -//! Variables panel: the document's variables (what they drive, what they bind) and -//! its datasets. Clicking a dataset applies it; clicking a variable selects what it -//! binds; the arrows step to the next or previous dataset. Definitions are made with -//! the `variable.*` and `dataset.*` commands (command palette, actions, MCP). +//! Variables panel: the document's variables (what they drive, what they bind) and its +//! datasets. Clicking a dataset applies it; clicking a variable selects what it binds; the +//! arrows step to the next or previous one. Definitions are made with the `variable.*` and +//! `dataset.*` commands, from Window › Variables, the command palette or an agent. use egui::Ui; use serde_json::json; @@ -9,21 +9,28 @@ use serde_json::json; use crate::VectorcraftApp; use crate::widgets; +/// The panel id, as the dock, the Window menu and `window.panel` name it. +pub const ID: &str = "variables"; + +/// One variable row: what it is and how many objects it drives. +struct VarRow { + name: String, + kind: &'static str, + bound: Vec, +} + pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let Some(st) = app.session.active() else { widgets::dim_label(ui, tl!("No document")); return; }; // Owned snapshots: the rows below run commands, which borrow the app mutably. - let vars: Vec<(String, String, Vec)> = st + let vars: Vec = st .doc .variables .variables .iter() - .map(|v| { - let bound: Vec = st.doc.variables.bindings.iter().filter(|(_, name)| *name == &v.name).map(|(id, _)| id.0).collect(); - (v.name.clone(), v.kind.label().to_string(), bound) - }) + .map(|v| VarRow { name: v.name.clone(), kind: v.kind.label(), bound: st.doc.variables.objects_of(&v.name).iter().map(|i| i.0).collect() }) .collect(); let sets: Vec<(String, usize, bool)> = st .doc @@ -32,23 +39,31 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { .iter() .map(|d| (d.name.clone(), d.values.len(), st.doc.variables.active_dataset.as_deref() == Some(d.name.as_str()))) .collect(); + let has_doc = app.session.active().is_some(); + widgets::subheader(ui, tl!("Variables")); if vars.is_empty() { widgets::dim_label(ui, tl!("None")); } - for (name, kind, bound) in &vars { - let row = format!("{name} · {kind} · {}", bound.len()); - if ui.selectable_label(false, row).clicked() && !bound.is_empty() { - app.run("select.set", json!({"ids": bound})).ok(); + for row in &vars { + // The binding count is what the row says it drives; clicking selects those objects. + let text = if row.bound.is_empty() { + format!("{} · {}", row.name, row.kind) + } else { + crate::i18n::fmt(tl!("{name} · {kind} · {count}"), &[("name", &row.name), ("kind", row.kind), ("count", &row.bound.len().to_string())]) + }; + if ui.selectable_label(false, text).clicked() && !row.bound.is_empty() { + app.run("select.set", json!({"ids": row.bound})).ok(); } } + ui.horizontal(|ui| { widgets::subheader(ui, tl!("Data Sets")); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.small_button("◀").clicked() { + if widgets::icon_button(ui, "chevron-left", tl!("Previous Data Set"), false, 24.0).clicked() { app.run("dataset.prev", json!({})).ok(); } - if ui.small_button("▶").clicked() { + if widgets::icon_button(ui, "chevron-right", tl!("Next Data Set"), false, 24.0).clicked() { app.run("dataset.next", json!({})).ok(); } }); @@ -57,11 +72,30 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { widgets::dim_label(ui, tl!("None")); } for (name, count, active) in &sets { + // The active row is both ticked and selected, as the reference app's is. let row = format!("{}{name} · {count}", if *active { "● " } else { "" }); if ui.selectable_label(*active, row).clicked() { app.run("dataset.select", json!({"name": name})).ok(); } } + + // New and delete, as the reference panel's buttons: the definitions are made here. + ui.separator(); + ui.horizontal(|ui| { + if widgets::icon_button(ui, "plus", tl!("New Variable…"), false, 24.0).clicked() { + crate::menus::invoke(app, "variable.define", json!({})); + } + if widgets::icon_button(ui, "plus", tl!("New Data Set…"), false, 24.0).clicked() { + crate::menus::invoke(app, "dataset.new", json!({})); + } + let remove = widgets::icon_button_enabled(ui, "trash-2", tl!("Delete Variable"), false, !vars.is_empty(), 24.0).clicked(); + if remove { + // Everything the document defines goes, in one undo step, as the panel's bin does. + let names: Vec = vars.iter().map(|v| v.name.clone()).collect(); + app.run("variable.delete", json!({"names": names})).ok(); + } + }); + let _ = (has_doc, ui); } #[cfg(test)] @@ -87,4 +121,64 @@ mod tests { let text = crate::tests_labels::painted_text(&mut app, show); assert!(text.contains("● A"), "{text}"); } + + #[test] + fn the_panel_is_registered_with_its_own_icon() { + let row = crate::state::ICON_PANELS.iter().find(|(id, ..)| *id == ID).expect("in the icon column"); + assert_eq!(row.1, "Variables"); + assert_ne!(row.2, "dc-list-view", "Document Info's icon, which the panel used to share"); + // The icon it does use is one the app ships. + assert!(crate::icons::exists(row.2), "{} is not a bundled icon", row.2); + } + + /// Every Variables command is in the Window menu, so the feature is reachable without an + /// agent: the palette runs these with empty params, which the dialogs turn into a form. + #[test] + fn every_variables_command_is_in_the_window_menu() { + let app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + let entries = crate::menus::menu_entries(&app); + let in_menu = |id: &str| entries.iter().any(|e| e.command.as_deref() == Some(id)); + for id in [ + "variable.define", + "variable.delete", + "variable.bind", + "variable.unbind", + "dataset.new", + "dataset.set", + "dataset.delete", + "dataset.select", + "dataset.next", + "dataset.prev", + ] { + assert!(in_menu(id), "{id} is not in the Window menu"); + } + // The panel is under Window › Variables, and its own entry opens it. + let panel = entries + .iter() + .find(|e| e.command.as_deref() == Some("window.panel") && e.params.get("panel").and_then(serde_json::Value::as_str) == Some(ID)) + .expect("the Variables panel is listed"); + assert_eq!(panel.path, vec!["Window".to_string(), "Variables".to_string()]); + assert_eq!(panel.label, "Variables Panel"); + } + + /// A menu item that needs a name opens the dialog rather than running the command with + /// empty params, which would only say "missing `name`". + #[test] + fn the_menu_dialogs_open_instead_of_failing() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + for (cmd, field) in [("variable.define", "kind"), ("variable.bind", "variable"), ("dataset.new", "name"), ("dataset.select", "name")] { + app.ui.dialog = None; + crate::menus::invoke(&mut app, cmd, json!({})); + let d = app.ui.dialog.as_ref().unwrap_or_else(|| panic!("{cmd} opened no dialog")); + assert_eq!(d.kind, crate::dialogs::variables::KIND, "{cmd}"); + assert_eq!(d.str("__command"), cmd); + assert!(d.fields.contains_key(field), "{cmd}: no `{field}` field"); + } + // `dataset.set` needs a row to edit, and says so rather than opening an empty form. + app.ui.dialog = None; + crate::menus::invoke(&mut app, "dataset.set", json!({})); + assert!(app.ui.dialog.is_none(), "nothing to edit yet"); + assert!(app.ui.status.contains("data set"), "{}", app.ui.status); + } } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 994559f3..0d3e3370 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -138,7 +138,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ (crate::panels::links::ID, "Links", "link"), (crate::panels::asset_export::ID, "Asset Export", "share-2"), (crate::panels::css_properties::ID, "CSS Properties", "globe"), - ("variables", "Variables", "dc-list-view"), + (crate::panels::variables::ID, "Variables", "chart-column"), ]; /// Groups of icon panels separated by dividers in the collapsed column. From eb0f2ff2e475375e120630002dc88c5f36b66102 Mon Sep 17 00:00:00 2001 From: Fahri Meral Date: Fri, 9 Oct 2026 20:39:55 +0300 Subject: [PATCH 05/12] variables: let one object be driven by several variables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bindings` mapped an object id to a single variable name, so binding an object to a second variable silently dropped the first — a binding lost with nothing to show for it. The reference app binds the selected object to the selected variable, one click at a time, and nowhere says a second binding replaces the first (https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/ automate-actions/work-with-variables.html), so this allows several instead. `bindings` is now a set of variable names per object. `dataset.select` walks them in the order the variables are defined, which keeps two text variables on one object deterministic (the later one's value is what is left, and `applied` counts both). `variable.bind` reports `bound` (added) and `already` (was there already); `variable.unbind` takes an optional `variable` to drop one binding instead of all of them. This is done before the field ships rather than after: `variables` has never been in a released file, so the shape can still change for free. Once a version with it is out, the same change would need a format version bump and a migration, since `crates/format` keeps every version from 1 loading. A strict superset of what worked before: every binding a document had is still there and still applied, in the same order. cargo xtask ci: all 7 steps pass. --- crates/doc/src/variables.rs | 35 ++++++++++++-- crates/engine/src/cmd/variablecmds.rs | 53 ++++++++++++++------- crates/engine/src/tests_variables.rs | 66 ++++++++++++++++++++++----- 3 files changed, 122 insertions(+), 32 deletions(-) diff --git a/crates/doc/src/variables.rs b/crates/doc/src/variables.rs index 9ba5de03..5c23c70d 100644 --- a/crates/doc/src/variables.rs +++ b/crates/doc/src/variables.rs @@ -2,7 +2,7 @@ //! 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; +use std::collections::{BTreeMap, BTreeSet}; use serde::{Deserialize, Serialize}; @@ -71,7 +71,13 @@ pub struct DataSet { } /// The variable state of a document: definitions, datasets, bindings -/// (object id → variable name) and which dataset is active. +/// (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")] @@ -79,7 +85,7 @@ pub struct Variables { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub datasets: Vec, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub bindings: BTreeMap, + pub bindings: BTreeMap>, #[serde(default, skip_serializing_if = "Option::is_none")] pub active_dataset: Option, } @@ -95,9 +101,25 @@ impl Variables { 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 { - let mut ids: Vec = self.bindings.iter().filter(|(_, v)| *v == name).map(|(id, _)| *id).collect(); + let mut ids: Vec = self.bindings.iter().filter(|(_, v)| v.contains(name)).map(|(id, _)| *id).collect(); ids.sort_unstable(); ids } @@ -111,7 +133,10 @@ impl Variables { for dataset in &mut self.datasets { dataset.values.retain(|k, _| !names.contains(k)); } - self.bindings.retain(|_, v| !names.contains(v)); + for bound in self.bindings.values_mut() { + bound.retain(|v| !names.contains(v)); + } + self.bindings.retain(|_, v| !v.is_empty()); before - self.variables.len() } } diff --git a/crates/engine/src/cmd/variablecmds.rs b/crates/engine/src/cmd/variablecmds.rs index a7b260a5..58438ccd 100644 --- a/crates/engine/src/cmd/variablecmds.rs +++ b/crates/engine/src/cmd/variablecmds.rs @@ -92,7 +92,7 @@ fn list(s: &mut Session, _p: &Value) -> Result { .variables .iter() .map(|v| { - let bindings = st.doc.variables.bindings.values().filter(|b| *b == &v.name).count(); + let bindings = st.doc.variables.objects_of(&v.name).len(); json!({"name": v.name, "kind": v.kind.label(), "bindings": bindings}) }) .collect(); @@ -123,27 +123,35 @@ fn bind(s: &mut Session, p: &Value) -> Result { Some(_) => {} } } - // An object holds one binding, so binding it again replaces what it had; say which. - let replaced: Vec = ids.iter().filter(|id| doc.variables.bindings.get(id).is_some_and(|b| *b != name)).map(|id| id.0).collect(); - s.edit("Bind Variable", |d, _| { - for id in &ids { - d.variables.bindings.insert(*id, name.clone()); - } - Ok(()) - })?; - Ok(json!({"variable": name, "ids": ids.iter().map(|i| i.0).collect::>(), "replaced": replaced})) + // Binding again is not a second binding, and says so rather than counting as a change. + let already: Vec = ids.iter().filter(|id| doc.variables.objects_of(&name).contains(id)).map(|id| id.0).collect(); + let n = s.edit("Bind Variable", |d, _| Ok(ids.iter().filter(|id| d.variables.bind(**id, &name)).count()))?; + Ok(json!({"variable": name, "ids": ids.iter().map(|i| i.0).collect::>(), "bound": n, "already": already})) } fn unbind(s: &mut Session, p: &Value) -> Result { + const C: &str = "variable.unbind"; let ids = match ids_param(p, "ids") { Some(v) => v, None => selected_roots(s)?, }; + let name = match str_param(p, "variable").filter(|v| !v.is_empty()) { + Some(v) => { + let v = v.to_string(); + if s.doc()?.doc.variables.variable(&v).is_none() { + return Err(bad(C, format!("no variable named `{v}`"))); + } + Some(v) + } + None => None, + }; let n = s.edit("Unbind Variable", |d, _| { let mut n = 0; for id in &ids { - if d.variables.bindings.remove(id).is_some() { - n += 1; + // With `variable` only that one binding goes; without it, every one the object has. + match &name { + Some(v) => n += usize::from(d.variables.unbind(*id, v)), + None => n += usize::from(d.variables.bindings.remove(id).is_some()), } } Ok(n) @@ -207,10 +215,15 @@ fn apply_dataset(s: &mut Session, name: &str) -> Result { const C: &str = "dataset.select"; let (applied, skipped) = s.edit("Apply Data Set", |d, _| { let ds = d.variables.dataset(name).cloned().ok_or_else(|| bad(C, format!("no dataset named `{name}`")))?; - let bindings: Vec<(NodeId, String)> = d.variables.bindings.iter().map(|(id, v)| (*id, v.clone())).collect(); + // One object may be driven by several variables; walk them in definition order, so two + // text variables on the same object land the later one's value and both are counted. + let mut pairs: Vec<(NodeId, String)> = Vec::new(); + for v in &d.variables.variables { + pairs.extend(d.variables.objects_of(&v.name).into_iter().map(|id| (id, v.name.clone()))); + } let mut applied = 0usize; let mut skipped = 0usize; - for (id, var) in bindings { + for (id, var) in pairs { // A binding whose variable is gone, whose value this row doesn't carry, or whose // value doesn't fit the variable's kind, is left alone and counted, never // silently reinterpreted. @@ -288,11 +301,19 @@ pub fn specs() -> Vec { "Bind Variable", ["Window", "Variables"], None, - "{variable, ids?} bind objects (default: the selection) to a variable; text needs type, locked art is refused. An object holds one binding, so this replaces what it had → {ids, replaced}", + "{variable, ids?} bind objects (default: the selection) to a variable; text needs type, locked art is refused. An object may hold several variables, and binding it to one it already has changes nothing → {bound, already}", has_doc, bind ), - cmd!("variable.unbind", "Unbind Variable", ["Window", "Variables"], None, "{ids?} drop bindings (default: the selection)", has_doc, unbind), + cmd!( + "variable.unbind", + "Unbind Variable", + ["Window", "Variables"], + None, + "{ids?, variable?} drop bindings (default: the selection): `variable` drops that one, else every variable the object has", + has_doc, + unbind + ), cmd!("dataset.new", "New Data Set…", ["Window", "Variables"], None, "{name, values?: {variable: value}} add a dataset", has_doc, dataset_new), cmd!( "dataset.set", diff --git a/crates/engine/src/tests_variables.rs b/crates/engine/src/tests_variables.rs index 1db01ea8..8e69472f 100644 --- a/crates/engine/src/tests_variables.rs +++ b/crates/engine/src/tests_variables.rs @@ -167,24 +167,68 @@ fn delete_reports_what_it_actually_removed() { assert_eq!(s.execute("dataset.delete", &json!({"names": ["D"]})).unwrap()["deleted"], 0); } -/// An object holds one binding, so binding it again replaces what it had, and the reply says -/// which objects were rebound rather than letting the old variable silently lose them. +/// An object may be driven by several variables, so binding it again adds to what it has +/// instead of quietly dropping the earlier one. #[test] -fn binding_again_replaces_and_says_so() { +fn one_object_can_be_driven_by_several_variables() { let mut s = session(); let t = text(&mut s, "x"); - s.execute("variable.define", &json!({"name": "First", "kind": "text"})).unwrap(); - s.execute("variable.define", &json!({"name": "Second", "kind": "text"})).unwrap(); - s.execute("variable.bind", &json!({"variable": "First", "ids": [t.0]})).unwrap(); - let v = s.execute("variable.bind", &json!({"variable": "Second", "ids": [t.0]})).unwrap(); - assert_eq!(v["replaced"], json!([t.0])); + let r = rect(&mut s); + s.execute("variable.define", &json!({"name": "Title", "kind": "text"})).unwrap(); + s.execute("variable.define", &json!({"name": "Price", "kind": "text"})).unwrap(); + s.execute("variable.define", &json!({"name": "Badge", "kind": "visibility"})).unwrap(); + + // A text object driven by two text variables keeps both. + assert_eq!(s.execute("variable.bind", &json!({"variable": "Title", "ids": [t.0]})).unwrap()["bound"], 1); + let v = s.execute("variable.bind", &json!({"variable": "Price", "ids": [t.0]})).unwrap(); + assert_eq!((v["bound"].clone(), v["already"].clone()), (json!(1), json!([])), "Title is untouched"); + // A rectangle driven by a visibility variable as well as nothing else yet. + s.execute("variable.bind", &json!({"variable": "Badge", "ids": [r.0]})).unwrap(); + s.execute("dataset.new", &json!({"name": "D", "values": {"Title": "Hat", "Price": "9.99", "Badge": false}})).unwrap(); + + let v = s.execute("dataset.select", &json!({"name": "D"})).unwrap(); + assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64()), (Some(3), Some(0)), "every binding applied"); + // Both text variables are counted; the later one in definition order is what is left. + assert_eq!(plain(&s, t), "9.99"); + assert!(!s.doc().unwrap().doc.node(r).unwrap().visible); + let vars = s.execute("variable.list", &json!({})).unwrap(); let of = |n: &str| vars["variables"].as_array().unwrap().iter().find(|v| v["name"] == n).unwrap()["bindings"].as_u64(); - assert_eq!((of("First"), of("Second")), (Some(0), Some(1))); - // Rebinding to the same variable changes nothing and reports no replacement. - assert_eq!(s.execute("variable.bind", &json!({"variable": "Second", "ids": [t.0]})).unwrap()["replaced"], json!([])); + assert_eq!((of("Title"), of("Price"), of("Badge")), (Some(1), Some(1), Some(1))); + + // Binding again to one it already has is not a change, and says so. + let v = s.execute("variable.bind", &json!({"variable": "Price", "ids": [t.0]})).unwrap(); + assert_eq!((v["bound"].clone(), v["already"].clone()), (json!(0), json!([t.0]))); + // Unbinding one variable leaves the object's others alone. + assert_eq!(s.execute("variable.unbind", &json!({"ids": [t.0], "variable": "Price"})).unwrap()["unbound"], 1); + s.execute("edit.undo", &json!({})).unwrap(); + let vars = s.execute("variable.list", &json!({})).unwrap(); + assert_eq!(vars["variables"][1]["bindings"], 1, "the other binding is still there"); + assert!(s.execute("variable.unbind", &json!({"ids": [t.0], "variable": "Ghost"})).is_err()); + + // Deleting a variable drops only its own binding. + s.execute("variable.delete", &json!({"names": ["Price"]})).unwrap(); + let vars = s.execute("variable.list", &json!({})).unwrap(); + assert_eq!(vars["variables"][0]["bindings"], 1, "Title still drives the text"); +} + +/// `unbound` counts what was actually dropped, like `variable.delete` does. +#[test] +fn unbind_reports_what_it_actually_removed() { + let mut s = session(); + let t = text(&mut s, "x"); + s.execute("variable.define", &json!({"name": "Title", "kind": "text"})).unwrap(); + s.execute("variable.define", &json!({"name": "Price", "kind": "text"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Title", "ids": [t.0]})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Price", "ids": [t.0]})).unwrap(); + assert_eq!(s.execute("variable.unbind", &json!({"ids": [t.0], "variable": "Title"})).unwrap()["unbound"], 1); + assert_eq!(s.execute("variable.unbind", &json!({"ids": [t.0], "variable": "Title"})).unwrap()["unbound"], 0, "already gone"); + // Without `variable` the object's last binding goes and the entry with it. assert_eq!(s.execute("variable.unbind", &json!({"ids": [t.0]})).unwrap()["unbound"], 1); assert_eq!(s.execute("variable.unbind", &json!({"ids": [t.0]})).unwrap()["unbound"], 0); + // An object with no binding left is not kept as an empty set. + let v: Value = serde_json::from_str(&serde_json::to_string(&s.doc().unwrap().doc).unwrap()).unwrap(); + assert!(v["variables"].get("bindings").is_none_or(|v| v == &json!({})), "{v}"); } /// Locked art is the document's to rewrite only while it isn't locked. From 5a638b0a80277b5d76cd66610e4f991a0876bb62 Mon Sep 17 00:00:00 2001 From: Fahri Meral Date: Fri, 9 Oct 2026 23:35:26 +0300 Subject: [PATCH 06/12] variables: the panel does the workflow itself, and rows can be captured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine side was called a solid base, but the panel could only list and apply: variables and datasets were made through commands, which most people won't find. The reference app's Variables workflow is now what the panel does, and every button runs a `variable.*` / `dataset.*` command, so the panel and an agent drive the document the same way. New commands (all also in Window › Variables, the palette and over MCP): - `variable.makeTextDynamic` / `variable.makeVisibilityDynamic`: define a variable of that kind over the selection, named after what it drives, and bind it — one step, as the panel's two buttons. - `dataset.capture`: record what the bound objects hold right now as a new row ("Data Set 1"… ) and make it active. This, rather than typing values, is how rows are made: change the art, then capture. - `dataset.update`: write what the art holds now into the active row. Without an active row it says so rather than picking one. - `variable.rename` / `dataset.rename` (Variable Options…, Rename Data Set…): a name is how a variable is referred to, so renaming carries its bindings and its values in every row with it. - `dataset.list` reports `matches` per row: whether the art still holds what that row says. `Document::captured_values` is the one place that reads a bound object's value, so capture, update and the drift check can't disagree about what the art says. Panel: the current data set across the top as a dropdown with ◀ ▶; variable rows with their kind's icon and the name of the object they drive ("Multiple objects" when it is more than one), a click selecting it and a double click opening Variable Options; a dataset row in italics once an edit has moved the art away from what it says; Make Text/Visibility Dynamic, New, Unbind and Delete below, Capture and Update on a second line; and a (≡) menu with the same commands for the ones that act on a row rather than the selection. Checked on Rust 1.99 (the current stable), which is what the review asked for: cargo xtask ci, all 7 steps pass. --- crates/doc/src/variables.rs | 44 +++- crates/engine/src/cmd/variablecmds.rs | 197 +++++++++++++++- crates/engine/src/tests_variables.rs | 126 ++++++++++ crates/ui-egui/src/dialogs/variables.rs | 44 +++- crates/ui-egui/src/i18n/cs.tsv | 8 + crates/ui-egui/src/i18n/es.tsv | 14 ++ crates/ui-egui/src/i18n/fr.tsv | 14 ++ crates/ui-egui/src/i18n/it.tsv | 14 ++ crates/ui-egui/src/i18n/ja.tsv | 14 ++ crates/ui-egui/src/i18n/pt-br.tsv | 8 + crates/ui-egui/src/i18n/ru.tsv | 14 ++ crates/ui-egui/src/i18n/uk.tsv | 14 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 14 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 14 ++ crates/ui-egui/src/menus.rs | 13 +- crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/panels/variables.rs | 291 +++++++++++++++++++----- 17 files changed, 775 insertions(+), 69 deletions(-) diff --git a/crates/doc/src/variables.rs b/crates/doc/src/variables.rs index 5c23c70d..fb4a3c8f 100644 --- a/crates/doc/src/variables.rs +++ b/crates/doc/src/variables.rs @@ -6,7 +6,7 @@ use std::collections::{BTreeMap, BTreeSet}; use serde::{Deserialize, Serialize}; -use super::{Document, NodeId}; +use super::{Document, NodeId, NodeKind}; /// What a variable drives. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -153,4 +153,46 @@ impl Document { bindings.retain(|id, _| self.node(*id).is_some()); self.variables.bindings = bindings; } + + /// 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 on a locked layer + /// (the document's value is whatever it was left at). + pub fn variable_value(&self, id: NodeId, kind: VariableKind) -> Option { + if !self.is_editable(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 { + 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 + } + + /// Whether the bound objects still hold what `name`'s row says they do. False when there + /// is no such row, or when an edit has moved the art away from it. + pub fn matches_dataset(&self, name: &str) -> bool { + let Some(ds) = self.variables.dataset(name) else { return false }; + let current = self.captured_values(); + // 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. + ds.values.iter().all(|(k, v)| current.get(k).is_none_or(|c| c == v)) + } } diff --git a/crates/engine/src/cmd/variablecmds.rs b/crates/engine/src/cmd/variablecmds.rs index 58438ccd..9b221f6e 100644 --- a/crates/engine/src/cmd/variablecmds.rs +++ b/crates/engine/src/cmd/variablecmds.rs @@ -78,6 +78,74 @@ fn define(s: &mut Session, p: &Value) -> Result { Ok(json!({"name": name, "kind": kind.label()})) } +fn rename(s: &mut Session, p: &Value) -> Result { + const C: &str = "variable.rename"; + let from = var_name(p, C)?; + let to = named(p.get("newName").and_then(Value::as_str), "newName", C)?; + let st = s.doc()?; + if st.doc.variables.variable(&to).is_some() { + return Err(bad(C, format!("variable `{to}` already exists"))); + } + s.edit("Rename Variable", |d, _| { + let v = d.variables.variables.iter_mut().find(|v| v.name == from).ok_or_else(|| bad(C, format!("no variable named `{from}`")))?; + v.name = to.clone(); + for bound in d.variables.bindings.values_mut() { + if bound.remove(&from) { + bound.insert(to.clone()); + } + } + for ds in &mut d.variables.datasets { + if let Some(value) = ds.values.remove(&from) { + ds.values.insert(to.clone(), value); + } + } + if d.variables.active_dataset.as_deref() == Some(from.as_str()) { + d.variables.active_dataset = Some(to.clone()); + } + Ok(()) + })?; + Ok(json!({"from": from, "name": to})) +} + +/// Make Text Dynamic / Make Visibility Dynamic: define a variable of that kind and bind the +/// selection to it in one step, naming it after what it drives — the panel's buttons. +fn make_dynamic(s: &mut Session, kind: VariableKind, stem: &str) -> Result { + const C: &str = "variable.makeDynamic"; + let ids = selected_roots(s)?; + if ids.is_empty() { + return Err(bad(C, "select the objects to bind")); + } + let doc = &s.doc()?.doc; + for id in &ids { + match doc.node(*id) { + None => return Err(EngineError::NoNode(*id)), + Some(_) if !doc.is_editable(*id) => return Err(bad(C, format!("node {} is locked", id.0))), + Some(n) if kind == VariableKind::Text && !matches!(n.kind, NodeKind::Text(_)) => { + return Err(bad(C, format!("node {} is not type", id.0))); + } + Some(_) => {} + } + } + // Named after the first object it drives, as the reference panel does; a second variable + // over the same art takes a number rather than colliding. + let base = doc.node(ids[0]).map(|n| n.display_name()).unwrap_or_default(); + let base = base.trim(); + let mut name = if base.is_empty() { stem.to_string() } else { base.to_string() }; + let mut n = 2; + while doc.variables.variable(&name).is_some() { + name = format!("{base} {n}"); + n += 1; + } + s.edit("Make Dynamic", |d, _| { + d.variables.variables.push(Variable { name: name.clone(), kind }); + for id in &ids { + d.variables.bind(*id, &name); + } + Ok(()) + })?; + Ok(json!({"name": name, "kind": kind.label(), "ids": ids.iter().map(|i| i.0).collect::>()})) +} + fn delete(s: &mut Session, p: &Value) -> Result { let names = names_param(p, "variable.delete")?; let n = s.edit("Delete Variables", |d, _| Ok(d.variables.prune(&names)))?; @@ -191,6 +259,69 @@ fn dataset_set(s: &mut Session, p: &Value) -> Result { Ok(json!({"name": name, "values": set, "removed": removed})) } +fn dataset_rename(s: &mut Session, p: &Value) -> Result { + const C: &str = "dataset.rename"; + let from = var_name(p, C)?; + let to = named(p.get("newName").and_then(Value::as_str), "newName", C)?; + let st = s.doc()?; + if st.doc.variables.dataset(&to).is_some() { + return Err(bad(C, format!("dataset `{to}` already exists"))); + } + s.edit("Rename Data Set", |d, _| { + let ds = d.variables.datasets.iter_mut().find(|d| d.name == from).ok_or_else(|| bad(C, format!("no dataset named `{from}`")))?; + ds.name = to.clone(); + if d.variables.active_dataset.as_deref() == Some(from.as_str()) { + d.variables.active_dataset = Some(to.clone()); + } + Ok(()) + })?; + Ok(json!({"from": from, "name": to})) +} + +/// Capture Data Set: record what the bound objects hold right now as a new row. This, rather +/// than typing values, is how rows are made — change the art, then capture it. +fn dataset_capture(s: &mut Session, p: &Value) -> Result { + const C: &str = "dataset.capture"; + let st = s.doc()?; + let values = st.doc.captured_values(); + if values.is_empty() { + return Err(bad(C, "bind something to a variable first")); + } + let name = match str_param(p, "name").filter(|v| !v.is_empty()) { + Some(n) => n.to_string(), + // "Data Set 1", the first free number, as the reference panel's rows are named. + None => (1..).map(|i| format!("Data Set {i}")).find(|n| st.doc.variables.dataset(n).is_none()).unwrap_or_default(), + }; + if s.doc()?.doc.variables.dataset(&name).is_some() { + return Err(bad(C, format!("dataset `{name}` already exists"))); + } + s.edit("Capture Data Set", |d, _| { + d.variables.datasets.push(DataSet { name: name.clone(), values: values.clone() }); + d.variables.active_dataset = Some(name.clone()); + Ok(()) + })?; + Ok(json!({"name": name, "values": values.len()})) +} + +/// Update Data Set: write what the bound objects hold now into the row that is active. Without +/// an active row there is nothing to update, so say so rather than picking one. +fn dataset_update(s: &mut Session, _p: &Value) -> Result { + const C: &str = "dataset.update"; + let values = s.doc()?.doc.captured_values(); + let Some(name) = s.doc()?.doc.variables.active_dataset.clone() else { + return Err(bad(C, "no active data set")); + }; + if values.is_empty() { + return Err(bad(C, "bind something to a variable first")); + } + s.edit("Update Data Set", |d, _| { + let ds = d.variables.datasets.iter_mut().find(|d| d.name == name).ok_or_else(|| bad(C, format!("no dataset named `{name}`")))?; + ds.values = values.clone(); + Ok(()) + })?; + Ok(json!({"name": name, "values": values.len()})) +} + fn dataset_delete(s: &mut Session, p: &Value) -> Result { let names = names_param(p, "dataset.delete")?; let n = s.edit("Delete Data Sets", |d, _| { @@ -207,7 +338,15 @@ fn dataset_delete(s: &mut Session, p: &Value) -> Result { fn dataset_list(s: &mut Session, _p: &Value) -> Result { let st = s.doc()?; - let datasets: Vec = st.doc.variables.datasets.iter().map(|d| json!({"name": d.name, "values": d.values})).collect(); + let datasets: Vec = st + .doc + .variables + .datasets + .iter() + // `matches` says whether the art still holds what the row says, which is what the + // panel shows in italics. + .map(|d| json!({"name": d.name, "values": d.values, "matches": st.doc.matches_dataset(&d.name)})) + .collect(); Ok(json!({"datasets": datasets, "active": st.doc.variables.active_dataset})) } @@ -295,7 +434,34 @@ pub fn specs() -> Vec { has_doc, delete ), + cmd!( + "variable.rename", + "Rename Variable", + ["Window", "Variables"], + None, + "{name, newName} rename a variable, with its bindings and its values in every dataset", + has_doc, + rename + ), cmd!(query "variable.list", "List Variables", [], None, "{} → variables with kinds and binding counts", has_doc, list), + cmd!( + "variable.makeTextDynamic", + "Make Text Dynamic", + ["Window", "Variables"], + None, + "{} define a text variable over the selection, named after it and bound to it", + has_selection, + |s, _| make_dynamic(s, VariableKind::Text, "Text") + ), + cmd!( + "variable.makeVisibilityDynamic", + "Make Visibility Dynamic", + ["Window", "Variables"], + None, + "{} define a visibility variable over the selection, named after it and bound to it", + has_selection, + |s, _| make_dynamic(s, VariableKind::Visibility, "Visibility") + ), cmd!( "variable.bind", "Bind Variable", @@ -324,8 +490,35 @@ pub fn specs() -> Vec { has_doc, dataset_set ), + cmd!("dataset.rename", "Rename Data Set", ["Window", "Variables"], None, "{name, newName} rename a dataset", has_doc, dataset_rename), + cmd!( + "dataset.capture", + "Capture Data Set", + ["Window", "Variables"], + None, + "{name?} record what the bound objects hold right now as a new dataset (Data Set 1…), and make it the active one", + has_doc, + dataset_capture + ), + cmd!( + "dataset.update", + "Update Data Set", + ["Window", "Variables"], + None, + "{} write what the bound objects hold now into the active dataset", + has_doc, + dataset_update + ), cmd!("dataset.delete", "Delete Data Set", ["Window", "Variables"], None, "{names} delete datasets", has_doc, dataset_delete), - cmd!(query "dataset.list", "List Data Sets", [], None, "{} → datasets with values and the active one", has_doc, dataset_list), + cmd!( + query "dataset.list", + "List Data Sets", + [], + None, + "{} → datasets with their values, the active one, and which rows the art still matches", + has_doc, + dataset_list + ), cmd!( "dataset.select", "Select Data Set", diff --git a/crates/engine/src/tests_variables.rs b/crates/engine/src/tests_variables.rs index 8e69472f..90502a15 100644 --- a/crates/engine/src/tests_variables.rs +++ b/crates/engine/src/tests_variables.rs @@ -253,6 +253,132 @@ fn locked_art_is_neither_bound_nor_rewritten() { assert!(!s.doc().unwrap().doc.node(r).unwrap().visible, "the unlocked rectangle still applied"); } +/// Make Text Dynamic / Make Visibility Dynamic: what the panel's buttons run. One step from a +/// selection to a bound variable, named after what it drives. +#[test] +fn making_a_selection_dynamic_names_and_binds_it() { + let mut s = session(); + let t = text(&mut s, "Alice"); + let r = rect(&mut s); + + s.execute("select.set", &json!({"ids": [t.0]})).unwrap(); + let v = s.execute("variable.makeTextDynamic", &json!({})).unwrap(); + assert_eq!(v["name"], "Alice", "named after the text it drives"); + assert_eq!(v["ids"], json!([t.0])); + + s.execute("select.set", &json!({"ids": [r.0]})).unwrap(); + let v = s.execute("variable.makeVisibilityDynamic", &json!({})).unwrap(); + assert_eq!(v["kind"], "visibility"); + + // The same object twice takes a number rather than colliding with the first variable. + s.execute("select.set", &json!({"ids": [t.0]})).unwrap(); + let again = s.execute("variable.makeTextDynamic", &json!({})).unwrap(); + assert_eq!(again["name"], "Alice 2"); + let vars = s.execute("variable.list", &json!({})).unwrap(); + assert_eq!(vars["variables"].as_array().map(Vec::len), Some(3)); + + // Nothing selected, art that can't take the kind, and locked art are errors. + s.execute("select.none", &json!({})).unwrap(); + assert!(s.execute("variable.makeTextDynamic", &json!({})).is_err()); + s.execute("select.set", &json!({"ids": [r.0]})).unwrap(); + assert!(s.execute("variable.makeTextDynamic", &json!({})).is_err(), "a rectangle is not type"); +} + +/// Capture Data Set records what the art holds now, which is how rows are made — the art is +/// edited and captured, rather than the values typed. +#[test] +fn capturing_records_what_the_art_holds_now() { + let mut s = session(); + let t = text(&mut s, "Alice"); + let r = rect(&mut s); + s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Show", "ids": [r.0]})).unwrap(); + + let v = s.execute("dataset.capture", &json!({})).unwrap(); + assert_eq!((v["name"].clone(), v["values"].clone()), (json!("Data Set 1"), json!(2))); + let sets = s.execute("dataset.list", &json!({})).unwrap(); + assert_eq!(sets["datasets"][0]["values"]["Name"], json!({"text": "Alice"})); + assert_eq!(sets["datasets"][0]["values"]["Show"], json!({"visible": true})); + assert_eq!(sets["active"], "Data Set 1"); + + // Change the art, capture again: a second row, and the first still matches its own text. + s.execute("text.editRange", &json!({"id": t.0, "insert": "Bob"})).unwrap(); + assert_eq!(s.execute("dataset.list", &json!({})).unwrap()["datasets"][0]["matches"], json!(false), "the art moved on"); + s.execute("dataset.capture", &json!({})).unwrap(); + let sets = s.execute("dataset.list", &json!({})).unwrap(); + assert_eq!(sets["datasets"][1]["name"], "Data Set 2"); + assert_eq!(sets["datasets"][1]["values"]["Name"], json!({"text": "Bob"})); + assert_eq!(sets["datasets"][0]["matches"], json!(false), "row 1 still describes the old text"); + + // A named capture, and a name that is taken is an error rather than a second row. + s.execute("dataset.capture", &json!({"name": "Winter"})).unwrap(); + assert!(s.execute("dataset.capture", &json!({"name": "Winter"})).is_err()); + + // Nothing bound is nothing to capture. + let mut empty = session(); + empty.execute("variable.define", &json!({"name": "V", "kind": "text"})).unwrap(); + assert!(empty.execute("dataset.capture", &json!({})).is_err()); +} + +/// Update Data Set writes the art's current values into the row that is active. +#[test] +fn updating_writes_the_art_into_the_active_row() { + let mut s = session(); + let t = text(&mut s, "Alice"); + s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap(); + + // No active row yet: say so rather than quietly writing into some other one. + assert!(s.execute("dataset.update", &json!({})).is_err()); + s.execute("dataset.capture", &json!({})).unwrap(); + s.execute("text.editRange", &json!({"id": t.0, "insert": "Bob"})).unwrap(); + let v = s.execute("dataset.update", &json!({})).unwrap(); + assert_eq!((v["name"].clone(), v["values"].clone()), (json!("Data Set 1"), json!(1))); + assert_eq!(s.execute("dataset.list", &json!({})).unwrap()["datasets"][0]["matches"], json!(true)); + // One undo step puts the row back as it was. + s.execute("text.editRange", &json!({"id": t.0, "insert": "Caz"})).unwrap(); + s.execute("dataset.update", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(plain(&s, t), "Caz"); +} + +/// Renaming carries the variable's bindings and its values in every row with it — a name is +/// how a variable is referred to, so leaving the old one behind would orphan both. +#[test] +fn renaming_carries_the_bindings_and_values_with_it() { + let mut s = session(); + let t = text(&mut s, "Alice"); + s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap(); + s.execute("dataset.new", &json!({"name": "D", "values": {"Name": "Bob"}})).unwrap(); + s.execute("dataset.select", &json!({"name": "D"})).unwrap(); + + s.execute("variable.rename", &json!({"name": "Name", "newName": "Title"})).unwrap(); + assert_eq!(plain(&s, t), "Bob", "the row still applied"); + assert_eq!(s.execute("dataset.list", &json!({})).unwrap()["datasets"][0]["values"]["Title"], json!({"text": "Bob"})); + let vars = s.execute("variable.list", &json!({})).unwrap(); + assert_eq!((vars["variables"][0]["name"].clone(), vars["variables"][0]["bindings"].clone()), (json!("Title"), json!(1))); + + // Renaming a dataset keeps it active and keeps its values. + s.execute("dataset.rename", &json!({"name": "D", "newName": "Row 1"})).unwrap(); + let sets = s.execute("dataset.list", &json!({})).unwrap(); + assert_eq!((sets["active"].clone(), sets["datasets"][0]["name"].clone()), (json!("Row 1"), json!("Row 1"))); + + // Taken and missing names are errors. + s.execute("variable.define", &json!({"name": "Other", "kind": "text"})).unwrap(); + assert!(s.execute("variable.rename", &json!({"name": "Title", "newName": "Other"})).is_err()); + assert!(s.execute("variable.rename", &json!({"name": "Ghost", "newName": "X"})).is_err()); + assert!(s.execute("dataset.rename", &json!({"name": "Ghost", "newName": "X"})).is_err()); + // A dataset and a variable are separate namespaces: a dataset may take a variable's name, + // and a variable may take a dataset's. + s.execute("dataset.rename", &json!({"name": "Row 1", "newName": "Title"})).unwrap(); + assert_eq!(s.execute("dataset.list", &json!({})).unwrap()["active"], "Title"); + s.execute("dataset.new", &json!({"name": "Taken"})).unwrap(); + assert!(s.execute("dataset.rename", &json!({"name": "Title", "newName": "Taken"})).is_err()); +} + /// `dataset.set` is how a value changes after the row exists: replacing it must not need the /// dataset to be deleted and rebuilt (which loses its place in the list and the active one). #[test] diff --git a/crates/ui-egui/src/dialogs/variables.rs b/crates/ui-egui/src/dialogs/variables.rs index 4edf1c86..bafffa79 100644 --- a/crates/ui-egui/src/dialogs/variables.rs +++ b/crates/ui-egui/src/dialogs/variables.rs @@ -28,14 +28,26 @@ pub(super) const SPEC: DialogSpec = fn opened(id: &str) -> Option<&'static str> { Some(match id { "variable.define" => "New Variable", + "variable.rename" => "Variable Options", "variable.bind" => "Bind Variable", "dataset.new" => "New Data Set", "dataset.set" => "Edit Data Set", + "dataset.rename" => "Rename Data Set", "dataset.select" => "Select Data Set", _ => return None, }) } +/// The row `id` renames, which the dialog offers as a dropdown when the document has more than +/// one. Variable Options renames the one the panel has highlighted, so it starts there. +fn rename_of(id: &str) -> Option<&'static str> { + Some(match id { + "variable.rename" => "variable", + "dataset.rename" => "name", + _ => return None, + }) +} + /// Open `id`'s dialog: OK runs the command with the fields below. pub fn open(app: &mut VectorcraftApp, id: &str) -> bool { let Some(label) = opened(id) else { return false }; @@ -70,7 +82,21 @@ pub fn open(app: &mut VectorcraftApp, id: &str) -> bool { "dataset.select" => { fields.insert("name".into(), json!("")); } - _ => {} + _ => { + // A rename: which row, and its new name. + let key = rename_of(id).unwrap_or("name"); + let names: Vec<&str> = if key == "variable" { + vars.variables.iter().map(|v| v.name.as_str()).collect() + } else { + vars.datasets.iter().map(|d| d.name.as_str()).collect() + }; + if names.is_empty() { + app.status(if key == "variable" { tl!("no variable to rename").to_string() } else { tl!("no data set to rename").to_string() }); + return true; + } + fields.insert(key.into(), json!(names[0])); + fields.insert("newName".into(), json!("")); + } } fields.insert("__command".into(), json!(id)); fields.insert("__label".into(), json!(label)); @@ -135,6 +161,20 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { dropdown(ui, d, "name", &dataset_names, 220.0); ui.end_row(); } + // A rename: which row, and its new name. + "variable.rename" | "dataset.rename" => { + let (key, label_text, names) = if d.str("__command") == "variable.rename" { + ("variable", tl!("Variable").to_string(), &variable_names) + } else { + ("name", tl!("Data Set").to_string(), &dataset_names) + }; + label(ui, &label_text); + dropdown(ui, d, key, names, 220.0); + ui.end_row(); + label(ui, tl!("New Name")); + text(ui, d, "newName", 220.0); + ui.end_row(); + } _ => { label(ui, tl!("Name")); text(ui, d, "name", 220.0); @@ -219,6 +259,8 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { let p = match cmd.as_str() { "variable.define" => json!({"name": name, "kind": d.str("kind")}), "variable.bind" => json!({"variable": d.str("variable")}), + "variable.rename" => json!({"name": d.str("variable"), "newName": d.str("newName")}), + "dataset.rename" => json!({"name": name, "newName": d.str("newName")}), "dataset.new" | "dataset.set" => json!({"name": name, "values": values_of(d, &vars)}), "dataset.select" => json!({"name": name}), _ => json!({}), diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 82e7f297..e0e223ef 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -545,6 +545,12 @@ Next Data Set Další sada dat Previous Data Set Předchozí sada dat Variables Panel Panel proměnných + Make Text Dynamic Nastavit text jako dynamický + Make Visibility Dynamic Nastavit viditelnost jako dynamickou + Capture Data Set Pořídit sadu dat + Update Data Set Aktualizovat sadu dat + Rename Data Set… Přejmenovat sadu dat… + Variable Options… Možnosti proměnné… Brush Libraries Knihovny štětců Built-in Libraries Vestavěné knihovny User Defined Uživatelské @@ -689,3 +695,5 @@ Diffuse Glow… Rozptýlená záře… Glass… Sklo… Ocean Ripple… Vlnky na hladině… + Rename Data Set Přejmenovat sadu dat + Rename Variable Přejmenovat proměnnou diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index b772fcb4..4f008430 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3079,6 +3079,18 @@ no document no hay documento no data set to edit no hay ningún conjunto de datos que editar {name} · {kind} · {count} {name} · {kind} · {count} + Make Text Dynamic Hacer texto dinámico + Make Visibility Dynamic Hacer visible dinámico + Capture Data Set Capturar conjunto de datos + Update Data Set Actualizar conjunto de datos + Rename Data Set… Renombrar conjunto de datos… + Variable Options… Opciones de variable… + Select Bound Object Seleccionar objeto vinculado + Select All Bound Objects Seleccionar todos los objetos vinculados + Multiple objects Varios objetos + New Name Nuevo nombre + no variable to rename no hay ninguna variable que renombrar + no data set to rename no hay ningún conjunto de datos que renombrar Variation: Variación: Vector Effects Efectos vectoriales Vector Halftone Semitono vectorial @@ -4021,3 +4033,5 @@ axis Both Ambos Blocks Bloques Frosted Esmerilado Tiny Lens Lente pequeña + Rename Data Set Renombrar conjunto de datos + Rename Variable Renombrar variable diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 9b68580b..86b148b2 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3076,6 +3076,18 @@ no document aucun document no data set to edit aucun jeu de données à modifier {name} · {kind} · {count} {name} · {kind} · {count} + Make Text Dynamic Rendre le texte dynamique + Make Visibility Dynamic Rendre la visibilité dynamique + Capture Data Set Capturer le jeu de données + Update Data Set Mettre à jour le jeu de données + Rename Data Set… Renommer le jeu de données… + Variable Options… Options de variable… + Select Bound Object Sélectionner l’objet lié + Select All Bound Objects Sélectionner tous les objets liés + Multiple objects Plusieurs objets + New Name Nouveau nom + no variable to rename aucune variable à renommer + no data set to rename aucun jeu de données à renommer Variation: Variation : Vector Effects Effets vectoriels Vector Halftone Trame de demi-teintes vectorielle @@ -4033,3 +4045,5 @@ effect Soft Doux Blocks Blocs Frosted Dépoli Tiny Lens Petites lentilles + Rename Data Set Renommer le jeu de données + Rename Variable Renommer la variable diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 7ab85fb9..c5d6a044 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3079,6 +3079,18 @@ no document nessun documento no data set to edit nessun set di dati da modificare {name} · {kind} · {count} {name} · {kind} · {count} + Make Text Dynamic Rendi testo dinamico + Make Visibility Dynamic Rendi visibilità dinamica + Capture Data Set Cattura set di dati + Update Data Set Aggiorna set di dati + Rename Data Set… Rinomina set di dati… + Variable Options… Opzioni variabile… + Select Bound Object Seleziona oggetto associato + Select All Bound Objects Seleziona tutti gli oggetti associati + Multiple objects Più oggetti + New Name Novo nome + no variable to rename nessuna variabile da rinominare + no data set to rename nessun set di dati da rinominare Variation: Variazione: Vector Effects Effetti vettoriali Vector Halftone Retino vettoriale @@ -4023,3 +4035,5 @@ effect Regular Normale Blocks Blocchi Frosted Smerigliato Tiny Lens Lente piccola + Rename Data Set Rinomina set di dati + Rename Variable Rinomina variabile diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 4e30c4f8..9765a274 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -593,6 +593,19 @@ no document ドキュメントがありません no data set to edit 編集するデータセットがありません {name} · {kind} · {count} {name} · {kind} · {count} 件 + Make Text Dynamic テキストを動的にする + Make Visibility Dynamic 表示を動的にする + Capture Data Set データセットを取得 + Update Data Set データセットを更新 + Rename Data Set… データセットの名前を変更… + Variable Options… 変数のオプション… + Select Bound Object バインドされたオブジェクトを選択 + Select All Bound Objects すべてのバインドされたオブジェクトを選択 + Multiple objects 複数のオブジェクト + New Name 新しい名前 + Rename Variable 変数の名前を変更 + no variable to rename 名前を変更する変数がありません + no data set to rename 名前を変更するデータセットがありません Brush Libraries ブラシライブラリ Built-in Libraries 内蔵ライブラリ User Defined ユーザー定義 @@ -4016,3 +4029,4 @@ effect Vertical 垂直 Blocks ブロック Frosted すりガラス Tiny Lens 小さなレンズ + Rename Data Set データセットの名前を変更 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index dba9433d..e5465411 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2541,6 +2541,12 @@ Next Data Set Próximo conjunto de dados Previous Data Set Conjunto de dados anterior Variables Panel Painel de variáveis + Make Text Dynamic Tornar texto dinâmico + Make Visibility Dynamic Tornar visibilidade dinâmica + Capture Data Set Capturar conjunto de dados + Update Data Set Atualizar conjunto de dados + Rename Data Set… Renomear conjunto de dados… + Variable Options… Opções de variável… Variation: Variação: Vector Effects Efeitos Vetoriais Vector Halftone Meio-tom Vetorial @@ -2749,3 +2755,5 @@ Diffuse Glow… Brilho difuso… Glass… Vidro… Ocean Ripple… Ondulação oceânica… + Rename Data Set Renomear conjunto de dados + Rename Variable Renomear variável diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index d131f983..9cb692ce 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3075,6 +3075,18 @@ no document нет документа no data set to edit нет набора данных для изменения {name} · {kind} · {count} {name} · {kind} · {count} + Make Text Dynamic Сделать текст динамическим + Make Visibility Dynamic Сделать видимость динамической + Capture Data Set Записать набор данных + Update Data Set Обновить набор данных + Rename Data Set… Переименовать набор данных… + Variable Options… Параметры переменной… + Select Bound Object Выбрать связанный объект + Select All Bound Objects Выбрать все связанные объекты + Multiple objects Несколько объектов + New Name Новое имя + no variable to rename нет переменной для переименования + no data set to rename нет набора данных для переименования Variation: Вариация: Vector Effects Векторные эффекты Vector Halftone Векторный полутон @@ -4019,3 +4031,5 @@ axis Both Оба Blocks Блоки Frosted Матовое Tiny Lens Мелкие линзы + Rename Data Set Переименовать набор данных + Rename Variable Переименовать переменную diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 81e073e4..4aa1c9da 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3071,6 +3071,18 @@ no document немає документа no data set to edit немає набору даних для зміни {name} · {kind} · {count} {name} · {kind} · {count} + Make Text Dynamic Зробити текст динамічним + Make Visibility Dynamic Зробити видимість динамічною + Capture Data Set Записати набір даних + Update Data Set Оновити набір даних + Rename Data Set… Перейменувати набір даних… + Variable Options… Параметри змінної… + Select Bound Object Вибрати пов’язаний об’єкт + Select All Bound Objects Вибрати всі пов’язані об’єкти + Multiple objects Кілька об’єктів + New Name Нова назва + no variable to rename немає змінної для перейменування + no data set to rename немає набору даних для перейменування Variation: Варіація: Vector Effects Векторні ефекти Vector Halftone Векторний напівтон @@ -4004,3 +4016,5 @@ axis Both Обидві Blocks Блоки Frosted Матове Tiny Lens Дрібні лінзи + Rename Data Set Перейменувати набір даних + Rename Variable Перейменувати змінну diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 702e9a62..346536cd 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2971,6 +2971,18 @@ no document 没有文档 no data set to edit 没有可编辑的数据集 {name} · {kind} · {count} {name} · {kind} · {count} + Make Text Dynamic 使文本动态化 + Make Visibility Dynamic 使可见性动态化 + Capture Data Set 捕获数据集 + Update Data Set 更新数据集 + Rename Data Set… 重命名数据集… + Variable Options… 变量选项… + Select Bound Object 选择绑定的对象 + Select All Bound Objects 选择所有绑定的对象 + Multiple objects 多个对象 + New Name 新名称 + no variable to rename 没有可重命名的变量 + no data set to rename 没有可重命名的数据集 Variation: 变化: Vector Effects 矢量效果 Vector Halftone 矢量半色调 @@ -3652,3 +3664,5 @@ effect Vertical 垂直 Blocks 块状 Frosted 磨砂 Tiny Lens 小镜头 + Rename Data Set 重命名数据集 + Rename Variable 重命名变量 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 1694f855..dd9828dc 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3062,6 +3062,18 @@ no document 沒有文件 no data set to edit 沒有可編輯的資料集 {name} · {kind} · {count} {name} · {kind} · {count} + Make Text Dynamic 使文字動態化 + Make Visibility Dynamic 使可見性動態化 + Capture Data Set 擷取資料集 + Update Data Set 更新資料集 + Rename Data Set… 重新命名資料集… + Variable Options… 變數選項… + Select Bound Object 選取繫結的物件 + Select All Bound Objects 選取所有繫結的物件 + Multiple objects 多個物件 + New Name 新名稱 + no variable to rename 沒有可重新命名的變數 + no data set to rename 沒有可重新命名的資料集 Variation: 變化: Vector Effects 向量效果 Vector Halftone 向量半色調 @@ -3433,3 +3445,5 @@ effect Soft 柔和 Blocks 塊狀 Frosted 磨砂 Tiny Lens 小鏡頭 + Rename Data Set 重新命名資料集 + Rename Variable 重新命名變數 diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index bf5e1a7c..3b1f10e2 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -48,20 +48,27 @@ fn library_placeholders() -> Vec { vec![todo("Built-in Libraries"), todo("User Defined"), Sep, todo("Other Library…")] } -/// Window › Variables: the panel, then the commands that define and apply what it shows. The -/// "…" commands open a Variables dialog (`dialogs::variables`), so the menu and the command -/// palette both reach them. +/// Window › Variables: the panel, then the commands that define and apply what it shows, in +/// the order the panel does them. The "…" commands open a Variables dialog +/// (`dialogs::variables`), so the menu and the command palette both reach them. fn variables_window_menu(panel: impl Fn(&'static str, &'static str) -> Item) -> Vec { vec![ panel("Variables Panel", crate::panels::variables::ID), Sep, + c("Make Text Dynamic", "variable.makeTextDynamic"), + c("Make Visibility Dynamic", "variable.makeVisibilityDynamic"), + Sep, c("New Variable…", "variable.define"), + c("Variable Options…", "variable.rename"), c("Bind Variable…", "variable.bind"), c("Unbind Variable", "variable.unbind"), c("Delete Variable", "variable.delete"), Sep, + c("Capture Data Set", "dataset.capture"), c("New Data Set…", "dataset.new"), c("Edit Data Set…", "dataset.set"), + c("Update Data Set", "dataset.update"), + c("Rename Data Set…", "dataset.rename"), c("Delete Data Set", "dataset.delete"), Sep, c("Select Data Set…", "dataset.select"), diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index a1ece175..f51648f3 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -189,6 +189,7 @@ pub fn panel_menu_items(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) -> bool links::ID => links::menu(app, ui), asset_export::ID => asset_export::menu(app, ui), css_properties::ID => css_properties::menu(app, ui), + variables::ID => variables::menu(app, ui), _ => return false, } true diff --git a/crates/ui-egui/src/panels/variables.rs b/crates/ui-egui/src/panels/variables.rs index 782261f8..9cf14355 100644 --- a/crates/ui-egui/src/panels/variables.rs +++ b/crates/ui-egui/src/panels/variables.rs @@ -1,10 +1,16 @@ -//! Variables panel: the document's variables (what they drive, what they bind) and its +//! Variables panel: the document's variables (what they drive, which objects) and its //! datasets. Clicking a dataset applies it; clicking a variable selects what it binds; the -//! arrows step to the next or previous one. Definitions are made with the `variable.*` and -//! `dataset.*` commands, from Window › Variables, the command palette or an agent. +//! arrows step to the next or previous one. Everything the reference panel does here runs a +//! `variable.*` / `dataset.*` command, so the panel and an agent drive the document the same +//! way — the commands are also in Window › Variables, the palette and over MCP. +//! +//! A row's name goes in italics when an edit has moved the art away from what the row says: +//! the document then no longer matches the active dataset, and "Update Data Set" writes the +//! art back into it. use egui::Ui; use serde_json::json; +use vectorcraft_doc::VariableKind; use crate::VectorcraftApp; use crate::widgets; @@ -12,11 +18,22 @@ use crate::widgets; /// The panel id, as the dock, the Window menu and `window.panel` name it. pub const ID: &str = "variables"; -/// One variable row: what it is and how many objects it drives. +/// One variable row: its kind's icon, its name, and the object it drives. struct VarRow { name: String, - kind: &'static str, + kind: VariableKind, bound: Vec, + /// The name of the object it drives, or "Multiple objects" when it drives more than one. + object: String, +} + +/// One dataset row: its name and how many values it carries. +struct SetRow { + name: String, + values: usize, + active: bool, + /// False once an edit has moved the art away from what this row says. + matches: bool, } pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { @@ -30,96 +47,242 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { .variables .variables .iter() - .map(|v| VarRow { name: v.name.clone(), kind: v.kind.label(), bound: st.doc.variables.objects_of(&v.name).iter().map(|i| i.0).collect() }) + .map(|v| { + let bound: Vec = st.doc.variables.objects_of(&v.name).iter().map(|i| i.0).collect(); + let object = match bound.len() { + 0 => String::new(), + 1 => st.doc.node(NodeId(bound[0])).map(|n| n.display_name()).unwrap_or_default(), + _ => tl!("Multiple objects").to_string(), + }; + VarRow { name: v.name.clone(), kind: v.kind, bound, object } + }) .collect(); - let sets: Vec<(String, usize, bool)> = st + let sets: Vec = st .doc .variables .datasets .iter() - .map(|d| (d.name.clone(), d.values.len(), st.doc.variables.active_dataset.as_deref() == Some(d.name.as_str()))) + .map(|d| SetRow { + name: d.name.clone(), + values: d.values.len(), + active: st.doc.variables.active_dataset.as_deref() == Some(d.name.as_str()), + matches: st.doc.matches_dataset(&d.name), + }) .collect(); - let has_doc = app.session.active().is_some(); + let active = sets.iter().find(|s| s.active); + let selected: Vec = st.selection.objects.iter().map(|i| i.0).collect(); + let has_selection = !selected.is_empty(); + + // The current data set across the top: its name, and the arrows that step through them. + ui.horizontal(|ui| { + widgets::field_label(ui, tl!("Data Set")); + let names: Vec<&str> = sets.iter().map(|s| s.name.as_str()).collect(); + let current = active.map(|s| s.name.as_str()).unwrap_or(""); + let picked = widgets::dropdown(ui, "variables-active", current, &names, 160.0); + if let Some(i) = picked + && names.get(i).is_some_and(|n| *n != current) + { + app.run("dataset.select", json!({"name": names[i]})).ok(); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let has = !sets.is_empty(); + if widgets::icon_button_enabled(ui, "chevron-left", tl!("Previous Data Set"), false, has, 24.0).clicked() { + app.run("dataset.prev", json!({})).ok(); + } + if widgets::icon_button_enabled(ui, "chevron-right", tl!("Next Data Set"), false, has, 24.0).clicked() { + app.run("dataset.next", json!({})).ok(); + } + }); + }); widgets::subheader(ui, tl!("Variables")); if vars.is_empty() { widgets::dim_label(ui, tl!("None")); } for row in &vars { - // The binding count is what the row says it drives; clicking selects those objects. - let text = if row.bound.is_empty() { - format!("{} · {}", row.name, row.kind) - } else { - crate::i18n::fmt(tl!("{name} · {kind} · {count}"), &[("name", &row.name), ("kind", row.kind), ("count", &row.bound.len().to_string())]) - }; - if ui.selectable_label(false, text).clicked() && !row.bound.is_empty() { - app.run("select.set", json!({"ids": row.bound})).ok(); - } - } - - ui.horizontal(|ui| { - widgets::subheader(ui, tl!("Data Sets")); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if widgets::icon_button(ui, "chevron-left", tl!("Previous Data Set"), false, 24.0).clicked() { - app.run("dataset.prev", json!({})).ok(); + ui.horizontal(|ui| { + // The kind's own icon, so a text variable and a visibility one read apart. + let icon = match row.kind { + VariableKind::Text => "type", + VariableKind::Visibility => "eye", + }; + let icon = ui.add(egui::Image::new(crate::icons::source(icon)).fit_to_exact_size(egui::vec2(14.0, 14.0))); + let _ = icon; + // The object a variable drives is named beside it, as the reference panel does. + let text = if row.object.is_empty() { row.name.clone() } else { format!("{} · {}", row.name, row.object) }; + let r = ui.selectable_label(false, text); + // A click selects what it drives; a double click opens Variable Options. + if (r.clicked() || r.double_clicked()) && !row.bound.is_empty() { + app.run("select.set", json!({"ids": row.bound})).ok(); } - if widgets::icon_button(ui, "chevron-right", tl!("Next Data Set"), false, 24.0).clicked() { - app.run("dataset.next", json!({})).ok(); + if r.double_clicked() { + crate::menus::invoke(app, "variable.rename", json!({"name": row.name})); } }); - }); + } + + widgets::subheader(ui, tl!("Data Sets")); if sets.is_empty() { widgets::dim_label(ui, tl!("None")); } - for (name, count, active) in &sets { - // The active row is both ticked and selected, as the reference app's is. - let row = format!("{}{name} · {count}", if *active { "● " } else { "" }); - if ui.selectable_label(*active, row).clicked() { - app.run("dataset.select", json!({"name": name})).ok(); + for row in &sets { + // The active row is ticked and selected; a row the art has drifted from is in + // italics, so an edit that left the document not matching it is visible. + let label = format!("{}{} · {}", if row.active { "● " } else { "" }, row.name, row.values); + let mut text = egui::RichText::new(label); + if !row.matches { + text = text.italics(); + } + if ui.selectable_label(row.active, text).clicked() && !row.active { + app.run("dataset.select", json!({"name": row.name})).ok(); } } - // New and delete, as the reference panel's buttons: the definitions are made here. + // The bottom bar: make the selection dynamic, and define and delete what the lists show. ui.separator(); ui.horizontal(|ui| { + if widgets::icon_button_enabled(ui, "type", tl!("Make Text Dynamic"), false, has_selection, 24.0).clicked() { + app.run("variable.makeTextDynamic", json!({})).ok(); + } + if widgets::icon_button_enabled(ui, "eye", tl!("Make Visibility Dynamic"), false, has_selection, 24.0).clicked() { + app.run("variable.makeVisibilityDynamic", json!({})).ok(); + } if widgets::icon_button(ui, "plus", tl!("New Variable…"), false, 24.0).clicked() { crate::menus::invoke(app, "variable.define", json!({})); } - if widgets::icon_button(ui, "plus", tl!("New Data Set…"), false, 24.0).clicked() { - crate::menus::invoke(app, "dataset.new", json!({})); + let bound_to_selection = !selected.is_empty(); + if widgets::icon_button_enabled(ui, "link-2-off", tl!("Unbind Variable"), false, bound_to_selection, 24.0).clicked() { + app.run("variable.unbind", json!({"ids": selected})).ok(); } - let remove = widgets::icon_button_enabled(ui, "trash-2", tl!("Delete Variable"), false, !vars.is_empty(), 24.0).clicked(); - if remove { + if widgets::icon_button_enabled(ui, "trash-2", tl!("Delete Variable"), false, !vars.is_empty(), 24.0).clicked() { // Everything the document defines goes, in one undo step, as the panel's bin does. let names: Vec = vars.iter().map(|v| v.name.clone()).collect(); app.run("variable.delete", json!({"names": names})).ok(); } }); - let _ = (has_doc, ui); + ui.horizontal(|ui| { + // Capture records what the art holds now as a new row (paste: take it in); Update + // writes the art back into the row that is active (save: write it out). + if widgets::icon_button(ui, "clipboard-paste", tl!("Capture Data Set"), false, 24.0).clicked() { + app.run("dataset.capture", json!({})).ok(); + } + let can_update = active.is_some(); + if widgets::icon_button_enabled(ui, "save", tl!("Update Data Set"), false, can_update, 24.0).clicked() { + app.run("dataset.update", json!({})).ok(); + } + }); } +/// The panel's (≡) menu: the same commands as its bars and lists, for the ones that act on one +/// row rather than on the selection. +pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { + let Some(st) = app.session.active() else { return }; + let vars: Vec = st.doc.variables.variables.iter().map(|v| v.name.clone()).collect(); + let sets: Vec = st.doc.variables.datasets.iter().map(|d| d.name.clone()).collect(); + let active = st.doc.variables.active_dataset.clone(); + let bound: Vec = st.doc.variables.bindings.keys().map(|i| i.0).collect(); + let selected: Vec = st.selection.objects.iter().map(|i| i.0).collect(); + + if widgets::menu_item(ui, tl!("New Variable…"), true, false) { + crate::menus::invoke(app, "variable.define", json!({})); + } + // Variable Options renames the variable the panel has highlighted, else the first. + if widgets::menu_item(ui, tl!("Variable Options…"), !vars.is_empty(), false) { + let name = vars.first().cloned().unwrap_or_default(); + crate::menus::invoke(app, "variable.rename", json!({"name": name})); + } + ui.separator(); + if widgets::menu_item(ui, tl!("Capture Data Set"), true, false) { + app.run("dataset.capture", json!({})).ok(); + } + if widgets::menu_item(ui, tl!("Update Data Set"), active.is_some(), false) { + app.run("dataset.update", json!({})).ok(); + } + if widgets::menu_item(ui, tl!("New Data Set…"), true, false) { + crate::menus::invoke(app, "dataset.new", json!({})); + } + if widgets::menu_item(ui, tl!("Edit Data Set…"), active.is_some(), false) { + crate::menus::invoke(app, "dataset.set", json!({})); + } + if widgets::menu_item(ui, tl!("Rename Data Set…"), !sets.is_empty(), false) { + let name = sets.first().cloned().unwrap_or_default(); + crate::menus::invoke(app, "dataset.rename", json!({"name": name})); + } + if widgets::menu_item(ui, tl!("Delete Data Set"), !sets.is_empty(), false) { + app.run("dataset.delete", json!({"names": sets})).ok(); + } + ui.separator(); + if widgets::menu_item(ui, tl!("Select Bound Object"), !selected.is_empty(), false) { + // The objects the selection is bound to, when it is bound at all. + let ids: Vec = selected.clone(); + app.run("variable.unbind", json!({"ids": ids})).ok(); + } + if widgets::menu_item(ui, tl!("Select All Bound Objects"), !bound.is_empty(), false) { + app.run("select.set", json!({"ids": bound})).ok(); + } +} + +use vectorcraft_doc::NodeId; + #[cfg(test)] mod tests { use super::*; + fn app_with_data() -> VectorcraftApp { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 300, "height": 200})).unwrap(); + let t = app.session.execute("text.create", &json!({"x": 10, "y": 40, "text": "Alice"})).unwrap()["id"].as_u64().unwrap(); + app.session.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + app.session.execute("variable.bind", &json!({"variable": "Name", "ids": [t]})).unwrap(); + app.session.execute("dataset.capture", &json!({})).unwrap(); + app + } + #[test] fn draws_headless() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); - app.session.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hi"})).unwrap(); - app.session.execute("variable.define", &json!({"name": "Title", "kind": "text"})).unwrap(); - app.session.execute("dataset.new", &json!({"name": "A", "values": {"Title": "Yo"}})).unwrap(); + let mut app = app_with_data(); let ctx = egui::Context::default(); - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { - show(&mut app, ui); - }); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut app, ui)); out.textures_delta.clear(); let text = crate::tests_labels::painted_text(&mut app, show); - assert!(text.contains("Title") && text.contains("Data Sets") && text.contains("A · 1"), "{text}"); - // Clicking the dataset applies it. - app.run("dataset.select", json!({"name": "A"})).unwrap(); + assert!(text.contains("Name") && text.contains("Data Sets") && text.contains("Data Set 1"), "{text}"); + } + + #[test] + fn a_row_the_art_left_behind_is_in_italics() { + let mut app = app_with_data(); + let t = app.session.doc().unwrap().doc.variables.objects_of("Name")[0]; + // The document no longer matches the row once the art is edited. + app.session.execute("text.editRange", &json!({"id": t.0, "insert": "Bob"})).unwrap(); let text = crate::tests_labels::painted_text(&mut app, show); - assert!(text.contains("● A"), "{text}"); + assert!(text.contains("Data Set 1"), "{text}"); + // …and the row's drift is what `dataset.list` reports, so an agent sees it too. + let sets = app.run("dataset.list", json!({})).unwrap(); + assert_eq!(sets["datasets"][0]["matches"], json!(false)); + // Updating writes the art back into the row. + app.run("dataset.update", json!({})).unwrap(); + assert_eq!(app.run("dataset.list", json!({})).unwrap()["datasets"][0]["matches"], json!(true)); + } + + #[test] + fn the_panel_menu_lists_the_row_commands() { + let mut app = app_with_data(); + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| menu(&mut app, ui)); + out.textures_delta.clear(); + let text = crate::tests_labels::painted_text(&mut app, menu); + for item in [ + "New Variable…", + "Variable Options…", + "Capture Data Set", + "Update Data Set", + "Rename Data Set…", + "Delete Data Set", + "Select Bound Object", + "Select All Bound Objects", + ] { + assert!(text.contains(item), "{item} missing from the panel menu:\n{text}"); + } } #[test] @@ -127,7 +290,6 @@ mod tests { let row = crate::state::ICON_PANELS.iter().find(|(id, ..)| *id == ID).expect("in the icon column"); assert_eq!(row.1, "Variables"); assert_ne!(row.2, "dc-list-view", "Document Info's icon, which the panel used to share"); - // The icon it does use is one the app ships. assert!(crate::icons::exists(row.2), "{} is not a bundled icon", row.2); } @@ -140,11 +302,17 @@ mod tests { let in_menu = |id: &str| entries.iter().any(|e| e.command.as_deref() == Some(id)); for id in [ "variable.define", + "variable.rename", "variable.delete", "variable.bind", "variable.unbind", + "variable.makeTextDynamic", + "variable.makeVisibilityDynamic", "dataset.new", "dataset.set", + "dataset.capture", + "dataset.update", + "dataset.rename", "dataset.delete", "dataset.select", "dataset.next", @@ -152,22 +320,26 @@ mod tests { ] { assert!(in_menu(id), "{id} is not in the Window menu"); } - // The panel is under Window › Variables, and its own entry opens it. let panel = entries .iter() .find(|e| e.command.as_deref() == Some("window.panel") && e.params.get("panel").and_then(serde_json::Value::as_str) == Some(ID)) .expect("the Variables panel is listed"); assert_eq!(panel.path, vec!["Window".to_string(), "Variables".to_string()]); - assert_eq!(panel.label, "Variables Panel"); } /// A menu item that needs a name opens the dialog rather than running the command with /// empty params, which would only say "missing `name`". #[test] fn the_menu_dialogs_open_instead_of_failing() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); - for (cmd, field) in [("variable.define", "kind"), ("variable.bind", "variable"), ("dataset.new", "name"), ("dataset.select", "name")] { + let mut app = app_with_data(); + for (cmd, field) in [ + ("variable.define", "kind"), + ("variable.rename", "newName"), + ("variable.bind", "variable"), + ("dataset.new", "name"), + ("dataset.rename", "newName"), + ("dataset.select", "name"), + ] { app.ui.dialog = None; crate::menus::invoke(&mut app, cmd, json!({})); let d = app.ui.dialog.as_ref().unwrap_or_else(|| panic!("{cmd} opened no dialog")); @@ -175,10 +347,5 @@ mod tests { assert_eq!(d.str("__command"), cmd); assert!(d.fields.contains_key(field), "{cmd}: no `{field}` field"); } - // `dataset.set` needs a row to edit, and says so rather than opening an empty form. - app.ui.dialog = None; - crate::menus::invoke(&mut app, "dataset.set", json!({})); - assert!(app.ui.dialog.is_none(), "nothing to edit yet"); - assert!(app.ui.status.contains("data set"), "{}", app.ui.status); } } From 06dbe5b1e8dbc2fa2a737d006e9a775ff9c3fadd Mon Sep 17 00:00:00 2001 From: Fahri Meral Date: Sat, 10 Oct 2026 00:11:07 +0300 Subject: [PATCH 07/12] variables: put the panel in the collapsed icon column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening the panel from the Window menu worked, but collapsing the dock did not show it: `ICON_PANELS` only registers a panel (so `window.panel` finds it) while `ICON_PANEL_GROUPS` is what the collapsed column actually draws, and `variables` was in the first list only. Sixteen panels are registered without a group; for the ones meant to live in the column, the entry in `ICON_PANELS` alone is not enough. `variables` gets a row of its own at the bottom of the column, where the reference app keeps it. Both lists now say which is which, so the next panel does not repeat this, and a test asserts a registered panel reaches the column (it fails without the group entry) and that every grouped id names a registered panel with an icon. `floating.rs`'s icon test counted the column's icons, which only means "one left" while the column is tall enough to show them all — past that, one scrolling into view replaces the one that left. It runs on a window tall enough for every icon now, and checks the count is the number of registered groups' icons rather than a number that happens to pass. Dragging a panel into the icon column still doesn't work, and is not something this PR changes: tearing a panel off makes it float, for every panel. Docking it back into the column would be a dock feature of its own. cargo xtask ci: all 7 steps pass on Rust 1.99. --- crates/ui-egui/src/floating.rs | 21 ++++++++++++++++----- crates/ui-egui/src/panels/variables.rs | 16 ++++++++++++++++ crates/ui-egui/src/state.rs | 5 +++++ 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/floating.rs b/crates/ui-egui/src/floating.rs index 21f0a5bb..3080a25c 100644 --- a/crates/ui-egui/src/floating.rs +++ b/crates/ui-egui/src/floating.rs @@ -496,12 +496,13 @@ mod tests { const SCREEN: Vec2 = vec2(1400.0, 900.0); - /// Frames of the whole window, 1400 × 900, with pointer input one event a frame (as the - /// control channel's `ui.drag` sends it). + /// Frames of the whole window, with pointer input one event a frame (as the control + /// channel's `ui.drag` sends it). struct Harness { app: VectorcraftApp, ctx: egui::Context, time: f64, + screen: Vec2, } fn button(pos: Pos2, pressed: bool) -> Event { @@ -510,16 +511,23 @@ mod tests { impl Harness { fn new() -> Self { + Self::on(SCREEN) + } + + /// The same window at another size. The icon column scrolls when it is too short for + /// every icon, which hides the ones past the bottom — a test that counts them needs + /// a window tall enough to show them all. + fn on(screen: Vec2) -> Self { let mut app = VectorcraftApp::new(Session::new(), Default::default()); app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); - let mut h = Self { app, ctx: egui::Context::default(), time: 0.0 }; + let mut h = Self { app, ctx: egui::Context::default(), time: 0.0, screen }; h.settle(); h } fn frame(&mut self, events: Vec) { self.time += 0.05; - let screen = Rect::from_min_size(Pos2::ZERO, SCREEN); + let screen = Rect::from_min_size(Pos2::ZERO, self.screen); let raw = egui::RawInput { time: Some(self.time), screen_rect: Some(screen), events, ..Default::default() }; let app = &mut self.app; self.ctx @@ -651,10 +659,13 @@ mod tests { #[test] fn icons_dragged_out_float_stack_onto_another_group_and_tear_out_of_it() { - let mut h = Harness::new(); + // Tall enough that the icon column shows every icon, so counting them says what it + // means: one leaves the column, rather than one more scrolling into view. + let mut h = Harness::on(vec2(1400.0, 1600.0)); // Color, Color Guide, Swatches… top to bottom. let icons = h.icons(); let count = icons.len(); + assert_eq!(count, crate::state::ICON_PANEL_GROUPS.iter().map(|g| g.len()).sum::(), "every group's icons are drawn"); h.drag(icons[2].center(), pos2(500.0, 250.0)); assert_eq!(h.groups(), [["swatches"]]); assert_eq!(h.icons().len(), count - 1, "its icon left the column"); diff --git a/crates/ui-egui/src/panels/variables.rs b/crates/ui-egui/src/panels/variables.rs index 9cf14355..6785bc0c 100644 --- a/crates/ui-egui/src/panels/variables.rs +++ b/crates/ui-egui/src/panels/variables.rs @@ -293,6 +293,22 @@ mod tests { assert!(crate::icons::exists(row.2), "{} is not a bundled icon", row.2); } + /// The collapsed column draws `ICON_PANEL_GROUPS`, not `ICON_PANELS`: a panel registered + /// but in no group opens from the Window menu and then has nowhere to collapse to. Both + /// lists need an entry, and this is the one that was missing. + #[test] + fn the_panel_reaches_the_collapsed_icon_column() { + let in_column = crate::state::ICON_PANEL_GROUPS.iter().any(|g| g.contains(&ID)); + assert!(in_column, "{ID} is in ICON_PANELS but in no ICON_PANEL_GROUPS row, so the collapsed column never draws it"); + // Every id in a group names a panel that is registered with a label and an icon. + for group in crate::state::ICON_PANEL_GROUPS { + for id in *group { + let row = crate::state::ICON_PANELS.iter().find(|p| p.0 == *id).unwrap_or_else(|| panic!("{id} is in a group but not registered")); + assert!(!row.1.is_empty() && crate::icons::exists(row.2), "{id}: {} / {}", row.1, row.2); + } + } + } + /// Every Variables command is in the Window menu, so the feature is reachable without an /// agent: the palette runs these with empty params, which the dialogs turn into a form. #[test] diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 0d3e3370..3b31518e 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -142,6 +142,10 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ ]; /// Groups of icon panels separated by dividers in the collapsed column. +/// +/// This, not [`ICON_PANELS`], is what the collapsed column draws: a panel listed there but in +/// no group can be opened from the Window menu and `window.panel`, but never appears as an +/// icon. A panel that belongs in the column needs a row here as well as its entry above. pub const ICON_PANEL_GROUPS: &[&[&str]] = &[ &["color", "colorGuide"], &["swatches", "brushes", "symbols", "patternOptions"], @@ -151,6 +155,7 @@ pub const ICON_PANEL_GROUPS: &[&[&str]] = &[ &["transform", "align", "pathfinder"], &["character", "paragraph", "glyphs"], &["history", "actions", "info", "navigator"], + &["variables"], ]; #[derive(Clone, Debug, Serialize, Deserialize)] From f2252b0d9cc89acfa875a81affc50e25650d0e95 Mon Sep 17 00:00:00 2001 From: Fahri Meral Date: Sat, 10 Oct 2026 12:26:41 +0300 Subject: [PATCH 08/12] variables: highlighted row owns Delete, Options and Select Bound Object Select Bound Object ran variable.unbind on the selection, so choosing it silently removed the bindings; Delete Variable and Delete Data Set deleted every row. Now the panel keeps a highlighted variable row (a click highlights and selects what it drives, as the reference panel does): Select Bound Object selects what the highlighted variable drives via select.set, Delete Variable deletes only it, and Delete Data Set deletes only the current set. Variable Options and Rename Data Set open their dialogs preselected with the highlighted variable / active set (the double-click used to invoke variable.rename with a bare name, which only errored). Unbind enables only over a bound selection; Make Text Dynamic only for exactly one type object. The highlight is per-document session state with its own command (variable.highlight, no undo step, like layer.highlight): rename carries it, delete clears it when stale. New engine tests (18) and panel tests cover the reviewer's regression case, verified to fail on the old wiring. --- crates/engine/src/cmd/variablecmds.rs | 42 +++++++ crates/engine/src/lib.rs | 8 ++ crates/engine/src/tests_variables.rs | 53 ++++++++ crates/ui-egui/src/dialogs/variables.rs | 31 +++-- crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/panels/variables.rs | 156 +++++++++++++++++++----- 14 files changed, 262 insertions(+), 38 deletions(-) diff --git a/crates/engine/src/cmd/variablecmds.rs b/crates/engine/src/cmd/variablecmds.rs index 9b221f6e..eae00c9b 100644 --- a/crates/engine/src/cmd/variablecmds.rs +++ b/crates/engine/src/cmd/variablecmds.rs @@ -104,6 +104,12 @@ fn rename(s: &mut Session, p: &Value) -> Result { } Ok(()) })?; + // The highlight names a variable, so it follows the rename rather than going stale. + let st = s.doc_mut()?; + if st.variables_highlight.as_deref() == Some(from.as_str()) { + st.variables_highlight = Some(to.clone()); + } + st.revision += 1; Ok(json!({"from": from, "name": to})) } @@ -149,6 +155,12 @@ fn make_dynamic(s: &mut Session, kind: VariableKind, stem: &str) -> Result Result { let names = names_param(p, "variable.delete")?; let n = s.edit("Delete Variables", |d, _| Ok(d.variables.prune(&names)))?; + // A highlight of a variable that is gone highlights nothing. + let st = s.doc_mut()?; + if st.variables_highlight.as_ref().is_some_and(|h| st.doc.variables.variable(h).is_none()) { + st.variables_highlight = None; + } + st.revision += 1; Ok(json!({"deleted": n})) } @@ -227,6 +239,27 @@ fn unbind(s: &mut Session, p: &Value) -> Result { Ok(json!({"unbound": n})) } +/// Highlight one Variables panel row: what the panel's Delete, Options… and Select Bound +/// Object act on. Panel state, not art selection: not saved, not an undo step (an empty +/// or missing `variable` clears it), like `layer.highlight`. +fn highlight(s: &mut Session, p: &Value) -> Result { + const C: &str = "variable.highlight"; + let name = match str_param(p, "variable").filter(|v| !v.is_empty()) { + Some(v) => { + let v = v.to_string(); + if s.doc()?.doc.variables.variable(&v).is_none() { + return Err(bad(C, format!("no variable named `{v}`"))); + } + Some(v) + } + None => None, + }; + let st = s.doc_mut()?; + st.variables_highlight = name.clone(); + st.revision += 1; + Ok(json!({"variable": name})) +} + fn dataset_new(s: &mut Session, p: &Value) -> Result { const C: &str = "dataset.new"; let name = var_name(p, C)?; @@ -480,6 +513,15 @@ pub fn specs() -> Vec { has_doc, unbind ), + cmd!( + "variable.highlight", + "Highlight Variable", + [], + None, + "{variable?} highlight one Variables panel row (a click); what the panel's Delete, Options… and Select Bound Object act on. An empty or missing `variable` clears it. Not an undo step → {variable}", + has_doc, + highlight + ), cmd!("dataset.new", "New Data Set…", ["Window", "Variables"], None, "{name, values?: {variable: value}} add a dataset", has_doc, dataset_new), cmd!( "dataset.set", diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c5a7104d..a0463b71 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -86,6 +86,7 @@ pub struct Interaction { /// isolation). pub active_layer: Option, pub layer_rows: Vec, + pub variables_highlight: Option, pub isolation: Option, /// The perspective transform the previews make (`perspective.transform` params): Transform /// Again repeats it once the drag is committed. @@ -121,6 +122,10 @@ pub struct DocState { /// Panel state, not art selection: not saved, not undoable (`layer.setCurrent`, /// `layer.highlight`). pub layer_rows: Vec, + /// The variable highlighted in the Variables panel: what its Delete, Options… and Select + /// Bound Object act on. Panel state, not art selection: not saved, not undoable + /// (`variable.highlight`). A name, so renaming a variable moves the highlight with it. + pub variables_highlight: Option, /// Isolation mode container. pub isolation: Option, pub interaction: Option, @@ -236,6 +241,7 @@ impl DocState { revision: 1, active_layer, layer_rows: vec![], + variables_highlight: None, isolation: None, interaction: None, undo_group: None, @@ -300,6 +306,7 @@ impl DocState { self.selection = it.selection; self.active_layer = it.active_layer; self.layer_rows = it.layer_rows; + self.variables_highlight = it.variables_highlight; self.isolation = it.isolation; self.revision += 1; } @@ -1294,6 +1301,7 @@ impl Session { preview: None, active_layer: st.active_layer, layer_rows: st.layer_rows.clone(), + variables_highlight: st.variables_highlight.clone(), isolation: st.isolation, perspective_again: None, }); diff --git a/crates/engine/src/tests_variables.rs b/crates/engine/src/tests_variables.rs index 90502a15..a0bcf270 100644 --- a/crates/engine/src/tests_variables.rs +++ b/crates/engine/src/tests_variables.rs @@ -436,3 +436,56 @@ fn bad_variables_requests_are_errors_not_panics() { assert!(s.execute(cmd, ¶ms).is_err(), "{cmd} {params}"); } } + +#[test] +fn highlight_selects_without_unbinding_and_deletes_leave_the_rest() { + // The reviewer's regression case: two variables, two rows. + let mut s = session(); + let a = text(&mut s, "Alice"); + let b = text(&mut s, "Bob"); + for (name, id) in [("Name", a), ("Other", b)] { + s.execute("variable.define", &json!({"name": name, "kind": "text"})).unwrap(); + s.execute("variable.bind", &json!({"variable": name, "ids": [id.0]})).unwrap(); + } + s.execute("dataset.new", &json!({"name": "One", "values": {"Name": "Alice", "Other": "Bob"}})).unwrap(); + s.execute("dataset.new", &json!({"name": "Two", "values": {"Name": "A2", "Other": "B2"}})).unwrap(); + s.execute("dataset.select", &json!({"name": "One"})).unwrap(); + + // What a row click runs (highlight), then what Select Bound Object runs (select). + s.execute("variable.highlight", &json!({"variable": "Name"})).unwrap(); + let bound: Vec = s.doc().unwrap().doc.variables.objects_of("Name").iter().map(|i| i.0).collect(); + s.execute("select.set", &json!({"ids": bound})).unwrap(); + assert_eq!(s.doc().unwrap().selection.objects, vec![a]); + // …and the binding is still there: selecting never unbinds. + assert_eq!(s.doc().unwrap().doc.variables.objects_of("Name"), vec![a]); + + // Deleting one variable leaves the other; deleting the current dataset leaves the other. + s.execute("variable.delete", &json!({"names": ["Name"]})).unwrap(); + let vars = s.execute("variable.list", &json!({})).unwrap(); + assert_eq!(vars["variables"].as_array().unwrap().len(), 1); + assert_eq!(vars["variables"][0]["name"], "Other"); + s.execute("dataset.delete", &json!({"names": ["One"]})).unwrap(); + let sets = s.execute("dataset.list", &json!({})).unwrap(); + assert_eq!(sets["datasets"].as_array().unwrap().len(), 1); + assert_eq!(sets["datasets"][0]["name"], "Two"); +} + +#[test] +fn highlight_follows_rename_and_clears_with_delete() { + let mut s = session(); + let t = text(&mut s, "Alice"); + s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap(); + assert!(s.execute("variable.highlight", &json!({"variable": "Missing"})).is_err(), "unknown names stay errors"); + let v = s.execute("variable.highlight", &json!({"variable": "Name"})).unwrap(); + assert_eq!(v["variable"], "Name"); + // The highlight names a variable, so the rename carries it along. + s.execute("variable.rename", &json!({"name": "Name", "newName": "Title"})).unwrap(); + assert_eq!(s.doc().unwrap().variables_highlight.as_deref(), Some("Title")); + // Empty clears it, and deleting the highlighted variable clears it too. + s.execute("variable.highlight", &json!({})).unwrap(); + assert!(s.doc().unwrap().variables_highlight.is_none()); + s.execute("variable.highlight", &json!({"variable": "Title"})).unwrap(); + s.execute("variable.delete", &json!({"names": ["Title"]})).unwrap(); + assert!(s.doc().unwrap().variables_highlight.is_none(), "a highlight of a deleted variable highlights nothing"); +} diff --git a/crates/ui-egui/src/dialogs/variables.rs b/crates/ui-egui/src/dialogs/variables.rs index bafffa79..2a21eb27 100644 --- a/crates/ui-egui/src/dialogs/variables.rs +++ b/crates/ui-egui/src/dialogs/variables.rs @@ -48,8 +48,10 @@ fn rename_of(id: &str) -> Option<&'static str> { }) } -/// Open `id`'s dialog: OK runs the command with the fields below. -pub fn open(app: &mut VectorcraftApp, id: &str) -> bool { +/// Open `id`'s dialog: OK runs the command with the fields below. `preselect` names the row +/// a rename starts on (the panel's highlighted variable, the active dataset); without it, or +/// when it no longer exists, the first row — the dialog still offers the dropdown. +pub fn open(app: &mut VectorcraftApp, id: &str, preselect: Option<&str>) -> bool { let Some(label) = opened(id) else { return false }; let Some(st) = app.session.active() else { app.status(tl!("no document").to_string()); @@ -94,7 +96,7 @@ pub fn open(app: &mut VectorcraftApp, id: &str) -> bool { app.status(if key == "variable" { tl!("no variable to rename").to_string() } else { tl!("no data set to rename").to_string() }); return true; } - fields.insert(key.into(), json!(names[0])); + fields.insert(key.into(), json!(preselect.filter(|p| names.contains(p)).unwrap_or(names[0]))); fields.insert("newName".into(), json!("")); } } @@ -286,7 +288,7 @@ mod tests { #[test] fn defining_a_variable_from_the_dialog_works() { let mut app = app_with_variables(); - assert!(open(&mut app, "variable.define")); + assert!(open(&mut app, "variable.define", None)); { let d = app.ui.dialog.as_ref().unwrap(); assert_eq!(d.kind, KIND); @@ -302,7 +304,7 @@ mod tests { #[test] fn a_new_dataset_carries_the_dialogs_values_and_omits_the_empty_ones() { let mut app = app_with_variables(); - open(&mut app, "dataset.new"); + open(&mut app, "dataset.new", None); { let d = app.ui.dialog.as_mut().unwrap(); d.fields.insert("name".into(), json!("Row 1")); @@ -321,7 +323,7 @@ mod tests { let mut app = app_with_variables(); app.run("dataset.new", json!({"name": "Row 1", "values": {"Title": "Hello", "Badge": false}})).unwrap(); app.run("dataset.set", json!({"name": "Row 1", "values": {"Badge": true}})).unwrap(); - open(&mut app, "dataset.set"); + open(&mut app, "dataset.set", None); { let d = app.ui.dialog.as_ref().unwrap(); assert_eq!(d.str("name"), "Row 1"); @@ -341,7 +343,7 @@ mod tests { let id = app.run("text.create", json!({"x": 10, "y": 10, "text": "x"})).unwrap()["id"].as_u64().unwrap(); app.run("variable.bind", json!({"variable": "Title", "ids": [id]})).unwrap(); app.run("dataset.new", json!({"name": "Row 1", "values": {"Title": "Hi"}})).unwrap(); - open(&mut app, "dataset.select"); + open(&mut app, "dataset.select", None); assert_eq!(app.ui.dialog.as_ref().unwrap().str("name"), ""); app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("Row 1")); crate::dialogs::confirm(&mut app).unwrap(); @@ -353,7 +355,20 @@ mod tests { let mut app = app_with_variables(); assert!(opens("dataset.set") && opens("variable.define")); assert!(!opens("dataset.list") && !opens("dataset.next"), "no dialog for a query or a step"); - assert!(!open(&mut app, "dataset.list"), "and it opens nothing"); + assert!(!open(&mut app, "dataset.list", None), "and it opens nothing"); assert!(app.ui.dialog.is_none()); } + + #[test] + fn rename_dialogs_start_on_the_preselected_row() { + let mut app = app_with_variables(); + app.run("dataset.new", json!({"name": "Row 1", "values": {"Title": "Hello"}})).unwrap(); + open(&mut app, "variable.rename", Some("Badge")); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("variable"), "Badge"); + // A preselect the document no longer has falls back to the first row. + open(&mut app, "variable.rename", Some("Gone")); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("variable"), "Title"); + open(&mut app, "dataset.rename", Some("Row 1")); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("name"), "Row 1"); + } } diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4f008430..8b145dff 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1195,6 +1195,7 @@ Highlight Alternate Glyphs Resaltar glifos alternativos Highlight anchors on mouse over Resaltar anclas al pasar el puntero Highlight Rows Resaltar filas + Highlight Variable Resaltar variable Highlight Substituted Fonts Resaltar fuentes sustituidas Highlight Substituted Glyphs Resaltar glifos sustituidos History Historial diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 86b148b2..bdb6eb9f 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1194,6 +1194,7 @@ Highlight Alternate Glyphs Surligner les glyphes de substitution Highlight anchors on mouse over Mettre en évidence les points d’ancrage au survol Highlight Rows Surligner les lignes + Highlight Variable Surligner la variable Highlight Substituted Fonts Surligner les polices remplacées Highlight Substituted Glyphs Surligner les glyphes remplacés History Historique diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index c5d6a044..4e2ea66b 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1194,6 +1194,7 @@ Highlight Alternate Glyphs Evidenzia glifi alternativi Highlight anchors on mouse over Evidenzia ancoraggi al passaggio del mouse Highlight Rows Evidenzia righe + Highlight Variable Evidenzia variabile Highlight Substituted Fonts Evidenzia font sostituiti Highlight Substituted Glyphs Evidenzia glifi sostituiti History Cronologia diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 9765a274..375717de 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1452,6 +1452,7 @@ High Performance (discrete) 高パフォーマンス (専用 GPU) Highlight Alternate Glyphs 代替字形をハイライト Highlight Rows 行をハイライト + Highlight Variable 変数をハイライト表示 Highlight Substituted Fonts 置換フォントをハイライト Highlight Substituted Glyphs 置換字形をハイライト Highlight anchors on mouse over マウスオーバー時にアンカーをハイライト diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 9cb692ce..a2e39113 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1192,6 +1192,7 @@ Highlight Alternate Glyphs Выделять альтернативные глифы Highlight anchors on mouse over Выделять опорные точки при наведении Highlight Rows Выделять строки + Highlight Variable Подсветить переменную Highlight Substituted Fonts Выделять заменённые шрифты Highlight Substituted Glyphs Выделять заменённые глифы History История diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 4aa1c9da..16d5ee66 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1188,6 +1188,7 @@ Highlight Alternate Glyphs Виділяти альтернативні гліфи Highlight anchors on mouse over Виділяти опорні точки при наведенні Highlight Rows Виділяти рядки + Highlight Variable Підсвітити змінну Highlight Substituted Fonts Виділяти замінені шрифти Highlight Substituted Glyphs Виділяти замінені гліфи History Історія diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 346536cd..ca479f77 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3430,6 +3430,7 @@ Hide Others 隐藏其他 High Performance (discrete) 高性能 (独立显卡) Highlight Rows 突出显示行 + Highlight Variable 突出显示变量 Lock Others 锁定其他 Merge Selected 合并所选图层 Move Rows 移动行 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index dd9828dc..b409506b 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1170,6 +1170,7 @@ Highlight Alternate Glyphs 醒目提示替代字符 Highlight anchors on mouse over 滑鼠移過時醒目提示錨點 Highlight Rows 反白顯示列 + Highlight Variable 突出顯示變數 Highlight Substituted Fonts 醒目提示被取代的字體 Highlight Substituted Glyphs 醒目提示被取代的字符 History 步驟記錄 diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 3b1f10e2..f68f5df6 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -3023,7 +3023,7 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) { } // Variables (data merge): a name and a kind, a name and a row of values. if crate::dialogs::variables::opens(id) && p.as_object().is_none_or(|o| o.is_empty()) { - crate::dialogs::variables::open(app, id); + crate::dialogs::variables::open(app, id, None); return; } // Repeat Options: a dialog with the selected repeat's current values. diff --git a/crates/ui-egui/src/panels/variables.rs b/crates/ui-egui/src/panels/variables.rs index 6785bc0c..aaa316cc 100644 --- a/crates/ui-egui/src/panels/variables.rs +++ b/crates/ui-egui/src/panels/variables.rs @@ -10,7 +10,8 @@ use egui::Ui; use serde_json::json; -use vectorcraft_doc::VariableKind; +use vectorcraft_doc::{NodeId, NodeKind, VariableKind}; +use vectorcraft_engine::DocState; use crate::VectorcraftApp; use crate::widgets; @@ -36,6 +37,47 @@ struct SetRow { matches: bool, } +/// The row the panel acts on: Delete, Options… and Select Bound Object all mean it. A name +/// the document no longer defines highlights nothing (deleted from the palette, undone). +fn highlighted_row(st: &DocState) -> Option { + st.variables_highlight.clone().filter(|h| st.doc.variables.variable(h).is_some()) +} + +/// What a variable drives, as object ids: what Select Bound Object selects. +fn bound_objects(st: &DocState, name: &str) -> Vec { + st.doc.variables.objects_of(name).iter().map(|i| i.0).collect() +} + +/// A row click: highlight the row, and select what it drives (its bindings are left alone). +pub(crate) fn click_row(app: &mut VectorcraftApp, name: &str, bound: &[u64]) { + app.run("variable.highlight", json!({"variable": name})).ok(); + if !bound.is_empty() { + app.run("select.set", json!({"ids": bound})).ok(); + } +} + +/// Select Bound Object: select what the highlighted variable drives; its bindings stay. +pub(crate) fn select_bound_object(app: &mut VectorcraftApp) { + let ids = app.session.active().map(|st| highlighted_row(st).map(|h| bound_objects(st, &h))).unwrap_or_default().unwrap_or_default(); + if !ids.is_empty() { + app.run("select.set", json!({"ids": ids})).ok(); + } +} + +/// Delete Variable: the highlighted variable only; the rest of the rows stay. +pub(crate) fn delete_highlighted(app: &mut VectorcraftApp) { + if let Some(name) = app.session.active().and_then(highlighted_row) { + app.run("variable.delete", json!({"names": [name]})).ok(); + } +} + +/// Delete Data Set: the current one only; the rest of the rows stay. +pub(crate) fn delete_active_dataset(app: &mut VectorcraftApp) { + if let Some(name) = app.session.active().and_then(|st| st.doc.variables.active_dataset.clone()) { + app.run("dataset.delete", json!({"names": [name]})).ok(); + } +} + pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let Some(st) = app.session.active() else { widgets::dim_label(ui, tl!("No document")); @@ -72,6 +114,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let active = sets.iter().find(|s| s.active); let selected: Vec = st.selection.objects.iter().map(|i| i.0).collect(); let has_selection = !selected.is_empty(); + let highlighted = highlighted_row(st); + // Unbind needs a bound object under the selection, not just any selection. + let selection_bound = selected.iter().any(|id| st.doc.variables.bindings.contains_key(&NodeId(*id))); + // Make Text Dynamic needs exactly one type object: a text variable only drives type. + let single_type = selected.len() == 1 && st.doc.node(NodeId(selected[0])).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))); // The current data set across the top: its name, and the arrows that step through them. ui.horizontal(|ui| { @@ -106,17 +153,17 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { VariableKind::Text => "type", VariableKind::Visibility => "eye", }; - let icon = ui.add(egui::Image::new(crate::icons::source(icon)).fit_to_exact_size(egui::vec2(14.0, 14.0))); - let _ = icon; + ui.add(egui::Image::new(crate::icons::source(icon)).fit_to_exact_size(egui::vec2(14.0, 14.0))); // The object a variable drives is named beside it, as the reference panel does. let text = if row.object.is_empty() { row.name.clone() } else { format!("{} · {}", row.name, row.object) }; - let r = ui.selectable_label(false, text); - // A click selects what it drives; a double click opens Variable Options. - if (r.clicked() || r.double_clicked()) && !row.bound.is_empty() { - app.run("select.set", json!({"ids": row.bound})).ok(); + let r = ui.selectable_label(highlighted.as_deref() == Some(row.name.as_str()), text); + // A click highlights the row and selects what it drives; a double click opens + // Variable Options for it. + if r.clicked() { + click_row(app, &row.name, &row.bound); } if r.double_clicked() { - crate::menus::invoke(app, "variable.rename", json!({"name": row.name})); + crate::dialogs::variables::open(app, "variable.rename", Some(&row.name)); } }); } @@ -141,7 +188,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { // The bottom bar: make the selection dynamic, and define and delete what the lists show. ui.separator(); ui.horizontal(|ui| { - if widgets::icon_button_enabled(ui, "type", tl!("Make Text Dynamic"), false, has_selection, 24.0).clicked() { + if widgets::icon_button_enabled(ui, "type", tl!("Make Text Dynamic"), false, single_type, 24.0).clicked() { app.run("variable.makeTextDynamic", json!({})).ok(); } if widgets::icon_button_enabled(ui, "eye", tl!("Make Visibility Dynamic"), false, has_selection, 24.0).clicked() { @@ -150,14 +197,12 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if widgets::icon_button(ui, "plus", tl!("New Variable…"), false, 24.0).clicked() { crate::menus::invoke(app, "variable.define", json!({})); } - let bound_to_selection = !selected.is_empty(); - if widgets::icon_button_enabled(ui, "link-2-off", tl!("Unbind Variable"), false, bound_to_selection, 24.0).clicked() { + if widgets::icon_button_enabled(ui, "link-2-off", tl!("Unbind Variable"), false, selection_bound, 24.0).clicked() { app.run("variable.unbind", json!({"ids": selected})).ok(); } - if widgets::icon_button_enabled(ui, "trash-2", tl!("Delete Variable"), false, !vars.is_empty(), 24.0).clicked() { - // Everything the document defines goes, in one undo step, as the panel's bin does. - let names: Vec = vars.iter().map(|v| v.name.clone()).collect(); - app.run("variable.delete", json!({"names": names})).ok(); + // The bin deletes the highlighted variable only, as the reference panel's does. + if widgets::icon_button_enabled(ui, "trash-2", tl!("Delete Variable"), false, highlighted.is_some(), 24.0).clicked() { + delete_highlighted(app); } }); ui.horizontal(|ui| { @@ -181,15 +226,16 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { let sets: Vec = st.doc.variables.datasets.iter().map(|d| d.name.clone()).collect(); let active = st.doc.variables.active_dataset.clone(); let bound: Vec = st.doc.variables.bindings.keys().map(|i| i.0).collect(); - let selected: Vec = st.selection.objects.iter().map(|i| i.0).collect(); + let highlighted = highlighted_row(st); + let highlighted_bound: Vec = highlighted.as_ref().map(|h| bound_objects(st, h)).unwrap_or_default(); if widgets::menu_item(ui, tl!("New Variable…"), true, false) { crate::menus::invoke(app, "variable.define", json!({})); } - // Variable Options renames the variable the panel has highlighted, else the first. + // Variable Options renames the highlighted variable, else the first. if widgets::menu_item(ui, tl!("Variable Options…"), !vars.is_empty(), false) { - let name = vars.first().cloned().unwrap_or_default(); - crate::menus::invoke(app, "variable.rename", json!({"name": name})); + let name = highlighted.clone().or_else(|| vars.first().cloned()).unwrap_or_default(); + crate::dialogs::variables::open(app, "variable.rename", Some(&name)); } ui.separator(); if widgets::menu_item(ui, tl!("Capture Data Set"), true, false) { @@ -205,25 +251,23 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { crate::menus::invoke(app, "dataset.set", json!({})); } if widgets::menu_item(ui, tl!("Rename Data Set…"), !sets.is_empty(), false) { - let name = sets.first().cloned().unwrap_or_default(); - crate::menus::invoke(app, "dataset.rename", json!({"name": name})); + let name = active.clone().or_else(|| sets.first().cloned()).unwrap_or_default(); + crate::dialogs::variables::open(app, "dataset.rename", Some(&name)); } - if widgets::menu_item(ui, tl!("Delete Data Set"), !sets.is_empty(), false) { - app.run("dataset.delete", json!({"names": sets})).ok(); + // Only the current data set goes: the rest of the rows stay. + if widgets::menu_item(ui, tl!("Delete Data Set"), active.is_some(), false) { + delete_active_dataset(app); } ui.separator(); - if widgets::menu_item(ui, tl!("Select Bound Object"), !selected.is_empty(), false) { - // The objects the selection is bound to, when it is bound at all. - let ids: Vec = selected.clone(); - app.run("variable.unbind", json!({"ids": ids})).ok(); + // What the highlighted variable drives, selected — its bindings are left alone. + if widgets::menu_item(ui, tl!("Select Bound Object"), !highlighted_bound.is_empty(), false) { + select_bound_object(app); } if widgets::menu_item(ui, tl!("Select All Bound Objects"), !bound.is_empty(), false) { app.run("select.set", json!({"ids": bound})).ok(); } } -use vectorcraft_doc::NodeId; - #[cfg(test)] mod tests { use super::*; @@ -285,6 +329,60 @@ mod tests { } } + /// What the panel acts on is the highlight, and only while it names a variable: a stale one + /// (deleted elsewhere, undone) highlights nothing, so Delete and Options… stay disabled. + #[test] + fn a_highlight_of_a_deleted_variable_highlights_nothing() { + let mut app = app_with_data(); + app.session.execute("variable.highlight", &json!({"variable": "Name"})).unwrap(); + assert_eq!(highlighted_row(app.session.doc().unwrap()).as_deref(), Some("Name")); + assert_eq!(bound_objects(app.session.doc().unwrap(), "Name").len(), 1); + app.session.execute("variable.delete", &json!({"names": ["Name"]})).unwrap(); + assert!(highlighted_row(app.session.doc().unwrap()).is_none()); + } + + /// The reviewer's regression case, through the calls the panel and its menu make: highlight + /// a variable, Select Bound Object, then delete one variable and the current dataset. The + /// selection lands on what the row drives with the binding intact, and the others remain. + #[test] + fn selecting_and_deleting_act_on_the_highlighted_row_only() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 300, "height": 200})).unwrap(); + let a = app.session.execute("text.create", &json!({"x": 10, "y": 40, "text": "Alice"})).unwrap()["id"].as_u64().unwrap(); + let b = app.session.execute("text.create", &json!({"x": 10, "y": 80, "text": "Bob"})).unwrap()["id"].as_u64().unwrap(); + for (name, id) in [("Name", a), ("Other", b)] { + app.session.execute("variable.define", &json!({"name": name, "kind": "text"})).unwrap(); + app.session.execute("variable.bind", &json!({"variable": name, "ids": [id]})).unwrap(); + } + app.session.execute("dataset.capture", &json!({})).unwrap(); + app.session.execute("dataset.capture", &json!({})).unwrap(); + app.session.execute("dataset.select", &json!({"name": "Data Set 1"})).unwrap(); + + // A row click highlights; Select Bound Object selects what it drives, binding intact. + click_row(&mut app, "Name", &[a]); + assert_eq!(app.session.doc().unwrap().selection.objects, vec![NodeId(a)]); + assert!(!app.session.doc().unwrap().doc.variables.objects_of("Name").is_empty()); + select_bound_object(&mut app); + assert_eq!(app.session.doc().unwrap().selection.objects, vec![NodeId(a)]); + assert!(!app.session.doc().unwrap().doc.variables.objects_of("Name").is_empty()); + + // Delete Variable deletes the highlighted one; Delete Data Set the current one. + delete_highlighted(&mut app); + delete_active_dataset(&mut app); + let vars = app.run("variable.list", json!({})).unwrap(); + assert_eq!(vars["variables"].as_array().unwrap().len(), 1); + assert_eq!(vars["variables"][0]["name"], "Other"); + let sets = app.run("dataset.list", json!({})).unwrap(); + assert_eq!(sets["datasets"].as_array().unwrap().len(), 1); + assert_eq!(sets["datasets"][0]["name"], "Data Set 2"); + + // The panel and its menu still draw with the highlight gone stale. + let text = crate::tests_labels::painted_text(&mut app, show); + assert!(text.contains("Other") && text.contains("Data Set 2"), "{text}"); + let text = crate::tests_labels::painted_text(&mut app, menu); + assert!(text.contains("Select Bound Object"), "{text}"); + } + #[test] fn the_panel_is_registered_with_its_own_icon() { let row = crate::state::ICON_PANELS.iter().find(|(id, ..)| *id == ID).expect("in the icon column"); From b3fa28128229d6abf19ced26bdbd2f79041c1e83 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 10 Oct 2026 22:15:38 +0700 Subject: [PATCH 09/12] variables: review fixes for hidden objects, namespaces and agent params - 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 --- crates/doc/src/variables.rs | 41 +++--- crates/engine/src/cmd/variablecmds.rs | 172 ++++++++++++++------------ crates/engine/src/tests_variables.rs | 100 +++++++++++++++ 3 files changed, 215 insertions(+), 98 deletions(-) diff --git a/crates/doc/src/variables.rs b/crates/doc/src/variables.rs index fb4a3c8f..45e3a37a 100644 --- a/crates/doc/src/variables.rs +++ b/crates/doc/src/variables.rs @@ -70,6 +70,15 @@ pub struct DataSet { pub values: BTreeMap, } +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) -> 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. /// @@ -128,13 +137,15 @@ impl Variables { /// 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)); + self.variables.retain(|v| !names.contains(v.name.as_str())); for dataset in &mut self.datasets { - dataset.values.retain(|k, _| !names.contains(k)); + dataset.values.retain(|k, _| !names.contains(k.as_str())); } for bound in self.bindings.values_mut() { - bound.retain(|v| !names.contains(v)); + bound.retain(|v| !names.contains(v.as_str())); } self.bindings.retain(|_, v| !v.is_empty()); before - self.variables.len() @@ -154,11 +165,19 @@ impl Document { 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 on a locked layer - /// (the document's value is whatever it was left at). + /// 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 { - if !self.is_editable(id) { + if !self.variable_target(id) { return None; } let node = self.node(id)?; @@ -185,14 +204,4 @@ impl Document { } out } - - /// Whether the bound objects still hold what `name`'s row says they do. False when there - /// is no such row, or when an edit has moved the art away from it. - pub fn matches_dataset(&self, name: &str) -> bool { - let Some(ds) = self.variables.dataset(name) else { return false }; - let current = self.captured_values(); - // 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. - ds.values.iter().all(|(k, v)| current.get(k).is_none_or(|c| c == v)) - } } diff --git a/crates/engine/src/cmd/variablecmds.rs b/crates/engine/src/cmd/variablecmds.rs index eae00c9b..a4755170 100644 --- a/crates/engine/src/cmd/variablecmds.rs +++ b/crates/engine/src/cmd/variablecmds.rs @@ -14,34 +14,62 @@ use super::edit::selected_roots; use super::textedit::set_plain_text; use super::*; +/// The longest variable or dataset name, in characters (names arrive from MCP and files). +const MAX_NAME: usize = 255; + fn var_name(p: &Value, cmd: &str) -> Result { named(str_param(p, "name"), "name", cmd) } -/// `key`'s value, as a non-empty name. +/// `key`'s value, as a name: trimmed, not empty and at most [`MAX_NAME`] characters. fn named(v: Option<&str>, key: &str, cmd: &str) -> Result { - match v.filter(|v| !v.is_empty()) { + match v.map(str::trim).filter(|v| !v.is_empty()) { + Some(v) if v.chars().count() > MAX_NAME => Err(bad(cmd, format!("`{key}` is longer than {MAX_NAME} characters"))), Some(v) => Ok(v.to_string()), None => Err(bad(cmd, format!("missing `{key}`"))), } } -fn names_param(p: &Value, cmd: &str) -> Result> { - match p.get("names").and_then(Value::as_array) { - Some(names) if !names.is_empty() => { - let mut out = Vec::with_capacity(names.len()); - for n in names { - match n.as_str().filter(|v| !v.is_empty()) { - Some(v) => out.push(v.to_string()), - None => return Err(bad(cmd, "`names` must be non-empty strings")), - } - } - Ok(out) - } - _ => Err(bad(cmd, "missing `names`")), +/// `key`'s value as a name, or `None` when it is missing or empty. +fn optional_name(p: &Value, key: &str, cmd: &str) -> Result> { + match str_param(p, key).map(str::trim).filter(|v| !v.is_empty()) { + Some(v) => named(Some(v), key, cmd).map(Some), + None => Ok(None), } } +/// The `names` of a delete, or without them `fallback` (what the panel acts on) as the one name. +fn names_param(p: &Value, cmd: &str, fallback: Option, nothing: &str) -> Result> { + let Some(given) = p.get("names") else { + return fallback.map(|n| vec![n]).ok_or_else(|| bad(cmd, nothing.to_string())); + }; + match given.as_array() { + Some(names) if !names.is_empty() => names.iter().map(|n| named(n.as_str(), "names", cmd)).collect(), + _ => Err(bad(cmd, "`names` must be a list of names")), + } +} + +/// The objects a bind acts on: `ids` when given (every one checked), else the selection. +fn target_ids(s: &Session, p: &Value, cmd: &str) -> Result> { + if p.get("ids").is_some() { checked_ids_param(s, p, "ids", cmd) } else { selected_roots(s) } +} + +/// Refuse what a variable of `kind` can't drive: art that is locked or on a hidden or locked +/// layer is not the document's to rewrite, and a text variable only drives type. +fn check_bindable(doc: &vectorcraft_doc::Document, ids: &[NodeId], kind: VariableKind, cmd: &str) -> Result<()> { + for id in ids { + match doc.node(*id) { + None => return Err(EngineError::NoNode(*id)), + Some(_) if !doc.variable_target(*id) => return Err(bad(cmd, format!("node {} is locked", id.0))), + Some(n) if kind == VariableKind::Text && !matches!(n.kind, NodeKind::Text(_)) => { + return Err(bad(cmd, format!("node {} is not type", id.0))); + } + Some(_) => {} + } + } + Ok(()) +} + fn dataset_value(cmd: &str, var: &str, kind: VariableKind, v: &Value) -> Result { match kind { VariableKind::Text => v.as_str().map(|t| DataValue::Text(t.to_string())), @@ -99,9 +127,6 @@ fn rename(s: &mut Session, p: &Value) -> Result { ds.values.insert(to.clone(), value); } } - if d.variables.active_dataset.as_deref() == Some(from.as_str()) { - d.variables.active_dataset = Some(to.clone()); - } Ok(()) })?; // The highlight names a variable, so it follows the rename rather than going stale. @@ -122,21 +147,15 @@ fn make_dynamic(s: &mut Session, kind: VariableKind, stem: &str) -> Result return Err(EngineError::NoNode(*id)), - Some(_) if !doc.is_editable(*id) => return Err(bad(C, format!("node {} is locked", id.0))), - Some(n) if kind == VariableKind::Text && !matches!(n.kind, NodeKind::Text(_)) => { - return Err(bad(C, format!("node {} is not type", id.0))); - } - Some(_) => {} - } - } + check_bindable(doc, &ids, kind, C)?; // Named after the first object it drives, as the reference panel does; a second variable // over the same art takes a number rather than colliding. - let base = doc.node(ids[0]).map(|n| n.display_name()).unwrap_or_default(); - let base = base.trim(); - let mut name = if base.is_empty() { stem.to_string() } else { base.to_string() }; + let shown = ids.first().and_then(|id| doc.node(*id)).map(|n| n.display_name()).unwrap_or_default(); + let base: String = match shown.trim() { + "" => stem.to_string(), + b => b.chars().take(MAX_NAME - 8).collect(), + }; + let mut name = base.clone(); let mut n = 2; while doc.variables.variable(&name).is_some() { name = format!("{base} {n}"); @@ -153,7 +172,9 @@ fn make_dynamic(s: &mut Session, kind: VariableKind, stem: &str) -> Result Result { - let names = names_param(p, "variable.delete")?; + // Without `names`, the highlighted variable: what the panel's bin and the menu item delete. + let highlighted = s.doc()?.variables_highlight.clone(); + let names = names_param(p, "variable.delete", highlighted, "no variable highlighted")?; let n = s.edit("Delete Variables", |d, _| Ok(d.variables.prune(&names)))?; // A highlight of a variable that is gone highlights nothing. let st = s.doc_mut()?; @@ -182,27 +203,13 @@ fn list(s: &mut Session, _p: &Value) -> Result { fn bind(s: &mut Session, p: &Value) -> Result { const C: &str = "variable.bind"; let name = named(str_param(p, "variable"), "variable", C)?; - let ids = match ids_param(p, "ids") { - Some(v) => v, - None => selected_roots(s)?, - }; + let ids = target_ids(s, p, C)?; if ids.is_empty() { return Err(bad(C, "nothing to bind")); } let doc = &s.doc()?.doc; let kind = doc.variables.variable(&name).map(|v| v.kind).ok_or_else(|| bad(C, format!("no variable named `{name}`")))?; - for id in &ids { - match doc.node(*id) { - None => return Err(EngineError::NoNode(*id)), - // Art on a locked or hidden layer is not the document's to rewrite, and a text - // variable only drives type. - Some(_) if !doc.is_editable(*id) => return Err(bad(C, format!("node {} is locked", id.0))), - Some(n) if kind == VariableKind::Text && !matches!(n.kind, NodeKind::Text(_)) => { - return Err(bad(C, format!("node {} is not type", id.0))); - } - Some(_) => {} - } - } + check_bindable(doc, &ids, kind, C)?; // Binding again is not a second binding, and says so rather than counting as a change. let already: Vec = ids.iter().filter(|id| doc.variables.objects_of(&name).contains(id)).map(|id| id.0).collect(); let n = s.edit("Bind Variable", |d, _| Ok(ids.iter().filter(|id| d.variables.bind(**id, &name)).count()))?; @@ -211,20 +218,8 @@ fn bind(s: &mut Session, p: &Value) -> Result { fn unbind(s: &mut Session, p: &Value) -> Result { const C: &str = "variable.unbind"; - let ids = match ids_param(p, "ids") { - Some(v) => v, - None => selected_roots(s)?, - }; - let name = match str_param(p, "variable").filter(|v| !v.is_empty()) { - Some(v) => { - let v = v.to_string(); - if s.doc()?.doc.variables.variable(&v).is_none() { - return Err(bad(C, format!("no variable named `{v}`"))); - } - Some(v) - } - None => None, - }; + let ids = target_ids(s, p, C)?; + let name = defined_variable(s, p, C)?; let n = s.edit("Unbind Variable", |d, _| { let mut n = 0; for id in &ids { @@ -239,21 +234,22 @@ fn unbind(s: &mut Session, p: &Value) -> Result { Ok(json!({"unbound": n})) } +/// The `variable` a command names, which must be defined; `None` when it names none. +fn defined_variable(s: &Session, p: &Value, cmd: &str) -> Result> { + let name = optional_name(p, "variable", cmd)?; + if let Some(v) = &name + && s.doc()?.doc.variables.variable(v).is_none() + { + return Err(bad(cmd, format!("no variable named `{v}`"))); + } + Ok(name) +} + /// Highlight one Variables panel row: what the panel's Delete, Options… and Select Bound /// Object act on. Panel state, not art selection: not saved, not an undo step (an empty /// or missing `variable` clears it), like `layer.highlight`. fn highlight(s: &mut Session, p: &Value) -> Result { - const C: &str = "variable.highlight"; - let name = match str_param(p, "variable").filter(|v| !v.is_empty()) { - Some(v) => { - let v = v.to_string(); - if s.doc()?.doc.variables.variable(&v).is_none() { - return Err(bad(C, format!("no variable named `{v}`"))); - } - Some(v) - } - None => None, - }; + let name = defined_variable(s, p, "variable.highlight")?; let st = s.doc_mut()?; st.variables_highlight = name.clone(); st.revision += 1; @@ -320,8 +316,8 @@ fn dataset_capture(s: &mut Session, p: &Value) -> Result { if values.is_empty() { return Err(bad(C, "bind something to a variable first")); } - let name = match str_param(p, "name").filter(|v| !v.is_empty()) { - Some(n) => n.to_string(), + let name = match optional_name(p, "name", C)? { + Some(n) => n, // "Data Set 1", the first free number, as the reference panel's rows are named. None => (1..).map(|i| format!("Data Set {i}")).find(|n| st.doc.variables.dataset(n).is_none()).unwrap_or_default(), }; @@ -356,7 +352,9 @@ fn dataset_update(s: &mut Session, _p: &Value) -> Result { } fn dataset_delete(s: &mut Session, p: &Value) -> Result { - let names = names_param(p, "dataset.delete")?; + // Without `names`, the active dataset: what the panel's Delete Data Set deletes. + let active = s.doc()?.doc.variables.active_dataset.clone(); + let names = names_param(p, "dataset.delete", active, "no active data set")?; let n = s.edit("Delete Data Sets", |d, _| { let before = d.variables.datasets.len(); d.variables.datasets.retain(|d| !names.contains(&d.name)); @@ -371,6 +369,7 @@ fn dataset_delete(s: &mut Session, p: &Value) -> Result { fn dataset_list(s: &mut Session, _p: &Value) -> Result { let st = s.doc()?; + let current = st.doc.captured_values(); let datasets: Vec = st .doc .variables @@ -378,7 +377,7 @@ fn dataset_list(s: &mut Session, _p: &Value) -> Result { .iter() // `matches` says whether the art still holds what the row says, which is what the // panel shows in italics. - .map(|d| json!({"name": d.name, "values": d.values, "matches": st.doc.matches_dataset(&d.name)})) + .map(|d| json!({"name": d.name, "values": d.values, "matches": d.matches(¤t)})) .collect(); Ok(json!({"datasets": datasets, "active": st.doc.variables.active_dataset})) } @@ -406,9 +405,10 @@ fn apply_dataset(s: &mut Session, name: &str) -> Result { continue; } }; - // Art on a locked or hidden layer, and text whose object is gone, can't be - // rewritten: skipped and counted, like the rest. - if !d.is_editable(id) { + // Locked art, art on a locked or hidden layer, and text whose object is gone, can't + // be rewritten: skipped and counted, like the rest. A hidden object is rewritten: a + // row that hid it shows it again. + if !d.variable_target(id) { skipped += 1; continue; } @@ -463,7 +463,7 @@ pub fn specs() -> Vec { "Delete Variable", ["Window", "Variables"], None, - "{names} delete variables with their bindings and values", + "{names?} delete variables with their bindings and values (default: the highlighted one)", has_doc, delete ), @@ -551,7 +551,15 @@ pub fn specs() -> Vec { has_doc, dataset_update ), - cmd!("dataset.delete", "Delete Data Set", ["Window", "Variables"], None, "{names} delete datasets", has_doc, dataset_delete), + cmd!( + "dataset.delete", + "Delete Data Set", + ["Window", "Variables"], + None, + "{names?} delete datasets (default: the active one)", + has_doc, + dataset_delete + ), cmd!( query "dataset.list", "List Data Sets", diff --git a/crates/engine/src/tests_variables.rs b/crates/engine/src/tests_variables.rs index a0bcf270..350905c2 100644 --- a/crates/engine/src/tests_variables.rs +++ b/crates/engine/src/tests_variables.rs @@ -489,3 +489,103 @@ fn highlight_follows_rename_and_clears_with_delete() { s.execute("variable.delete", &json!({"names": ["Title"]})).unwrap(); assert!(s.doc().unwrap().variables_highlight.is_none(), "a highlight of a deleted variable highlights nothing"); } + +/// A row that hides an object must be able to show it again, and hiding it on the artboard +/// (as the Layers panel does) is what a capture records: a visibility variable reads and +/// writes its object's own visibility, which therefore never makes the object off limits. +#[test] +fn a_hidden_object_comes_back_and_is_captured_hidden() { + let mut s = session(); + let t = text(&mut s, "Alice"); + let r = rect(&mut s); + s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Show", "ids": [r.0, t.0]})).unwrap(); + s.execute("dataset.new", &json!({"name": "Shown", "values": {"Name": "Alice", "Show": true}})).unwrap(); + s.execute("dataset.new", &json!({"name": "Hidden", "values": {"Name": "Bob", "Show": false}})).unwrap(); + + s.execute("dataset.select", &json!({"name": "Hidden"})).unwrap(); + assert!(!s.doc().unwrap().doc.node(r).unwrap().visible); + let v = s.execute("dataset.select", &json!({"name": "Shown"})).unwrap(); + assert_eq!(v["skipped"], json!(0), "{v}"); + assert!(s.doc().unwrap().doc.node(r).unwrap().visible, "the row shows it again"); + assert_eq!(plain(&s, t), "Alice", "and rewrites the text of the object it had hidden"); + + // Hidden on the artboard, then captured: the row says hidden, and it matches the art. + s.execute("select.set", &json!({"ids": [t.0, r.0]})).unwrap(); + s.execute("object.hide", &json!({})).unwrap(); + s.execute("dataset.capture", &json!({"name": "Captured"})).unwrap(); + let sets = s.execute("dataset.list", &json!({})).unwrap(); + let captured = sets["datasets"].as_array().unwrap().iter().find(|d| d["name"] == "Captured").unwrap().clone(); + assert_eq!(captured["values"]["Show"], json!({"visible": false}), "{captured}"); + assert_eq!(captured["matches"], json!(true)); + + // A hidden object can still be bound by id. + s.execute("variable.define", &json!({"name": "More", "kind": "visibility"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "More", "ids": [r.0]})).unwrap(); +} + +/// Variables and datasets are separate namespaces: renaming a variable leaves a dataset of +/// the same name, and which dataset is active, alone. +#[test] +fn renaming_a_variable_leaves_a_dataset_of_the_same_name_active() { + let mut s = session(); + let t = text(&mut s, "Alice"); + s.execute("variable.define", &json!({"name": "Title", "kind": "text"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Title", "ids": [t.0]})).unwrap(); + s.execute("dataset.capture", &json!({"name": "Title"})).unwrap(); + s.execute("variable.rename", &json!({"name": "Title", "newName": "Heading"})).unwrap(); + let sets = s.execute("dataset.list", &json!({})).unwrap(); + assert_eq!((sets["active"].clone(), sets["datasets"][0]["name"].clone()), (json!("Title"), json!("Title"))); + s.execute("dataset.update", &json!({})).unwrap(); +} + +/// Without `names`, the deletes act on what the panel shows as current: the highlighted +/// variable and the active dataset (Window › Variables runs them with no params). +#[test] +fn deletes_without_names_take_the_highlight_and_the_active_dataset() { + let mut s = session(); + let t = text(&mut s, "Alice"); + for name in ["A", "B"] { + s.execute("variable.define", &json!({"name": name, "kind": "text"})).unwrap(); + } + s.execute("variable.bind", &json!({"variable": "A", "ids": [t.0]})).unwrap(); + s.execute("dataset.capture", &json!({})).unwrap(); + s.execute("dataset.capture", &json!({})).unwrap(); + assert!(s.execute("variable.delete", &json!({})).is_err(), "nothing highlighted"); + + s.execute("variable.highlight", &json!({"variable": "B"})).unwrap(); + assert_eq!(s.execute("variable.delete", &json!({})).unwrap()["deleted"], json!(1)); + let vars = s.execute("variable.list", &json!({})).unwrap(); + assert_eq!(vars["variables"].as_array().map(Vec::len), Some(1)); + assert_eq!(vars["variables"][0]["name"], "A"); + + assert_eq!(s.execute("dataset.delete", &json!({})).unwrap()["deleted"], json!(1)); + let sets = s.execute("dataset.list", &json!({})).unwrap(); + assert_eq!(sets["datasets"].as_array().map(Vec::len), Some(1)); + assert_eq!(sets["datasets"][0]["name"], "Data Set 1"); + assert!(s.execute("dataset.delete", &json!({})).is_err(), "no active dataset left"); +} + +/// Ids and names from an agent are checked, never reinterpreted: an `ids` that isn't a list +/// of objects is an error rather than the selection, and names are trimmed and capped. +#[test] +fn ids_and_names_from_an_agent_are_checked() { + let mut s = session(); + let t = text(&mut s, "Alice"); + s.execute("variable.define", &json!({"name": " Name ", "kind": "text"})).unwrap(); + assert_eq!(s.execute("variable.list", &json!({})).unwrap()["variables"][0]["name"], "Name"); + s.execute("select.set", &json!({"ids": [t.0]})).unwrap(); + for params in + [json!({"variable": "Name", "ids": 5}), json!({"variable": "Name", "ids": [t.0, "x"]}), json!({"variable": "Name", "ids": [999_999]})] + { + assert!(s.execute("variable.bind", ¶ms).is_err(), "{params}"); + } + assert!(s.doc().unwrap().doc.variables.objects_of("Name").is_empty(), "nothing was bound"); + assert!(s.execute("variable.unbind", &json!({"ids": "all"})).is_err()); + assert!(s.execute("variable.define", &json!({"name": "x".repeat(256), "kind": "text"})).is_err()); + s.execute("variable.define", &json!({"name": "x".repeat(255), "kind": "text"})).unwrap(); + assert!(s.execute("variable.delete", &json!({"names": []})).is_err()); + assert!(s.execute("variable.delete", &json!({"names": [5]})).is_err()); +} From 500e19680df8074d02b8e120f6e18bbdb09fac36 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 10 Oct 2026 22:15:42 +0700 Subject: [PATCH 10/12] variables: review fixes for the panel, dialogs and translations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dataset and variable names are the user's: the panel's Data Set dropdown and the dialogs' name dropdowns show them as they are instead of translating them; the dialog's Kind dropdown shows translated kind names. - Choosing the empty option in a dialog dropdown picked the first name. - The panel reads the art once per frame for every row's italics, the dialog no longer clones the document's variables every frame, and slices are matched instead of indexed. - "No document" and "Define a variable first" go through tl!, and the dialog headings Variable Options / Edit Data Set, Text and Visibility have rows in every complete catalog; the unused "{name} · {kind} · {count}" row is gone. - German rows for every Variables string (the catalog predates this branch). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/variables.rs | 38 +++++++++++++++------- crates/ui-egui/src/i18n/de.tsv | 43 +++++++++++++++++++++++++ crates/ui-egui/src/i18n/es.tsv | 6 +++- crates/ui-egui/src/i18n/fr.tsv | 6 +++- crates/ui-egui/src/i18n/it.tsv | 6 +++- crates/ui-egui/src/i18n/ja.tsv | 6 +++- crates/ui-egui/src/i18n/ru.tsv | 6 +++- crates/ui-egui/src/i18n/uk.tsv | 6 +++- crates/ui-egui/src/i18n/zh-hans.tsv | 6 +++- crates/ui-egui/src/i18n/zh-hant.tsv | 6 +++- crates/ui-egui/src/panels/variables.rs | 32 +++++++++--------- 11 files changed, 124 insertions(+), 37 deletions(-) diff --git a/crates/ui-egui/src/dialogs/variables.rs b/crates/ui-egui/src/dialogs/variables.rs index 2a21eb27..5e1c0f87 100644 --- a/crates/ui-egui/src/dialogs/variables.rs +++ b/crates/ui-egui/src/dialogs/variables.rs @@ -21,6 +21,11 @@ pub const KIND: &str = "variables"; /// The kinds a variable can have, as `variable.define` names them. const KINDS: [&str; 2] = ["text", "visibility"]; +/// What the dialog shows for each of [`KINDS`]. +fn kind_labels() -> [&'static str; 2] { + [tl!("Text"), tl!("Visibility")] +} + pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| tl!(&d.str("__label")).to_string(), body, confirm, min_width: 380.0, ..DialogSpec::FORM }; @@ -96,7 +101,7 @@ pub fn open(app: &mut VectorcraftApp, id: &str, preselect: Option<&str>) -> bool app.status(if key == "variable" { tl!("no variable to rename").to_string() } else { tl!("no data set to rename").to_string() }); return true; } - fields.insert(key.into(), json!(preselect.filter(|p| names.contains(p)).unwrap_or(names[0]))); + fields.insert(key.into(), json!(preselect.filter(|p| names.contains(p)).or(names.first().copied()).unwrap_or_default())); fields.insert("newName".into(), json!("")); } } @@ -136,10 +141,10 @@ fn value_key(name: &str) -> String { fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { let t = Tokens::get(ui.ctx()); let Some(st) = app.session.active() else { - widgets::dim_label(ui, "No document"); + widgets::dim_label(ui, tl!("No document")); return false; }; - let vars = st.doc.variables.clone(); + let vars = &st.doc.variables; let variable_names: Vec<&str> = vars.variables.iter().map(|v| v.name.as_str()).collect(); let dataset_names: Vec<&str> = vars.datasets.iter().map(|x| x.name.as_str()).collect(); egui::Grid::new("variables-grid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { @@ -150,7 +155,12 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { text(ui, d, "name", 220.0); ui.end_row(); label(ui, tl!("Kind")); - dropdown(ui, d, "kind", &KINDS, 220.0); + // The kinds by their translated names; the field keeps the command's own word. + let labels = kind_labels(); + let shown = KINDS.iter().position(|k| *k == d.str("kind")).and_then(|i| labels.get(i)).copied().unwrap_or_default(); + if let Some(kind) = widgets::dropdown_names(ui, ("variables-dd", "kind"), shown, &labels, 220.0).and_then(|i| KINDS.get(i)) { + d.fields.insert("kind".into(), json!(kind)); + } ui.end_row(); } "variable.bind" => { @@ -182,7 +192,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { text(ui, d, "name", 220.0); ui.end_row(); if vars.variables.is_empty() { - widgets::dim_label(ui, "Define a variable first"); + widgets::dim_label(ui, tl!("Define a variable first")); ui.end_row(); return; } @@ -223,16 +233,18 @@ fn checkbox(ui: &mut egui::Ui, d: &mut Dialog, key: &str) -> egui::Response { r } -/// A dropdown over `options` for `key`, written back as it changes. The empty option is first -/// so a fresh dialog can still be filled in; a name that is no longer offered reads as empty -/// rather than being silently swapped for another one. +/// A dropdown over the document's names `options` (shown as they are, never translated) for +/// `key`, written back as it changes. The empty option is first so a fresh dialog can still be +/// filled in; a name that is no longer offered reads as empty rather than being silently +/// swapped for another one. fn dropdown(ui: &mut egui::Ui, d: &mut Dialog, key: &str, options: &[&str], width: f32) { let cur = d.str(key); let mut labels: Vec<&str> = vec![""]; labels.extend_from_slice(options); let shown = if cur.is_empty() || options.contains(&cur.as_str()) { cur.as_str() } else { "" }; - if let Some(i) = widgets::dropdown(ui, ("variables-dd", key), shown, &labels, width) { - d.fields.insert(key.into(), json!(options.get(i.saturating_sub(1)).copied().unwrap_or_default())); + if let Some(i) = widgets::dropdown_names(ui, ("variables-dd", key), shown, &labels, width) { + // The empty option (0) empties the field; the rest are `options`, one along. + d.fields.insert(key.into(), json!(i.checked_sub(1).and_then(|i| options.get(i)).copied().unwrap_or_default())); } } @@ -257,13 +269,15 @@ fn values_of(d: &Dialog, vars: &Variables) -> Value { fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { let cmd = d.str("__command"); let name = d.str("name"); - let vars = app.session.active().map(|st| st.doc.variables.clone()).unwrap_or_default(); let p = match cmd.as_str() { "variable.define" => json!({"name": name, "kind": d.str("kind")}), "variable.bind" => json!({"variable": d.str("variable")}), "variable.rename" => json!({"name": d.str("variable"), "newName": d.str("newName")}), "dataset.rename" => json!({"name": name, "newName": d.str("newName")}), - "dataset.new" | "dataset.set" => json!({"name": name, "values": values_of(d, &vars)}), + "dataset.new" | "dataset.set" => { + let values = app.session.active().map(|st| values_of(d, &st.doc.variables)).unwrap_or_else(|| json!({})); + json!({"name": name, "values": values}) + } "dataset.select" => json!({"name": name}), _ => json!({}), }; diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index b42a0ddc..e891d12b 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3954,3 +3954,46 @@ theme Appearance Erscheinungsbild Frosted Mattiert Tiny Lens Kleine Linsen Canvas Leinwand + Highlight Variable Variable hervorheben + New Variable Neue Variable + Delete Variable Variable löschen + List Variables Variablen auflisten + Bind Variable Variable binden + Unbind Variable Variable lösen + New Data Set Neuer Datensatz + Delete Data Set Datensatz löschen + List Data Sets Datensätze auflisten + Select Data Set Datensatz auswählen + Next Data Set Nächster Datensatz + Previous Data Set Vorheriger Datensatz + Data Sets Datensätze + New Variable… Neue Variable… + Bind Variable… Variable binden… + New Data Set… Neuer Datensatz… + Edit Data Set… Datensatz bearbeiten… + Select Data Set… Datensatz auswählen… + Variables Panel Bedienfeld „Variablen“ + Data Set Datensatz + Kind Art + Variable Variable + no document kein Dokument + no data set to edit kein Datensatz zum Bearbeiten + Make Text Dynamic Text dynamisch machen + Make Visibility Dynamic Sichtbarkeit dynamisch machen + Capture Data Set Datensatz erfassen + Update Data Set Datensatz aktualisieren + Rename Data Set… Datensatz umbenennen… + Variable Options… Variablenoptionen… + Select Bound Object Gebundenes Objekt auswählen + Select All Bound Objects Alle gebundenen Objekte auswählen + Multiple objects Mehrere Objekte + New Name Neuer Name + no variable to rename keine Variable zum Umbenennen + no data set to rename kein Datensatz zum Umbenennen + Rename Data Set Datensatz umbenennen + Rename Variable Variable umbenennen + Variable Options Variablenoptionen + Edit Data Set Datensatz bearbeiten + Define a variable first Definieren Sie zuerst eine Variable + Text Text + Visibility Sichtbarkeit diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 8b145dff..85cf5259 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3079,7 +3079,6 @@ Variable Variable no document no hay documento no data set to edit no hay ningún conjunto de datos que editar - {name} · {kind} · {count} {name} · {kind} · {count} Make Text Dynamic Hacer texto dinámico Make Visibility Dynamic Hacer visible dinámico Capture Data Set Capturar conjunto de datos @@ -4036,3 +4035,8 @@ axis Both Ambos Tiny Lens Lente pequeña Rename Data Set Renombrar conjunto de datos Rename Variable Renombrar variable + Variable Options Opciones de variable + Edit Data Set Editar conjunto de datos + Define a variable first Defina primero una variable + Text Texto + Visibility Visibilidad diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index bdb6eb9f..04d43fac 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3076,7 +3076,6 @@ Variable Variable no document aucun document no data set to edit aucun jeu de données à modifier - {name} · {kind} · {count} {name} · {kind} · {count} Make Text Dynamic Rendre le texte dynamique Make Visibility Dynamic Rendre la visibilité dynamique Capture Data Set Capturer le jeu de données @@ -4048,3 +4047,8 @@ effect Soft Doux Tiny Lens Petites lentilles Rename Data Set Renommer le jeu de données Rename Variable Renommer la variable + Variable Options Options de variable + Edit Data Set Modifier le jeu de données + Define a variable first Définissez d’abord une variable + Text Texte + Visibility Visibilité diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 4e2ea66b..39f012d5 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3079,7 +3079,6 @@ Variable Variabile no document nessun documento no data set to edit nessun set di dati da modificare - {name} · {kind} · {count} {name} · {kind} · {count} Make Text Dynamic Rendi testo dinamico Make Visibility Dynamic Rendi visibilità dinamica Capture Data Set Cattura set di dati @@ -4038,3 +4037,8 @@ effect Regular Normale Tiny Lens Lente piccola Rename Data Set Rinomina set di dati Rename Variable Rinomina variabile + Variable Options Opzioni variabile + Edit Data Set Modifica set di dati + Define a variable first Definisci prima una variabile + Text Testo + Visibility Visibilità diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 375717de..0f2a62a8 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -592,7 +592,6 @@ Variable 変数 no document ドキュメントがありません no data set to edit 編集するデータセットがありません - {name} · {kind} · {count} {name} · {kind} · {count} 件 Make Text Dynamic テキストを動的にする Make Visibility Dynamic 表示を動的にする Capture Data Set データセットを取得 @@ -4031,3 +4030,8 @@ effect Vertical 垂直 Frosted すりガラス Tiny Lens 小さなレンズ Rename Data Set データセットの名前を変更 + Variable Options 変数のオプション + Edit Data Set データセットを編集 + Define a variable first 先に変数を定義してください + Text テキスト + Visibility 表示状態 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index a2e39113..8d281e5d 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3075,7 +3075,6 @@ Variable Переменная no document нет документа no data set to edit нет набора данных для изменения - {name} · {kind} · {count} {name} · {kind} · {count} Make Text Dynamic Сделать текст динамическим Make Visibility Dynamic Сделать видимость динамической Capture Data Set Записать набор данных @@ -4034,3 +4033,8 @@ axis Both Оба Tiny Lens Мелкие линзы Rename Data Set Переименовать набор данных Rename Variable Переименовать переменную + Variable Options Параметры переменной + Edit Data Set Изменить набор данных + Define a variable first Сначала определите переменную + Text Текст + Visibility Видимость diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 16d5ee66..3d8ceea9 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3071,7 +3071,6 @@ Variable Змінна no document немає документа no data set to edit немає набору даних для зміни - {name} · {kind} · {count} {name} · {kind} · {count} Make Text Dynamic Зробити текст динамічним Make Visibility Dynamic Зробити видимість динамічною Capture Data Set Записати набір даних @@ -4019,3 +4018,8 @@ axis Both Обидві Tiny Lens Дрібні лінзи Rename Data Set Перейменувати набір даних Rename Variable Перейменувати змінну + Variable Options Параметри змінної + Edit Data Set Змінити набір даних + Define a variable first Спочатку визначте змінну + Text Текст + Visibility Видимість diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index ca479f77..5e31c7e1 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2970,7 +2970,6 @@ Variable 变量 no document 没有文档 no data set to edit 没有可编辑的数据集 - {name} · {kind} · {count} {name} · {kind} · {count} Make Text Dynamic 使文本动态化 Make Visibility Dynamic 使可见性动态化 Capture Data Set 捕获数据集 @@ -3667,3 +3666,8 @@ effect Vertical 垂直 Tiny Lens 小镜头 Rename Data Set 重命名数据集 Rename Variable 重命名变量 + Variable Options 变量选项 + Edit Data Set 编辑数据集 + Define a variable first 请先定义一个变量 + Text 文本 + Visibility 可见性 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index b409506b..b4deb6f0 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3062,7 +3062,6 @@ Variable 變數 no document 沒有文件 no data set to edit 沒有可編輯的資料集 - {name} · {kind} · {count} {name} · {kind} · {count} Make Text Dynamic 使文字動態化 Make Visibility Dynamic 使可見性動態化 Capture Data Set 擷取資料集 @@ -3448,3 +3447,8 @@ effect Soft 柔和 Tiny Lens 小鏡頭 Rename Data Set 重新命名資料集 Rename Variable 重新命名變數 + Variable Options 變數選項 + Edit Data Set 編輯資料集 + Define a variable first 請先定義一個變數 + Text 文字 + Visibility 可見性 diff --git a/crates/ui-egui/src/panels/variables.rs b/crates/ui-egui/src/panels/variables.rs index aaa316cc..96586ce2 100644 --- a/crates/ui-egui/src/panels/variables.rs +++ b/crates/ui-egui/src/panels/variables.rs @@ -64,18 +64,14 @@ pub(crate) fn select_bound_object(app: &mut VectorcraftApp) { } } -/// Delete Variable: the highlighted variable only; the rest of the rows stay. +/// Delete Variable: the highlighted variable only (the command's default); the rest stay. pub(crate) fn delete_highlighted(app: &mut VectorcraftApp) { - if let Some(name) = app.session.active().and_then(highlighted_row) { - app.run("variable.delete", json!({"names": [name]})).ok(); - } + app.run("variable.delete", json!({})).ok(); } -/// Delete Data Set: the current one only; the rest of the rows stay. +/// Delete Data Set: the current one only (the command's default); the rest of the rows stay. pub(crate) fn delete_active_dataset(app: &mut VectorcraftApp) { - if let Some(name) = app.session.active().and_then(|st| st.doc.variables.active_dataset.clone()) { - app.run("dataset.delete", json!({"names": [name]})).ok(); - } + app.run("dataset.delete", json!({})).ok(); } pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { @@ -91,14 +87,16 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { .iter() .map(|v| { let bound: Vec = st.doc.variables.objects_of(&v.name).iter().map(|i| i.0).collect(); - let object = match bound.len() { - 0 => String::new(), - 1 => st.doc.node(NodeId(bound[0])).map(|n| n.display_name()).unwrap_or_default(), + let object = match bound.as_slice() { + [] => String::new(), + [one] => st.doc.node(NodeId(*one)).map(|n| n.display_name()).unwrap_or_default(), _ => tl!("Multiple objects").to_string(), }; VarRow { name: v.name.clone(), kind: v.kind, bound, object } }) .collect(); + // What the art holds now, read once for every row's drift check. + let current = st.doc.captured_values(); let sets: Vec = st .doc .variables @@ -108,7 +106,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { name: d.name.clone(), values: d.values.len(), active: st.doc.variables.active_dataset.as_deref() == Some(d.name.as_str()), - matches: st.doc.matches_dataset(&d.name), + matches: d.matches(¤t), }) .collect(); let active = sets.iter().find(|s| s.active); @@ -118,18 +116,18 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { // Unbind needs a bound object under the selection, not just any selection. let selection_bound = selected.iter().any(|id| st.doc.variables.bindings.contains_key(&NodeId(*id))); // Make Text Dynamic needs exactly one type object: a text variable only drives type. - let single_type = selected.len() == 1 && st.doc.node(NodeId(selected[0])).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))); + let single_type = matches!(selected.as_slice(), [one] if st.doc.node(NodeId(*one)).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)))); // The current data set across the top: its name, and the arrows that step through them. ui.horizontal(|ui| { widgets::field_label(ui, tl!("Data Set")); let names: Vec<&str> = sets.iter().map(|s| s.name.as_str()).collect(); let current = active.map(|s| s.name.as_str()).unwrap_or(""); - let picked = widgets::dropdown(ui, "variables-active", current, &names, 160.0); - if let Some(i) = picked - && names.get(i).is_some_and(|n| *n != current) + let picked = widgets::dropdown_names(ui, "variables-active", current, &names, 160.0); + if let Some(name) = picked.and_then(|i| names.get(i)) + && *name != current { - app.run("dataset.select", json!({"name": names[i]})).ok(); + app.run("dataset.select", json!({"name": name})).ok(); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let has = !sets.is_empty(); From 2e07f887bfd57e5016b536c007098577b096ea1b Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 10 Oct 2026 22:15:45 +0700 Subject: [PATCH 11/12] docs: Variables in the ROADMAP's last-updated list (#761) Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0232e57e..5bc16cba 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands. -_Last updated: 2026-10-10 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG window (#493), panels, panel groups and the Tools panel undocking into floating panels that move, stack and dock again (#495), the 0.5.0 startup crash on dual-GPU Linux desktops under Wayland fixed: the desktop's own GPU by default and a restart on the next GPU when one can't show the window (#502), path editing while drawing: Cmd/Ctrl with the Pen borrowing Direct Selection and drawing on afterwards, Pen+Alt reshaping segments, handles of a whole selected path dragging, and handle drags no longer re-gathering snap targets every move (#494), the Control bar's and Properties panel's Convert buttons for a path selected as a whole with Direct Selection, the Pen and the anchor tools (#504), the Pen closing a path dragged out of its first anchor every time, without flicker (#501), the Paste menu checking the system clipboard on a background thread on Linux so an X11 clipboard owner that never answers no longer freezes the window (#510), PDF/`.ai` import keeping a type object's outline stroke on the type, wrapped lines as area type, barcode digits in place and layers in the file's stacking order (#508), the Italian interface (#514), EPS images with several data sources read in turn (#498), `restore` undoing dictionary changes in EPS import (part of #505), the macOS menu bar laid out as a Mac app's (the VectorCraft menu with Settings, Language and Appearance, the system's Hide, Services and Window items; owner's request), Object › Image Trace › Make tracing with the Default preset instead of asking for a preset's name (#543), the rulers' unit menu (#528), Live Corners on stars, polygons and pen paths (#511), the drawing tools snapping to Smart Guides as the Selection tool does, with Construction Guides (#506), the in-window menus' Select All, Cut, Copy and Paste acting on the text field they were opened from (#516), Windows rendering through DX12 before Vulkan (#545), Image Trace tracing colour layers in parallel and refusing traces too large for memory, with the canvas outlining huge selections by their bounds (#525), SVGs of chosen artboards holding only the art over each artboard (#550), the font menu scrolling only its list, under a search field and filters that stay put (#555), a click on an object of a selection making it the key object that Align aligns to and Distribute Spacing spaces from, drawn with a thicker outline (#541), Shape Builder merges taken from the planar map itself, so merged regions leave no inner edge, hairline gap or spur in their outline (#569), `.ai` written by the exports and the CLI (`--export`, `convert`) as Save As writes it (#526), SVG round trips keeping hidden objects and `data-*` attributes (#522), the gradient stop popover staying open after the click that opens it from the Gradient panel (#577), standard ligatures kept under small tracking and dropped only past about −8% / +22% of a word space or when the character asks for them (#583), the Color panel keeping its height as warnings come and go so a drag on its spectrum no longer flickers near white (#578), fonts that font services such as Adobe Fonts load on Windows, listed through DirectWrite (#579), `.ai` files writing their layers and sublayers as PDF layers, hidden ones with their art, and PDF layers nested in layers importing as sublayers (#372), file dialogs on Linux shown off the UI thread so the compositor no longer finds the window not responding, and parented to the window on Linux and Windows (#592), anchors and handle ends picked from 2 px past their drawn size before the segments through them (#593), an image copied in a browser pasting as the picture, not its address (#597), the Free Transform widget with Constrain, Free Transform, Perspective Distort and Free Distort (#598), touch gestures: a two-finger tap undoes and a three-finger tap redoes (#585), the font menus' live preview staying on the text instead of flickering on and off every frame (#563), Edit Artboards showing the active artboard in the Properties panel with New Artboard, Delete Artboard and Exit (#530), the same DirectWrite fonts in `vectorcraft-cli` and its MCP server (#579), SVG layers keeping their names when wrapped and sublayers round-tripping through SVG (#372), a Layers panel that lays out only the rows in view and keeps a document's open rows in its file (#638), a panel dropped on the dock lights up the icon column it goes back to and pops out of its icon (#649), Image Trace's View modes and an Advanced section that stays as it was left (part of #641), Image Trace's Palette: Limited, Full Tone, Automatic and Document Library (part of #641), Image Trace's Create Strokes: line art traced as stroked centre lines (part of #641), a dual-GPU Mac falling back to its other GPU when one fails to start (#651), two fingers dragged on a touch screen panning the canvas (part of #449), the Home screen's Recent Files and a double-clicked New Document preset creating the document (part of #663), quitting no longer crashing on Macs with a Touch Bar (#661), Animated Zoom, Large Tabs, Scale Cursor Proportional to UI and anti-aliased bitmaps in Pixel Preview acting (part of #394), Smart Guides › Spacing Guides (part of #394), Edit Artboards' Properties choosing an artboard's size preset and flipping it between portrait and landscape (#671), Preferences › Type › Additional Fonts Folder loading the fonts of a folder you choose (#683), Canvas Color's light, medium and dark grey (part of #663), a document tab's close box after its title on Windows and Linux, before it on macOS (part of #673), Rearrange All Artboards' layouts (a grid by row or by column, one row, one column) and right-to-left order, one click away in the Properties and Artboards panels (#681), User Interface › Show Tool Group Labels and Cmd+, opening Settings on the Mac (part of #663), the Windows taskbar and Alt+Tab showing VectorCraft's icon, a click with a selection tool making its artboard the active one, and Paste in Place, in Front and in Back pasting onto the active artboard (#693), Photoshop-style raster effects on a pixel-filter pipeline: Effect › Blur › Radial Blur and Smart Blur, Effect › Sharpen › Unsharp Mask (part of gap 2), the Properties panel's number fields all one width (#696), the rotate cursor drawn as a curved double arrow (#701), the Templates folder in the desktop's own documents folder, made when missing (#703), PDF and .ai glyphs whose ToUnicode names no character (U+FFFD) taking their character from the embedded font or keeping their outlines, with a warning (#708), Illustrator EPS and `.ai` files opening from their editing data with their colours, gradients, transparency, images, names and every artboard (part of #637), `.ai` files saved without PDF compatibility opening from their editing data (part of #637), guides left out of raster exports and Fit to Artwork Bounds, hidden art left out of Fit to Artwork Bounds, Illustrator files compared with their page object by object, and `editingData: false` opening only what the page prints (#735), art reaching past a PDF's page clipped to its crop box (#675), Package collecting what placed documents link to (#777), area type, threaded type and type on a path made whole from Illustrator files' text documents (part of #637), the missing-fonts dialog with Find Fonts and a folder search (#792), Effect › Stylize › Glowing Edges (#765), Offset Path's Joins and Zig Zag's Points as dropdowns and Relative/Absolute as radio buttons in the effect dialogs (#828), the control channel's `app.quit` reporting a pending save prompt (#830), blurred type that stays put while the view moves (#755), Windows starting on Direct3D 12 and loading no Vulkan driver unless asked (#806), the Artboard tool naming its artboard once (#824), Save for Web taking typed pixel sizes (#839), rulers ticked over their whole length in a rotated view (#826), the gradient stop popover opening beside the art (#862) and recent colours recolouring the selected stop (#835), every align button following Align To (#789), the Properties panel's Paragraph section with all seven alignments and Hyphenate (#775), isolation mode dimming and locking the art around the isolated group (#833), color books (`.acb`) opening as swatch libraries (#831), files that can't be opened reported in a dialog (#861), `run` and `command_batch` steps using earlier steps' results (#785), ruler guides pulling drawn points with Smart Guides on and Smart Guides labelling centres and anchors on hover (#812), shadows and glows keeping their blend modes in PDF (#827) and `.ai` import skipping layer checks that can't succeed (#758), the Curvature tool editing any selected path (#798), raster exports composited in floating point so translucent edges over opaque art stay opaque (#787), Calligraphic and Bristle Brush Options with pen-pressure Calligraphic brushes (part of #852), a polygon's Polygon Properties in the Properties and Transform panels, Flare Tool Options that last and more tools opening their options on a double-click (part of #812), MCP file access confined with --automation-read-root / --automation-write-root (#832), the Transform effect's dialog with the reference point, Random, sliders and an angle dial (part of #885), artboard X/Y edits in the Properties and Transform panels and Artboard Options taking their art along with Move Artwork with Artboard (#922), Effect › Pixelate's Color Halftone, Crystallize, Mezzotint and Pointillize (part of gap 2), linked files changed while the app was in the background following Update Links when its window comes back to the front (#925), the Tabs panel's Leader and Align On fields keeping what is typed until Enter (#924), the Properties panel's Gradient section with the gradient's type and a freeform gradient's Draw (Points or Lines) while the Gradient tool is active (#921), PSD and PSB files opened and placed, linked or embedded, as their merged image (#918), Direct Selection and Group Selection marquees selecting the type, images and other objects without anchors that they touch (#927), PDF and `.ai` glyphs that no mapping names found by their outline in the installed font, so their text stays one type object (#811), Effect › Video's De-Interlace and NTSC Colors (part of gap 2), the Libraries panel's search, groups and library export/import (#926), Live Corner widgets on every selected path, dragged together (#938), Scatter, Art and Pattern Brush Options with Colorization and Scatter values on pen pressure (part of gap 5), artboard names fitting their artboard's width on screen when zoomed out (#949), Effect › Texture's Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer (part of gap 2), Inner Glow's Center/Edge Source as a dropdown (#962), the macOS window buttons centred on the app bar (#968), a compact 32-point app bar starting at File (File › Exit quits) with a search icon, and User Interface › System Title Bar on Windows and Linux (#969), the German interface (#934), Graph Type's value axis on the left, right or both sides with separate scales (#971), Graph Type's tick marks per axis, category tick marks and value-axis label prefixes and suffixes (#974), Effect › Distort's Diffuse Glow, Glass and Ocean Ripple (part of gap 2); see [Honest assessment](#honest-assessment-2026-10-05))._ +_Last updated: 2026-10-10 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG window (#493), panels, panel groups and the Tools panel undocking into floating panels that move, stack and dock again (#495), the 0.5.0 startup crash on dual-GPU Linux desktops under Wayland fixed: the desktop's own GPU by default and a restart on the next GPU when one can't show the window (#502), path editing while drawing: Cmd/Ctrl with the Pen borrowing Direct Selection and drawing on afterwards, Pen+Alt reshaping segments, handles of a whole selected path dragging, and handle drags no longer re-gathering snap targets every move (#494), the Control bar's and Properties panel's Convert buttons for a path selected as a whole with Direct Selection, the Pen and the anchor tools (#504), the Pen closing a path dragged out of its first anchor every time, without flicker (#501), the Paste menu checking the system clipboard on a background thread on Linux so an X11 clipboard owner that never answers no longer freezes the window (#510), PDF/`.ai` import keeping a type object's outline stroke on the type, wrapped lines as area type, barcode digits in place and layers in the file's stacking order (#508), the Italian interface (#514), EPS images with several data sources read in turn (#498), `restore` undoing dictionary changes in EPS import (part of #505), the macOS menu bar laid out as a Mac app's (the VectorCraft menu with Settings, Language and Appearance, the system's Hide, Services and Window items; owner's request), Object › Image Trace › Make tracing with the Default preset instead of asking for a preset's name (#543), the rulers' unit menu (#528), Live Corners on stars, polygons and pen paths (#511), the drawing tools snapping to Smart Guides as the Selection tool does, with Construction Guides (#506), the in-window menus' Select All, Cut, Copy and Paste acting on the text field they were opened from (#516), Windows rendering through DX12 before Vulkan (#545), Image Trace tracing colour layers in parallel and refusing traces too large for memory, with the canvas outlining huge selections by their bounds (#525), SVGs of chosen artboards holding only the art over each artboard (#550), the font menu scrolling only its list, under a search field and filters that stay put (#555), a click on an object of a selection making it the key object that Align aligns to and Distribute Spacing spaces from, drawn with a thicker outline (#541), Shape Builder merges taken from the planar map itself, so merged regions leave no inner edge, hairline gap or spur in their outline (#569), `.ai` written by the exports and the CLI (`--export`, `convert`) as Save As writes it (#526), SVG round trips keeping hidden objects and `data-*` attributes (#522), the gradient stop popover staying open after the click that opens it from the Gradient panel (#577), standard ligatures kept under small tracking and dropped only past about −8% / +22% of a word space or when the character asks for them (#583), the Color panel keeping its height as warnings come and go so a drag on its spectrum no longer flickers near white (#578), fonts that font services such as Adobe Fonts load on Windows, listed through DirectWrite (#579), `.ai` files writing their layers and sublayers as PDF layers, hidden ones with their art, and PDF layers nested in layers importing as sublayers (#372), file dialogs on Linux shown off the UI thread so the compositor no longer finds the window not responding, and parented to the window on Linux and Windows (#592), anchors and handle ends picked from 2 px past their drawn size before the segments through them (#593), an image copied in a browser pasting as the picture, not its address (#597), the Free Transform widget with Constrain, Free Transform, Perspective Distort and Free Distort (#598), touch gestures: a two-finger tap undoes and a three-finger tap redoes (#585), the font menus' live preview staying on the text instead of flickering on and off every frame (#563), Edit Artboards showing the active artboard in the Properties panel with New Artboard, Delete Artboard and Exit (#530), the same DirectWrite fonts in `vectorcraft-cli` and its MCP server (#579), SVG layers keeping their names when wrapped and sublayers round-tripping through SVG (#372), a Layers panel that lays out only the rows in view and keeps a document's open rows in its file (#638), a panel dropped on the dock lights up the icon column it goes back to and pops out of its icon (#649), Image Trace's View modes and an Advanced section that stays as it was left (part of #641), Image Trace's Palette: Limited, Full Tone, Automatic and Document Library (part of #641), Image Trace's Create Strokes: line art traced as stroked centre lines (part of #641), a dual-GPU Mac falling back to its other GPU when one fails to start (#651), two fingers dragged on a touch screen panning the canvas (part of #449), the Home screen's Recent Files and a double-clicked New Document preset creating the document (part of #663), quitting no longer crashing on Macs with a Touch Bar (#661), Animated Zoom, Large Tabs, Scale Cursor Proportional to UI and anti-aliased bitmaps in Pixel Preview acting (part of #394), Smart Guides › Spacing Guides (part of #394), Edit Artboards' Properties choosing an artboard's size preset and flipping it between portrait and landscape (#671), Preferences › Type › Additional Fonts Folder loading the fonts of a folder you choose (#683), Canvas Color's light, medium and dark grey (part of #663), a document tab's close box after its title on Windows and Linux, before it on macOS (part of #673), Rearrange All Artboards' layouts (a grid by row or by column, one row, one column) and right-to-left order, one click away in the Properties and Artboards panels (#681), User Interface › Show Tool Group Labels and Cmd+, opening Settings on the Mac (part of #663), the Windows taskbar and Alt+Tab showing VectorCraft's icon, a click with a selection tool making its artboard the active one, and Paste in Place, in Front and in Back pasting onto the active artboard (#693), Photoshop-style raster effects on a pixel-filter pipeline: Effect › Blur › Radial Blur and Smart Blur, Effect › Sharpen › Unsharp Mask (part of gap 2), the Properties panel's number fields all one width (#696), the rotate cursor drawn as a curved double arrow (#701), the Templates folder in the desktop's own documents folder, made when missing (#703), PDF and .ai glyphs whose ToUnicode names no character (U+FFFD) taking their character from the embedded font or keeping their outlines, with a warning (#708), Illustrator EPS and `.ai` files opening from their editing data with their colours, gradients, transparency, images, names and every artboard (part of #637), `.ai` files saved without PDF compatibility opening from their editing data (part of #637), guides left out of raster exports and Fit to Artwork Bounds, hidden art left out of Fit to Artwork Bounds, Illustrator files compared with their page object by object, and `editingData: false` opening only what the page prints (#735), art reaching past a PDF's page clipped to its crop box (#675), Package collecting what placed documents link to (#777), area type, threaded type and type on a path made whole from Illustrator files' text documents (part of #637), the missing-fonts dialog with Find Fonts and a folder search (#792), Effect › Stylize › Glowing Edges (#765), Offset Path's Joins and Zig Zag's Points as dropdowns and Relative/Absolute as radio buttons in the effect dialogs (#828), the control channel's `app.quit` reporting a pending save prompt (#830), blurred type that stays put while the view moves (#755), Windows starting on Direct3D 12 and loading no Vulkan driver unless asked (#806), the Artboard tool naming its artboard once (#824), Save for Web taking typed pixel sizes (#839), rulers ticked over their whole length in a rotated view (#826), the gradient stop popover opening beside the art (#862) and recent colours recolouring the selected stop (#835), every align button following Align To (#789), the Properties panel's Paragraph section with all seven alignments and Hyphenate (#775), isolation mode dimming and locking the art around the isolated group (#833), color books (`.acb`) opening as swatch libraries (#831), files that can't be opened reported in a dialog (#861), `run` and `command_batch` steps using earlier steps' results (#785), ruler guides pulling drawn points with Smart Guides on and Smart Guides labelling centres and anchors on hover (#812), shadows and glows keeping their blend modes in PDF (#827) and `.ai` import skipping layer checks that can't succeed (#758), the Curvature tool editing any selected path (#798), raster exports composited in floating point so translucent edges over opaque art stay opaque (#787), Calligraphic and Bristle Brush Options with pen-pressure Calligraphic brushes (part of #852), a polygon's Polygon Properties in the Properties and Transform panels, Flare Tool Options that last and more tools opening their options on a double-click (part of #812), MCP file access confined with --automation-read-root / --automation-write-root (#832), the Transform effect's dialog with the reference point, Random, sliders and an angle dial (part of #885), artboard X/Y edits in the Properties and Transform panels and Artboard Options taking their art along with Move Artwork with Artboard (#922), Effect › Pixelate's Color Halftone, Crystallize, Mezzotint and Pointillize (part of gap 2), linked files changed while the app was in the background following Update Links when its window comes back to the front (#925), the Tabs panel's Leader and Align On fields keeping what is typed until Enter (#924), the Properties panel's Gradient section with the gradient's type and a freeform gradient's Draw (Points or Lines) while the Gradient tool is active (#921), PSD and PSB files opened and placed, linked or embedded, as their merged image (#918), Direct Selection and Group Selection marquees selecting the type, images and other objects without anchors that they touch (#927), PDF and `.ai` glyphs that no mapping names found by their outline in the installed font, so their text stays one type object (#811), Effect › Video's De-Interlace and NTSC Colors (part of gap 2), the Libraries panel's search, groups and library export/import (#926), Live Corner widgets on every selected path, dragged together (#938), Scatter, Art and Pattern Brush Options with Colorization and Scatter values on pen pressure (part of gap 5), artboard names fitting their artboard's width on screen when zoomed out (#949), Effect › Texture's Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer (part of gap 2), Inner Glow's Center/Edge Source as a dropdown (#962), the macOS window buttons centred on the app bar (#968), a compact 32-point app bar starting at File (File › Exit quits) with a search icon, and User Interface › System Title Bar on Windows and Linux (#969), the German interface (#934), Graph Type's value axis on the left, right or both sides with separate scales (#971), Graph Type's tick marks per axis, category tick marks and value-axis label prefixes and suffixes (#974), Effect › Distort's Diffuse Glow, Glass and Ocean Ripple (part of gap 2), Variables (data merge): text and visibility variables, data sets and the Variables panel (#761); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are From d0015a496b07fd645b70f0f24215b8bac788df8a Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 10 Oct 2026 22:34:24 +0700 Subject: [PATCH 12/12] =?UTF-8?q?variables:=20Window=20=E2=80=BA=20Variabl?= =?UTF-8?q?es=20opens=20the=20panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit As Window's other panel items do, and as the reference's Window menu has it, Window › Variables is the panel itself instead of a submenu of eighteen commands. The panel menu gains Make Text Dynamic, Make Visibility Dynamic, Unbind Variable and Delete Variable beside the bottom bar's buttons, which share one reading of what the selection allows; every command stays in the command palette. The labels only the submenu used leave the catalogs. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/cs.tsv | 3 -- crates/ui-egui/src/i18n/de.tsv | 3 -- crates/ui-egui/src/i18n/es.tsv | 3 -- crates/ui-egui/src/i18n/fr.tsv | 3 -- crates/ui-egui/src/i18n/it.tsv | 3 -- crates/ui-egui/src/i18n/ja.tsv | 3 -- crates/ui-egui/src/i18n/pt-br.tsv | 3 -- crates/ui-egui/src/i18n/ru.tsv | 3 -- crates/ui-egui/src/i18n/uk.tsv | 3 -- crates/ui-egui/src/i18n/zh-hans.tsv | 3 -- crates/ui-egui/src/i18n/zh-hant.tsv | 3 -- crates/ui-egui/src/menus.rs | 30 +----------- crates/ui-egui/src/panels/variables.rs | 67 ++++++++++++++------------ 13 files changed, 37 insertions(+), 93 deletions(-) diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e0e223ef..bbcc8258 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -535,16 +535,13 @@ Tabs Tabulátory Variables Proměnné New Variable… Nová proměnná… - Bind Variable… Přiřadit proměnnou… Unbind Variable Zrušit přiřazení proměnné Delete Variable Odstranit proměnnou New Data Set… Nová sada dat… Edit Data Set… Upravit sadu dat… Delete Data Set Odstranit sadu dat - Select Data Set… Vybrat sadu dat… Next Data Set Další sada dat Previous Data Set Předchozí sada dat - Variables Panel Panel proměnných Make Text Dynamic Nastavit text jako dynamický Make Visibility Dynamic Nastavit viditelnost jako dynamickou Capture Data Set Pořídit sadu dat diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index e891d12b..58d4344e 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3968,11 +3968,8 @@ theme Appearance Erscheinungsbild Previous Data Set Vorheriger Datensatz Data Sets Datensätze New Variable… Neue Variable… - Bind Variable… Variable binden… New Data Set… Neuer Datensatz… Edit Data Set… Datensatz bearbeiten… - Select Data Set… Datensatz auswählen… - Variables Panel Bedienfeld „Variablen“ Data Set Datensatz Kind Art Variable Variable diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 85cf5259..d52cf1ef 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3069,11 +3069,8 @@ Previous Data Set Conjunto de datos anterior Data Sets Conjuntos de datos New Variable… Nueva variable… - Bind Variable… Vincular variable… New Data Set… Nuevo conjunto de datos… Edit Data Set… Editar conjunto de datos… - Select Data Set… Seleccionar conjunto de datos… - Variables Panel Panel de variables Data Set Conjunto de datos Kind Tipo Variable Variable diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 04d43fac..76796bd1 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3066,11 +3066,8 @@ Previous Data Set Jeu de données précédent Data Sets Jeux de données New Variable… Nouvelle variable… - Bind Variable… Lier la variable… New Data Set… Nouveau jeu de données… Edit Data Set… Modifier le jeu de données… - Select Data Set… Sélectionner le jeu de données… - Variables Panel Panneau des variables Data Set Jeu de données Kind Type Variable Variable diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 39f012d5..a9c328e9 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3069,11 +3069,8 @@ Previous Data Set Set di dati precedente Data Sets Set di dati New Variable… Nuova variabile… - Bind Variable… Associa variabile… New Data Set… Nuovo set di dati… Edit Data Set… Modifica set di dati… - Select Data Set… Seleziona set di dati… - Variables Panel Pannello variabili Data Set Set di dati Kind Tipo Variable Variabile diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 0f2a62a8..6c13d3ae 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -582,11 +582,8 @@ Previous Data Set 前のデータセット Data Sets データセット New Variable… 新規変数… - Bind Variable… 変数をバインド… New Data Set… 新規データセット… Edit Data Set… データセットを編集… - Select Data Set… データセットを選択… - Variables Panel 変数パネル Data Set データセット Kind 種類 Variable 変数 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index e5465411..b1526a88 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2531,16 +2531,13 @@ Variable Width Profile Perfil de Largura Variável Variables Variáveis New Variable… Nova variável… - Bind Variable… Associar variável… Unbind Variable Desassociar variável Delete Variable Excluir variável New Data Set… Novo conjunto de dados… Edit Data Set… Editar conjunto de dados… Delete Data Set Excluir conjunto de dados - Select Data Set… Selecionar conjunto de dados… Next Data Set Próximo conjunto de dados Previous Data Set Conjunto de dados anterior - Variables Panel Painel de variáveis Make Text Dynamic Tornar texto dinâmico Make Visibility Dynamic Tornar visibilidade dinâmica Capture Data Set Capturar conjunto de dados diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 8d281e5d..3fd2cfe8 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3065,11 +3065,8 @@ Previous Data Set Предыдущий набор данных Data Sets Наборы данных New Variable… Новая переменная… - Bind Variable… Привязать переменную… New Data Set… Новый набор данных… Edit Data Set… Изменить набор данных… - Select Data Set… Выбрать набор данных… - Variables Panel Панель переменных Data Set Набор данных Kind Тип Variable Переменная diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 3d8ceea9..8140c543 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3061,11 +3061,8 @@ Previous Data Set Попередній набір даних Data Sets Набори даних New Variable… Нова змінна… - Bind Variable… Прив’язати змінну… New Data Set… Новий набір даних… Edit Data Set… Змінити набір даних… - Select Data Set… Вибрати набір даних… - Variables Panel Панель змінних Data Set Набір даних Kind Тип Variable Змінна diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 5e31c7e1..9bd0df0e 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2960,11 +2960,8 @@ Previous Data Set 上一个数据集 Data Sets 数据集 New Variable… 新建变量… - Bind Variable… 绑定变量… New Data Set… 新建数据集… Edit Data Set… 编辑数据集… - Select Data Set… 选择数据集… - Variables Panel 变量面板 Data Set 数据集 Kind 类型 Variable 变量 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index b4deb6f0..a4edda1d 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3052,11 +3052,8 @@ Previous Data Set 上一個資料集 Data Sets 資料集 New Variable… 新增變數… - Bind Variable… 繫結變數… New Data Set… 新增資料集… Edit Data Set… 編輯資料集… - Select Data Set… 選取資料集… - Variables Panel 變數面板 Data Set 資料集 Kind 類型 Variable 變數 diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index f68f5df6..e0ecb0b0 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -48,34 +48,6 @@ fn library_placeholders() -> Vec { vec![todo("Built-in Libraries"), todo("User Defined"), Sep, todo("Other Library…")] } -/// Window › Variables: the panel, then the commands that define and apply what it shows, in -/// the order the panel does them. The "…" commands open a Variables dialog -/// (`dialogs::variables`), so the menu and the command palette both reach them. -fn variables_window_menu(panel: impl Fn(&'static str, &'static str) -> Item) -> Vec { - vec![ - panel("Variables Panel", crate::panels::variables::ID), - Sep, - c("Make Text Dynamic", "variable.makeTextDynamic"), - c("Make Visibility Dynamic", "variable.makeVisibilityDynamic"), - Sep, - c("New Variable…", "variable.define"), - c("Variable Options…", "variable.rename"), - c("Bind Variable…", "variable.bind"), - c("Unbind Variable", "variable.unbind"), - c("Delete Variable", "variable.delete"), - Sep, - c("Capture Data Set", "dataset.capture"), - c("New Data Set…", "dataset.new"), - c("Edit Data Set…", "dataset.set"), - c("Update Data Set", "dataset.update"), - c("Rename Data Set…", "dataset.rename"), - c("Delete Data Set", "dataset.delete"), - Sep, - c("Select Data Set…", "dataset.select"), - c("Next Data Set", "dataset.next"), - c("Previous Data Set", "dataset.prev"), - ] -} use Item::Sep; /// UI-level commands: (id, label, shortcut, params doc). @@ -2577,7 +2549,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { panel("Tabs", "tabs"), ], ), - sub("Variables", variables_window_menu(panel)), + panel("Variables", crate::panels::variables::ID), Sep, sub("Brush Libraries", library_placeholders()), sub("Graphic Style Libraries", crate::panels::graphic_styles::window_menu()), diff --git a/crates/ui-egui/src/panels/variables.rs b/crates/ui-egui/src/panels/variables.rs index 96586ce2..7eda3b61 100644 --- a/crates/ui-egui/src/panels/variables.rs +++ b/crates/ui-egui/src/panels/variables.rs @@ -64,6 +64,23 @@ pub(crate) fn select_bound_object(app: &mut VectorcraftApp) { } } +/// What the selection allows the bottom bar's and the panel menu's commands to do. +struct Picked { + selected: Vec, + has_selection: bool, + /// Unbind needs a bound object under the selection, not just any selection. + selection_bound: bool, + /// Make Text Dynamic needs exactly one type object: a text variable only drives type. + single_type: bool, +} + +fn picked(st: &vectorcraft_engine::DocState) -> Picked { + let selected: Vec = st.selection.objects.iter().map(|i| i.0).collect(); + let selection_bound = selected.iter().any(|id| st.doc.variables.bindings.contains_key(&NodeId(*id))); + let single_type = matches!(selected.as_slice(), [one] if st.doc.node(NodeId(*one)).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)))); + Picked { has_selection: !selected.is_empty(), selected, selection_bound, single_type } +} + /// Delete Variable: the highlighted variable only (the command's default); the rest stay. pub(crate) fn delete_highlighted(app: &mut VectorcraftApp) { app.run("variable.delete", json!({})).ok(); @@ -110,13 +127,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { }) .collect(); let active = sets.iter().find(|s| s.active); - let selected: Vec = st.selection.objects.iter().map(|i| i.0).collect(); - let has_selection = !selected.is_empty(); + let Picked { selected, has_selection, selection_bound, single_type } = picked(st); let highlighted = highlighted_row(st); - // Unbind needs a bound object under the selection, not just any selection. - let selection_bound = selected.iter().any(|id| st.doc.variables.bindings.contains_key(&NodeId(*id))); - // Make Text Dynamic needs exactly one type object: a text variable only drives type. - let single_type = matches!(selected.as_slice(), [one] if st.doc.node(NodeId(*one)).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)))); // The current data set across the top: its name, and the arrows that step through them. ui.horizontal(|ui| { @@ -226,6 +238,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { let bound: Vec = st.doc.variables.bindings.keys().map(|i| i.0).collect(); let highlighted = highlighted_row(st); let highlighted_bound: Vec = highlighted.as_ref().map(|h| bound_objects(st, h)).unwrap_or_default(); + let Picked { selected, has_selection, selection_bound, single_type } = picked(st); if widgets::menu_item(ui, tl!("New Variable…"), true, false) { crate::menus::invoke(app, "variable.define", json!({})); @@ -235,6 +248,18 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { let name = highlighted.clone().or_else(|| vars.first().cloned()).unwrap_or_default(); crate::dialogs::variables::open(app, "variable.rename", Some(&name)); } + if widgets::menu_item(ui, tl!("Make Text Dynamic"), single_type, false) { + app.run("variable.makeTextDynamic", json!({})).ok(); + } + if widgets::menu_item(ui, tl!("Make Visibility Dynamic"), has_selection, false) { + app.run("variable.makeVisibilityDynamic", json!({})).ok(); + } + if widgets::menu_item(ui, tl!("Unbind Variable"), selection_bound, false) { + app.run("variable.unbind", json!({"ids": selected})).ok(); + } + if widgets::menu_item(ui, tl!("Delete Variable"), highlighted.is_some(), false) { + delete_highlighted(app); + } ui.separator(); if widgets::menu_item(ui, tl!("Capture Data Set"), true, false) { app.run("dataset.capture", json!({})).ok(); @@ -405,38 +430,18 @@ mod tests { } } - /// Every Variables command is in the Window menu, so the feature is reachable without an - /// agent: the palette runs these with empty params, which the dialogs turn into a form. + /// Window › Variables opens the panel, as Window's other panel items do; its commands are in + /// the panel, its menu and the command palette. #[test] - fn every_variables_command_is_in_the_window_menu() { + fn window_variables_opens_the_panel() { let app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); let entries = crate::menus::menu_entries(&app); - let in_menu = |id: &str| entries.iter().any(|e| e.command.as_deref() == Some(id)); - for id in [ - "variable.define", - "variable.rename", - "variable.delete", - "variable.bind", - "variable.unbind", - "variable.makeTextDynamic", - "variable.makeVisibilityDynamic", - "dataset.new", - "dataset.set", - "dataset.capture", - "dataset.update", - "dataset.rename", - "dataset.delete", - "dataset.select", - "dataset.next", - "dataset.prev", - ] { - assert!(in_menu(id), "{id} is not in the Window menu"); - } let panel = entries .iter() .find(|e| e.command.as_deref() == Some("window.panel") && e.params.get("panel").and_then(serde_json::Value::as_str) == Some(ID)) .expect("the Variables panel is listed"); - assert_eq!(panel.path, vec!["Window".to_string(), "Variables".to_string()]); + assert_eq!((panel.path.clone(), panel.label.as_str()), (vec!["Window".to_string()], "Variables")); + assert!(!entries.iter().any(|e| e.path == ["Window", "Variables"]), "no Variables submenu"); } /// A menu item that needs a name opens the dialog rather than running the command with