mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 02:37:01 -06:00
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:
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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!({}),
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user