Merge pull request #761 from fahrim/mcp-variables

Variables (data merge): text/visibility variables, datasets, panel
This commit is contained in:
BFlat 2026-10-10 23:35:20 +07:00 committed by GitHub
commit 45516a6a96
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 2726 additions and 12 deletions

View File

@ -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.

File diff suppressed because one or more lines are too long

View File

@ -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<SavedSelection>,
/// 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(),

207
crates/doc/src/variables.rs Normal file
View File

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

View File

@ -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());

View File

@ -112,6 +112,23 @@ fn text_ref(s: &Session, id: NodeId) -> Result<TextObject> {
}
}
/// 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 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))?;
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<usize> {
p.get(k).and_then(Value::as_u64).map(|v| v as usize)
}

View File

@ -0,0 +1,590 @@
//! 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 std::collections::BTreeMap;
use serde_json::{Value, json};
use vectorcraft_doc::{DataSet, DataValue, NodeId, NodeKind, Variable, VariableKind, Variables};
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<String> {
named(str_param(p, "name"), "name", cmd)
}
/// `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<String> {
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}`"))),
}
}
/// `key`'s value as a name, or `None` when it is missing or empty.
fn optional_name(p: &Value, key: &str, cmd: &str) -> Result<Option<String>> {
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<String>, nothing: &str) -> Result<Vec<String>> {
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<Vec<NodeId>> {
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<DataValue> {
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")))
}
/// 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<BTreeMap<String, DataValue>> {
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 = 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)
}
fn define(s: &mut Session, p: &Value) -> Result<Value> {
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(Variable { name: name.clone(), kind });
Ok(())
})?;
Ok(json!({"name": name, "kind": kind.label()}))
}
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
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);
}
}
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}))
}
/// 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<Value> {
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;
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 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}");
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::<Vec<_>>()}))
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
// 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()?;
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}))
}
fn list(s: &mut Session, _p: &Value) -> Result<Value> {
let st = s.doc()?;
let variables: Vec<Value> = st
.doc
.variables
.variables
.iter()
.map(|v| {
let bindings = st.doc.variables.objects_of(&v.name).len();
json!({"name": v.name, "kind": v.kind.label(), "bindings": bindings})
})
.collect();
Ok(json!({"variables": variables}))
}
fn bind(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "variable.bind";
let name = named(str_param(p, "variable"), "variable", C)?;
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}`")))?;
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<u64> = 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::<Vec<_>>(), "bound": n, "already": already}))
}
fn unbind(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "variable.unbind";
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 {
// 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)
})?;
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<Option<String>> {
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<Value> {
let name = defined_variable(s, p, "variable.highlight")?;
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<Value> {
const C: &str = "dataset.new";
let name = var_name(p, C)?;
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(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<Value> {
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<String> = 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_rename(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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 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(),
};
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<Value> {
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<Value> {
// 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));
// 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;
}
Ok(before - d.variables.datasets.len())
})?;
Ok(json!({"deleted": n}))
}
fn dataset_list(s: &mut Session, _p: &Value) -> Result<Value> {
let st = s.doc()?;
let current = st.doc.captured_values();
let datasets: Vec<Value> = 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": d.matches(&current)}))
.collect();
Ok(json!({"datasets": datasets, "active": st.doc.variables.active_dataset}))
}
fn apply_dataset(s: &mut Session, name: &str) -> Result<Value> {
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}`")))?;
// 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 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.
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;
}
};
// 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;
}
let done = match (kind, &value) {
// 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 {
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<Value> {
apply_dataset(s, &var_name(p, "dataset.select")?)
}
fn dataset_step(s: &mut Session, cmd: &str, forward: bool) -> Result<Value> {
let st = s.doc()?;
let n = st.doc.variables.datasets.len();
if n == 0 {
return Err(bad(cmd, "no datasets"));
}
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)
}
pub fn specs() -> Vec<CommandSpec> {
vec![
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 (default: the highlighted one)",
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",
["Window", "Variables"],
None,
"{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?, variable?} drop bindings (default: the selection): `variable` drops that one, else every variable the object has",
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",
"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.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 (default: the active one)",
has_doc,
dataset_delete
),
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",
["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.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)
}),
]
}

View File

@ -86,6 +86,7 @@ pub struct Interaction {
/// isolation).
pub active_layer: Option<NodeId>,
pub layer_rows: Vec<NodeId>,
pub variables_highlight: Option<String>,
pub isolation: Option<NodeId>,
/// 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<NodeId>,
/// 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<String>,
/// Isolation mode container.
pub isolation: Option<NodeId>,
pub interaction: Option<Interaction>,
@ -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;
}
@ -1226,6 +1233,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 {
@ -1290,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,
});
@ -1663,6 +1675,8 @@ mod tests_typearea;
#[cfg(test)]
mod tests_units;
#[cfg(test)]
mod tests_variables;
#[cfg(test)]
mod tests_webexport;
#[cfg(test)]
mod tests_widthpoints;

View File

@ -0,0 +1,591 @@
//! 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_leaves_datasets_alone() {
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!({})));
// 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();
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!({})));
}
/// 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 may be driven by several variables, so binding it again adds to what it has
/// instead of quietly dropping the earlier one.
#[test]
fn one_object_can_be_driven_by_several_variables() {
let mut s = session();
let t = text(&mut s, "x");
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("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.
#[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");
}
/// 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]
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();
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, &params).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<u64> = 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");
}
/// 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", &params).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());
}

View File

@ -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

View File

@ -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)),

View File

@ -0,0 +1,388 @@
//! 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"];
/// 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 };
/// 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.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. `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());
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(&current.values), &mut fields);
}
"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!(preselect.filter(|p| names.contains(p)).or(names.first().copied()).unwrap_or_default()));
fields.insert("newName".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<String, DataValue>>, out: &mut serde_json::Map<String, Value>) {
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, tl!("No document"));
return false;
};
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| {
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"));
// 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" => {
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();
}
// 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);
ui.end_row();
if vars.variables.is_empty() {
widgets::dim_label(ui, tl!("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 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_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()));
}
}
/// 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<Value, String> {
let cmd = d.str("__command");
let name = d.str("name");
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" => {
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!({}),
};
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", None));
{
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", None);
{
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", None);
{
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", 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();
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", 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");
}
}

View File

@ -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<Event>) {
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::<usize>(), "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");

View File

@ -534,6 +534,20 @@
Paragraph Styles Styly odstavců
Tabs Tabulátory
Variables Proměnné
New Variable… Nová proměnná…
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
Next Data Set Další sada dat
Previous Data Set Předchozí sada dat
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é
@ -678,3 +692,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

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3954,3 +3954,43 @@ 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…
New Data Set… Neuer Datensatz…
Edit Data Set… Datensatz bearbeiten…
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

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -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
@ -3055,6 +3056,38 @@
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
New Variable… Nueva variable…
New Data Set… Nuevo conjunto de datos…
Edit Data Set… Editar conjunto de datos…
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
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
@ -3997,3 +4030,10 @@ axis Both Ambos
Blocks Bloques
Frosted Esmerilado
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

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -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
@ -3052,6 +3053,38 @@
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
New Variable… Nouvelle variable…
New Data Set… Nouveau jeu de données…
Edit Data Set… Modifier le jeu de données…
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
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
@ -4009,3 +4042,10 @@ 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
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é

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -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
@ -3055,6 +3056,38 @@
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
New Variable… Nuova variabile…
New Data Set… Nuovo set di dati…
Edit Data Set… Modifica set di dati…
Data Set Set di dati
Kind Tipo
Variable Variabile
no document nessun documento
no data set to edit nessun set di dati da modificare
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
@ -3999,3 +4032,10 @@ effect Regular Normale
Blocks Blocchi
Frosted Smerigliato
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à

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -569,6 +569,39 @@
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 データセット
New Variable… 新規変数…
New Data Set… 新規データセット…
Edit Data Set… データセットを編集…
Data Set データセット
Kind 種類
Variable 変数
no document ドキュメントがありません
no data set to edit 編集するデータセットがありません
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 ユーザー定義
@ -1415,6 +1448,7 @@
High Performance (discrete) 高パフォーマンス (専用 GPU)
Highlight Alternate Glyphs 代替字形をハイライト
Highlight Rows 行をハイライト
Highlight Variable 変数をハイライト表示
Highlight Substituted Fonts 置換フォントをハイライト
Highlight Substituted Glyphs 置換字形をハイライト
Highlight anchors on mouse over マウスオーバー時にアンカーをハイライト
@ -3992,3 +4026,9 @@ effect Vertical 垂直
Blocks ブロック
Frosted すりガラス
Tiny Lens 小さなレンズ
Rename Data Set データセットの名前を変更
Variable Options 変数のオプション
Edit Data Set データセットを編集
Define a variable first 先に変数を定義してください
Text テキスト
Visibility 表示状態

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -2530,6 +2530,20 @@
V: V:
Variable Width Profile Perfil de Largura Variável
Variables Variáveis
New Variable… Nova 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
Next Data Set Próximo conjunto de dados
Previous Data Set Conjunto de dados anterior
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
@ -2738,3 +2752,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

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -1192,6 +1192,7 @@
Highlight Alternate Glyphs Выделять альтернативные глифы
Highlight anchors on mouse over Выделять опорные точки при наведении
Highlight Rows Выделять строки
Highlight Variable Подсветить переменную
Highlight Substituted Fonts Выделять заменённые шрифты
Highlight Substituted Glyphs Выделять заменённые глифы
History История
@ -3051,6 +3052,38 @@
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 Наборы данных
New Variable… Новая переменная…
New Data Set… Новый набор данных…
Edit Data Set… Изменить набор данных…
Data Set Набор данных
Kind Тип
Variable Переменная
no document нет документа
no data set to edit нет набора данных для изменения
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 Векторный полутон
@ -3995,3 +4028,10 @@ axis Both Оба
Blocks Блоки
Frosted Матовое
Tiny Lens Мелкие линзы
Rename Data Set Переименовать набор данных
Rename Variable Переименовать переменную
Variable Options Параметры переменной
Edit Data Set Изменить набор данных
Define a variable first Сначала определите переменную
Text Текст
Visibility Видимость

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -1188,6 +1188,7 @@
Highlight Alternate Glyphs Виділяти альтернативні гліфи
Highlight anchors on mouse over Виділяти опорні точки при наведенні
Highlight Rows Виділяти рядки
Highlight Variable Підсвітити змінну
Highlight Substituted Fonts Виділяти замінені шрифти
Highlight Substituted Glyphs Виділяти замінені гліфи
History Історія
@ -3047,6 +3048,38 @@
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 Набори даних
New Variable… Нова змінна…
New Data Set… Новий набір даних…
Edit Data Set… Змінити набір даних…
Data Set Набір даних
Kind Тип
Variable Змінна
no document немає документа
no data set to edit немає набору даних для зміни
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 Векторний напівтон
@ -3980,3 +4013,10 @@ axis Both Обидві
Blocks Блоки
Frosted Матове
Tiny Lens Дрібні лінзи
Rename Data Set Перейменувати набір даних
Rename Variable Перейменувати змінну
Variable Options Параметри змінної
Edit Data Set Змінити набір даних
Define a variable first Спочатку визначте змінну
Text Текст
Visibility Видимість

Can't render this file because it has a wrong number of fields in line 13.

View File

@ -2947,6 +2947,38 @@
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 数据集
New Variable… 新建变量…
New Data Set… 新建数据集…
Edit Data Set… 编辑数据集…
Data Set 数据集
Kind 类型
Variable 变量
no document 没有文档
no data set to edit 没有可编辑的数据集
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 矢量半色调
@ -3394,6 +3426,7 @@
Hide Others 隐藏其他
High Performance (discrete) 高性能 (独立显卡)
Highlight Rows 突出显示行
Highlight Variable 突出显示变量
Lock Others 锁定其他
Merge Selected 合并所选图层
Move Rows 移动行
@ -3628,3 +3661,10 @@ effect Vertical 垂直
Blocks 块状
Frosted 磨砂
Tiny Lens 小镜头
Rename Data Set 重命名数据集
Rename Variable 重命名变量
Variable Options 变量选项
Edit Data Set 编辑数据集
Define a variable first 请先定义一个变量
Text 文本
Visibility 可见性

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -1170,6 +1170,7 @@
Highlight Alternate Glyphs 醒目提示替代字符
Highlight anchors on mouse over 滑鼠移過時醒目提示錨點
Highlight Rows 反白顯示列
Highlight Variable 突出顯示變數
Highlight Substituted Fonts 醒目提示被取代的字體
Highlight Substituted Glyphs 醒目提示被取代的字符
History 步驟記錄
@ -3038,6 +3039,38 @@
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 資料集
New Variable… 新增變數…
New Data Set… 新增資料集…
Edit Data Set… 編輯資料集…
Data Set 資料集
Kind 類型
Variable 變數
no document 沒有文件
no data set to edit 沒有可編輯的資料集
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 向量半色調
@ -3409,3 +3442,10 @@ effect Soft 柔和
Blocks 塊狀
Frosted 磨砂
Tiny Lens 小鏡頭
Rename Data Set 重新命名資料集
Rename Variable 重新命名變數
Variable Options 變數選項
Edit Data Set 編輯資料集
Define a variable first 請先定義一個變數
Text 文字
Visibility 可見性

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -47,6 +47,7 @@ fn sub(label: &'static str, items: Vec<Item>) -> Item {
fn library_placeholders() -> Vec<Item> {
vec![todo("Built-in Libraries"), todo("User Defined"), Sep, todo("Other Library…")]
}
use Item::Sep;
/// UI-level commands: (id, label, shortcut, params doc).
@ -2548,7 +2549,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec<Item>)> {
panel("Tabs", "tabs"),
],
),
todo("Variables"),
panel("Variables", crate::panels::variables::ID),
Sep,
sub("Brush Libraries", library_placeholders()),
sub("Graphic Style Libraries", crate::panels::graphic_styles::window_menu()),
@ -2992,6 +2993,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, None);
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())

View File

@ -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::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),
@ -187,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

View File

@ -0,0 +1,468 @@
//! 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. 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::{NodeId, NodeKind, VariableKind};
use vectorcraft_engine::DocState;
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: its kind's icon, its name, and the object it drives.
struct VarRow {
name: String,
kind: VariableKind,
bound: Vec<u64>,
/// 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,
}
/// 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<String> {
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<u64> {
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();
}
}
/// What the selection allows the bottom bar's and the panel menu's commands to do.
struct Picked {
selected: Vec<u64>,
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<u64> = 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();
}
/// 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) {
app.run("dataset.delete", json!({})).ok();
}
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<VarRow> = st
.doc
.variables
.variables
.iter()
.map(|v| {
let bound: Vec<u64> = st.doc.variables.objects_of(&v.name).iter().map(|i| i.0).collect();
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<SetRow> = st
.doc
.variables
.datasets
.iter()
.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: d.matches(&current),
})
.collect();
let active = sets.iter().find(|s| s.active);
let Picked { selected, has_selection, selection_bound, single_type } = picked(st);
let highlighted = highlighted_row(st);
// 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_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": name})).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 {
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",
};
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(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::dialogs::variables::open(app, "variable.rename", Some(&row.name));
}
});
}
widgets::subheader(ui, tl!("Data Sets"));
if sets.is_empty() {
widgets::dim_label(ui, tl!("None"));
}
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();
}
}
// 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, 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() {
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_enabled(ui, "link-2-off", tl!("Unbind Variable"), false, selection_bound, 24.0).clicked() {
app.run("variable.unbind", json!({"ids": selected})).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| {
// 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<String> = st.doc.variables.variables.iter().map(|v| v.name.clone()).collect();
let sets: Vec<String> = st.doc.variables.datasets.iter().map(|d| d.name.clone()).collect();
let active = st.doc.variables.active_dataset.clone();
let bound: Vec<u64> = st.doc.variables.bindings.keys().map(|i| i.0).collect();
let highlighted = highlighted_row(st);
let highlighted_bound: Vec<u64> = 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!({}));
}
// Variable Options renames the highlighted variable, else the first.
if widgets::menu_item(ui, tl!("Variable Options…"), !vars.is_empty(), false) {
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();
}
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 = active.clone().or_else(|| sets.first().cloned()).unwrap_or_default();
crate::dialogs::variables::open(app, "dataset.rename", Some(&name));
}
// 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();
// 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();
}
}
#[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 = app_with_data();
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("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("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}");
}
}
/// 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");
assert_eq!(row.1, "Variables");
assert_ne!(row.2, "dc-list-view", "Document Info's icon, which the panel used to share");
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);
}
}
}
/// 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 window_variables_opens_the_panel() {
let app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
let entries = crate::menus::menu_entries(&app);
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.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
/// empty params, which would only say "missing `name`".
#[test]
fn the_menu_dialogs_open_instead_of_failing() {
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"));
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");
}
}
}

View File

@ -138,9 +138,14 @@ 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"),
(crate::panels::variables::ID, "Variables", "chart-column"),
];
/// 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"],
@ -150,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)]