From 5a638b0a80277b5d76cd66610e4f991a0876bb62 Mon Sep 17 00:00:00 2001 From: Fahri Meral Date: Fri, 9 Oct 2026 23:35:26 +0300 Subject: [PATCH] variables: the panel does the workflow itself, and rows can be captured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine side was called a solid base, but the panel could only list and apply: variables and datasets were made through commands, which most people won't find. The reference app's Variables workflow is now what the panel does, and every button runs a `variable.*` / `dataset.*` command, so the panel and an agent drive the document the same way. New commands (all also in Window › Variables, the palette and over MCP): - `variable.makeTextDynamic` / `variable.makeVisibilityDynamic`: define a variable of that kind over the selection, named after what it drives, and bind it — one step, as the panel's two buttons. - `dataset.capture`: record what the bound objects hold right now as a new row ("Data Set 1"… ) and make it active. This, rather than typing values, is how rows are made: change the art, then capture. - `dataset.update`: write what the art holds now into the active row. Without an active row it says so rather than picking one. - `variable.rename` / `dataset.rename` (Variable Options…, Rename Data Set…): a name is how a variable is referred to, so renaming carries its bindings and its values in every row with it. - `dataset.list` reports `matches` per row: whether the art still holds what that row says. `Document::captured_values` is the one place that reads a bound object's value, so capture, update and the drift check can't disagree about what the art says. Panel: the current data set across the top as a dropdown with ◀ ▶; variable rows with their kind's icon and the name of the object they drive ("Multiple objects" when it is more than one), a click selecting it and a double click opening Variable Options; a dataset row in italics once an edit has moved the art away from what it says; Make Text/Visibility Dynamic, New, Unbind and Delete below, Capture and Update on a second line; and a (≡) menu with the same commands for the ones that act on a row rather than the selection. Checked on Rust 1.99 (the current stable), which is what the review asked for: cargo xtask ci, all 7 steps pass. --- crates/doc/src/variables.rs | 44 +++- crates/engine/src/cmd/variablecmds.rs | 197 +++++++++++++++- crates/engine/src/tests_variables.rs | 126 ++++++++++ crates/ui-egui/src/dialogs/variables.rs | 44 +++- crates/ui-egui/src/i18n/cs.tsv | 8 + crates/ui-egui/src/i18n/es.tsv | 14 ++ crates/ui-egui/src/i18n/fr.tsv | 14 ++ crates/ui-egui/src/i18n/it.tsv | 14 ++ crates/ui-egui/src/i18n/ja.tsv | 14 ++ crates/ui-egui/src/i18n/pt-br.tsv | 8 + crates/ui-egui/src/i18n/ru.tsv | 14 ++ crates/ui-egui/src/i18n/uk.tsv | 14 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 14 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 14 ++ crates/ui-egui/src/menus.rs | 13 +- crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/panels/variables.rs | 291 +++++++++++++++++++----- 17 files changed, 775 insertions(+), 69 deletions(-) diff --git a/crates/doc/src/variables.rs b/crates/doc/src/variables.rs index 5c23c70d3..fb4a3c8ff 100644 --- a/crates/doc/src/variables.rs +++ b/crates/doc/src/variables.rs @@ -6,7 +6,7 @@ use std::collections::{BTreeMap, BTreeSet}; use serde::{Deserialize, Serialize}; -use super::{Document, NodeId}; +use super::{Document, NodeId, NodeKind}; /// What a variable drives. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -153,4 +153,46 @@ impl Document { bindings.retain(|id, _| self.node(*id).is_some()); self.variables.bindings = bindings; } + + /// What the object `id` holds for the variable of `kind`: its characters, or whether it + /// shows. `None` when the object is gone, is not of that kind, or is on a locked layer + /// (the document's value is whatever it was left at). + pub fn variable_value(&self, id: NodeId, kind: VariableKind) -> Option { + if !self.is_editable(id) { + return None; + } + let node = self.node(id)?; + match (kind, &node.kind) { + (VariableKind::Text, NodeKind::Text(t)) => Some(DataValue::Text(t.plain_text())), + (VariableKind::Visibility, _) => Some(DataValue::Visible(node.visible)), + (VariableKind::Text, _) => None, + } + } + + /// The values a dataset captured right now: what each bound object holds, in the order the + /// variables are defined. `dataset.capture` writes these into a new row and + /// `dataset.update` into the active one; the Variables panel compares the active row with + /// them to tell whether the art still matches what it says. + pub fn captured_values(&self) -> BTreeMap { + let mut out = BTreeMap::new(); + for v in &self.variables.variables { + // An object driven by several variables of the same kind reads from the first one + // bound, so a capture is one value per variable and applies back the same way. + let value = self.variables.objects_of(&v.name).into_iter().find_map(|id| self.variable_value(id, v.kind)); + if let Some(value) = value { + out.insert(v.name.clone(), value); + } + } + out + } + + /// Whether the bound objects still hold what `name`'s row says they do. False when there + /// is no such row, or when an edit has moved the art away from it. + pub fn matches_dataset(&self, name: &str) -> bool { + let Some(ds) = self.variables.dataset(name) else { return false }; + let current = self.captured_values(); + // A value the art can't report (its object is locked or gone) isn't drift: the row + // keeps it, and applying it again would skip it too. + ds.values.iter().all(|(k, v)| current.get(k).is_none_or(|c| c == v)) + } } diff --git a/crates/engine/src/cmd/variablecmds.rs b/crates/engine/src/cmd/variablecmds.rs index 58438ccda..9b221f6eb 100644 --- a/crates/engine/src/cmd/variablecmds.rs +++ b/crates/engine/src/cmd/variablecmds.rs @@ -78,6 +78,74 @@ fn define(s: &mut Session, p: &Value) -> Result { Ok(json!({"name": name, "kind": kind.label()})) } +fn rename(s: &mut Session, p: &Value) -> Result { + const C: &str = "variable.rename"; + let from = var_name(p, C)?; + let to = named(p.get("newName").and_then(Value::as_str), "newName", C)?; + let st = s.doc()?; + if st.doc.variables.variable(&to).is_some() { + return Err(bad(C, format!("variable `{to}` already exists"))); + } + s.edit("Rename Variable", |d, _| { + let v = d.variables.variables.iter_mut().find(|v| v.name == from).ok_or_else(|| bad(C, format!("no variable named `{from}`")))?; + v.name = to.clone(); + for bound in d.variables.bindings.values_mut() { + if bound.remove(&from) { + bound.insert(to.clone()); + } + } + for ds in &mut d.variables.datasets { + if let Some(value) = ds.values.remove(&from) { + ds.values.insert(to.clone(), value); + } + } + if d.variables.active_dataset.as_deref() == Some(from.as_str()) { + d.variables.active_dataset = Some(to.clone()); + } + Ok(()) + })?; + Ok(json!({"from": from, "name": to})) +} + +/// Make Text Dynamic / Make Visibility Dynamic: define a variable of that kind and bind the +/// selection to it in one step, naming it after what it drives — the panel's buttons. +fn make_dynamic(s: &mut Session, kind: VariableKind, stem: &str) -> Result { + const C: &str = "variable.makeDynamic"; + let ids = selected_roots(s)?; + if ids.is_empty() { + return Err(bad(C, "select the objects to bind")); + } + let doc = &s.doc()?.doc; + for id in &ids { + match doc.node(*id) { + None => return Err(EngineError::NoNode(*id)), + Some(_) if !doc.is_editable(*id) => return Err(bad(C, format!("node {} is locked", id.0))), + Some(n) if kind == VariableKind::Text && !matches!(n.kind, NodeKind::Text(_)) => { + return Err(bad(C, format!("node {} is not type", id.0))); + } + Some(_) => {} + } + } + // Named after the first object it drives, as the reference panel does; a second variable + // over the same art takes a number rather than colliding. + let base = doc.node(ids[0]).map(|n| n.display_name()).unwrap_or_default(); + let base = base.trim(); + let mut name = if base.is_empty() { stem.to_string() } else { base.to_string() }; + let mut n = 2; + while doc.variables.variable(&name).is_some() { + name = format!("{base} {n}"); + n += 1; + } + s.edit("Make Dynamic", |d, _| { + d.variables.variables.push(Variable { name: name.clone(), kind }); + for id in &ids { + d.variables.bind(*id, &name); + } + Ok(()) + })?; + Ok(json!({"name": name, "kind": kind.label(), "ids": ids.iter().map(|i| i.0).collect::>()})) +} + fn delete(s: &mut Session, p: &Value) -> Result { let names = names_param(p, "variable.delete")?; let n = s.edit("Delete Variables", |d, _| Ok(d.variables.prune(&names)))?; @@ -191,6 +259,69 @@ fn dataset_set(s: &mut Session, p: &Value) -> Result { Ok(json!({"name": name, "values": set, "removed": removed})) } +fn dataset_rename(s: &mut Session, p: &Value) -> Result { + const C: &str = "dataset.rename"; + let from = var_name(p, C)?; + let to = named(p.get("newName").and_then(Value::as_str), "newName", C)?; + let st = s.doc()?; + if st.doc.variables.dataset(&to).is_some() { + return Err(bad(C, format!("dataset `{to}` already exists"))); + } + s.edit("Rename Data Set", |d, _| { + let ds = d.variables.datasets.iter_mut().find(|d| d.name == from).ok_or_else(|| bad(C, format!("no dataset named `{from}`")))?; + ds.name = to.clone(); + if d.variables.active_dataset.as_deref() == Some(from.as_str()) { + d.variables.active_dataset = Some(to.clone()); + } + Ok(()) + })?; + Ok(json!({"from": from, "name": to})) +} + +/// Capture Data Set: record what the bound objects hold right now as a new row. This, rather +/// than typing values, is how rows are made — change the art, then capture it. +fn dataset_capture(s: &mut Session, p: &Value) -> Result { + const C: &str = "dataset.capture"; + let st = s.doc()?; + let values = st.doc.captured_values(); + if values.is_empty() { + return Err(bad(C, "bind something to a variable first")); + } + let name = match str_param(p, "name").filter(|v| !v.is_empty()) { + Some(n) => n.to_string(), + // "Data Set 1", the first free number, as the reference panel's rows are named. + None => (1..).map(|i| format!("Data Set {i}")).find(|n| st.doc.variables.dataset(n).is_none()).unwrap_or_default(), + }; + if s.doc()?.doc.variables.dataset(&name).is_some() { + return Err(bad(C, format!("dataset `{name}` already exists"))); + } + s.edit("Capture Data Set", |d, _| { + d.variables.datasets.push(DataSet { name: name.clone(), values: values.clone() }); + d.variables.active_dataset = Some(name.clone()); + Ok(()) + })?; + Ok(json!({"name": name, "values": values.len()})) +} + +/// Update Data Set: write what the bound objects hold now into the row that is active. Without +/// an active row there is nothing to update, so say so rather than picking one. +fn dataset_update(s: &mut Session, _p: &Value) -> Result { + const C: &str = "dataset.update"; + let values = s.doc()?.doc.captured_values(); + let Some(name) = s.doc()?.doc.variables.active_dataset.clone() else { + return Err(bad(C, "no active data set")); + }; + if values.is_empty() { + return Err(bad(C, "bind something to a variable first")); + } + s.edit("Update Data Set", |d, _| { + let ds = d.variables.datasets.iter_mut().find(|d| d.name == name).ok_or_else(|| bad(C, format!("no dataset named `{name}`")))?; + ds.values = values.clone(); + Ok(()) + })?; + Ok(json!({"name": name, "values": values.len()})) +} + fn dataset_delete(s: &mut Session, p: &Value) -> Result { let names = names_param(p, "dataset.delete")?; let n = s.edit("Delete Data Sets", |d, _| { @@ -207,7 +338,15 @@ fn dataset_delete(s: &mut Session, p: &Value) -> Result { fn dataset_list(s: &mut Session, _p: &Value) -> Result { let st = s.doc()?; - let datasets: Vec = st.doc.variables.datasets.iter().map(|d| json!({"name": d.name, "values": d.values})).collect(); + let datasets: Vec = st + .doc + .variables + .datasets + .iter() + // `matches` says whether the art still holds what the row says, which is what the + // panel shows in italics. + .map(|d| json!({"name": d.name, "values": d.values, "matches": st.doc.matches_dataset(&d.name)})) + .collect(); Ok(json!({"datasets": datasets, "active": st.doc.variables.active_dataset})) } @@ -295,7 +434,34 @@ pub fn specs() -> Vec { has_doc, delete ), + cmd!( + "variable.rename", + "Rename Variable", + ["Window", "Variables"], + None, + "{name, newName} rename a variable, with its bindings and its values in every dataset", + has_doc, + rename + ), cmd!(query "variable.list", "List Variables", [], None, "{} → variables with kinds and binding counts", has_doc, list), + cmd!( + "variable.makeTextDynamic", + "Make Text Dynamic", + ["Window", "Variables"], + None, + "{} define a text variable over the selection, named after it and bound to it", + has_selection, + |s, _| make_dynamic(s, VariableKind::Text, "Text") + ), + cmd!( + "variable.makeVisibilityDynamic", + "Make Visibility Dynamic", + ["Window", "Variables"], + None, + "{} define a visibility variable over the selection, named after it and bound to it", + has_selection, + |s, _| make_dynamic(s, VariableKind::Visibility, "Visibility") + ), cmd!( "variable.bind", "Bind Variable", @@ -324,8 +490,35 @@ pub fn specs() -> Vec { has_doc, dataset_set ), + cmd!("dataset.rename", "Rename Data Set", ["Window", "Variables"], None, "{name, newName} rename a dataset", has_doc, dataset_rename), + cmd!( + "dataset.capture", + "Capture Data Set", + ["Window", "Variables"], + None, + "{name?} record what the bound objects hold right now as a new dataset (Data Set 1…), and make it the active one", + has_doc, + dataset_capture + ), + cmd!( + "dataset.update", + "Update Data Set", + ["Window", "Variables"], + None, + "{} write what the bound objects hold now into the active dataset", + has_doc, + dataset_update + ), cmd!("dataset.delete", "Delete Data Set", ["Window", "Variables"], None, "{names} delete datasets", has_doc, dataset_delete), - cmd!(query "dataset.list", "List Data Sets", [], None, "{} → datasets with values and the active one", has_doc, dataset_list), + cmd!( + query "dataset.list", + "List Data Sets", + [], + None, + "{} → datasets with their values, the active one, and which rows the art still matches", + has_doc, + dataset_list + ), cmd!( "dataset.select", "Select Data Set", diff --git a/crates/engine/src/tests_variables.rs b/crates/engine/src/tests_variables.rs index 8e69472f6..90502a15d 100644 --- a/crates/engine/src/tests_variables.rs +++ b/crates/engine/src/tests_variables.rs @@ -253,6 +253,132 @@ fn locked_art_is_neither_bound_nor_rewritten() { assert!(!s.doc().unwrap().doc.node(r).unwrap().visible, "the unlocked rectangle still applied"); } +/// Make Text Dynamic / Make Visibility Dynamic: what the panel's buttons run. One step from a +/// selection to a bound variable, named after what it drives. +#[test] +fn making_a_selection_dynamic_names_and_binds_it() { + let mut s = session(); + let t = text(&mut s, "Alice"); + let r = rect(&mut s); + + s.execute("select.set", &json!({"ids": [t.0]})).unwrap(); + let v = s.execute("variable.makeTextDynamic", &json!({})).unwrap(); + assert_eq!(v["name"], "Alice", "named after the text it drives"); + assert_eq!(v["ids"], json!([t.0])); + + s.execute("select.set", &json!({"ids": [r.0]})).unwrap(); + let v = s.execute("variable.makeVisibilityDynamic", &json!({})).unwrap(); + assert_eq!(v["kind"], "visibility"); + + // The same object twice takes a number rather than colliding with the first variable. + s.execute("select.set", &json!({"ids": [t.0]})).unwrap(); + let again = s.execute("variable.makeTextDynamic", &json!({})).unwrap(); + assert_eq!(again["name"], "Alice 2"); + let vars = s.execute("variable.list", &json!({})).unwrap(); + assert_eq!(vars["variables"].as_array().map(Vec::len), Some(3)); + + // Nothing selected, art that can't take the kind, and locked art are errors. + s.execute("select.none", &json!({})).unwrap(); + assert!(s.execute("variable.makeTextDynamic", &json!({})).is_err()); + s.execute("select.set", &json!({"ids": [r.0]})).unwrap(); + assert!(s.execute("variable.makeTextDynamic", &json!({})).is_err(), "a rectangle is not type"); +} + +/// Capture Data Set records what the art holds now, which is how rows are made — the art is +/// edited and captured, rather than the values typed. +#[test] +fn capturing_records_what_the_art_holds_now() { + let mut s = session(); + let t = text(&mut s, "Alice"); + let r = rect(&mut s); + s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Show", "ids": [r.0]})).unwrap(); + + let v = s.execute("dataset.capture", &json!({})).unwrap(); + assert_eq!((v["name"].clone(), v["values"].clone()), (json!("Data Set 1"), json!(2))); + let sets = s.execute("dataset.list", &json!({})).unwrap(); + assert_eq!(sets["datasets"][0]["values"]["Name"], json!({"text": "Alice"})); + assert_eq!(sets["datasets"][0]["values"]["Show"], json!({"visible": true})); + assert_eq!(sets["active"], "Data Set 1"); + + // Change the art, capture again: a second row, and the first still matches its own text. + s.execute("text.editRange", &json!({"id": t.0, "insert": "Bob"})).unwrap(); + assert_eq!(s.execute("dataset.list", &json!({})).unwrap()["datasets"][0]["matches"], json!(false), "the art moved on"); + s.execute("dataset.capture", &json!({})).unwrap(); + let sets = s.execute("dataset.list", &json!({})).unwrap(); + assert_eq!(sets["datasets"][1]["name"], "Data Set 2"); + assert_eq!(sets["datasets"][1]["values"]["Name"], json!({"text": "Bob"})); + assert_eq!(sets["datasets"][0]["matches"], json!(false), "row 1 still describes the old text"); + + // A named capture, and a name that is taken is an error rather than a second row. + s.execute("dataset.capture", &json!({"name": "Winter"})).unwrap(); + assert!(s.execute("dataset.capture", &json!({"name": "Winter"})).is_err()); + + // Nothing bound is nothing to capture. + let mut empty = session(); + empty.execute("variable.define", &json!({"name": "V", "kind": "text"})).unwrap(); + assert!(empty.execute("dataset.capture", &json!({})).is_err()); +} + +/// Update Data Set writes the art's current values into the row that is active. +#[test] +fn updating_writes_the_art_into_the_active_row() { + let mut s = session(); + let t = text(&mut s, "Alice"); + s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap(); + + // No active row yet: say so rather than quietly writing into some other one. + assert!(s.execute("dataset.update", &json!({})).is_err()); + s.execute("dataset.capture", &json!({})).unwrap(); + s.execute("text.editRange", &json!({"id": t.0, "insert": "Bob"})).unwrap(); + let v = s.execute("dataset.update", &json!({})).unwrap(); + assert_eq!((v["name"].clone(), v["values"].clone()), (json!("Data Set 1"), json!(1))); + assert_eq!(s.execute("dataset.list", &json!({})).unwrap()["datasets"][0]["matches"], json!(true)); + // One undo step puts the row back as it was. + s.execute("text.editRange", &json!({"id": t.0, "insert": "Caz"})).unwrap(); + s.execute("dataset.update", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(plain(&s, t), "Caz"); +} + +/// Renaming carries the variable's bindings and its values in every row with it — a name is +/// how a variable is referred to, so leaving the old one behind would orphan both. +#[test] +fn renaming_carries_the_bindings_and_values_with_it() { + let mut s = session(); + let t = text(&mut s, "Alice"); + s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap(); + s.execute("dataset.new", &json!({"name": "D", "values": {"Name": "Bob"}})).unwrap(); + s.execute("dataset.select", &json!({"name": "D"})).unwrap(); + + s.execute("variable.rename", &json!({"name": "Name", "newName": "Title"})).unwrap(); + assert_eq!(plain(&s, t), "Bob", "the row still applied"); + assert_eq!(s.execute("dataset.list", &json!({})).unwrap()["datasets"][0]["values"]["Title"], json!({"text": "Bob"})); + let vars = s.execute("variable.list", &json!({})).unwrap(); + assert_eq!((vars["variables"][0]["name"].clone(), vars["variables"][0]["bindings"].clone()), (json!("Title"), json!(1))); + + // Renaming a dataset keeps it active and keeps its values. + s.execute("dataset.rename", &json!({"name": "D", "newName": "Row 1"})).unwrap(); + let sets = s.execute("dataset.list", &json!({})).unwrap(); + assert_eq!((sets["active"].clone(), sets["datasets"][0]["name"].clone()), (json!("Row 1"), json!("Row 1"))); + + // Taken and missing names are errors. + s.execute("variable.define", &json!({"name": "Other", "kind": "text"})).unwrap(); + assert!(s.execute("variable.rename", &json!({"name": "Title", "newName": "Other"})).is_err()); + assert!(s.execute("variable.rename", &json!({"name": "Ghost", "newName": "X"})).is_err()); + assert!(s.execute("dataset.rename", &json!({"name": "Ghost", "newName": "X"})).is_err()); + // A dataset and a variable are separate namespaces: a dataset may take a variable's name, + // and a variable may take a dataset's. + s.execute("dataset.rename", &json!({"name": "Row 1", "newName": "Title"})).unwrap(); + assert_eq!(s.execute("dataset.list", &json!({})).unwrap()["active"], "Title"); + s.execute("dataset.new", &json!({"name": "Taken"})).unwrap(); + assert!(s.execute("dataset.rename", &json!({"name": "Title", "newName": "Taken"})).is_err()); +} + /// `dataset.set` is how a value changes after the row exists: replacing it must not need the /// dataset to be deleted and rebuilt (which loses its place in the list and the active one). #[test] diff --git a/crates/ui-egui/src/dialogs/variables.rs b/crates/ui-egui/src/dialogs/variables.rs index 4edf1c86c..bafffa791 100644 --- a/crates/ui-egui/src/dialogs/variables.rs +++ b/crates/ui-egui/src/dialogs/variables.rs @@ -28,14 +28,26 @@ pub(super) const SPEC: DialogSpec = fn opened(id: &str) -> Option<&'static str> { Some(match id { "variable.define" => "New Variable", + "variable.rename" => "Variable Options", "variable.bind" => "Bind Variable", "dataset.new" => "New Data Set", "dataset.set" => "Edit Data Set", + "dataset.rename" => "Rename Data Set", "dataset.select" => "Select Data Set", _ => return None, }) } +/// The row `id` renames, which the dialog offers as a dropdown when the document has more than +/// one. Variable Options renames the one the panel has highlighted, so it starts there. +fn rename_of(id: &str) -> Option<&'static str> { + Some(match id { + "variable.rename" => "variable", + "dataset.rename" => "name", + _ => return None, + }) +} + /// Open `id`'s dialog: OK runs the command with the fields below. pub fn open(app: &mut VectorcraftApp, id: &str) -> bool { let Some(label) = opened(id) else { return false }; @@ -70,7 +82,21 @@ pub fn open(app: &mut VectorcraftApp, id: &str) -> bool { "dataset.select" => { fields.insert("name".into(), json!("")); } - _ => {} + _ => { + // A rename: which row, and its new name. + let key = rename_of(id).unwrap_or("name"); + let names: Vec<&str> = if key == "variable" { + vars.variables.iter().map(|v| v.name.as_str()).collect() + } else { + vars.datasets.iter().map(|d| d.name.as_str()).collect() + }; + if names.is_empty() { + app.status(if key == "variable" { tl!("no variable to rename").to_string() } else { tl!("no data set to rename").to_string() }); + return true; + } + fields.insert(key.into(), json!(names[0])); + fields.insert("newName".into(), json!("")); + } } fields.insert("__command".into(), json!(id)); fields.insert("__label".into(), json!(label)); @@ -135,6 +161,20 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { dropdown(ui, d, "name", &dataset_names, 220.0); ui.end_row(); } + // A rename: which row, and its new name. + "variable.rename" | "dataset.rename" => { + let (key, label_text, names) = if d.str("__command") == "variable.rename" { + ("variable", tl!("Variable").to_string(), &variable_names) + } else { + ("name", tl!("Data Set").to_string(), &dataset_names) + }; + label(ui, &label_text); + dropdown(ui, d, key, names, 220.0); + ui.end_row(); + label(ui, tl!("New Name")); + text(ui, d, "newName", 220.0); + ui.end_row(); + } _ => { label(ui, tl!("Name")); text(ui, d, "name", 220.0); @@ -219,6 +259,8 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { let p = match cmd.as_str() { "variable.define" => json!({"name": name, "kind": d.str("kind")}), "variable.bind" => json!({"variable": d.str("variable")}), + "variable.rename" => json!({"name": d.str("variable"), "newName": d.str("newName")}), + "dataset.rename" => json!({"name": name, "newName": d.str("newName")}), "dataset.new" | "dataset.set" => json!({"name": name, "values": values_of(d, &vars)}), "dataset.select" => json!({"name": name}), _ => json!({}), diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 82e7f2979..e0e223eff 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -545,6 +545,12 @@ Next Data Set Další sada dat Previous Data Set Předchozí sada dat Variables Panel Panel proměnných + Make Text Dynamic Nastavit text jako dynamický + Make Visibility Dynamic Nastavit viditelnost jako dynamickou + Capture Data Set Pořídit sadu dat + Update Data Set Aktualizovat sadu dat + Rename Data Set… Přejmenovat sadu dat… + Variable Options… Možnosti proměnné… Brush Libraries Knihovny štětců Built-in Libraries Vestavěné knihovny User Defined Uživatelské @@ -689,3 +695,5 @@ Diffuse Glow… Rozptýlená záře… Glass… Sklo… Ocean Ripple… Vlnky na hladině… + Rename Data Set Přejmenovat sadu dat + Rename Variable Přejmenovat proměnnou diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index b772fcb41..4f0084300 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3079,6 +3079,18 @@ no document no hay documento no data set to edit no hay ningún conjunto de datos que editar {name} · {kind} · {count} {name} · {kind} · {count} + Make Text Dynamic Hacer texto dinámico + Make Visibility Dynamic Hacer visible dinámico + Capture Data Set Capturar conjunto de datos + Update Data Set Actualizar conjunto de datos + Rename Data Set… Renombrar conjunto de datos… + Variable Options… Opciones de variable… + Select Bound Object Seleccionar objeto vinculado + Select All Bound Objects Seleccionar todos los objetos vinculados + Multiple objects Varios objetos + New Name Nuevo nombre + no variable to rename no hay ninguna variable que renombrar + no data set to rename no hay ningún conjunto de datos que renombrar Variation: Variación: Vector Effects Efectos vectoriales Vector Halftone Semitono vectorial @@ -4021,3 +4033,5 @@ axis Both Ambos Blocks Bloques Frosted Esmerilado Tiny Lens Lente pequeña + Rename Data Set Renombrar conjunto de datos + Rename Variable Renombrar variable diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 9b68580bc..86b148b24 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3076,6 +3076,18 @@ no document aucun document no data set to edit aucun jeu de données à modifier {name} · {kind} · {count} {name} · {kind} · {count} + Make Text Dynamic Rendre le texte dynamique + Make Visibility Dynamic Rendre la visibilité dynamique + Capture Data Set Capturer le jeu de données + Update Data Set Mettre à jour le jeu de données + Rename Data Set… Renommer le jeu de données… + Variable Options… Options de variable… + Select Bound Object Sélectionner l’objet lié + Select All Bound Objects Sélectionner tous les objets liés + Multiple objects Plusieurs objets + New Name Nouveau nom + no variable to rename aucune variable à renommer + no data set to rename aucun jeu de données à renommer Variation: Variation : Vector Effects Effets vectoriels Vector Halftone Trame de demi-teintes vectorielle @@ -4033,3 +4045,5 @@ effect Soft Doux Blocks Blocs Frosted Dépoli Tiny Lens Petites lentilles + Rename Data Set Renommer le jeu de données + Rename Variable Renommer la variable diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 7ab85fb9e..c5d6a0442 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3079,6 +3079,18 @@ no document nessun documento no data set to edit nessun set di dati da modificare {name} · {kind} · {count} {name} · {kind} · {count} + Make Text Dynamic Rendi testo dinamico + Make Visibility Dynamic Rendi visibilità dinamica + Capture Data Set Cattura set di dati + Update Data Set Aggiorna set di dati + Rename Data Set… Rinomina set di dati… + Variable Options… Opzioni variabile… + Select Bound Object Seleziona oggetto associato + Select All Bound Objects Seleziona tutti gli oggetti associati + Multiple objects Più oggetti + New Name Novo nome + no variable to rename nessuna variabile da rinominare + no data set to rename nessun set di dati da rinominare Variation: Variazione: Vector Effects Effetti vettoriali Vector Halftone Retino vettoriale @@ -4023,3 +4035,5 @@ effect Regular Normale Blocks Blocchi Frosted Smerigliato Tiny Lens Lente piccola + Rename Data Set Rinomina set di dati + Rename Variable Rinomina variabile diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 4e30c4f89..9765a2747 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -593,6 +593,19 @@ no document ドキュメントがありません no data set to edit 編集するデータセットがありません {name} · {kind} · {count} {name} · {kind} · {count} 件 + Make Text Dynamic テキストを動的にする + Make Visibility Dynamic 表示を動的にする + Capture Data Set データセットを取得 + Update Data Set データセットを更新 + Rename Data Set… データセットの名前を変更… + Variable Options… 変数のオプション… + Select Bound Object バインドされたオブジェクトを選択 + Select All Bound Objects すべてのバインドされたオブジェクトを選択 + Multiple objects 複数のオブジェクト + New Name 新しい名前 + Rename Variable 変数の名前を変更 + no variable to rename 名前を変更する変数がありません + no data set to rename 名前を変更するデータセットがありません Brush Libraries ブラシライブラリ Built-in Libraries 内蔵ライブラリ User Defined ユーザー定義 @@ -4016,3 +4029,4 @@ effect Vertical 垂直 Blocks ブロック Frosted すりガラス Tiny Lens 小さなレンズ + Rename Data Set データセットの名前を変更 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index dba9433db..e54654118 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2541,6 +2541,12 @@ Next Data Set Próximo conjunto de dados Previous Data Set Conjunto de dados anterior Variables Panel Painel de variáveis + Make Text Dynamic Tornar texto dinâmico + Make Visibility Dynamic Tornar visibilidade dinâmica + Capture Data Set Capturar conjunto de dados + Update Data Set Atualizar conjunto de dados + Rename Data Set… Renomear conjunto de dados… + Variable Options… Opções de variável… Variation: Variação: Vector Effects Efeitos Vetoriais Vector Halftone Meio-tom Vetorial @@ -2749,3 +2755,5 @@ Diffuse Glow… Brilho difuso… Glass… Vidro… Ocean Ripple… Ondulação oceânica… + Rename Data Set Renomear conjunto de dados + Rename Variable Renomear variável diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index d131f9836..9cb692cea 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3075,6 +3075,18 @@ no document нет документа no data set to edit нет набора данных для изменения {name} · {kind} · {count} {name} · {kind} · {count} + Make Text Dynamic Сделать текст динамическим + Make Visibility Dynamic Сделать видимость динамической + Capture Data Set Записать набор данных + Update Data Set Обновить набор данных + Rename Data Set… Переименовать набор данных… + Variable Options… Параметры переменной… + Select Bound Object Выбрать связанный объект + Select All Bound Objects Выбрать все связанные объекты + Multiple objects Несколько объектов + New Name Новое имя + no variable to rename нет переменной для переименования + no data set to rename нет набора данных для переименования Variation: Вариация: Vector Effects Векторные эффекты Vector Halftone Векторный полутон @@ -4019,3 +4031,5 @@ axis Both Оба Blocks Блоки Frosted Матовое Tiny Lens Мелкие линзы + Rename Data Set Переименовать набор данных + Rename Variable Переименовать переменную diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 81e073e44..4aa1c9da0 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3071,6 +3071,18 @@ no document немає документа no data set to edit немає набору даних для зміни {name} · {kind} · {count} {name} · {kind} · {count} + Make Text Dynamic Зробити текст динамічним + Make Visibility Dynamic Зробити видимість динамічною + Capture Data Set Записати набір даних + Update Data Set Оновити набір даних + Rename Data Set… Перейменувати набір даних… + Variable Options… Параметри змінної… + Select Bound Object Вибрати пов’язаний об’єкт + Select All Bound Objects Вибрати всі пов’язані об’єкти + Multiple objects Кілька об’єктів + New Name Нова назва + no variable to rename немає змінної для перейменування + no data set to rename немає набору даних для перейменування Variation: Варіація: Vector Effects Векторні ефекти Vector Halftone Векторний напівтон @@ -4004,3 +4016,5 @@ axis Both Обидві Blocks Блоки Frosted Матове Tiny Lens Дрібні лінзи + Rename Data Set Перейменувати набір даних + Rename Variable Перейменувати змінну diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 702e9a62a..346536cd3 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2971,6 +2971,18 @@ no document 没有文档 no data set to edit 没有可编辑的数据集 {name} · {kind} · {count} {name} · {kind} · {count} + Make Text Dynamic 使文本动态化 + Make Visibility Dynamic 使可见性动态化 + Capture Data Set 捕获数据集 + Update Data Set 更新数据集 + Rename Data Set… 重命名数据集… + Variable Options… 变量选项… + Select Bound Object 选择绑定的对象 + Select All Bound Objects 选择所有绑定的对象 + Multiple objects 多个对象 + New Name 新名称 + no variable to rename 没有可重命名的变量 + no data set to rename 没有可重命名的数据集 Variation: 变化: Vector Effects 矢量效果 Vector Halftone 矢量半色调 @@ -3652,3 +3664,5 @@ effect Vertical 垂直 Blocks 块状 Frosted 磨砂 Tiny Lens 小镜头 + Rename Data Set 重命名数据集 + Rename Variable 重命名变量 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 1694f8557..dd9828dcf 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3062,6 +3062,18 @@ no document 沒有文件 no data set to edit 沒有可編輯的資料集 {name} · {kind} · {count} {name} · {kind} · {count} + Make Text Dynamic 使文字動態化 + Make Visibility Dynamic 使可見性動態化 + Capture Data Set 擷取資料集 + Update Data Set 更新資料集 + Rename Data Set… 重新命名資料集… + Variable Options… 變數選項… + Select Bound Object 選取繫結的物件 + Select All Bound Objects 選取所有繫結的物件 + Multiple objects 多個物件 + New Name 新名稱 + no variable to rename 沒有可重新命名的變數 + no data set to rename 沒有可重新命名的資料集 Variation: 變化: Vector Effects 向量效果 Vector Halftone 向量半色調 @@ -3433,3 +3445,5 @@ effect Soft 柔和 Blocks 塊狀 Frosted 磨砂 Tiny Lens 小鏡頭 + Rename Data Set 重新命名資料集 + Rename Variable 重新命名變數 diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index bf5e1a7cc..3b1f10e25 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -48,20 +48,27 @@ fn library_placeholders() -> Vec { vec![todo("Built-in Libraries"), todo("User Defined"), Sep, todo("Other Library…")] } -/// Window › Variables: the panel, then the commands that define and apply what it shows. The -/// "…" commands open a Variables dialog (`dialogs::variables`), so the menu and the command -/// palette both reach them. +/// Window › Variables: the panel, then the commands that define and apply what it shows, in +/// the order the panel does them. The "…" commands open a Variables dialog +/// (`dialogs::variables`), so the menu and the command palette both reach them. fn variables_window_menu(panel: impl Fn(&'static str, &'static str) -> Item) -> Vec { vec![ panel("Variables Panel", crate::panels::variables::ID), Sep, + c("Make Text Dynamic", "variable.makeTextDynamic"), + c("Make Visibility Dynamic", "variable.makeVisibilityDynamic"), + Sep, c("New Variable…", "variable.define"), + c("Variable Options…", "variable.rename"), c("Bind Variable…", "variable.bind"), c("Unbind Variable", "variable.unbind"), c("Delete Variable", "variable.delete"), Sep, + c("Capture Data Set", "dataset.capture"), c("New Data Set…", "dataset.new"), c("Edit Data Set…", "dataset.set"), + c("Update Data Set", "dataset.update"), + c("Rename Data Set…", "dataset.rename"), c("Delete Data Set", "dataset.delete"), Sep, c("Select Data Set…", "dataset.select"), diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index a1ece175a..f51648f35 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -189,6 +189,7 @@ pub fn panel_menu_items(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) -> bool links::ID => links::menu(app, ui), asset_export::ID => asset_export::menu(app, ui), css_properties::ID => css_properties::menu(app, ui), + variables::ID => variables::menu(app, ui), _ => return false, } true diff --git a/crates/ui-egui/src/panels/variables.rs b/crates/ui-egui/src/panels/variables.rs index 782261f8d..9cf143558 100644 --- a/crates/ui-egui/src/panels/variables.rs +++ b/crates/ui-egui/src/panels/variables.rs @@ -1,10 +1,16 @@ -//! Variables panel: the document's variables (what they drive, what they bind) and its +//! Variables panel: the document's variables (what they drive, which objects) and its //! datasets. Clicking a dataset applies it; clicking a variable selects what it binds; the -//! arrows step to the next or previous one. Definitions are made with the `variable.*` and -//! `dataset.*` commands, from Window › Variables, the command palette or an agent. +//! arrows step to the next or previous one. Everything the reference panel does here runs a +//! `variable.*` / `dataset.*` command, so the panel and an agent drive the document the same +//! way — the commands are also in Window › Variables, the palette and over MCP. +//! +//! A row's name goes in italics when an edit has moved the art away from what the row says: +//! the document then no longer matches the active dataset, and "Update Data Set" writes the +//! art back into it. use egui::Ui; use serde_json::json; +use vectorcraft_doc::VariableKind; use crate::VectorcraftApp; use crate::widgets; @@ -12,11 +18,22 @@ use crate::widgets; /// The panel id, as the dock, the Window menu and `window.panel` name it. pub const ID: &str = "variables"; -/// One variable row: what it is and how many objects it drives. +/// One variable row: its kind's icon, its name, and the object it drives. struct VarRow { name: String, - kind: &'static str, + kind: VariableKind, bound: Vec, + /// The name of the object it drives, or "Multiple objects" when it drives more than one. + object: String, +} + +/// One dataset row: its name and how many values it carries. +struct SetRow { + name: String, + values: usize, + active: bool, + /// False once an edit has moved the art away from what this row says. + matches: bool, } pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { @@ -30,96 +47,242 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { .variables .variables .iter() - .map(|v| VarRow { name: v.name.clone(), kind: v.kind.label(), bound: st.doc.variables.objects_of(&v.name).iter().map(|i| i.0).collect() }) + .map(|v| { + let bound: Vec = st.doc.variables.objects_of(&v.name).iter().map(|i| i.0).collect(); + let object = match bound.len() { + 0 => String::new(), + 1 => st.doc.node(NodeId(bound[0])).map(|n| n.display_name()).unwrap_or_default(), + _ => tl!("Multiple objects").to_string(), + }; + VarRow { name: v.name.clone(), kind: v.kind, bound, object } + }) .collect(); - let sets: Vec<(String, usize, bool)> = st + let sets: Vec = st .doc .variables .datasets .iter() - .map(|d| (d.name.clone(), d.values.len(), st.doc.variables.active_dataset.as_deref() == Some(d.name.as_str()))) + .map(|d| SetRow { + name: d.name.clone(), + values: d.values.len(), + active: st.doc.variables.active_dataset.as_deref() == Some(d.name.as_str()), + matches: st.doc.matches_dataset(&d.name), + }) .collect(); - let has_doc = app.session.active().is_some(); + let active = sets.iter().find(|s| s.active); + let selected: Vec = st.selection.objects.iter().map(|i| i.0).collect(); + let has_selection = !selected.is_empty(); + + // The current data set across the top: its name, and the arrows that step through them. + ui.horizontal(|ui| { + widgets::field_label(ui, tl!("Data Set")); + let names: Vec<&str> = sets.iter().map(|s| s.name.as_str()).collect(); + let current = active.map(|s| s.name.as_str()).unwrap_or(""); + let picked = widgets::dropdown(ui, "variables-active", current, &names, 160.0); + if let Some(i) = picked + && names.get(i).is_some_and(|n| *n != current) + { + app.run("dataset.select", json!({"name": names[i]})).ok(); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let has = !sets.is_empty(); + if widgets::icon_button_enabled(ui, "chevron-left", tl!("Previous Data Set"), false, has, 24.0).clicked() { + app.run("dataset.prev", json!({})).ok(); + } + if widgets::icon_button_enabled(ui, "chevron-right", tl!("Next Data Set"), false, has, 24.0).clicked() { + app.run("dataset.next", json!({})).ok(); + } + }); + }); widgets::subheader(ui, tl!("Variables")); if vars.is_empty() { widgets::dim_label(ui, tl!("None")); } for row in &vars { - // The binding count is what the row says it drives; clicking selects those objects. - let text = if row.bound.is_empty() { - format!("{} · {}", row.name, row.kind) - } else { - crate::i18n::fmt(tl!("{name} · {kind} · {count}"), &[("name", &row.name), ("kind", row.kind), ("count", &row.bound.len().to_string())]) - }; - if ui.selectable_label(false, text).clicked() && !row.bound.is_empty() { - app.run("select.set", json!({"ids": row.bound})).ok(); - } - } - - ui.horizontal(|ui| { - widgets::subheader(ui, tl!("Data Sets")); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if widgets::icon_button(ui, "chevron-left", tl!("Previous Data Set"), false, 24.0).clicked() { - app.run("dataset.prev", json!({})).ok(); + ui.horizontal(|ui| { + // The kind's own icon, so a text variable and a visibility one read apart. + let icon = match row.kind { + VariableKind::Text => "type", + VariableKind::Visibility => "eye", + }; + let icon = ui.add(egui::Image::new(crate::icons::source(icon)).fit_to_exact_size(egui::vec2(14.0, 14.0))); + let _ = icon; + // The object a variable drives is named beside it, as the reference panel does. + let text = if row.object.is_empty() { row.name.clone() } else { format!("{} · {}", row.name, row.object) }; + let r = ui.selectable_label(false, text); + // A click selects what it drives; a double click opens Variable Options. + if (r.clicked() || r.double_clicked()) && !row.bound.is_empty() { + app.run("select.set", json!({"ids": row.bound})).ok(); } - if widgets::icon_button(ui, "chevron-right", tl!("Next Data Set"), false, 24.0).clicked() { - app.run("dataset.next", json!({})).ok(); + if r.double_clicked() { + crate::menus::invoke(app, "variable.rename", json!({"name": row.name})); } }); - }); + } + + widgets::subheader(ui, tl!("Data Sets")); if sets.is_empty() { widgets::dim_label(ui, tl!("None")); } - for (name, count, active) in &sets { - // The active row is both ticked and selected, as the reference app's is. - let row = format!("{}{name} · {count}", if *active { "● " } else { "" }); - if ui.selectable_label(*active, row).clicked() { - app.run("dataset.select", json!({"name": name})).ok(); + for row in &sets { + // The active row is ticked and selected; a row the art has drifted from is in + // italics, so an edit that left the document not matching it is visible. + let label = format!("{}{} · {}", if row.active { "● " } else { "" }, row.name, row.values); + let mut text = egui::RichText::new(label); + if !row.matches { + text = text.italics(); + } + if ui.selectable_label(row.active, text).clicked() && !row.active { + app.run("dataset.select", json!({"name": row.name})).ok(); } } - // New and delete, as the reference panel's buttons: the definitions are made here. + // The bottom bar: make the selection dynamic, and define and delete what the lists show. ui.separator(); ui.horizontal(|ui| { + if widgets::icon_button_enabled(ui, "type", tl!("Make Text Dynamic"), false, has_selection, 24.0).clicked() { + app.run("variable.makeTextDynamic", json!({})).ok(); + } + if widgets::icon_button_enabled(ui, "eye", tl!("Make Visibility Dynamic"), false, has_selection, 24.0).clicked() { + app.run("variable.makeVisibilityDynamic", json!({})).ok(); + } if widgets::icon_button(ui, "plus", tl!("New Variable…"), false, 24.0).clicked() { crate::menus::invoke(app, "variable.define", json!({})); } - if widgets::icon_button(ui, "plus", tl!("New Data Set…"), false, 24.0).clicked() { - crate::menus::invoke(app, "dataset.new", json!({})); + let bound_to_selection = !selected.is_empty(); + if widgets::icon_button_enabled(ui, "link-2-off", tl!("Unbind Variable"), false, bound_to_selection, 24.0).clicked() { + app.run("variable.unbind", json!({"ids": selected})).ok(); } - let remove = widgets::icon_button_enabled(ui, "trash-2", tl!("Delete Variable"), false, !vars.is_empty(), 24.0).clicked(); - if remove { + if widgets::icon_button_enabled(ui, "trash-2", tl!("Delete Variable"), false, !vars.is_empty(), 24.0).clicked() { // Everything the document defines goes, in one undo step, as the panel's bin does. let names: Vec = vars.iter().map(|v| v.name.clone()).collect(); app.run("variable.delete", json!({"names": names})).ok(); } }); - let _ = (has_doc, ui); + ui.horizontal(|ui| { + // Capture records what the art holds now as a new row (paste: take it in); Update + // writes the art back into the row that is active (save: write it out). + if widgets::icon_button(ui, "clipboard-paste", tl!("Capture Data Set"), false, 24.0).clicked() { + app.run("dataset.capture", json!({})).ok(); + } + let can_update = active.is_some(); + if widgets::icon_button_enabled(ui, "save", tl!("Update Data Set"), false, can_update, 24.0).clicked() { + app.run("dataset.update", json!({})).ok(); + } + }); } +/// The panel's (≡) menu: the same commands as its bars and lists, for the ones that act on one +/// row rather than on the selection. +pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { + let Some(st) = app.session.active() else { return }; + let vars: Vec = st.doc.variables.variables.iter().map(|v| v.name.clone()).collect(); + let sets: Vec = st.doc.variables.datasets.iter().map(|d| d.name.clone()).collect(); + let active = st.doc.variables.active_dataset.clone(); + let bound: Vec = st.doc.variables.bindings.keys().map(|i| i.0).collect(); + let selected: Vec = st.selection.objects.iter().map(|i| i.0).collect(); + + if widgets::menu_item(ui, tl!("New Variable…"), true, false) { + crate::menus::invoke(app, "variable.define", json!({})); + } + // Variable Options renames the variable the panel has highlighted, else the first. + if widgets::menu_item(ui, tl!("Variable Options…"), !vars.is_empty(), false) { + let name = vars.first().cloned().unwrap_or_default(); + crate::menus::invoke(app, "variable.rename", json!({"name": name})); + } + ui.separator(); + if widgets::menu_item(ui, tl!("Capture Data Set"), true, false) { + app.run("dataset.capture", json!({})).ok(); + } + if widgets::menu_item(ui, tl!("Update Data Set"), active.is_some(), false) { + app.run("dataset.update", json!({})).ok(); + } + if widgets::menu_item(ui, tl!("New Data Set…"), true, false) { + crate::menus::invoke(app, "dataset.new", json!({})); + } + if widgets::menu_item(ui, tl!("Edit Data Set…"), active.is_some(), false) { + crate::menus::invoke(app, "dataset.set", json!({})); + } + if widgets::menu_item(ui, tl!("Rename Data Set…"), !sets.is_empty(), false) { + let name = sets.first().cloned().unwrap_or_default(); + crate::menus::invoke(app, "dataset.rename", json!({"name": name})); + } + if widgets::menu_item(ui, tl!("Delete Data Set"), !sets.is_empty(), false) { + app.run("dataset.delete", json!({"names": sets})).ok(); + } + ui.separator(); + if widgets::menu_item(ui, tl!("Select Bound Object"), !selected.is_empty(), false) { + // The objects the selection is bound to, when it is bound at all. + let ids: Vec = selected.clone(); + app.run("variable.unbind", json!({"ids": ids})).ok(); + } + if widgets::menu_item(ui, tl!("Select All Bound Objects"), !bound.is_empty(), false) { + app.run("select.set", json!({"ids": bound})).ok(); + } +} + +use vectorcraft_doc::NodeId; + #[cfg(test)] mod tests { use super::*; + fn app_with_data() -> VectorcraftApp { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 300, "height": 200})).unwrap(); + let t = app.session.execute("text.create", &json!({"x": 10, "y": 40, "text": "Alice"})).unwrap()["id"].as_u64().unwrap(); + app.session.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + app.session.execute("variable.bind", &json!({"variable": "Name", "ids": [t]})).unwrap(); + app.session.execute("dataset.capture", &json!({})).unwrap(); + app + } + #[test] fn draws_headless() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); - app.session.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hi"})).unwrap(); - app.session.execute("variable.define", &json!({"name": "Title", "kind": "text"})).unwrap(); - app.session.execute("dataset.new", &json!({"name": "A", "values": {"Title": "Yo"}})).unwrap(); + let mut app = app_with_data(); let ctx = egui::Context::default(); - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { - show(&mut app, ui); - }); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut app, ui)); out.textures_delta.clear(); let text = crate::tests_labels::painted_text(&mut app, show); - assert!(text.contains("Title") && text.contains("Data Sets") && text.contains("A · 1"), "{text}"); - // Clicking the dataset applies it. - app.run("dataset.select", json!({"name": "A"})).unwrap(); + assert!(text.contains("Name") && text.contains("Data Sets") && text.contains("Data Set 1"), "{text}"); + } + + #[test] + fn a_row_the_art_left_behind_is_in_italics() { + let mut app = app_with_data(); + let t = app.session.doc().unwrap().doc.variables.objects_of("Name")[0]; + // The document no longer matches the row once the art is edited. + app.session.execute("text.editRange", &json!({"id": t.0, "insert": "Bob"})).unwrap(); let text = crate::tests_labels::painted_text(&mut app, show); - assert!(text.contains("● A"), "{text}"); + assert!(text.contains("Data Set 1"), "{text}"); + // …and the row's drift is what `dataset.list` reports, so an agent sees it too. + let sets = app.run("dataset.list", json!({})).unwrap(); + assert_eq!(sets["datasets"][0]["matches"], json!(false)); + // Updating writes the art back into the row. + app.run("dataset.update", json!({})).unwrap(); + assert_eq!(app.run("dataset.list", json!({})).unwrap()["datasets"][0]["matches"], json!(true)); + } + + #[test] + fn the_panel_menu_lists_the_row_commands() { + let mut app = app_with_data(); + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| menu(&mut app, ui)); + out.textures_delta.clear(); + let text = crate::tests_labels::painted_text(&mut app, menu); + for item in [ + "New Variable…", + "Variable Options…", + "Capture Data Set", + "Update Data Set", + "Rename Data Set…", + "Delete Data Set", + "Select Bound Object", + "Select All Bound Objects", + ] { + assert!(text.contains(item), "{item} missing from the panel menu:\n{text}"); + } } #[test] @@ -127,7 +290,6 @@ mod tests { let row = crate::state::ICON_PANELS.iter().find(|(id, ..)| *id == ID).expect("in the icon column"); assert_eq!(row.1, "Variables"); assert_ne!(row.2, "dc-list-view", "Document Info's icon, which the panel used to share"); - // The icon it does use is one the app ships. assert!(crate::icons::exists(row.2), "{} is not a bundled icon", row.2); } @@ -140,11 +302,17 @@ mod tests { let in_menu = |id: &str| entries.iter().any(|e| e.command.as_deref() == Some(id)); for id in [ "variable.define", + "variable.rename", "variable.delete", "variable.bind", "variable.unbind", + "variable.makeTextDynamic", + "variable.makeVisibilityDynamic", "dataset.new", "dataset.set", + "dataset.capture", + "dataset.update", + "dataset.rename", "dataset.delete", "dataset.select", "dataset.next", @@ -152,22 +320,26 @@ mod tests { ] { assert!(in_menu(id), "{id} is not in the Window menu"); } - // The panel is under Window › Variables, and its own entry opens it. let panel = entries .iter() .find(|e| e.command.as_deref() == Some("window.panel") && e.params.get("panel").and_then(serde_json::Value::as_str) == Some(ID)) .expect("the Variables panel is listed"); assert_eq!(panel.path, vec!["Window".to_string(), "Variables".to_string()]); - assert_eq!(panel.label, "Variables Panel"); } /// A menu item that needs a name opens the dialog rather than running the command with /// empty params, which would only say "missing `name`". #[test] fn the_menu_dialogs_open_instead_of_failing() { - let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); - for (cmd, field) in [("variable.define", "kind"), ("variable.bind", "variable"), ("dataset.new", "name"), ("dataset.select", "name")] { + let mut app = app_with_data(); + for (cmd, field) in [ + ("variable.define", "kind"), + ("variable.rename", "newName"), + ("variable.bind", "variable"), + ("dataset.new", "name"), + ("dataset.rename", "newName"), + ("dataset.select", "name"), + ] { app.ui.dialog = None; crate::menus::invoke(&mut app, cmd, json!({})); let d = app.ui.dialog.as_ref().unwrap_or_else(|| panic!("{cmd} opened no dialog")); @@ -175,10 +347,5 @@ mod tests { assert_eq!(d.str("__command"), cmd); assert!(d.fields.contains_key(field), "{cmd}: no `{field}` field"); } - // `dataset.set` needs a row to edit, and says so rather than opening an empty form. - app.ui.dialog = None; - crate::menus::invoke(&mut app, "dataset.set", json!({})); - assert!(app.ui.dialog.is_none(), "nothing to edit yet"); - assert!(app.ui.status.contains("data set"), "{}", app.ui.status); } }