variables: the panel does the workflow itself, and rows can be captured

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.
This commit is contained in:
Fahri Meral
2026-10-10 23:34:21 +07:00
committed by bflatastic
parent eb0f2ff2e4
commit 5a638b0a80
17 changed files with 775 additions and 69 deletions
+43 -1
View File
@@ -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<DataValue> {
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<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
}
/// 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))
}
}
+195 -2
View File
@@ -78,6 +78,74 @@ fn define(s: &mut Session, p: &Value) -> Result<Value> {
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);
}
}
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<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;
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::<Vec<_>>()}))
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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 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<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> {
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<Value> {
fn dataset_list(s: &mut Session, _p: &Value) -> Result<Value> {
let st = s.doc()?;
let datasets: Vec<Value> = st.doc.variables.datasets.iter().map(|d| json!({"name": d.name, "values": d.values})).collect();
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": 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<CommandSpec> {
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<CommandSpec> {
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",
+126
View File
@@ -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]
+43 -1
View File
@@ -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<Value, String> {
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!({}),
+8
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+14
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+14
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+14
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+14
View File
@@ -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 データセットの名前を変更
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+14
View File
@@ -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 Переименовать переменную
Can't render this file because it contains an unexpected character in line 2 and column 73.
+14
View File
@@ -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 Перейменувати змінну
Can't render this file because it contains an unexpected character in line 659 and column 40.
+14
View File
@@ -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 重命名变量
Can't render this file because it contains an unexpected character in line 2 and column 73.
+14
View File
@@ -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 重新命名變數
Can't render this file because it contains an unexpected character in line 6 and column 47.
+10 -3
View File
@@ -48,20 +48,27 @@ fn library_placeholders() -> Vec<Item> {
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<Item> {
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"),
+1
View File
@@ -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
+229 -62
View File
@@ -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<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,
}
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<u64> = 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<SetRow> = 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<u64> = 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<String> = 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<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 selected: Vec<u64> = 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<u64> = 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);
}
}