Merge pull request #761 from fahrim/mcp-variables
Variables (data merge): text/visibility variables, datasets, panel
This commit is contained in:
commit
45516a6a96
|
|
@ -221,7 +221,7 @@ self-assessed, so the [honest assessment](ROADMAP.md#honest-assessment-2026-10-0
|
|||
- 3D and Materials;
|
||||
- the Photoshop-style raster effects (Effect Gallery);
|
||||
- CJK composition for vertical type (vertical type itself has initial support, with a Japanese interface);
|
||||
- Variables and scripting;
|
||||
- scripting, and the rest of Variables (image and graph kinds, dataset import);
|
||||
- an interaction-fidelity pass covering every tool's modifiers and small behaviours;
|
||||
- packaging for Windows and Linux.
|
||||
|
||||
|
|
|
|||
12
ROADMAP.md
12
ROADMAP.md
File diff suppressed because one or more lines are too long
|
|
@ -41,6 +41,7 @@ pub mod style_libs;
|
|||
pub mod swatches;
|
||||
pub mod text;
|
||||
pub mod trace;
|
||||
pub mod variables;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
|
@ -91,6 +92,7 @@ pub use text::{
|
|||
TextWrap, VerticalAlign, WrapShape,
|
||||
};
|
||||
pub use trace::TraceView;
|
||||
pub use variables::{DataSet, DataValue, Variable, VariableKind, Variables};
|
||||
pub use vectorcraft_color as color;
|
||||
pub use vectorcraft_geom as geom;
|
||||
|
||||
|
|
@ -561,6 +563,9 @@ pub struct Document {
|
|||
/// Select → Save Selection… (at most [`SavedSelection::MAX`]); saved with the document.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub saved_selections: Vec<SavedSelection>,
|
||||
/// Variables (data merge): named values bound to objects, applied per dataset.
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub variables: Variables,
|
||||
#[serde(default)]
|
||||
pub grid: GridPrefs,
|
||||
#[serde(default = "ppi72")]
|
||||
|
|
@ -693,6 +698,7 @@ impl Document {
|
|||
guides: vec![],
|
||||
views: vec![],
|
||||
saved_selections: vec![],
|
||||
variables: Variables::default(),
|
||||
grid: GridPrefs::default(),
|
||||
raster_effects_ppi: 72.0,
|
||||
images: BTreeMap::new(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,207 @@
|
|||
//! Variables (data merge): named values bound to objects, with datasets that apply
|
||||
//! them all at once. One template document produces many variants (badges, price
|
||||
//! lists, localized copies) by selecting a dataset instead of editing each object.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{Document, NodeId, NodeKind};
|
||||
|
||||
/// What a variable drives.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum VariableKind {
|
||||
/// A type object's characters.
|
||||
#[default]
|
||||
Text,
|
||||
/// Whether bound objects show.
|
||||
Visibility,
|
||||
}
|
||||
|
||||
impl VariableKind {
|
||||
/// Parse the `kind` of `variable.define`: `text` or `visibility` (`visible` is accepted
|
||||
/// as the same thing, as the panel and the docs call it both).
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"text" => Some(Self::Text),
|
||||
"visibility" | "visible" => Some(Self::Visibility),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The kind's own name, as `variable.define` takes it and `variable.list` reports it.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Text => "text",
|
||||
Self::Visibility => "visibility",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One named variable.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Variable {
|
||||
pub name: String,
|
||||
pub kind: VariableKind,
|
||||
}
|
||||
|
||||
/// One value a dataset gives a variable.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DataValue {
|
||||
Text(String),
|
||||
Visible(bool),
|
||||
}
|
||||
|
||||
impl DataValue {
|
||||
/// Whether this value fits the variable's kind (a dataset may carry values for
|
||||
/// variables of either kind; each binding takes what fits it).
|
||||
pub fn fits(&self, kind: VariableKind) -> bool {
|
||||
matches!((self, kind), (Self::Text(_), VariableKind::Text) | (Self::Visible(_), VariableKind::Visibility))
|
||||
}
|
||||
}
|
||||
|
||||
/// One named row of values, applied with `dataset.select`.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct DataSet {
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub values: BTreeMap<String, DataValue>,
|
||||
}
|
||||
|
||||
impl DataSet {
|
||||
/// Whether the art, as [`Document::captured_values`] read it, still holds what this row
|
||||
/// says. A value the art can't report (its object is locked or gone) isn't drift: the row
|
||||
/// keeps it, and applying it again would skip it too.
|
||||
pub fn matches(&self, current: &BTreeMap<String, DataValue>) -> bool {
|
||||
self.values.iter().all(|(k, v)| current.get(k).is_none_or(|c| c == v))
|
||||
}
|
||||
}
|
||||
|
||||
/// The variable state of a document: definitions, datasets, bindings
|
||||
/// (object id → the variables bound to it) and which dataset is active.
|
||||
///
|
||||
/// An object may be driven by several variables — the reference app binds the selected object
|
||||
/// to the selected variable, one click at a time, and never says a second binding replaces the
|
||||
/// first — so a binding is a set. `dataset.select` applies them in the order the variables are
|
||||
/// defined, which is what makes two text variables on one object deterministic: the last one
|
||||
/// wins, and `applied` counts both.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Variables {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub variables: Vec<Variable>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub datasets: Vec<DataSet>,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub bindings: BTreeMap<NodeId, BTreeSet<String>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub active_dataset: Option<String>,
|
||||
}
|
||||
|
||||
impl Variables {
|
||||
/// The variable `name`, if the document defines it.
|
||||
pub fn variable(&self, name: &str) -> Option<&Variable> {
|
||||
self.variables.iter().find(|v| v.name == name)
|
||||
}
|
||||
|
||||
/// The dataset `name`, if the document has it.
|
||||
pub fn dataset(&self, name: &str) -> Option<&DataSet> {
|
||||
self.datasets.iter().find(|d| d.name == name)
|
||||
}
|
||||
|
||||
/// Bind `id` to the variable `name`. True when it was not bound to it already.
|
||||
pub fn bind(&mut self, id: NodeId, name: &str) -> bool {
|
||||
self.bindings.entry(id).or_default().insert(name.to_string())
|
||||
}
|
||||
|
||||
/// Drop `id`'s binding to `name` (and the binding itself when that was its last variable).
|
||||
/// True when there was one.
|
||||
pub fn unbind(&mut self, id: NodeId, name: &str) -> bool {
|
||||
let Some(bound) = self.bindings.get_mut(&id) else { return false };
|
||||
let dropped = bound.remove(name);
|
||||
if bound.is_empty() {
|
||||
self.bindings.remove(&id);
|
||||
}
|
||||
dropped
|
||||
}
|
||||
|
||||
/// The objects bound to the variable `name`, in document order.
|
||||
pub fn objects_of(&self, name: &str) -> Vec<NodeId> {
|
||||
let mut ids: Vec<NodeId> = self.bindings.iter().filter(|(_, v)| v.contains(name)).map(|(id, _)| *id).collect();
|
||||
ids.sort_unstable();
|
||||
ids
|
||||
}
|
||||
|
||||
/// Drop the variables `names`, the dataset values that name them and the bindings to
|
||||
/// them. Datasets named here are left alone: their names are their own namespace (the
|
||||
/// active one is dropped by whoever deletes the dataset).
|
||||
pub fn prune(&mut self, names: &[String]) -> usize {
|
||||
// A set, so a long list of names costs a lookup per value rather than a scan.
|
||||
let names: BTreeSet<&str> = names.iter().map(String::as_str).collect();
|
||||
let before = self.variables.len();
|
||||
self.variables.retain(|v| !names.contains(v.name.as_str()));
|
||||
for dataset in &mut self.datasets {
|
||||
dataset.values.retain(|k, _| !names.contains(k.as_str()));
|
||||
}
|
||||
for bound in self.bindings.values_mut() {
|
||||
bound.retain(|v| !names.contains(v.as_str()));
|
||||
}
|
||||
self.bindings.retain(|_, v| !v.is_empty());
|
||||
before - self.variables.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Forget the bindings of objects that are no longer in the document (after every edit),
|
||||
/// like [`Document::prune_assets`] does for Asset Export's. A binding outliving its object
|
||||
/// would count as a skip in every dataset application and grow the saved file forever.
|
||||
pub fn prune_variable_bindings(&mut self) {
|
||||
if self.variables.bindings.keys().all(|id| self.node(*id).is_some()) {
|
||||
return;
|
||||
}
|
||||
let mut bindings = std::mem::take(&mut self.variables.bindings);
|
||||
bindings.retain(|id, _| self.node(*id).is_some());
|
||||
self.variables.bindings = bindings;
|
||||
}
|
||||
|
||||
/// Whether a variable may read and rewrite the object `id`: it is in the document, neither
|
||||
/// it nor anything above it is locked, and nothing above it is hidden. Its own visibility
|
||||
/// doesn't count, as that is what a visibility variable drives: a row that hid it must be
|
||||
/// able to show it again, and a capture must record it hidden.
|
||||
pub fn variable_target(&self, id: NodeId) -> bool {
|
||||
self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| !n.locked && (n.visible || *i == id))))
|
||||
}
|
||||
|
||||
/// What the object `id` holds for the variable of `kind`: its characters, or whether it
|
||||
/// shows. `None` when the object is gone, is not of that kind, or is locked or on a hidden
|
||||
/// or locked layer (the document's value is whatever it was left at).
|
||||
pub fn variable_value(&self, id: NodeId, kind: VariableKind) -> Option<DataValue> {
|
||||
if !self.variable_target(id) {
|
||||
return None;
|
||||
}
|
||||
let node = self.node(id)?;
|
||||
match (kind, &node.kind) {
|
||||
(VariableKind::Text, NodeKind::Text(t)) => Some(DataValue::Text(t.plain_text())),
|
||||
(VariableKind::Visibility, _) => Some(DataValue::Visible(node.visible)),
|
||||
(VariableKind::Text, _) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The values a dataset captured right now: what each bound object holds, in the order the
|
||||
/// variables are defined. `dataset.capture` writes these into a new row and
|
||||
/// `dataset.update` into the active one; the Variables panel compares the active row with
|
||||
/// them to tell whether the art still matches what it says.
|
||||
pub fn captured_values(&self) -> BTreeMap<String, DataValue> {
|
||||
let mut out = BTreeMap::new();
|
||||
for v in &self.variables.variables {
|
||||
// An object driven by several variables of the same kind reads from the first one
|
||||
// bound, so a capture is one value per variable and applies back the same way.
|
||||
let value = self.variables.objects_of(&v.name).into_iter().find_map(|id| self.variable_value(id, v.kind));
|
||||
if let Some(value) = value {
|
||||
out.insert(v.name.clone(), value);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
|
@ -77,6 +77,7 @@ pub(crate) mod textwrap;
|
|||
pub(crate) mod threads;
|
||||
pub(crate) mod typecmd;
|
||||
mod typemenu;
|
||||
mod variablecmds;
|
||||
pub(crate) mod views;
|
||||
pub mod wand;
|
||||
pub mod webexport;
|
||||
|
|
@ -235,6 +236,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
|||
v.extend(maskedit::specs());
|
||||
v.extend(tabs::specs());
|
||||
v.extend(docmenu::specs());
|
||||
v.extend(variablecmds::specs());
|
||||
v.extend(docinfo::specs());
|
||||
v.extend(panelcmds::specs());
|
||||
v.extend(buildcmds::specs());
|
||||
|
|
|
|||
|
|
@ -112,6 +112,23 @@ fn text_ref(s: &Session, id: NodeId) -> Result<TextObject> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Replace a type object's whole content: the new text takes the replaced text's
|
||||
/// style and paragraph styles follow, exactly as `text.editRange` over the full
|
||||
/// range does. Dataset apply and friends share this so Variables edits behave
|
||||
/// like Type tool edits.
|
||||
pub(crate) fn set_plain_text(d: &mut vectorcraft_doc::Document, id: NodeId, text: &str) -> Result<()> {
|
||||
let len = match d.node(id).map(|n| &n.kind) {
|
||||
Some(NodeKind::Text(t)) => edit::runs_len(&t.runs),
|
||||
Some(_) => return Err(EngineError::Other(format!("node {} is not text", id.0))),
|
||||
None => return Err(EngineError::NoNode(id)),
|
||||
};
|
||||
let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?;
|
||||
t.splice_paras(0, len, text);
|
||||
edit::replace_range(&mut t.runs, 0, len, text);
|
||||
refresh_bounds(t);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn byte_param(p: &Value, k: &str) -> Option<usize> {
|
||||
p.get(k).and_then(Value::as_u64).map(|v| v as usize)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,590 @@
|
|||
//! Variables (data merge): named values bound to objects, applied per dataset.
|
||||
//!
|
||||
//! One template document produces many variants: bind a type object to a `text`
|
||||
//! variable and anything to a `visibility` variable, put a row of values in each
|
||||
//! dataset, and `dataset.select` swaps the whole document to that row in one undo
|
||||
//! step. Agents reach every command through `run_command`, like all engine commands.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{DataSet, DataValue, NodeId, NodeKind, Variable, VariableKind, Variables};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::textedit::set_plain_text;
|
||||
use super::*;
|
||||
|
||||
/// The longest variable or dataset name, in characters (names arrive from MCP and files).
|
||||
const MAX_NAME: usize = 255;
|
||||
|
||||
fn var_name(p: &Value, cmd: &str) -> Result<String> {
|
||||
named(str_param(p, "name"), "name", cmd)
|
||||
}
|
||||
|
||||
/// `key`'s value, as a name: trimmed, not empty and at most [`MAX_NAME`] characters.
|
||||
fn named(v: Option<&str>, key: &str, cmd: &str) -> Result<String> {
|
||||
match v.map(str::trim).filter(|v| !v.is_empty()) {
|
||||
Some(v) if v.chars().count() > MAX_NAME => Err(bad(cmd, format!("`{key}` is longer than {MAX_NAME} characters"))),
|
||||
Some(v) => Ok(v.to_string()),
|
||||
None => Err(bad(cmd, format!("missing `{key}`"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// `key`'s value as a name, or `None` when it is missing or empty.
|
||||
fn optional_name(p: &Value, key: &str, cmd: &str) -> Result<Option<String>> {
|
||||
match str_param(p, key).map(str::trim).filter(|v| !v.is_empty()) {
|
||||
Some(v) => named(Some(v), key, cmd).map(Some),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// The `names` of a delete, or without them `fallback` (what the panel acts on) as the one name.
|
||||
fn names_param(p: &Value, cmd: &str, fallback: Option<String>, nothing: &str) -> Result<Vec<String>> {
|
||||
let Some(given) = p.get("names") else {
|
||||
return fallback.map(|n| vec![n]).ok_or_else(|| bad(cmd, nothing.to_string()));
|
||||
};
|
||||
match given.as_array() {
|
||||
Some(names) if !names.is_empty() => names.iter().map(|n| named(n.as_str(), "names", cmd)).collect(),
|
||||
_ => Err(bad(cmd, "`names` must be a list of names")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The objects a bind acts on: `ids` when given (every one checked), else the selection.
|
||||
fn target_ids(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
|
||||
if p.get("ids").is_some() { checked_ids_param(s, p, "ids", cmd) } else { selected_roots(s) }
|
||||
}
|
||||
|
||||
/// Refuse what a variable of `kind` can't drive: art that is locked or on a hidden or locked
|
||||
/// layer is not the document's to rewrite, and a text variable only drives type.
|
||||
fn check_bindable(doc: &vectorcraft_doc::Document, ids: &[NodeId], kind: VariableKind, cmd: &str) -> Result<()> {
|
||||
for id in ids {
|
||||
match doc.node(*id) {
|
||||
None => return Err(EngineError::NoNode(*id)),
|
||||
Some(_) if !doc.variable_target(*id) => return Err(bad(cmd, format!("node {} is locked", id.0))),
|
||||
Some(n) if kind == VariableKind::Text && !matches!(n.kind, NodeKind::Text(_)) => {
|
||||
return Err(bad(cmd, format!("node {} is not type", id.0)));
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn dataset_value(cmd: &str, var: &str, kind: VariableKind, v: &Value) -> Result<DataValue> {
|
||||
match kind {
|
||||
VariableKind::Text => v.as_str().map(|t| DataValue::Text(t.to_string())),
|
||||
VariableKind::Visibility => v.as_bool().map(DataValue::Visible),
|
||||
}
|
||||
.ok_or_else(|| bad(cmd, format!("value for `{var}` must match its variable kind")))
|
||||
}
|
||||
|
||||
/// The `values` object of `dataset.new` / `dataset.set`, each entry checked against its
|
||||
/// variable's kind. Every name must be a variable the document defines: a value nothing
|
||||
/// reads is a typo, not a value to keep.
|
||||
fn values_param(vars: &Variables, p: &Value, cmd: &str) -> Result<BTreeMap<String, DataValue>> {
|
||||
let mut out = BTreeMap::new();
|
||||
let Some(given) = p.get("values") else { return Ok(out) };
|
||||
let Some(obj) = given.as_object() else { return Err(bad(cmd, "`values` must be an object")) };
|
||||
for (var, v) in obj {
|
||||
let kind = vars.variable(var).map(|d| d.kind).ok_or_else(|| bad(cmd, format!("no variable named `{var}`")))?;
|
||||
out.insert(var.clone(), dataset_value(cmd, var, kind, v)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn define(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "variable.define";
|
||||
let name = var_name(p, C)?;
|
||||
let kind = str_param(p, "kind").and_then(VariableKind::parse).ok_or_else(|| bad(C, "`kind` must be `text` or `visibility`"))?;
|
||||
if s.doc()?.doc.variables.variable(&name).is_some() {
|
||||
return Err(bad(C, format!("variable `{name}` is already defined")));
|
||||
}
|
||||
s.edit("New Variable", |d, _| {
|
||||
d.variables.variables.push(Variable { name: name.clone(), kind });
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"name": name, "kind": kind.label()}))
|
||||
}
|
||||
|
||||
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "variable.rename";
|
||||
let from = var_name(p, C)?;
|
||||
let to = named(p.get("newName").and_then(Value::as_str), "newName", C)?;
|
||||
let st = s.doc()?;
|
||||
if st.doc.variables.variable(&to).is_some() {
|
||||
return Err(bad(C, format!("variable `{to}` already exists")));
|
||||
}
|
||||
s.edit("Rename Variable", |d, _| {
|
||||
let v = d.variables.variables.iter_mut().find(|v| v.name == from).ok_or_else(|| bad(C, format!("no variable named `{from}`")))?;
|
||||
v.name = to.clone();
|
||||
for bound in d.variables.bindings.values_mut() {
|
||||
if bound.remove(&from) {
|
||||
bound.insert(to.clone());
|
||||
}
|
||||
}
|
||||
for ds in &mut d.variables.datasets {
|
||||
if let Some(value) = ds.values.remove(&from) {
|
||||
ds.values.insert(to.clone(), value);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
// The highlight names a variable, so it follows the rename rather than going stale.
|
||||
let st = s.doc_mut()?;
|
||||
if st.variables_highlight.as_deref() == Some(from.as_str()) {
|
||||
st.variables_highlight = Some(to.clone());
|
||||
}
|
||||
st.revision += 1;
|
||||
Ok(json!({"from": from, "name": to}))
|
||||
}
|
||||
|
||||
/// Make Text Dynamic / Make Visibility Dynamic: define a variable of that kind and bind the
|
||||
/// selection to it in one step, naming it after what it drives — the panel's buttons.
|
||||
fn make_dynamic(s: &mut Session, kind: VariableKind, stem: &str) -> Result<Value> {
|
||||
const C: &str = "variable.makeDynamic";
|
||||
let ids = selected_roots(s)?;
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "select the objects to bind"));
|
||||
}
|
||||
let doc = &s.doc()?.doc;
|
||||
check_bindable(doc, &ids, kind, C)?;
|
||||
// Named after the first object it drives, as the reference panel does; a second variable
|
||||
// over the same art takes a number rather than colliding.
|
||||
let shown = ids.first().and_then(|id| doc.node(*id)).map(|n| n.display_name()).unwrap_or_default();
|
||||
let base: String = match shown.trim() {
|
||||
"" => stem.to_string(),
|
||||
b => b.chars().take(MAX_NAME - 8).collect(),
|
||||
};
|
||||
let mut name = base.clone();
|
||||
let mut n = 2;
|
||||
while doc.variables.variable(&name).is_some() {
|
||||
name = format!("{base} {n}");
|
||||
n += 1;
|
||||
}
|
||||
s.edit("Make Dynamic", |d, _| {
|
||||
d.variables.variables.push(Variable { name: name.clone(), kind });
|
||||
for id in &ids {
|
||||
d.variables.bind(*id, &name);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"name": name, "kind": kind.label(), "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Without `names`, the highlighted variable: what the panel's bin and the menu item delete.
|
||||
let highlighted = s.doc()?.variables_highlight.clone();
|
||||
let names = names_param(p, "variable.delete", highlighted, "no variable highlighted")?;
|
||||
let n = s.edit("Delete Variables", |d, _| Ok(d.variables.prune(&names)))?;
|
||||
// A highlight of a variable that is gone highlights nothing.
|
||||
let st = s.doc_mut()?;
|
||||
if st.variables_highlight.as_ref().is_some_and(|h| st.doc.variables.variable(h).is_none()) {
|
||||
st.variables_highlight = None;
|
||||
}
|
||||
st.revision += 1;
|
||||
Ok(json!({"deleted": n}))
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let variables: Vec<Value> = st
|
||||
.doc
|
||||
.variables
|
||||
.variables
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let bindings = st.doc.variables.objects_of(&v.name).len();
|
||||
json!({"name": v.name, "kind": v.kind.label(), "bindings": bindings})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({"variables": variables}))
|
||||
}
|
||||
|
||||
fn bind(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "variable.bind";
|
||||
let name = named(str_param(p, "variable"), "variable", C)?;
|
||||
let ids = target_ids(s, p, C)?;
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "nothing to bind"));
|
||||
}
|
||||
let doc = &s.doc()?.doc;
|
||||
let kind = doc.variables.variable(&name).map(|v| v.kind).ok_or_else(|| bad(C, format!("no variable named `{name}`")))?;
|
||||
check_bindable(doc, &ids, kind, C)?;
|
||||
// Binding again is not a second binding, and says so rather than counting as a change.
|
||||
let already: Vec<u64> = ids.iter().filter(|id| doc.variables.objects_of(&name).contains(id)).map(|id| id.0).collect();
|
||||
let n = s.edit("Bind Variable", |d, _| Ok(ids.iter().filter(|id| d.variables.bind(**id, &name)).count()))?;
|
||||
Ok(json!({"variable": name, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "bound": n, "already": already}))
|
||||
}
|
||||
|
||||
fn unbind(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "variable.unbind";
|
||||
let ids = target_ids(s, p, C)?;
|
||||
let name = defined_variable(s, p, C)?;
|
||||
let n = s.edit("Unbind Variable", |d, _| {
|
||||
let mut n = 0;
|
||||
for id in &ids {
|
||||
// With `variable` only that one binding goes; without it, every one the object has.
|
||||
match &name {
|
||||
Some(v) => n += usize::from(d.variables.unbind(*id, v)),
|
||||
None => n += usize::from(d.variables.bindings.remove(id).is_some()),
|
||||
}
|
||||
}
|
||||
Ok(n)
|
||||
})?;
|
||||
Ok(json!({"unbound": n}))
|
||||
}
|
||||
|
||||
/// The `variable` a command names, which must be defined; `None` when it names none.
|
||||
fn defined_variable(s: &Session, p: &Value, cmd: &str) -> Result<Option<String>> {
|
||||
let name = optional_name(p, "variable", cmd)?;
|
||||
if let Some(v) = &name
|
||||
&& s.doc()?.doc.variables.variable(v).is_none()
|
||||
{
|
||||
return Err(bad(cmd, format!("no variable named `{v}`")));
|
||||
}
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
/// Highlight one Variables panel row: what the panel's Delete, Options… and Select Bound
|
||||
/// Object act on. Panel state, not art selection: not saved, not an undo step (an empty
|
||||
/// or missing `variable` clears it), like `layer.highlight`.
|
||||
fn highlight(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = defined_variable(s, p, "variable.highlight")?;
|
||||
let st = s.doc_mut()?;
|
||||
st.variables_highlight = name.clone();
|
||||
st.revision += 1;
|
||||
Ok(json!({"variable": name}))
|
||||
}
|
||||
|
||||
fn dataset_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "dataset.new";
|
||||
let name = var_name(p, C)?;
|
||||
let st = s.doc()?;
|
||||
// A dataset and a variable are separate namespaces, so a dataset may share a variable's
|
||||
// name; it is its own row of values either way.
|
||||
let values = values_param(&st.doc.variables, p, C)?;
|
||||
if st.doc.variables.dataset(&name).is_some() {
|
||||
return Err(bad(C, format!("dataset `{name}` already exists")));
|
||||
}
|
||||
s.edit("New Data Set", |d, _| {
|
||||
d.variables.datasets.push(DataSet { name: name.clone(), values: values.clone() });
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"name": name}))
|
||||
}
|
||||
|
||||
/// Replace a dataset's values. A variable left out of `values` loses its value in this row,
|
||||
/// so the dataset ends up holding exactly what the call asked for.
|
||||
fn dataset_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "dataset.set";
|
||||
let name = var_name(p, C)?;
|
||||
let values = values_param(&s.doc()?.doc.variables, p, C)?;
|
||||
let (set, removed) = s.edit("Edit Data Set", |d, _| {
|
||||
let ds = d.variables.datasets.iter_mut().find(|x| x.name == name).ok_or_else(|| bad(C, format!("no dataset named `{name}`")))?;
|
||||
let removed: Vec<String> = ds.values.keys().filter(|k| !values.contains_key(*k)).cloned().collect();
|
||||
ds.values = values.clone();
|
||||
Ok((values.len(), removed))
|
||||
})?;
|
||||
Ok(json!({"name": name, "values": set, "removed": removed}))
|
||||
}
|
||||
|
||||
fn dataset_rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "dataset.rename";
|
||||
let from = var_name(p, C)?;
|
||||
let to = named(p.get("newName").and_then(Value::as_str), "newName", C)?;
|
||||
let st = s.doc()?;
|
||||
if st.doc.variables.dataset(&to).is_some() {
|
||||
return Err(bad(C, format!("dataset `{to}` already exists")));
|
||||
}
|
||||
s.edit("Rename Data Set", |d, _| {
|
||||
let ds = d.variables.datasets.iter_mut().find(|d| d.name == from).ok_or_else(|| bad(C, format!("no dataset named `{from}`")))?;
|
||||
ds.name = to.clone();
|
||||
if d.variables.active_dataset.as_deref() == Some(from.as_str()) {
|
||||
d.variables.active_dataset = Some(to.clone());
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"from": from, "name": to}))
|
||||
}
|
||||
|
||||
/// Capture Data Set: record what the bound objects hold right now as a new row. This, rather
|
||||
/// than typing values, is how rows are made — change the art, then capture it.
|
||||
fn dataset_capture(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "dataset.capture";
|
||||
let st = s.doc()?;
|
||||
let values = st.doc.captured_values();
|
||||
if values.is_empty() {
|
||||
return Err(bad(C, "bind something to a variable first"));
|
||||
}
|
||||
let name = match optional_name(p, "name", C)? {
|
||||
Some(n) => n,
|
||||
// "Data Set 1", the first free number, as the reference panel's rows are named.
|
||||
None => (1..).map(|i| format!("Data Set {i}")).find(|n| st.doc.variables.dataset(n).is_none()).unwrap_or_default(),
|
||||
};
|
||||
if s.doc()?.doc.variables.dataset(&name).is_some() {
|
||||
return Err(bad(C, format!("dataset `{name}` already exists")));
|
||||
}
|
||||
s.edit("Capture Data Set", |d, _| {
|
||||
d.variables.datasets.push(DataSet { name: name.clone(), values: values.clone() });
|
||||
d.variables.active_dataset = Some(name.clone());
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"name": name, "values": values.len()}))
|
||||
}
|
||||
|
||||
/// Update Data Set: write what the bound objects hold now into the row that is active. Without
|
||||
/// an active row there is nothing to update, so say so rather than picking one.
|
||||
fn dataset_update(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
const C: &str = "dataset.update";
|
||||
let values = s.doc()?.doc.captured_values();
|
||||
let Some(name) = s.doc()?.doc.variables.active_dataset.clone() else {
|
||||
return Err(bad(C, "no active data set"));
|
||||
};
|
||||
if values.is_empty() {
|
||||
return Err(bad(C, "bind something to a variable first"));
|
||||
}
|
||||
s.edit("Update Data Set", |d, _| {
|
||||
let ds = d.variables.datasets.iter_mut().find(|d| d.name == name).ok_or_else(|| bad(C, format!("no dataset named `{name}`")))?;
|
||||
ds.values = values.clone();
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"name": name, "values": values.len()}))
|
||||
}
|
||||
|
||||
fn dataset_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Without `names`, the active dataset: what the panel's Delete Data Set deletes.
|
||||
let active = s.doc()?.doc.variables.active_dataset.clone();
|
||||
let names = names_param(p, "dataset.delete", active, "no active data set")?;
|
||||
let n = s.edit("Delete Data Sets", |d, _| {
|
||||
let before = d.variables.datasets.len();
|
||||
d.variables.datasets.retain(|d| !names.contains(&d.name));
|
||||
// The active dataset's own name is the one that goes, not a variable's.
|
||||
if d.variables.active_dataset.as_ref().is_some_and(|a| names.contains(a)) {
|
||||
d.variables.active_dataset = None;
|
||||
}
|
||||
Ok(before - d.variables.datasets.len())
|
||||
})?;
|
||||
Ok(json!({"deleted": n}))
|
||||
}
|
||||
|
||||
fn dataset_list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let current = st.doc.captured_values();
|
||||
let datasets: Vec<Value> = st
|
||||
.doc
|
||||
.variables
|
||||
.datasets
|
||||
.iter()
|
||||
// `matches` says whether the art still holds what the row says, which is what the
|
||||
// panel shows in italics.
|
||||
.map(|d| json!({"name": d.name, "values": d.values, "matches": d.matches(¤t)}))
|
||||
.collect();
|
||||
Ok(json!({"datasets": datasets, "active": st.doc.variables.active_dataset}))
|
||||
}
|
||||
|
||||
fn apply_dataset(s: &mut Session, name: &str) -> Result<Value> {
|
||||
const C: &str = "dataset.select";
|
||||
let (applied, skipped) = s.edit("Apply Data Set", |d, _| {
|
||||
let ds = d.variables.dataset(name).cloned().ok_or_else(|| bad(C, format!("no dataset named `{name}`")))?;
|
||||
// One object may be driven by several variables; walk them in definition order, so two
|
||||
// text variables on the same object land the later one's value and both are counted.
|
||||
let mut pairs: Vec<(NodeId, String)> = Vec::new();
|
||||
for v in &d.variables.variables {
|
||||
pairs.extend(d.variables.objects_of(&v.name).into_iter().map(|id| (id, v.name.clone())));
|
||||
}
|
||||
let mut applied = 0usize;
|
||||
let mut skipped = 0usize;
|
||||
for (id, var) in pairs {
|
||||
// A binding whose variable is gone, whose value this row doesn't carry, or whose
|
||||
// value doesn't fit the variable's kind, is left alone and counted, never
|
||||
// silently reinterpreted.
|
||||
let (kind, value) = match (d.variables.variable(&var).map(|v| v.kind), ds.values.get(&var)) {
|
||||
(Some(kind), Some(value)) if value.fits(kind) => (kind, value.clone()),
|
||||
_ => {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// Locked art, art on a locked or hidden layer, and text whose object is gone, can't
|
||||
// be rewritten: skipped and counted, like the rest. A hidden object is rewritten: a
|
||||
// row that hid it shows it again.
|
||||
if !d.variable_target(id) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
let done = match (kind, &value) {
|
||||
// The Type tool's own whole-range replace, so a dataset's text is styled and
|
||||
// laid out exactly as typing it would be.
|
||||
(VariableKind::Text, DataValue::Text(text)) => set_plain_text(d, id, text).is_ok(),
|
||||
(VariableKind::Visibility, DataValue::Visible(show)) => d.node_mut(id).is_some_and(|n| {
|
||||
n.visible = *show;
|
||||
true
|
||||
}),
|
||||
_ => false,
|
||||
};
|
||||
if done {
|
||||
applied += 1;
|
||||
} else {
|
||||
skipped += 1;
|
||||
}
|
||||
}
|
||||
d.variables.active_dataset = Some(name.to_string());
|
||||
Ok((applied, skipped))
|
||||
})?;
|
||||
Ok(json!({"name": name, "applied": applied, "skipped": skipped}))
|
||||
}
|
||||
|
||||
fn dataset_select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
apply_dataset(s, &var_name(p, "dataset.select")?)
|
||||
}
|
||||
|
||||
fn dataset_step(s: &mut Session, cmd: &str, forward: bool) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let n = st.doc.variables.datasets.len();
|
||||
if n == 0 {
|
||||
return Err(bad(cmd, "no datasets"));
|
||||
}
|
||||
let at = st.doc.variables.active_dataset.as_deref().and_then(|a| st.doc.variables.datasets.iter().position(|d| d.name == a));
|
||||
let next = match (at, forward) {
|
||||
(Some(i), true) => (i + 1) % n,
|
||||
(Some(i), false) => (i + n - 1) % n,
|
||||
(None, true) => 0,
|
||||
(None, false) => n - 1,
|
||||
};
|
||||
let name = st.doc.variables.datasets.get(next).map(|d| d.name.clone()).ok_or_else(|| bad(cmd, "no datasets"))?;
|
||||
apply_dataset(s, &name)
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("variable.define", "New Variable…", ["Window", "Variables"], None, "{name, kind: text|visibility} define a variable", has_doc, define),
|
||||
cmd!(
|
||||
"variable.delete",
|
||||
"Delete Variable",
|
||||
["Window", "Variables"],
|
||||
None,
|
||||
"{names?} delete variables with their bindings and values (default: the highlighted one)",
|
||||
has_doc,
|
||||
delete
|
||||
),
|
||||
cmd!(
|
||||
"variable.rename",
|
||||
"Rename Variable",
|
||||
["Window", "Variables"],
|
||||
None,
|
||||
"{name, newName} rename a variable, with its bindings and its values in every dataset",
|
||||
has_doc,
|
||||
rename
|
||||
),
|
||||
cmd!(query "variable.list", "List Variables", [], None, "{} → variables with kinds and binding counts", has_doc, list),
|
||||
cmd!(
|
||||
"variable.makeTextDynamic",
|
||||
"Make Text Dynamic",
|
||||
["Window", "Variables"],
|
||||
None,
|
||||
"{} define a text variable over the selection, named after it and bound to it",
|
||||
has_selection,
|
||||
|s, _| make_dynamic(s, VariableKind::Text, "Text")
|
||||
),
|
||||
cmd!(
|
||||
"variable.makeVisibilityDynamic",
|
||||
"Make Visibility Dynamic",
|
||||
["Window", "Variables"],
|
||||
None,
|
||||
"{} define a visibility variable over the selection, named after it and bound to it",
|
||||
has_selection,
|
||||
|s, _| make_dynamic(s, VariableKind::Visibility, "Visibility")
|
||||
),
|
||||
cmd!(
|
||||
"variable.bind",
|
||||
"Bind Variable",
|
||||
["Window", "Variables"],
|
||||
None,
|
||||
"{variable, ids?} bind objects (default: the selection) to a variable; text needs type, locked art is refused. An object may hold several variables, and binding it to one it already has changes nothing → {bound, already}",
|
||||
has_doc,
|
||||
bind
|
||||
),
|
||||
cmd!(
|
||||
"variable.unbind",
|
||||
"Unbind Variable",
|
||||
["Window", "Variables"],
|
||||
None,
|
||||
"{ids?, variable?} drop bindings (default: the selection): `variable` drops that one, else every variable the object has",
|
||||
has_doc,
|
||||
unbind
|
||||
),
|
||||
cmd!(
|
||||
"variable.highlight",
|
||||
"Highlight Variable",
|
||||
[],
|
||||
None,
|
||||
"{variable?} highlight one Variables panel row (a click); what the panel's Delete, Options… and Select Bound Object act on. An empty or missing `variable` clears it. Not an undo step → {variable}",
|
||||
has_doc,
|
||||
highlight
|
||||
),
|
||||
cmd!("dataset.new", "New Data Set…", ["Window", "Variables"], None, "{name, values?: {variable: value}} add a dataset", has_doc, dataset_new),
|
||||
cmd!(
|
||||
"dataset.set",
|
||||
"Edit Data Set…",
|
||||
["Window", "Variables"],
|
||||
None,
|
||||
"{name, values?: {variable: value}} replace a dataset's values; a variable left out loses its value in this row → {values, removed}",
|
||||
has_doc,
|
||||
dataset_set
|
||||
),
|
||||
cmd!("dataset.rename", "Rename Data Set", ["Window", "Variables"], None, "{name, newName} rename a dataset", has_doc, dataset_rename),
|
||||
cmd!(
|
||||
"dataset.capture",
|
||||
"Capture Data Set",
|
||||
["Window", "Variables"],
|
||||
None,
|
||||
"{name?} record what the bound objects hold right now as a new dataset (Data Set 1…), and make it the active one",
|
||||
has_doc,
|
||||
dataset_capture
|
||||
),
|
||||
cmd!(
|
||||
"dataset.update",
|
||||
"Update Data Set",
|
||||
["Window", "Variables"],
|
||||
None,
|
||||
"{} write what the bound objects hold now into the active dataset",
|
||||
has_doc,
|
||||
dataset_update
|
||||
),
|
||||
cmd!(
|
||||
"dataset.delete",
|
||||
"Delete Data Set",
|
||||
["Window", "Variables"],
|
||||
None,
|
||||
"{names?} delete datasets (default: the active one)",
|
||||
has_doc,
|
||||
dataset_delete
|
||||
),
|
||||
cmd!(
|
||||
query "dataset.list",
|
||||
"List Data Sets",
|
||||
[],
|
||||
None,
|
||||
"{} → datasets with their values, the active one, and which rows the art still matches",
|
||||
has_doc,
|
||||
dataset_list
|
||||
),
|
||||
cmd!(
|
||||
"dataset.select",
|
||||
"Select Data Set",
|
||||
["Window", "Variables"],
|
||||
None,
|
||||
"{name} apply a dataset in one undo step → {applied, skipped}",
|
||||
has_doc,
|
||||
dataset_select
|
||||
),
|
||||
cmd!("dataset.next", "Next Data Set", ["Window", "Variables"], None, "{} apply the next dataset, wrapping", has_doc, |s, _| dataset_step(
|
||||
s,
|
||||
"dataset.next",
|
||||
true
|
||||
)),
|
||||
cmd!("dataset.prev", "Previous Data Set", ["Window", "Variables"], None, "{} apply the previous dataset, wrapping", has_doc, |s, _| {
|
||||
dataset_step(s, "dataset.prev", false)
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
|
@ -86,6 +86,7 @@ pub struct Interaction {
|
|||
/// isolation).
|
||||
pub active_layer: Option<NodeId>,
|
||||
pub layer_rows: Vec<NodeId>,
|
||||
pub variables_highlight: Option<String>,
|
||||
pub isolation: Option<NodeId>,
|
||||
/// The perspective transform the previews make (`perspective.transform` params): Transform
|
||||
/// Again repeats it once the drag is committed.
|
||||
|
|
@ -121,6 +122,10 @@ pub struct DocState {
|
|||
/// Panel state, not art selection: not saved, not undoable (`layer.setCurrent`,
|
||||
/// `layer.highlight`).
|
||||
pub layer_rows: Vec<NodeId>,
|
||||
/// The variable highlighted in the Variables panel: what its Delete, Options… and Select
|
||||
/// Bound Object act on. Panel state, not art selection: not saved, not undoable
|
||||
/// (`variable.highlight`). A name, so renaming a variable moves the highlight with it.
|
||||
pub variables_highlight: Option<String>,
|
||||
/// Isolation mode container.
|
||||
pub isolation: Option<NodeId>,
|
||||
pub interaction: Option<Interaction>,
|
||||
|
|
@ -236,6 +241,7 @@ impl DocState {
|
|||
revision: 1,
|
||||
active_layer,
|
||||
layer_rows: vec![],
|
||||
variables_highlight: None,
|
||||
isolation: None,
|
||||
interaction: None,
|
||||
undo_group: None,
|
||||
|
|
@ -300,6 +306,7 @@ impl DocState {
|
|||
self.selection = it.selection;
|
||||
self.active_layer = it.active_layer;
|
||||
self.layer_rows = it.layer_rows;
|
||||
self.variables_highlight = it.variables_highlight;
|
||||
self.isolation = it.isolation;
|
||||
self.revision += 1;
|
||||
}
|
||||
|
|
@ -1226,6 +1233,10 @@ impl Session {
|
|||
if !st.doc.assets.is_empty() {
|
||||
Arc::make_mut(&mut st.doc).prune_assets();
|
||||
}
|
||||
// Variables: bindings let go of deleted art.
|
||||
if !st.doc.variables.bindings.is_empty() {
|
||||
Arc::make_mut(&mut st.doc).prune_variable_bindings();
|
||||
}
|
||||
if doc_sane(&st.doc, &st.selection) {
|
||||
Ok(v)
|
||||
} else {
|
||||
|
|
@ -1290,6 +1301,7 @@ impl Session {
|
|||
preview: None,
|
||||
active_layer: st.active_layer,
|
||||
layer_rows: st.layer_rows.clone(),
|
||||
variables_highlight: st.variables_highlight.clone(),
|
||||
isolation: st.isolation,
|
||||
perspective_again: None,
|
||||
});
|
||||
|
|
@ -1663,6 +1675,8 @@ mod tests_typearea;
|
|||
#[cfg(test)]
|
||||
mod tests_units;
|
||||
#[cfg(test)]
|
||||
mod tests_variables;
|
||||
#[cfg(test)]
|
||||
mod tests_webexport;
|
||||
#[cfg(test)]
|
||||
mod tests_widthpoints;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,591 @@
|
|||
//! Variables (data merge): definitions with kinds, bindings to objects, datasets
|
||||
//! applied in one undo step, and validation that rejects instead of reinterpreting.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::NodeId;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn text(s: &mut Session, content: &str) -> NodeId {
|
||||
let r = s.execute("text.create", &json!({"x": 10, "y": 10, "text": content})).unwrap();
|
||||
NodeId(r["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session) -> NodeId {
|
||||
let r = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
|
||||
NodeId(r["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn plain(s: &Session, id: NodeId) -> String {
|
||||
match &s.doc().unwrap().doc.node(id).unwrap().kind {
|
||||
vectorcraft_doc::NodeKind::Text(t) => t.plain_text(),
|
||||
_ => panic!("not text"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_and_visibility_datasets_apply_in_one_undo_step() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "Alice");
|
||||
let r = rect(&mut s);
|
||||
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
|
||||
s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Show", "ids": [r.0]})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "B", "values": {"Name": "Bob", "Show": false}})).unwrap();
|
||||
|
||||
let v = s.execute("dataset.select", &json!({"name": "B"})).unwrap();
|
||||
assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64()), (Some(2), Some(0)));
|
||||
assert_eq!(plain(&s, t), "Bob");
|
||||
assert!(!s.doc().unwrap().doc.node(r).unwrap().visible);
|
||||
|
||||
// One undo step covers the whole dataset application.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(plain(&s, t), "Alice");
|
||||
assert!(s.doc().unwrap().doc.node(r).unwrap().visible);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(plain(&s, t), "Bob");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dataset_next_and_prev_wrap_around() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "x");
|
||||
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap();
|
||||
for name in ["A", "B"] {
|
||||
s.execute("dataset.new", &json!({"name": name, "values": {"Name": name}})).unwrap();
|
||||
}
|
||||
s.execute("dataset.next", &json!({})).unwrap();
|
||||
assert_eq!(plain(&s, t), "A");
|
||||
s.execute("dataset.next", &json!({})).unwrap();
|
||||
assert_eq!(plain(&s, t), "B");
|
||||
s.execute("dataset.next", &json!({})).unwrap();
|
||||
assert_eq!(plain(&s, t), "A");
|
||||
s.execute("dataset.prev", &json!({})).unwrap();
|
||||
assert_eq!(plain(&s, t), "B");
|
||||
let v = s.execute("dataset.list", &json!({})).unwrap();
|
||||
assert_eq!(v["active"], "B");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatched_and_missing_bindings_are_skipped_and_counted() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "x");
|
||||
let r = rect(&mut s);
|
||||
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
|
||||
// A text variable bound to a rectangle applies to nothing; a dataset value for an
|
||||
// unbound variable is ignored.
|
||||
s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "D", "values": {"Name": "y", "Ghost": "z"}})).unwrap_err();
|
||||
s.execute("dataset.new", &json!({"name": "D", "values": {"Name": "y"}})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Name", "ids": [r.0]})).unwrap_err();
|
||||
let v = s.execute("dataset.select", &json!({"name": "D"})).unwrap();
|
||||
assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64()), (Some(1), Some(0)));
|
||||
assert_eq!(plain(&s, t), "y");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_variable_cleans_bindings_values_and_leaves_datasets_alone() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "x");
|
||||
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "D", "values": {"Name": "y"}})).unwrap();
|
||||
s.execute("dataset.select", &json!({"name": "D"})).unwrap();
|
||||
s.execute("variable.delete", &json!({"names": ["Name"]})).unwrap();
|
||||
let vars = s.execute("variable.list", &json!({})).unwrap();
|
||||
assert_eq!(vars["variables"].as_array().map(Vec::len), Some(0));
|
||||
let sets = s.execute("dataset.list", &json!({})).unwrap();
|
||||
assert!(sets["datasets"][0].get("values").is_none_or(|v| v == &json!({})));
|
||||
// The dataset named "D" is still the active one: `variable.delete` prunes variables, and
|
||||
// the dataset's own name is its own namespace.
|
||||
assert_eq!(sets["active"], "D");
|
||||
s.execute("dataset.delete", &json!({"names": ["D"]})).unwrap();
|
||||
let sets = s.execute("dataset.list", &json!({})).unwrap();
|
||||
assert_eq!(sets["datasets"].as_array().map(Vec::len), Some(0));
|
||||
assert!(sets["active"].is_null());
|
||||
let v: Value = serde_json::from_str(&serde_json::to_string(&s.doc().unwrap().doc).unwrap()).unwrap();
|
||||
assert!(v["variables"].get("bindings").is_none_or(|v| v == &json!({})));
|
||||
}
|
||||
|
||||
/// A dataset may share a variable's name: they are separate namespaces, and deleting the
|
||||
/// variable must not silently unselect the dataset that happens to be called the same.
|
||||
#[test]
|
||||
fn a_dataset_survives_a_variable_of_the_same_name_being_deleted() {
|
||||
let mut s = session();
|
||||
s.execute("variable.define", &json!({"name": "Row", "kind": "text"})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "Row", "values": {"Row": "y"}})).unwrap();
|
||||
s.execute("dataset.select", &json!({"name": "Row"})).unwrap();
|
||||
s.execute("variable.delete", &json!({"names": ["Row"]})).unwrap();
|
||||
let sets = s.execute("dataset.list", &json!({})).unwrap();
|
||||
assert_eq!(sets["datasets"][0]["name"], "Row", "the dataset is not a variable");
|
||||
assert_eq!(sets["active"], "Row", "the active dataset keeps its name");
|
||||
}
|
||||
|
||||
/// Deleting art lets its bindings go, as it does for Asset Export's assets: a binding that
|
||||
/// outlived its object would count as a skip in every dataset application and grow the file.
|
||||
#[test]
|
||||
fn deleted_art_lets_its_bindings_go() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "x");
|
||||
let r = rect(&mut s);
|
||||
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
|
||||
s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Show", "ids": [r.0]})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "D", "values": {"Name": "y", "Show": false}})).unwrap();
|
||||
|
||||
s.execute("select.set", &json!({"ids": [t.0]})).unwrap();
|
||||
s.execute("edit.clear", &json!({})).unwrap();
|
||||
let vars = s.execute("variable.list", &json!({})).unwrap();
|
||||
let of = |name: &str| vars["variables"].as_array().map(|a| a.iter().find(|v| v["name"] == name).unwrap()["bindings"].as_u64().unwrap()).unwrap();
|
||||
assert_eq!(of("Name"), 0, "the deleted text object took its binding with it");
|
||||
assert_eq!(of("Show"), 1, "the rectangle is still bound");
|
||||
// Nothing stale is left for the application to skip.
|
||||
let v = s.execute("dataset.select", &json!({"name": "D"})).unwrap();
|
||||
assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64()), (Some(1), Some(0)));
|
||||
}
|
||||
|
||||
/// `deleted` counts what was deleted, not what was asked for.
|
||||
#[test]
|
||||
fn delete_reports_what_it_actually_removed() {
|
||||
let mut s = session();
|
||||
s.execute("variable.define", &json!({"name": "A", "kind": "text"})).unwrap();
|
||||
s.execute("variable.define", &json!({"name": "B", "kind": "text"})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "D"})).unwrap();
|
||||
assert_eq!(s.execute("variable.delete", &json!({"names": ["A"]})).unwrap()["deleted"], 1);
|
||||
assert_eq!(s.execute("variable.delete", &json!({"names": ["A"]})).unwrap()["deleted"], 0, "already gone");
|
||||
assert_eq!(s.execute("variable.delete", &json!({"names": ["A", "B"]})).unwrap()["deleted"], 1, "only B was left");
|
||||
assert_eq!(s.execute("dataset.delete", &json!({"names": ["D"]})).unwrap()["deleted"], 1);
|
||||
assert_eq!(s.execute("dataset.delete", &json!({"names": ["D"]})).unwrap()["deleted"], 0);
|
||||
}
|
||||
|
||||
/// An object may be driven by several variables, so binding it again adds to what it has
|
||||
/// instead of quietly dropping the earlier one.
|
||||
#[test]
|
||||
fn one_object_can_be_driven_by_several_variables() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "x");
|
||||
let r = rect(&mut s);
|
||||
s.execute("variable.define", &json!({"name": "Title", "kind": "text"})).unwrap();
|
||||
s.execute("variable.define", &json!({"name": "Price", "kind": "text"})).unwrap();
|
||||
s.execute("variable.define", &json!({"name": "Badge", "kind": "visibility"})).unwrap();
|
||||
|
||||
// A text object driven by two text variables keeps both.
|
||||
assert_eq!(s.execute("variable.bind", &json!({"variable": "Title", "ids": [t.0]})).unwrap()["bound"], 1);
|
||||
let v = s.execute("variable.bind", &json!({"variable": "Price", "ids": [t.0]})).unwrap();
|
||||
assert_eq!((v["bound"].clone(), v["already"].clone()), (json!(1), json!([])), "Title is untouched");
|
||||
// A rectangle driven by a visibility variable as well as nothing else yet.
|
||||
s.execute("variable.bind", &json!({"variable": "Badge", "ids": [r.0]})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "D", "values": {"Title": "Hat", "Price": "9.99", "Badge": false}})).unwrap();
|
||||
|
||||
let v = s.execute("dataset.select", &json!({"name": "D"})).unwrap();
|
||||
assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64()), (Some(3), Some(0)), "every binding applied");
|
||||
// Both text variables are counted; the later one in definition order is what is left.
|
||||
assert_eq!(plain(&s, t), "9.99");
|
||||
assert!(!s.doc().unwrap().doc.node(r).unwrap().visible);
|
||||
|
||||
let vars = s.execute("variable.list", &json!({})).unwrap();
|
||||
let of = |n: &str| vars["variables"].as_array().unwrap().iter().find(|v| v["name"] == n).unwrap()["bindings"].as_u64();
|
||||
assert_eq!((of("Title"), of("Price"), of("Badge")), (Some(1), Some(1), Some(1)));
|
||||
|
||||
// Binding again to one it already has is not a change, and says so.
|
||||
let v = s.execute("variable.bind", &json!({"variable": "Price", "ids": [t.0]})).unwrap();
|
||||
assert_eq!((v["bound"].clone(), v["already"].clone()), (json!(0), json!([t.0])));
|
||||
// Unbinding one variable leaves the object's others alone.
|
||||
assert_eq!(s.execute("variable.unbind", &json!({"ids": [t.0], "variable": "Price"})).unwrap()["unbound"], 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let vars = s.execute("variable.list", &json!({})).unwrap();
|
||||
assert_eq!(vars["variables"][1]["bindings"], 1, "the other binding is still there");
|
||||
assert!(s.execute("variable.unbind", &json!({"ids": [t.0], "variable": "Ghost"})).is_err());
|
||||
|
||||
// Deleting a variable drops only its own binding.
|
||||
s.execute("variable.delete", &json!({"names": ["Price"]})).unwrap();
|
||||
let vars = s.execute("variable.list", &json!({})).unwrap();
|
||||
assert_eq!(vars["variables"][0]["bindings"], 1, "Title still drives the text");
|
||||
}
|
||||
|
||||
/// `unbound` counts what was actually dropped, like `variable.delete` does.
|
||||
#[test]
|
||||
fn unbind_reports_what_it_actually_removed() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "x");
|
||||
s.execute("variable.define", &json!({"name": "Title", "kind": "text"})).unwrap();
|
||||
s.execute("variable.define", &json!({"name": "Price", "kind": "text"})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Title", "ids": [t.0]})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Price", "ids": [t.0]})).unwrap();
|
||||
assert_eq!(s.execute("variable.unbind", &json!({"ids": [t.0], "variable": "Title"})).unwrap()["unbound"], 1);
|
||||
assert_eq!(s.execute("variable.unbind", &json!({"ids": [t.0], "variable": "Title"})).unwrap()["unbound"], 0, "already gone");
|
||||
// Without `variable` the object's last binding goes and the entry with it.
|
||||
assert_eq!(s.execute("variable.unbind", &json!({"ids": [t.0]})).unwrap()["unbound"], 1);
|
||||
assert_eq!(s.execute("variable.unbind", &json!({"ids": [t.0]})).unwrap()["unbound"], 0);
|
||||
// An object with no binding left is not kept as an empty set.
|
||||
let v: Value = serde_json::from_str(&serde_json::to_string(&s.doc().unwrap().doc).unwrap()).unwrap();
|
||||
assert!(v["variables"].get("bindings").is_none_or(|v| v == &json!({})), "{v}");
|
||||
}
|
||||
|
||||
/// Locked art is the document's to rewrite only while it isn't locked.
|
||||
#[test]
|
||||
fn locked_art_is_neither_bound_nor_rewritten() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "x");
|
||||
let r = rect(&mut s);
|
||||
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
|
||||
s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Show", "ids": [r.0]})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "D", "values": {"Name": "y", "Show": false}})).unwrap();
|
||||
|
||||
s.execute("select.set", &json!({"ids": [t.0]})).unwrap();
|
||||
s.execute("object.lock", &json!({})).unwrap();
|
||||
assert!(s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).is_err(), "locked art takes no new binding");
|
||||
// The bindings made before the lock still count, and the application skips the locked art.
|
||||
let v = s.execute("dataset.select", &json!({"name": "D"})).unwrap();
|
||||
assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64()), (Some(1), Some(1)));
|
||||
assert_eq!(plain(&s, t), "x", "the locked text was left alone");
|
||||
assert!(!s.doc().unwrap().doc.node(r).unwrap().visible, "the unlocked rectangle still applied");
|
||||
}
|
||||
|
||||
/// Make Text Dynamic / Make Visibility Dynamic: what the panel's buttons run. One step from a
|
||||
/// selection to a bound variable, named after what it drives.
|
||||
#[test]
|
||||
fn making_a_selection_dynamic_names_and_binds_it() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "Alice");
|
||||
let r = rect(&mut s);
|
||||
|
||||
s.execute("select.set", &json!({"ids": [t.0]})).unwrap();
|
||||
let v = s.execute("variable.makeTextDynamic", &json!({})).unwrap();
|
||||
assert_eq!(v["name"], "Alice", "named after the text it drives");
|
||||
assert_eq!(v["ids"], json!([t.0]));
|
||||
|
||||
s.execute("select.set", &json!({"ids": [r.0]})).unwrap();
|
||||
let v = s.execute("variable.makeVisibilityDynamic", &json!({})).unwrap();
|
||||
assert_eq!(v["kind"], "visibility");
|
||||
|
||||
// The same object twice takes a number rather than colliding with the first variable.
|
||||
s.execute("select.set", &json!({"ids": [t.0]})).unwrap();
|
||||
let again = s.execute("variable.makeTextDynamic", &json!({})).unwrap();
|
||||
assert_eq!(again["name"], "Alice 2");
|
||||
let vars = s.execute("variable.list", &json!({})).unwrap();
|
||||
assert_eq!(vars["variables"].as_array().map(Vec::len), Some(3));
|
||||
|
||||
// Nothing selected, art that can't take the kind, and locked art are errors.
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
assert!(s.execute("variable.makeTextDynamic", &json!({})).is_err());
|
||||
s.execute("select.set", &json!({"ids": [r.0]})).unwrap();
|
||||
assert!(s.execute("variable.makeTextDynamic", &json!({})).is_err(), "a rectangle is not type");
|
||||
}
|
||||
|
||||
/// Capture Data Set records what the art holds now, which is how rows are made — the art is
|
||||
/// edited and captured, rather than the values typed.
|
||||
#[test]
|
||||
fn capturing_records_what_the_art_holds_now() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "Alice");
|
||||
let r = rect(&mut s);
|
||||
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
|
||||
s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Show", "ids": [r.0]})).unwrap();
|
||||
|
||||
let v = s.execute("dataset.capture", &json!({})).unwrap();
|
||||
assert_eq!((v["name"].clone(), v["values"].clone()), (json!("Data Set 1"), json!(2)));
|
||||
let sets = s.execute("dataset.list", &json!({})).unwrap();
|
||||
assert_eq!(sets["datasets"][0]["values"]["Name"], json!({"text": "Alice"}));
|
||||
assert_eq!(sets["datasets"][0]["values"]["Show"], json!({"visible": true}));
|
||||
assert_eq!(sets["active"], "Data Set 1");
|
||||
|
||||
// Change the art, capture again: a second row, and the first still matches its own text.
|
||||
s.execute("text.editRange", &json!({"id": t.0, "insert": "Bob"})).unwrap();
|
||||
assert_eq!(s.execute("dataset.list", &json!({})).unwrap()["datasets"][0]["matches"], json!(false), "the art moved on");
|
||||
s.execute("dataset.capture", &json!({})).unwrap();
|
||||
let sets = s.execute("dataset.list", &json!({})).unwrap();
|
||||
assert_eq!(sets["datasets"][1]["name"], "Data Set 2");
|
||||
assert_eq!(sets["datasets"][1]["values"]["Name"], json!({"text": "Bob"}));
|
||||
assert_eq!(sets["datasets"][0]["matches"], json!(false), "row 1 still describes the old text");
|
||||
|
||||
// A named capture, and a name that is taken is an error rather than a second row.
|
||||
s.execute("dataset.capture", &json!({"name": "Winter"})).unwrap();
|
||||
assert!(s.execute("dataset.capture", &json!({"name": "Winter"})).is_err());
|
||||
|
||||
// Nothing bound is nothing to capture.
|
||||
let mut empty = session();
|
||||
empty.execute("variable.define", &json!({"name": "V", "kind": "text"})).unwrap();
|
||||
assert!(empty.execute("dataset.capture", &json!({})).is_err());
|
||||
}
|
||||
|
||||
/// Update Data Set writes the art's current values into the row that is active.
|
||||
#[test]
|
||||
fn updating_writes_the_art_into_the_active_row() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "Alice");
|
||||
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap();
|
||||
|
||||
// No active row yet: say so rather than quietly writing into some other one.
|
||||
assert!(s.execute("dataset.update", &json!({})).is_err());
|
||||
s.execute("dataset.capture", &json!({})).unwrap();
|
||||
s.execute("text.editRange", &json!({"id": t.0, "insert": "Bob"})).unwrap();
|
||||
let v = s.execute("dataset.update", &json!({})).unwrap();
|
||||
assert_eq!((v["name"].clone(), v["values"].clone()), (json!("Data Set 1"), json!(1)));
|
||||
assert_eq!(s.execute("dataset.list", &json!({})).unwrap()["datasets"][0]["matches"], json!(true));
|
||||
// One undo step puts the row back as it was.
|
||||
s.execute("text.editRange", &json!({"id": t.0, "insert": "Caz"})).unwrap();
|
||||
s.execute("dataset.update", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(plain(&s, t), "Caz");
|
||||
}
|
||||
|
||||
/// Renaming carries the variable's bindings and its values in every row with it — a name is
|
||||
/// how a variable is referred to, so leaving the old one behind would orphan both.
|
||||
#[test]
|
||||
fn renaming_carries_the_bindings_and_values_with_it() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "Alice");
|
||||
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "D", "values": {"Name": "Bob"}})).unwrap();
|
||||
s.execute("dataset.select", &json!({"name": "D"})).unwrap();
|
||||
|
||||
s.execute("variable.rename", &json!({"name": "Name", "newName": "Title"})).unwrap();
|
||||
assert_eq!(plain(&s, t), "Bob", "the row still applied");
|
||||
assert_eq!(s.execute("dataset.list", &json!({})).unwrap()["datasets"][0]["values"]["Title"], json!({"text": "Bob"}));
|
||||
let vars = s.execute("variable.list", &json!({})).unwrap();
|
||||
assert_eq!((vars["variables"][0]["name"].clone(), vars["variables"][0]["bindings"].clone()), (json!("Title"), json!(1)));
|
||||
|
||||
// Renaming a dataset keeps it active and keeps its values.
|
||||
s.execute("dataset.rename", &json!({"name": "D", "newName": "Row 1"})).unwrap();
|
||||
let sets = s.execute("dataset.list", &json!({})).unwrap();
|
||||
assert_eq!((sets["active"].clone(), sets["datasets"][0]["name"].clone()), (json!("Row 1"), json!("Row 1")));
|
||||
|
||||
// Taken and missing names are errors.
|
||||
s.execute("variable.define", &json!({"name": "Other", "kind": "text"})).unwrap();
|
||||
assert!(s.execute("variable.rename", &json!({"name": "Title", "newName": "Other"})).is_err());
|
||||
assert!(s.execute("variable.rename", &json!({"name": "Ghost", "newName": "X"})).is_err());
|
||||
assert!(s.execute("dataset.rename", &json!({"name": "Ghost", "newName": "X"})).is_err());
|
||||
// A dataset and a variable are separate namespaces: a dataset may take a variable's name,
|
||||
// and a variable may take a dataset's.
|
||||
s.execute("dataset.rename", &json!({"name": "Row 1", "newName": "Title"})).unwrap();
|
||||
assert_eq!(s.execute("dataset.list", &json!({})).unwrap()["active"], "Title");
|
||||
s.execute("dataset.new", &json!({"name": "Taken"})).unwrap();
|
||||
assert!(s.execute("dataset.rename", &json!({"name": "Title", "newName": "Taken"})).is_err());
|
||||
}
|
||||
|
||||
/// `dataset.set` is how a value changes after the row exists: replacing it must not need the
|
||||
/// dataset to be deleted and rebuilt (which loses its place in the list and the active one).
|
||||
#[test]
|
||||
fn dataset_set_replaces_the_row_and_reports_what_it_dropped() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "x");
|
||||
let r = rect(&mut s);
|
||||
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
|
||||
s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Show", "ids": [r.0]})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "D", "values": {"Name": "y", "Show": false}})).unwrap();
|
||||
|
||||
// The dataset keeps its place and stays active while its values change.
|
||||
let v = s.execute("dataset.set", &json!({"name": "D", "values": {"Name": "z"}})).unwrap();
|
||||
assert_eq!((v["values"].clone(), v["removed"].clone()), (json!(1), json!(["Show"])));
|
||||
let sets = s.execute("dataset.list", &json!({})).unwrap();
|
||||
assert_eq!(sets["datasets"].as_array().map(Vec::len), Some(1));
|
||||
assert_eq!(sets["datasets"][0]["values"], json!({"Name": {"text": "z"}}));
|
||||
|
||||
s.execute("dataset.select", &json!({"name": "D"})).unwrap();
|
||||
assert_eq!(plain(&s, t), "z");
|
||||
assert!(s.doc().unwrap().doc.node(r).unwrap().visible, "`Show` has no value in this row now");
|
||||
|
||||
// Errors are errors, not a silent rewrite of some other row.
|
||||
assert!(s.execute("dataset.set", &json!({"name": "Missing"})).is_err());
|
||||
assert!(s.execute("dataset.set", &json!({"name": "D", "values": {"Ghost": "z"}})).is_err());
|
||||
assert!(s.execute("dataset.set", &json!({"name": "D", "values": {"Show": "yes"}})).is_err());
|
||||
assert!(s.execute("dataset.set", &json!({"name": "D", "values": []})).is_err());
|
||||
// One step, so one undo puts the row back as it was.
|
||||
s.execute("dataset.set", &json!({"name": "D", "values": {"Name": "w"}})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(plain(&s, t), "z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_variables_requests_are_errors_not_panics() {
|
||||
let mut s = session();
|
||||
s.execute("variable.define", &json!({"name": "N", "kind": "text"})).unwrap();
|
||||
for (cmd, params) in [
|
||||
("variable.define", json!({})),
|
||||
("variable.define", json!({"name": "", "kind": "text"})),
|
||||
("variable.define", json!({"name": "N", "kind": "colour"})),
|
||||
("variable.define", json!({"name": "N", "kind": "text"})),
|
||||
("variable.delete", json!({})),
|
||||
("variable.bind", json!({})),
|
||||
("variable.bind", json!({"variable": "N"})),
|
||||
("variable.bind", json!({"variable": "Missing", "ids": [1]})),
|
||||
("dataset.new", json!({})),
|
||||
("dataset.new", json!({"name": "D", "values": []})),
|
||||
("dataset.new", json!({"name": "D", "values": {"N": 5}})),
|
||||
("dataset.select", json!({"name": "Missing"})),
|
||||
("dataset.next", json!({})),
|
||||
] {
|
||||
assert!(s.execute(cmd, ¶ms).is_err(), "{cmd} {params}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlight_selects_without_unbinding_and_deletes_leave_the_rest() {
|
||||
// The reviewer's regression case: two variables, two rows.
|
||||
let mut s = session();
|
||||
let a = text(&mut s, "Alice");
|
||||
let b = text(&mut s, "Bob");
|
||||
for (name, id) in [("Name", a), ("Other", b)] {
|
||||
s.execute("variable.define", &json!({"name": name, "kind": "text"})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": name, "ids": [id.0]})).unwrap();
|
||||
}
|
||||
s.execute("dataset.new", &json!({"name": "One", "values": {"Name": "Alice", "Other": "Bob"}})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "Two", "values": {"Name": "A2", "Other": "B2"}})).unwrap();
|
||||
s.execute("dataset.select", &json!({"name": "One"})).unwrap();
|
||||
|
||||
// What a row click runs (highlight), then what Select Bound Object runs (select).
|
||||
s.execute("variable.highlight", &json!({"variable": "Name"})).unwrap();
|
||||
let bound: Vec<u64> = s.doc().unwrap().doc.variables.objects_of("Name").iter().map(|i| i.0).collect();
|
||||
s.execute("select.set", &json!({"ids": bound})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
|
||||
// …and the binding is still there: selecting never unbinds.
|
||||
assert_eq!(s.doc().unwrap().doc.variables.objects_of("Name"), vec![a]);
|
||||
|
||||
// Deleting one variable leaves the other; deleting the current dataset leaves the other.
|
||||
s.execute("variable.delete", &json!({"names": ["Name"]})).unwrap();
|
||||
let vars = s.execute("variable.list", &json!({})).unwrap();
|
||||
assert_eq!(vars["variables"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(vars["variables"][0]["name"], "Other");
|
||||
s.execute("dataset.delete", &json!({"names": ["One"]})).unwrap();
|
||||
let sets = s.execute("dataset.list", &json!({})).unwrap();
|
||||
assert_eq!(sets["datasets"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(sets["datasets"][0]["name"], "Two");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlight_follows_rename_and_clears_with_delete() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "Alice");
|
||||
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap();
|
||||
assert!(s.execute("variable.highlight", &json!({"variable": "Missing"})).is_err(), "unknown names stay errors");
|
||||
let v = s.execute("variable.highlight", &json!({"variable": "Name"})).unwrap();
|
||||
assert_eq!(v["variable"], "Name");
|
||||
// The highlight names a variable, so the rename carries it along.
|
||||
s.execute("variable.rename", &json!({"name": "Name", "newName": "Title"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().variables_highlight.as_deref(), Some("Title"));
|
||||
// Empty clears it, and deleting the highlighted variable clears it too.
|
||||
s.execute("variable.highlight", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().variables_highlight.is_none());
|
||||
s.execute("variable.highlight", &json!({"variable": "Title"})).unwrap();
|
||||
s.execute("variable.delete", &json!({"names": ["Title"]})).unwrap();
|
||||
assert!(s.doc().unwrap().variables_highlight.is_none(), "a highlight of a deleted variable highlights nothing");
|
||||
}
|
||||
|
||||
/// A row that hides an object must be able to show it again, and hiding it on the artboard
|
||||
/// (as the Layers panel does) is what a capture records: a visibility variable reads and
|
||||
/// writes its object's own visibility, which therefore never makes the object off limits.
|
||||
#[test]
|
||||
fn a_hidden_object_comes_back_and_is_captured_hidden() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "Alice");
|
||||
let r = rect(&mut s);
|
||||
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
|
||||
s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Show", "ids": [r.0, t.0]})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "Shown", "values": {"Name": "Alice", "Show": true}})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "Hidden", "values": {"Name": "Bob", "Show": false}})).unwrap();
|
||||
|
||||
s.execute("dataset.select", &json!({"name": "Hidden"})).unwrap();
|
||||
assert!(!s.doc().unwrap().doc.node(r).unwrap().visible);
|
||||
let v = s.execute("dataset.select", &json!({"name": "Shown"})).unwrap();
|
||||
assert_eq!(v["skipped"], json!(0), "{v}");
|
||||
assert!(s.doc().unwrap().doc.node(r).unwrap().visible, "the row shows it again");
|
||||
assert_eq!(plain(&s, t), "Alice", "and rewrites the text of the object it had hidden");
|
||||
|
||||
// Hidden on the artboard, then captured: the row says hidden, and it matches the art.
|
||||
s.execute("select.set", &json!({"ids": [t.0, r.0]})).unwrap();
|
||||
s.execute("object.hide", &json!({})).unwrap();
|
||||
s.execute("dataset.capture", &json!({"name": "Captured"})).unwrap();
|
||||
let sets = s.execute("dataset.list", &json!({})).unwrap();
|
||||
let captured = sets["datasets"].as_array().unwrap().iter().find(|d| d["name"] == "Captured").unwrap().clone();
|
||||
assert_eq!(captured["values"]["Show"], json!({"visible": false}), "{captured}");
|
||||
assert_eq!(captured["matches"], json!(true));
|
||||
|
||||
// A hidden object can still be bound by id.
|
||||
s.execute("variable.define", &json!({"name": "More", "kind": "visibility"})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "More", "ids": [r.0]})).unwrap();
|
||||
}
|
||||
|
||||
/// Variables and datasets are separate namespaces: renaming a variable leaves a dataset of
|
||||
/// the same name, and which dataset is active, alone.
|
||||
#[test]
|
||||
fn renaming_a_variable_leaves_a_dataset_of_the_same_name_active() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "Alice");
|
||||
s.execute("variable.define", &json!({"name": "Title", "kind": "text"})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Title", "ids": [t.0]})).unwrap();
|
||||
s.execute("dataset.capture", &json!({"name": "Title"})).unwrap();
|
||||
s.execute("variable.rename", &json!({"name": "Title", "newName": "Heading"})).unwrap();
|
||||
let sets = s.execute("dataset.list", &json!({})).unwrap();
|
||||
assert_eq!((sets["active"].clone(), sets["datasets"][0]["name"].clone()), (json!("Title"), json!("Title")));
|
||||
s.execute("dataset.update", &json!({})).unwrap();
|
||||
}
|
||||
|
||||
/// Without `names`, the deletes act on what the panel shows as current: the highlighted
|
||||
/// variable and the active dataset (Window › Variables runs them with no params).
|
||||
#[test]
|
||||
fn deletes_without_names_take_the_highlight_and_the_active_dataset() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "Alice");
|
||||
for name in ["A", "B"] {
|
||||
s.execute("variable.define", &json!({"name": name, "kind": "text"})).unwrap();
|
||||
}
|
||||
s.execute("variable.bind", &json!({"variable": "A", "ids": [t.0]})).unwrap();
|
||||
s.execute("dataset.capture", &json!({})).unwrap();
|
||||
s.execute("dataset.capture", &json!({})).unwrap();
|
||||
assert!(s.execute("variable.delete", &json!({})).is_err(), "nothing highlighted");
|
||||
|
||||
s.execute("variable.highlight", &json!({"variable": "B"})).unwrap();
|
||||
assert_eq!(s.execute("variable.delete", &json!({})).unwrap()["deleted"], json!(1));
|
||||
let vars = s.execute("variable.list", &json!({})).unwrap();
|
||||
assert_eq!(vars["variables"].as_array().map(Vec::len), Some(1));
|
||||
assert_eq!(vars["variables"][0]["name"], "A");
|
||||
|
||||
assert_eq!(s.execute("dataset.delete", &json!({})).unwrap()["deleted"], json!(1));
|
||||
let sets = s.execute("dataset.list", &json!({})).unwrap();
|
||||
assert_eq!(sets["datasets"].as_array().map(Vec::len), Some(1));
|
||||
assert_eq!(sets["datasets"][0]["name"], "Data Set 1");
|
||||
assert!(s.execute("dataset.delete", &json!({})).is_err(), "no active dataset left");
|
||||
}
|
||||
|
||||
/// Ids and names from an agent are checked, never reinterpreted: an `ids` that isn't a list
|
||||
/// of objects is an error rather than the selection, and names are trimmed and capped.
|
||||
#[test]
|
||||
fn ids_and_names_from_an_agent_are_checked() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "Alice");
|
||||
s.execute("variable.define", &json!({"name": " Name ", "kind": "text"})).unwrap();
|
||||
assert_eq!(s.execute("variable.list", &json!({})).unwrap()["variables"][0]["name"], "Name");
|
||||
s.execute("select.set", &json!({"ids": [t.0]})).unwrap();
|
||||
for params in
|
||||
[json!({"variable": "Name", "ids": 5}), json!({"variable": "Name", "ids": [t.0, "x"]}), json!({"variable": "Name", "ids": [999_999]})]
|
||||
{
|
||||
assert!(s.execute("variable.bind", ¶ms).is_err(), "{params}");
|
||||
}
|
||||
assert!(s.doc().unwrap().doc.variables.objects_of("Name").is_empty(), "nothing was bound");
|
||||
assert!(s.execute("variable.unbind", &json!({"ids": "all"})).is_err());
|
||||
assert!(s.execute("variable.define", &json!({"name": "x".repeat(256), "kind": "text"})).is_err());
|
||||
s.execute("variable.define", &json!({"name": "x".repeat(255), "kind": "text"})).unwrap();
|
||||
assert!(s.execute("variable.delete", &json!({"names": []})).is_err());
|
||||
assert!(s.execute("variable.delete", &json!({"names": [5]})).is_err());
|
||||
}
|
||||
|
|
@ -154,6 +154,15 @@ pub fn rich_session() -> Session {
|
|||
exec(&mut s, "symbol.new", json!({"name": "Dot"}));
|
||||
let t = id_of(&exec(&mut s, "text.create", json!({"x": 250, "y": 370, "text": "Tap to add", "size": 18})));
|
||||
exec(&mut s, "text.insertInline", json!({"id": t.0, "at": 4, "symbol": "Dot"}));
|
||||
// Variables (data merge): both kinds, bound to art, with two dataset rows and one applied,
|
||||
// so format round trips cover the definitions, the bindings and the rows.
|
||||
exec(&mut s, "variable.define", json!({"name": "Title", "kind": "text"}));
|
||||
exec(&mut s, "variable.define", json!({"name": "Badge", "kind": "visibility"}));
|
||||
exec(&mut s, "variable.bind", json!({"variable": "Title", "ids": [tx.0]}));
|
||||
exec(&mut s, "variable.bind", json!({"variable": "Badge", "ids": [a]}));
|
||||
exec(&mut s, "dataset.new", json!({"name": "Row 1", "values": {"Title": "Merged", "Badge": true}}));
|
||||
exec(&mut s, "dataset.new", json!({"name": "Row 2", "values": {"Title": "Other"}}));
|
||||
exec(&mut s, "dataset.select", json!({"name": "Row 1"}));
|
||||
let _ = (a, b, c, l);
|
||||
exec(&mut s, "select.none", json!({}));
|
||||
s
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ pub mod tile_edge_color;
|
|||
mod tools;
|
||||
mod transform;
|
||||
pub mod transform_each;
|
||||
pub mod variables;
|
||||
pub mod width_point;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
|
@ -212,6 +213,7 @@ registry! {
|
|||
ExportForScreens: ["exportForScreens"] => export_for_screens::SPEC,
|
||||
Recolor: [recolor::KIND] => recolor::SPEC,
|
||||
Command: ["command"] => command::SPEC,
|
||||
Variables: [variables::KIND] => variables::SPEC,
|
||||
Effect: ["effect"] => effect::SPEC,
|
||||
SaveChanges: [crate::unsaved::KIND] => save_changes::SPEC,
|
||||
Preferences: ["preferences"] => DialogSpec::window(crate::prefs_dialog::show, |app, _| crate::prefs_dialog::confirm(app)),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,388 @@
|
|||
//! The Variables dialogs (`variable.define`, `variable.bind`, `dataset.new`, `dataset.set`
|
||||
//! and `dataset.select`), opened from Window › Variables or the command palette.
|
||||
//!
|
||||
//! One dialog covers all of them: what it shows is what the command takes. A variable is a
|
||||
//! name and a kind; a dataset is a name and one field per variable — a text field for a
|
||||
//! `text` variable, a checkbox for a `visibility` one. `dataset.set` opens with the row's
|
||||
//! current values, so editing a row is editing what it already says.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{DataValue, VariableKind, Variables};
|
||||
|
||||
use super::{DialogSpec, run_and_close};
|
||||
use crate::VectorcraftApp;
|
||||
use crate::state::Dialog;
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets;
|
||||
|
||||
/// The dialog kind of every Variables dialog.
|
||||
pub const KIND: &str = "variables";
|
||||
|
||||
/// The kinds a variable can have, as `variable.define` names them.
|
||||
const KINDS: [&str; 2] = ["text", "visibility"];
|
||||
|
||||
/// What the dialog shows for each of [`KINDS`].
|
||||
fn kind_labels() -> [&'static str; 2] {
|
||||
[tl!("Text"), tl!("Visibility")]
|
||||
}
|
||||
|
||||
pub(super) const SPEC: DialogSpec =
|
||||
DialogSpec { heading: |d| tl!(&d.str("__label")).to_string(), body, confirm, min_width: 380.0, ..DialogSpec::FORM };
|
||||
|
||||
/// The command the dialog runs and what it is called.
|
||||
fn opened(id: &str) -> Option<&'static str> {
|
||||
Some(match id {
|
||||
"variable.define" => "New Variable",
|
||||
"variable.rename" => "Variable Options",
|
||||
"variable.bind" => "Bind Variable",
|
||||
"dataset.new" => "New Data Set",
|
||||
"dataset.set" => "Edit Data Set",
|
||||
"dataset.rename" => "Rename Data Set",
|
||||
"dataset.select" => "Select Data Set",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The row `id` renames, which the dialog offers as a dropdown when the document has more than
|
||||
/// one. Variable Options renames the one the panel has highlighted, so it starts there.
|
||||
fn rename_of(id: &str) -> Option<&'static str> {
|
||||
Some(match id {
|
||||
"variable.rename" => "variable",
|
||||
"dataset.rename" => "name",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Open `id`'s dialog: OK runs the command with the fields below. `preselect` names the row
|
||||
/// a rename starts on (the panel's highlighted variable, the active dataset); without it, or
|
||||
/// when it no longer exists, the first row — the dialog still offers the dropdown.
|
||||
pub fn open(app: &mut VectorcraftApp, id: &str, preselect: Option<&str>) -> bool {
|
||||
let Some(label) = opened(id) else { return false };
|
||||
let Some(st) = app.session.active() else {
|
||||
app.status(tl!("no document").to_string());
|
||||
return true;
|
||||
};
|
||||
let vars = st.doc.variables.clone();
|
||||
let mut fields = serde_json::Map::new();
|
||||
match id {
|
||||
"variable.define" => {
|
||||
fields.insert("name".into(), json!(""));
|
||||
fields.insert("kind".into(), json!(KINDS[0]));
|
||||
}
|
||||
"variable.bind" => {
|
||||
fields.insert("variable".into(), json!(""));
|
||||
}
|
||||
"dataset.new" => {
|
||||
fields.insert("name".into(), json!(""));
|
||||
value_fields(&vars, vars.datasets.first().map(|d| &d.values), &mut fields);
|
||||
}
|
||||
"dataset.set" => {
|
||||
// Without a row to edit there is nothing to open.
|
||||
let Some(name) = vars.active_dataset.clone().or_else(|| vars.datasets.first().map(|d| d.name.clone())) else {
|
||||
app.status(tl!("no data set to edit").to_string());
|
||||
return true;
|
||||
};
|
||||
let Some(current) = vars.dataset(&name) else { return true };
|
||||
fields.insert("name".into(), json!(name));
|
||||
value_fields(&vars, Some(¤t.values), &mut fields);
|
||||
}
|
||||
"dataset.select" => {
|
||||
fields.insert("name".into(), json!(""));
|
||||
}
|
||||
_ => {
|
||||
// A rename: which row, and its new name.
|
||||
let key = rename_of(id).unwrap_or("name");
|
||||
let names: Vec<&str> = if key == "variable" {
|
||||
vars.variables.iter().map(|v| v.name.as_str()).collect()
|
||||
} else {
|
||||
vars.datasets.iter().map(|d| d.name.as_str()).collect()
|
||||
};
|
||||
if names.is_empty() {
|
||||
app.status(if key == "variable" { tl!("no variable to rename").to_string() } else { tl!("no data set to rename").to_string() });
|
||||
return true;
|
||||
}
|
||||
fields.insert(key.into(), json!(preselect.filter(|p| names.contains(p)).or(names.first().copied()).unwrap_or_default()));
|
||||
fields.insert("newName".into(), json!(""));
|
||||
}
|
||||
}
|
||||
fields.insert("__command".into(), json!(id));
|
||||
fields.insert("__label".into(), json!(label));
|
||||
app.ui.dialog = Some(Dialog::new(KIND, Value::Object(fields)));
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether `id` opens one of these dialogs.
|
||||
pub fn opens(id: &str) -> bool {
|
||||
opened(id).is_some()
|
||||
}
|
||||
|
||||
/// One field per variable, prefilled from `current` (the row being edited, else the first row
|
||||
/// so a new one starts from something rather than from nothing).
|
||||
fn value_fields(vars: &Variables, current: Option<&std::collections::BTreeMap<String, DataValue>>, out: &mut serde_json::Map<String, Value>) {
|
||||
for v in &vars.variables {
|
||||
let value = match (current.and_then(|c| c.get(&v.name)), v.kind) {
|
||||
(Some(DataValue::Text(t)), _) => json!(t),
|
||||
(Some(DataValue::Visible(b)), _) => json!(b),
|
||||
// Without a value in the row a text field starts empty (no value in this row) and
|
||||
// a visibility one starts shown: its false is a value, so it is never "unset".
|
||||
(None, VariableKind::Text) => json!(""),
|
||||
(None, VariableKind::Visibility) => json!(true),
|
||||
};
|
||||
out.insert(value_key(&v.name), value);
|
||||
}
|
||||
}
|
||||
|
||||
/// A variable name as a field key. A field key shares the dialog's flat namespace with the
|
||||
/// command's own parameters, so names are prefixed to stay clear of `name` and `values`.
|
||||
fn value_key(name: &str) -> String {
|
||||
format!("value:{name}")
|
||||
}
|
||||
|
||||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let Some(st) = app.session.active() else {
|
||||
widgets::dim_label(ui, tl!("No document"));
|
||||
return false;
|
||||
};
|
||||
let vars = &st.doc.variables;
|
||||
let variable_names: Vec<&str> = vars.variables.iter().map(|v| v.name.as_str()).collect();
|
||||
let dataset_names: Vec<&str> = vars.datasets.iter().map(|x| x.name.as_str()).collect();
|
||||
egui::Grid::new("variables-grid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
|
||||
let label = |ui: &mut egui::Ui, text: &str| widgets::field_label(ui, egui::RichText::new(text).color(t.text));
|
||||
match d.str("__command").as_str() {
|
||||
"variable.define" => {
|
||||
label(ui, tl!("Name"));
|
||||
text(ui, d, "name", 220.0);
|
||||
ui.end_row();
|
||||
label(ui, tl!("Kind"));
|
||||
// The kinds by their translated names; the field keeps the command's own word.
|
||||
let labels = kind_labels();
|
||||
let shown = KINDS.iter().position(|k| *k == d.str("kind")).and_then(|i| labels.get(i)).copied().unwrap_or_default();
|
||||
if let Some(kind) = widgets::dropdown_names(ui, ("variables-dd", "kind"), shown, &labels, 220.0).and_then(|i| KINDS.get(i)) {
|
||||
d.fields.insert("kind".into(), json!(kind));
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
"variable.bind" => {
|
||||
label(ui, tl!("Variable"));
|
||||
dropdown(ui, d, "variable", &variable_names, 220.0);
|
||||
ui.end_row();
|
||||
}
|
||||
"dataset.select" => {
|
||||
label(ui, tl!("Data Set"));
|
||||
dropdown(ui, d, "name", &dataset_names, 220.0);
|
||||
ui.end_row();
|
||||
}
|
||||
// A rename: which row, and its new name.
|
||||
"variable.rename" | "dataset.rename" => {
|
||||
let (key, label_text, names) = if d.str("__command") == "variable.rename" {
|
||||
("variable", tl!("Variable").to_string(), &variable_names)
|
||||
} else {
|
||||
("name", tl!("Data Set").to_string(), &dataset_names)
|
||||
};
|
||||
label(ui, &label_text);
|
||||
dropdown(ui, d, key, names, 220.0);
|
||||
ui.end_row();
|
||||
label(ui, tl!("New Name"));
|
||||
text(ui, d, "newName", 220.0);
|
||||
ui.end_row();
|
||||
}
|
||||
_ => {
|
||||
label(ui, tl!("Name"));
|
||||
text(ui, d, "name", 220.0);
|
||||
ui.end_row();
|
||||
if vars.variables.is_empty() {
|
||||
widgets::dim_label(ui, tl!("Define a variable first"));
|
||||
ui.end_row();
|
||||
return;
|
||||
}
|
||||
// One row per variable, in the order they are defined, each with the editor
|
||||
// its kind takes.
|
||||
for v in &vars.variables {
|
||||
label(ui, &v.name);
|
||||
let key = value_key(&v.name);
|
||||
match v.kind {
|
||||
VariableKind::Text => text(ui, d, &key, 220.0),
|
||||
VariableKind::Visibility => checkbox(ui, d, &key),
|
||||
};
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
false
|
||||
}
|
||||
|
||||
/// A text field for `key`, written back as it is typed.
|
||||
fn text(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32) -> egui::Response {
|
||||
let mut s = d.str(key);
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut s).desired_width(width));
|
||||
if r.changed() {
|
||||
d.fields.insert(key.into(), json!(s));
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// A checkbox for `key`, written back as it is toggled.
|
||||
fn checkbox(ui: &mut egui::Ui, d: &mut Dialog, key: &str) -> egui::Response {
|
||||
let mut b = d.bool(key);
|
||||
let r = ui.checkbox(&mut b, "");
|
||||
if r.changed() {
|
||||
d.fields.insert(key.into(), json!(b));
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// A dropdown over the document's names `options` (shown as they are, never translated) for
|
||||
/// `key`, written back as it changes. The empty option is first so a fresh dialog can still be
|
||||
/// filled in; a name that is no longer offered reads as empty rather than being silently
|
||||
/// swapped for another one.
|
||||
fn dropdown(ui: &mut egui::Ui, d: &mut Dialog, key: &str, options: &[&str], width: f32) {
|
||||
let cur = d.str(key);
|
||||
let mut labels: Vec<&str> = vec![""];
|
||||
labels.extend_from_slice(options);
|
||||
let shown = if cur.is_empty() || options.contains(&cur.as_str()) { cur.as_str() } else { "" };
|
||||
if let Some(i) = widgets::dropdown_names(ui, ("variables-dd", key), shown, &labels, width) {
|
||||
// The empty option (0) empties the field; the rest are `options`, one along.
|
||||
d.fields.insert(key.into(), json!(i.checked_sub(1).and_then(|i| options.get(i)).copied().unwrap_or_default()));
|
||||
}
|
||||
}
|
||||
|
||||
/// The `values` object the dialog's fields mean: a text variable whose field is empty carries
|
||||
/// no value in this row, and a visibility checkbox is always a value — its false, too.
|
||||
fn values_of(d: &Dialog, vars: &Variables) -> Value {
|
||||
let mut out = serde_json::Map::new();
|
||||
for v in &vars.variables {
|
||||
let key = value_key(&v.name);
|
||||
if v.kind == VariableKind::Text {
|
||||
let text = d.str(&key);
|
||||
if !text.is_empty() {
|
||||
out.insert(v.name.clone(), json!(text));
|
||||
}
|
||||
} else {
|
||||
out.insert(v.name.clone(), json!(d.bool(&key)));
|
||||
}
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let cmd = d.str("__command");
|
||||
let name = d.str("name");
|
||||
let p = match cmd.as_str() {
|
||||
"variable.define" => json!({"name": name, "kind": d.str("kind")}),
|
||||
"variable.bind" => json!({"variable": d.str("variable")}),
|
||||
"variable.rename" => json!({"name": d.str("variable"), "newName": d.str("newName")}),
|
||||
"dataset.rename" => json!({"name": name, "newName": d.str("newName")}),
|
||||
"dataset.new" | "dataset.set" => {
|
||||
let values = app.session.active().map(|st| values_of(d, &st.doc.variables)).unwrap_or_else(|| json!({}));
|
||||
json!({"name": name, "values": values})
|
||||
}
|
||||
"dataset.select" => json!({"name": name}),
|
||||
_ => json!({}),
|
||||
};
|
||||
run_and_close(app, &cmd, p)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn app_with_variables() -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
app.run("variable.define", json!({"name": "Title", "kind": "text"})).unwrap();
|
||||
app.run("variable.define", json!({"name": "Badge", "kind": "visibility"})).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defining_a_variable_from_the_dialog_works() {
|
||||
let mut app = app_with_variables();
|
||||
assert!(open(&mut app, "variable.define", None));
|
||||
{
|
||||
let d = app.ui.dialog.as_ref().unwrap();
|
||||
assert_eq!(d.kind, KIND);
|
||||
assert_eq!(d.str("name"), "");
|
||||
assert_eq!(d.str("kind"), "text", "text is the default kind");
|
||||
}
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("Price"));
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
let vars = app.run("variable.list", json!({})).unwrap();
|
||||
assert!(vars["variables"].as_array().unwrap().iter().any(|v| v["name"] == "Price"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_dataset_carries_the_dialogs_values_and_omits_the_empty_ones() {
|
||||
let mut app = app_with_variables();
|
||||
open(&mut app, "dataset.new", None);
|
||||
{
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
d.fields.insert("name".into(), json!("Row 1"));
|
||||
d.fields.insert(value_key("Title"), json!("Hello"));
|
||||
d.fields.insert(value_key("Badge"), json!(false));
|
||||
}
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
let sets = app.run("dataset.list", json!({})).unwrap();
|
||||
assert_eq!(sets["datasets"][0]["name"], "Row 1");
|
||||
assert_eq!(sets["datasets"][0]["values"]["Title"], json!({"text": "Hello"}));
|
||||
assert_eq!(sets["datasets"][0]["values"]["Badge"], json!({"visible": false}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_a_dataset_opens_with_the_rows_own_values() {
|
||||
let mut app = app_with_variables();
|
||||
app.run("dataset.new", json!({"name": "Row 1", "values": {"Title": "Hello", "Badge": false}})).unwrap();
|
||||
app.run("dataset.set", json!({"name": "Row 1", "values": {"Badge": true}})).unwrap();
|
||||
open(&mut app, "dataset.set", None);
|
||||
{
|
||||
let d = app.ui.dialog.as_ref().unwrap();
|
||||
assert_eq!(d.str("name"), "Row 1");
|
||||
assert_eq!(d.str(&value_key("Title")), "", "the row carries no value for it");
|
||||
assert!(d.bool(&value_key("Badge")), "the row's own value");
|
||||
}
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert(value_key("Title"), json!("Changed"));
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
let sets = app.run("dataset.list", json!({})).unwrap();
|
||||
assert_eq!(sets["datasets"][0]["values"]["Title"], json!({"text": "Changed"}));
|
||||
assert_eq!(sets["datasets"][0]["values"]["Badge"], json!({"visible": true}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_a_dataset_from_the_dialog_applies_it() {
|
||||
let mut app = app_with_variables();
|
||||
let id = app.run("text.create", json!({"x": 10, "y": 10, "text": "x"})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("variable.bind", json!({"variable": "Title", "ids": [id]})).unwrap();
|
||||
app.run("dataset.new", json!({"name": "Row 1", "values": {"Title": "Hi"}})).unwrap();
|
||||
open(&mut app, "dataset.select", None);
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().str("name"), "");
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("Row 1"));
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
assert_eq!(app.run("text.getRange", json!({"id": id})).unwrap()["text"], "Hi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_variables_commands_open_one() {
|
||||
let mut app = app_with_variables();
|
||||
assert!(opens("dataset.set") && opens("variable.define"));
|
||||
assert!(!opens("dataset.list") && !opens("dataset.next"), "no dialog for a query or a step");
|
||||
assert!(!open(&mut app, "dataset.list", None), "and it opens nothing");
|
||||
assert!(app.ui.dialog.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_dialogs_start_on_the_preselected_row() {
|
||||
let mut app = app_with_variables();
|
||||
app.run("dataset.new", json!({"name": "Row 1", "values": {"Title": "Hello"}})).unwrap();
|
||||
open(&mut app, "variable.rename", Some("Badge"));
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().str("variable"), "Badge");
|
||||
// A preselect the document no longer has falls back to the first row.
|
||||
open(&mut app, "variable.rename", Some("Gone"));
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().str("variable"), "Title");
|
||||
open(&mut app, "dataset.rename", Some("Row 1"));
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().str("name"), "Row 1");
|
||||
}
|
||||
}
|
||||
|
|
@ -496,12 +496,13 @@ mod tests {
|
|||
|
||||
const SCREEN: Vec2 = vec2(1400.0, 900.0);
|
||||
|
||||
/// Frames of the whole window, 1400 × 900, with pointer input one event a frame (as the
|
||||
/// control channel's `ui.drag` sends it).
|
||||
/// Frames of the whole window, with pointer input one event a frame (as the control
|
||||
/// channel's `ui.drag` sends it).
|
||||
struct Harness {
|
||||
app: VectorcraftApp,
|
||||
ctx: egui::Context,
|
||||
time: f64,
|
||||
screen: Vec2,
|
||||
}
|
||||
|
||||
fn button(pos: Pos2, pressed: bool) -> Event {
|
||||
|
|
@ -510,16 +511,23 @@ mod tests {
|
|||
|
||||
impl Harness {
|
||||
fn new() -> Self {
|
||||
Self::on(SCREEN)
|
||||
}
|
||||
|
||||
/// The same window at another size. The icon column scrolls when it is too short for
|
||||
/// every icon, which hides the ones past the bottom — a test that counts them needs
|
||||
/// a window tall enough to show them all.
|
||||
fn on(screen: Vec2) -> Self {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
|
||||
let mut h = Self { app, ctx: egui::Context::default(), time: 0.0 };
|
||||
let mut h = Self { app, ctx: egui::Context::default(), time: 0.0, screen };
|
||||
h.settle();
|
||||
h
|
||||
}
|
||||
|
||||
fn frame(&mut self, events: Vec<Event>) {
|
||||
self.time += 0.05;
|
||||
let screen = Rect::from_min_size(Pos2::ZERO, SCREEN);
|
||||
let screen = Rect::from_min_size(Pos2::ZERO, self.screen);
|
||||
let raw = egui::RawInput { time: Some(self.time), screen_rect: Some(screen), events, ..Default::default() };
|
||||
let app = &mut self.app;
|
||||
self.ctx
|
||||
|
|
@ -651,10 +659,13 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn icons_dragged_out_float_stack_onto_another_group_and_tear_out_of_it() {
|
||||
let mut h = Harness::new();
|
||||
// Tall enough that the icon column shows every icon, so counting them says what it
|
||||
// means: one leaves the column, rather than one more scrolling into view.
|
||||
let mut h = Harness::on(vec2(1400.0, 1600.0));
|
||||
// Color, Color Guide, Swatches… top to bottom.
|
||||
let icons = h.icons();
|
||||
let count = icons.len();
|
||||
assert_eq!(count, crate::state::ICON_PANEL_GROUPS.iter().map(|g| g.len()).sum::<usize>(), "every group's icons are drawn");
|
||||
h.drag(icons[2].center(), pos2(500.0, 250.0));
|
||||
assert_eq!(h.groups(), [["swatches"]]);
|
||||
assert_eq!(h.icons().len(), count - 1, "its icon left the column");
|
||||
|
|
|
|||
|
|
@ -534,6 +534,20 @@
|
|||
Paragraph Styles Styly odstavců
|
||||
Tabs Tabulátory
|
||||
Variables Proměnné
|
||||
New Variable… Nová proměnná…
|
||||
Unbind Variable Zrušit přiřazení proměnné
|
||||
Delete Variable Odstranit proměnnou
|
||||
New Data Set… Nová sada dat…
|
||||
Edit Data Set… Upravit sadu dat…
|
||||
Delete Data Set Odstranit sadu dat
|
||||
Next Data Set Další sada dat
|
||||
Previous Data Set Předchozí sada dat
|
||||
Make Text Dynamic Nastavit text jako dynamický
|
||||
Make Visibility Dynamic Nastavit viditelnost jako dynamickou
|
||||
Capture Data Set Pořídit sadu dat
|
||||
Update Data Set Aktualizovat sadu dat
|
||||
Rename Data Set… Přejmenovat sadu dat…
|
||||
Variable Options… Možnosti proměnné…
|
||||
Brush Libraries Knihovny štětců
|
||||
Built-in Libraries Vestavěné knihovny
|
||||
User Defined Uživatelské
|
||||
|
|
@ -678,3 +692,5 @@
|
|||
Diffuse Glow… Rozptýlená záře…
|
||||
Glass… Sklo…
|
||||
Ocean Ripple… Vlnky na hladině…
|
||||
Rename Data Set Přejmenovat sadu dat
|
||||
Rename Variable Přejmenovat proměnnou
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -3954,3 +3954,43 @@ theme Appearance Erscheinungsbild
|
|||
Frosted Mattiert
|
||||
Tiny Lens Kleine Linsen
|
||||
Canvas Leinwand
|
||||
Highlight Variable Variable hervorheben
|
||||
New Variable Neue Variable
|
||||
Delete Variable Variable löschen
|
||||
List Variables Variablen auflisten
|
||||
Bind Variable Variable binden
|
||||
Unbind Variable Variable lösen
|
||||
New Data Set Neuer Datensatz
|
||||
Delete Data Set Datensatz löschen
|
||||
List Data Sets Datensätze auflisten
|
||||
Select Data Set Datensatz auswählen
|
||||
Next Data Set Nächster Datensatz
|
||||
Previous Data Set Vorheriger Datensatz
|
||||
Data Sets Datensätze
|
||||
New Variable… Neue Variable…
|
||||
New Data Set… Neuer Datensatz…
|
||||
Edit Data Set… Datensatz bearbeiten…
|
||||
Data Set Datensatz
|
||||
Kind Art
|
||||
Variable Variable
|
||||
no document kein Dokument
|
||||
no data set to edit kein Datensatz zum Bearbeiten
|
||||
Make Text Dynamic Text dynamisch machen
|
||||
Make Visibility Dynamic Sichtbarkeit dynamisch machen
|
||||
Capture Data Set Datensatz erfassen
|
||||
Update Data Set Datensatz aktualisieren
|
||||
Rename Data Set… Datensatz umbenennen…
|
||||
Variable Options… Variablenoptionen…
|
||||
Select Bound Object Gebundenes Objekt auswählen
|
||||
Select All Bound Objects Alle gebundenen Objekte auswählen
|
||||
Multiple objects Mehrere Objekte
|
||||
New Name Neuer Name
|
||||
no variable to rename keine Variable zum Umbenennen
|
||||
no data set to rename kein Datensatz zum Umbenennen
|
||||
Rename Data Set Datensatz umbenennen
|
||||
Rename Variable Variable umbenennen
|
||||
Variable Options Variablenoptionen
|
||||
Edit Data Set Datensatz bearbeiten
|
||||
Define a variable first Definieren Sie zuerst eine Variable
|
||||
Text Text
|
||||
Visibility Sichtbarkeit
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -1195,6 +1195,7 @@
|
|||
Highlight Alternate Glyphs Resaltar glifos alternativos
|
||||
Highlight anchors on mouse over Resaltar anclas al pasar el puntero
|
||||
Highlight Rows Resaltar filas
|
||||
Highlight Variable Resaltar variable
|
||||
Highlight Substituted Fonts Resaltar fuentes sustituidas
|
||||
Highlight Substituted Glyphs Resaltar glifos sustituidos
|
||||
History Historial
|
||||
|
|
@ -3055,6 +3056,38 @@
|
|||
V: V:
|
||||
Variable Width Profile Perfil de anchura variable
|
||||
Variables Variables
|
||||
New Variable Nueva variable
|
||||
Delete Variable Eliminar variable
|
||||
List Variables Enumerar variables
|
||||
Bind Variable Vincular variable
|
||||
Unbind Variable Desvincular variable
|
||||
New Data Set Nuevo conjunto de datos
|
||||
Delete Data Set Eliminar conjunto de datos
|
||||
List Data Sets Enumerar conjuntos de datos
|
||||
Select Data Set Seleccionar conjunto de datos
|
||||
Next Data Set Conjunto de datos siguiente
|
||||
Previous Data Set Conjunto de datos anterior
|
||||
Data Sets Conjuntos de datos
|
||||
New Variable… Nueva variable…
|
||||
New Data Set… Nuevo conjunto de datos…
|
||||
Edit Data Set… Editar conjunto de datos…
|
||||
Data Set Conjunto de datos
|
||||
Kind Tipo
|
||||
Variable Variable
|
||||
no document no hay documento
|
||||
no data set to edit no hay ningún conjunto de datos que editar
|
||||
Make Text Dynamic Hacer texto dinámico
|
||||
Make Visibility Dynamic Hacer visible dinámico
|
||||
Capture Data Set Capturar conjunto de datos
|
||||
Update Data Set Actualizar conjunto de datos
|
||||
Rename Data Set… Renombrar conjunto de datos…
|
||||
Variable Options… Opciones de variable…
|
||||
Select Bound Object Seleccionar objeto vinculado
|
||||
Select All Bound Objects Seleccionar todos los objetos vinculados
|
||||
Multiple objects Varios objetos
|
||||
New Name Nuevo nombre
|
||||
no variable to rename no hay ninguna variable que renombrar
|
||||
no data set to rename no hay ningún conjunto de datos que renombrar
|
||||
Variation: Variación:
|
||||
Vector Effects Efectos vectoriales
|
||||
Vector Halftone Semitono vectorial
|
||||
|
|
@ -3997,3 +4030,10 @@ axis Both Ambos
|
|||
Blocks Bloques
|
||||
Frosted Esmerilado
|
||||
Tiny Lens Lente pequeña
|
||||
Rename Data Set Renombrar conjunto de datos
|
||||
Rename Variable Renombrar variable
|
||||
Variable Options Opciones de variable
|
||||
Edit Data Set Editar conjunto de datos
|
||||
Define a variable first Defina primero una variable
|
||||
Text Texto
|
||||
Visibility Visibilidad
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -1194,6 +1194,7 @@
|
|||
Highlight Alternate Glyphs Surligner les glyphes de substitution
|
||||
Highlight anchors on mouse over Mettre en évidence les points d’ancrage au survol
|
||||
Highlight Rows Surligner les lignes
|
||||
Highlight Variable Surligner la variable
|
||||
Highlight Substituted Fonts Surligner les polices remplacées
|
||||
Highlight Substituted Glyphs Surligner les glyphes remplacés
|
||||
History Historique
|
||||
|
|
@ -3052,6 +3053,38 @@
|
|||
V: V :
|
||||
Variable Width Profile Profil de largeur variable
|
||||
Variables Variables
|
||||
New Variable Nouvelle variable
|
||||
Delete Variable Supprimer la variable
|
||||
List Variables Lister les variables
|
||||
Bind Variable Lier la variable
|
||||
Unbind Variable Délier la variable
|
||||
New Data Set Nouveau jeu de données
|
||||
Delete Data Set Supprimer le jeu de données
|
||||
List Data Sets Lister les jeux de données
|
||||
Select Data Set Sélectionner le jeu de données
|
||||
Next Data Set Jeu de données suivant
|
||||
Previous Data Set Jeu de données précédent
|
||||
Data Sets Jeux de données
|
||||
New Variable… Nouvelle variable…
|
||||
New Data Set… Nouveau jeu de données…
|
||||
Edit Data Set… Modifier le jeu de données…
|
||||
Data Set Jeu de données
|
||||
Kind Type
|
||||
Variable Variable
|
||||
no document aucun document
|
||||
no data set to edit aucun jeu de données à modifier
|
||||
Make Text Dynamic Rendre le texte dynamique
|
||||
Make Visibility Dynamic Rendre la visibilité dynamique
|
||||
Capture Data Set Capturer le jeu de données
|
||||
Update Data Set Mettre à jour le jeu de données
|
||||
Rename Data Set… Renommer le jeu de données…
|
||||
Variable Options… Options de variable…
|
||||
Select Bound Object Sélectionner l’objet lié
|
||||
Select All Bound Objects Sélectionner tous les objets liés
|
||||
Multiple objects Plusieurs objets
|
||||
New Name Nouveau nom
|
||||
no variable to rename aucune variable à renommer
|
||||
no data set to rename aucun jeu de données à renommer
|
||||
Variation: Variation :
|
||||
Vector Effects Effets vectoriels
|
||||
Vector Halftone Trame de demi-teintes vectorielle
|
||||
|
|
@ -4009,3 +4042,10 @@ effect Soft Doux
|
|||
Blocks Blocs
|
||||
Frosted Dépoli
|
||||
Tiny Lens Petites lentilles
|
||||
Rename Data Set Renommer le jeu de données
|
||||
Rename Variable Renommer la variable
|
||||
Variable Options Options de variable
|
||||
Edit Data Set Modifier le jeu de données
|
||||
Define a variable first Définissez d’abord une variable
|
||||
Text Texte
|
||||
Visibility Visibilité
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -1194,6 +1194,7 @@
|
|||
Highlight Alternate Glyphs Evidenzia glifi alternativi
|
||||
Highlight anchors on mouse over Evidenzia ancoraggi al passaggio del mouse
|
||||
Highlight Rows Evidenzia righe
|
||||
Highlight Variable Evidenzia variabile
|
||||
Highlight Substituted Fonts Evidenzia font sostituiti
|
||||
Highlight Substituted Glyphs Evidenzia glifi sostituiti
|
||||
History Cronologia
|
||||
|
|
@ -3055,6 +3056,38 @@
|
|||
V: V:
|
||||
Variable Width Profile Profilo larghezza variabile
|
||||
Variables Variabili
|
||||
New Variable Nuova variabile
|
||||
Delete Variable Elimina variabile
|
||||
List Variables Elenca variabili
|
||||
Bind Variable Collega variabile
|
||||
Unbind Variable Scollega variabile
|
||||
New Data Set Nuovo set di dati
|
||||
Delete Data Set Elimina set di dati
|
||||
List Data Sets Elenca set di dati
|
||||
Select Data Set Seleziona set di dati
|
||||
Next Data Set Set di dati successivo
|
||||
Previous Data Set Set di dati precedente
|
||||
Data Sets Set di dati
|
||||
New Variable… Nuova variabile…
|
||||
New Data Set… Nuovo set di dati…
|
||||
Edit Data Set… Modifica set di dati…
|
||||
Data Set Set di dati
|
||||
Kind Tipo
|
||||
Variable Variabile
|
||||
no document nessun documento
|
||||
no data set to edit nessun set di dati da modificare
|
||||
Make Text Dynamic Rendi testo dinamico
|
||||
Make Visibility Dynamic Rendi visibilità dinamica
|
||||
Capture Data Set Cattura set di dati
|
||||
Update Data Set Aggiorna set di dati
|
||||
Rename Data Set… Rinomina set di dati…
|
||||
Variable Options… Opzioni variabile…
|
||||
Select Bound Object Seleziona oggetto associato
|
||||
Select All Bound Objects Seleziona tutti gli oggetti associati
|
||||
Multiple objects Più oggetti
|
||||
New Name Novo nome
|
||||
no variable to rename nessuna variabile da rinominare
|
||||
no data set to rename nessun set di dati da rinominare
|
||||
Variation: Variazione:
|
||||
Vector Effects Effetti vettoriali
|
||||
Vector Halftone Retino vettoriale
|
||||
|
|
@ -3999,3 +4032,10 @@ effect Regular Normale
|
|||
Blocks Blocchi
|
||||
Frosted Smerigliato
|
||||
Tiny Lens Lente piccola
|
||||
Rename Data Set Rinomina set di dati
|
||||
Rename Variable Rinomina variabile
|
||||
Variable Options Opzioni variabile
|
||||
Edit Data Set Modifica set di dati
|
||||
Define a variable first Definisci prima una variabile
|
||||
Text Testo
|
||||
Visibility Visibilità
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -569,6 +569,39 @@
|
|||
Paragraph Styles 段落スタイル
|
||||
Tabs タブ
|
||||
Variables 変数
|
||||
New Variable 新規変数
|
||||
Delete Variable 変数を削除
|
||||
List Variables 変数一覧
|
||||
Bind Variable 変数をバインド
|
||||
Unbind Variable 変数のバインドを解除
|
||||
New Data Set 新規データセット
|
||||
Delete Data Set データセットを削除
|
||||
List Data Sets データセット一覧
|
||||
Select Data Set データセットを選択
|
||||
Next Data Set 次のデータセット
|
||||
Previous Data Set 前のデータセット
|
||||
Data Sets データセット
|
||||
New Variable… 新規変数…
|
||||
New Data Set… 新規データセット…
|
||||
Edit Data Set… データセットを編集…
|
||||
Data Set データセット
|
||||
Kind 種類
|
||||
Variable 変数
|
||||
no document ドキュメントがありません
|
||||
no data set to edit 編集するデータセットがありません
|
||||
Make Text Dynamic テキストを動的にする
|
||||
Make Visibility Dynamic 表示を動的にする
|
||||
Capture Data Set データセットを取得
|
||||
Update Data Set データセットを更新
|
||||
Rename Data Set… データセットの名前を変更…
|
||||
Variable Options… 変数のオプション…
|
||||
Select Bound Object バインドされたオブジェクトを選択
|
||||
Select All Bound Objects すべてのバインドされたオブジェクトを選択
|
||||
Multiple objects 複数のオブジェクト
|
||||
New Name 新しい名前
|
||||
Rename Variable 変数の名前を変更
|
||||
no variable to rename 名前を変更する変数がありません
|
||||
no data set to rename 名前を変更するデータセットがありません
|
||||
Brush Libraries ブラシライブラリ
|
||||
Built-in Libraries 内蔵ライブラリ
|
||||
User Defined ユーザー定義
|
||||
|
|
@ -1415,6 +1448,7 @@
|
|||
High Performance (discrete) 高パフォーマンス (専用 GPU)
|
||||
Highlight Alternate Glyphs 代替字形をハイライト
|
||||
Highlight Rows 行をハイライト
|
||||
Highlight Variable 変数をハイライト表示
|
||||
Highlight Substituted Fonts 置換フォントをハイライト
|
||||
Highlight Substituted Glyphs 置換字形をハイライト
|
||||
Highlight anchors on mouse over マウスオーバー時にアンカーをハイライト
|
||||
|
|
@ -3992,3 +4026,9 @@ effect Vertical 垂直
|
|||
Blocks ブロック
|
||||
Frosted すりガラス
|
||||
Tiny Lens 小さなレンズ
|
||||
Rename Data Set データセットの名前を変更
|
||||
Variable Options 変数のオプション
|
||||
Edit Data Set データセットを編集
|
||||
Define a variable first 先に変数を定義してください
|
||||
Text テキスト
|
||||
Visibility 表示状態
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -2530,6 +2530,20 @@
|
|||
V: V:
|
||||
Variable Width Profile Perfil de Largura Variável
|
||||
Variables Variáveis
|
||||
New Variable… Nova variável…
|
||||
Unbind Variable Desassociar variável
|
||||
Delete Variable Excluir variável
|
||||
New Data Set… Novo conjunto de dados…
|
||||
Edit Data Set… Editar conjunto de dados…
|
||||
Delete Data Set Excluir conjunto de dados
|
||||
Next Data Set Próximo conjunto de dados
|
||||
Previous Data Set Conjunto de dados anterior
|
||||
Make Text Dynamic Tornar texto dinâmico
|
||||
Make Visibility Dynamic Tornar visibilidade dinâmica
|
||||
Capture Data Set Capturar conjunto de dados
|
||||
Update Data Set Atualizar conjunto de dados
|
||||
Rename Data Set… Renomear conjunto de dados…
|
||||
Variable Options… Opções de variável…
|
||||
Variation: Variação:
|
||||
Vector Effects Efeitos Vetoriais
|
||||
Vector Halftone Meio-tom Vetorial
|
||||
|
|
@ -2738,3 +2752,5 @@
|
|||
Diffuse Glow… Brilho difuso…
|
||||
Glass… Vidro…
|
||||
Ocean Ripple… Ondulação oceânica…
|
||||
Rename Data Set Renomear conjunto de dados
|
||||
Rename Variable Renomear variável
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -1192,6 +1192,7 @@
|
|||
Highlight Alternate Glyphs Выделять альтернативные глифы
|
||||
Highlight anchors on mouse over Выделять опорные точки при наведении
|
||||
Highlight Rows Выделять строки
|
||||
Highlight Variable Подсветить переменную
|
||||
Highlight Substituted Fonts Выделять заменённые шрифты
|
||||
Highlight Substituted Glyphs Выделять заменённые глифы
|
||||
History История
|
||||
|
|
@ -3051,6 +3052,38 @@
|
|||
V: В:
|
||||
Variable Width Profile Профиль переменной ширины
|
||||
Variables Переменные
|
||||
New Variable Новая переменная
|
||||
Delete Variable Удалить переменную
|
||||
List Variables Перечислить переменные
|
||||
Bind Variable Привязать переменную
|
||||
Unbind Variable Отвязать переменную
|
||||
New Data Set Новый набор данных
|
||||
Delete Data Set Удалить набор данных
|
||||
List Data Sets Перечислить наборы данных
|
||||
Select Data Set Выделить набор данных
|
||||
Next Data Set Следующий набор данных
|
||||
Previous Data Set Предыдущий набор данных
|
||||
Data Sets Наборы данных
|
||||
New Variable… Новая переменная…
|
||||
New Data Set… Новый набор данных…
|
||||
Edit Data Set… Изменить набор данных…
|
||||
Data Set Набор данных
|
||||
Kind Тип
|
||||
Variable Переменная
|
||||
no document нет документа
|
||||
no data set to edit нет набора данных для изменения
|
||||
Make Text Dynamic Сделать текст динамическим
|
||||
Make Visibility Dynamic Сделать видимость динамической
|
||||
Capture Data Set Записать набор данных
|
||||
Update Data Set Обновить набор данных
|
||||
Rename Data Set… Переименовать набор данных…
|
||||
Variable Options… Параметры переменной…
|
||||
Select Bound Object Выбрать связанный объект
|
||||
Select All Bound Objects Выбрать все связанные объекты
|
||||
Multiple objects Несколько объектов
|
||||
New Name Новое имя
|
||||
no variable to rename нет переменной для переименования
|
||||
no data set to rename нет набора данных для переименования
|
||||
Variation: Вариация:
|
||||
Vector Effects Векторные эффекты
|
||||
Vector Halftone Векторный полутон
|
||||
|
|
@ -3995,3 +4028,10 @@ axis Both Оба
|
|||
Blocks Блоки
|
||||
Frosted Матовое
|
||||
Tiny Lens Мелкие линзы
|
||||
Rename Data Set Переименовать набор данных
|
||||
Rename Variable Переименовать переменную
|
||||
Variable Options Параметры переменной
|
||||
Edit Data Set Изменить набор данных
|
||||
Define a variable first Сначала определите переменную
|
||||
Text Текст
|
||||
Visibility Видимость
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -1188,6 +1188,7 @@
|
|||
Highlight Alternate Glyphs Виділяти альтернативні гліфи
|
||||
Highlight anchors on mouse over Виділяти опорні точки при наведенні
|
||||
Highlight Rows Виділяти рядки
|
||||
Highlight Variable Підсвітити змінну
|
||||
Highlight Substituted Fonts Виділяти замінені шрифти
|
||||
Highlight Substituted Glyphs Виділяти замінені гліфи
|
||||
History Історія
|
||||
|
|
@ -3047,6 +3048,38 @@
|
|||
V: В:
|
||||
Variable Width Profile Профіль змінної ширини
|
||||
Variables Змінні
|
||||
New Variable Нова змінна
|
||||
Delete Variable Видалити змінну
|
||||
List Variables Перерахувати змінні
|
||||
Bind Variable Прив'язати змінну
|
||||
Unbind Variable Відв'язати змінну
|
||||
New Data Set Новий набір даних
|
||||
Delete Data Set Видалити набір даних
|
||||
List Data Sets Перерахувати набори даних
|
||||
Select Data Set Виділити набір даних
|
||||
Next Data Set Наступний набір даних
|
||||
Previous Data Set Попередній набір даних
|
||||
Data Sets Набори даних
|
||||
New Variable… Нова змінна…
|
||||
New Data Set… Новий набір даних…
|
||||
Edit Data Set… Змінити набір даних…
|
||||
Data Set Набір даних
|
||||
Kind Тип
|
||||
Variable Змінна
|
||||
no document немає документа
|
||||
no data set to edit немає набору даних для зміни
|
||||
Make Text Dynamic Зробити текст динамічним
|
||||
Make Visibility Dynamic Зробити видимість динамічною
|
||||
Capture Data Set Записати набір даних
|
||||
Update Data Set Оновити набір даних
|
||||
Rename Data Set… Перейменувати набір даних…
|
||||
Variable Options… Параметри змінної…
|
||||
Select Bound Object Вибрати пов’язаний об’єкт
|
||||
Select All Bound Objects Вибрати всі пов’язані об’єкти
|
||||
Multiple objects Кілька об’єктів
|
||||
New Name Нова назва
|
||||
no variable to rename немає змінної для перейменування
|
||||
no data set to rename немає набору даних для перейменування
|
||||
Variation: Варіація:
|
||||
Vector Effects Векторні ефекти
|
||||
Vector Halftone Векторний напівтон
|
||||
|
|
@ -3980,3 +4013,10 @@ axis Both Обидві
|
|||
Blocks Блоки
|
||||
Frosted Матове
|
||||
Tiny Lens Дрібні лінзи
|
||||
Rename Data Set Перейменувати набір даних
|
||||
Rename Variable Перейменувати змінну
|
||||
Variable Options Параметри змінної
|
||||
Edit Data Set Змінити набір даних
|
||||
Define a variable first Спочатку визначте змінну
|
||||
Text Текст
|
||||
Visibility Видимість
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 13.
|
|
|
@ -2947,6 +2947,38 @@
|
|||
V: V:
|
||||
Variable Width Profile 变量宽度配置文件
|
||||
Variables 变量
|
||||
New Variable 新建变量
|
||||
Delete Variable 删除变量
|
||||
List Variables 列出变量
|
||||
Bind Variable 绑定变量
|
||||
Unbind Variable 解除变量绑定
|
||||
New Data Set 新建数据集
|
||||
Delete Data Set 删除数据集
|
||||
List Data Sets 列出数据集
|
||||
Select Data Set 选择数据集
|
||||
Next Data Set 下一个数据集
|
||||
Previous Data Set 上一个数据集
|
||||
Data Sets 数据集
|
||||
New Variable… 新建变量…
|
||||
New Data Set… 新建数据集…
|
||||
Edit Data Set… 编辑数据集…
|
||||
Data Set 数据集
|
||||
Kind 类型
|
||||
Variable 变量
|
||||
no document 没有文档
|
||||
no data set to edit 没有可编辑的数据集
|
||||
Make Text Dynamic 使文本动态化
|
||||
Make Visibility Dynamic 使可见性动态化
|
||||
Capture Data Set 捕获数据集
|
||||
Update Data Set 更新数据集
|
||||
Rename Data Set… 重命名数据集…
|
||||
Variable Options… 变量选项…
|
||||
Select Bound Object 选择绑定的对象
|
||||
Select All Bound Objects 选择所有绑定的对象
|
||||
Multiple objects 多个对象
|
||||
New Name 新名称
|
||||
no variable to rename 没有可重命名的变量
|
||||
no data set to rename 没有可重命名的数据集
|
||||
Variation: 变化:
|
||||
Vector Effects 矢量效果
|
||||
Vector Halftone 矢量半色调
|
||||
|
|
@ -3394,6 +3426,7 @@
|
|||
Hide Others 隐藏其他
|
||||
High Performance (discrete) 高性能 (独立显卡)
|
||||
Highlight Rows 突出显示行
|
||||
Highlight Variable 突出显示变量
|
||||
Lock Others 锁定其他
|
||||
Merge Selected 合并所选图层
|
||||
Move Rows 移动行
|
||||
|
|
@ -3628,3 +3661,10 @@ effect Vertical 垂直
|
|||
Blocks 块状
|
||||
Frosted 磨砂
|
||||
Tiny Lens 小镜头
|
||||
Rename Data Set 重命名数据集
|
||||
Rename Variable 重命名变量
|
||||
Variable Options 变量选项
|
||||
Edit Data Set 编辑数据集
|
||||
Define a variable first 请先定义一个变量
|
||||
Text 文本
|
||||
Visibility 可见性
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -1170,6 +1170,7 @@
|
|||
Highlight Alternate Glyphs 醒目提示替代字符
|
||||
Highlight anchors on mouse over 滑鼠移過時醒目提示錨點
|
||||
Highlight Rows 反白顯示列
|
||||
Highlight Variable 突出顯示變數
|
||||
Highlight Substituted Fonts 醒目提示被取代的字體
|
||||
Highlight Substituted Glyphs 醒目提示被取代的字符
|
||||
History 步驟記錄
|
||||
|
|
@ -3038,6 +3039,38 @@
|
|||
V: V:
|
||||
Variable Width Profile 可變寬度描述檔
|
||||
Variables 變數
|
||||
New Variable 新增變數
|
||||
Delete Variable 刪除變數
|
||||
List Variables 列出變數
|
||||
Bind Variable 綁定變數
|
||||
Unbind Variable 解除變數綁定
|
||||
New Data Set 新增資料集
|
||||
Delete Data Set 刪除資料集
|
||||
List Data Sets 列出資料集
|
||||
Select Data Set 選取資料集
|
||||
Next Data Set 下一個資料集
|
||||
Previous Data Set 上一個資料集
|
||||
Data Sets 資料集
|
||||
New Variable… 新增變數…
|
||||
New Data Set… 新增資料集…
|
||||
Edit Data Set… 編輯資料集…
|
||||
Data Set 資料集
|
||||
Kind 類型
|
||||
Variable 變數
|
||||
no document 沒有文件
|
||||
no data set to edit 沒有可編輯的資料集
|
||||
Make Text Dynamic 使文字動態化
|
||||
Make Visibility Dynamic 使可見性動態化
|
||||
Capture Data Set 擷取資料集
|
||||
Update Data Set 更新資料集
|
||||
Rename Data Set… 重新命名資料集…
|
||||
Variable Options… 變數選項…
|
||||
Select Bound Object 選取繫結的物件
|
||||
Select All Bound Objects 選取所有繫結的物件
|
||||
Multiple objects 多個物件
|
||||
New Name 新名稱
|
||||
no variable to rename 沒有可重新命名的變數
|
||||
no data set to rename 沒有可重新命名的資料集
|
||||
Variation: 變化:
|
||||
Vector Effects 向量效果
|
||||
Vector Halftone 向量半色調
|
||||
|
|
@ -3409,3 +3442,10 @@ effect Soft 柔和
|
|||
Blocks 塊狀
|
||||
Frosted 磨砂
|
||||
Tiny Lens 小鏡頭
|
||||
Rename Data Set 重新命名資料集
|
||||
Rename Variable 重新命名變數
|
||||
Variable Options 變數選項
|
||||
Edit Data Set 編輯資料集
|
||||
Define a variable first 請先定義一個變數
|
||||
Text 文字
|
||||
Visibility 可見性
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -47,6 +47,7 @@ fn sub(label: &'static str, items: Vec<Item>) -> Item {
|
|||
fn library_placeholders() -> Vec<Item> {
|
||||
vec![todo("Built-in Libraries"), todo("User Defined"), Sep, todo("Other Library…")]
|
||||
}
|
||||
|
||||
use Item::Sep;
|
||||
|
||||
/// UI-level commands: (id, label, shortcut, params doc).
|
||||
|
|
@ -2548,7 +2549,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec<Item>)> {
|
|||
panel("Tabs", "tabs"),
|
||||
],
|
||||
),
|
||||
todo("Variables"),
|
||||
panel("Variables", crate::panels::variables::ID),
|
||||
Sep,
|
||||
sub("Brush Libraries", library_placeholders()),
|
||||
sub("Graphic Style Libraries", crate::panels::graphic_styles::window_menu()),
|
||||
|
|
@ -2992,6 +2993,11 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) {
|
|||
}
|
||||
return;
|
||||
}
|
||||
// Variables (data merge): a name and a kind, a name and a row of values.
|
||||
if crate::dialogs::variables::opens(id) && p.as_object().is_none_or(|o| o.is_empty()) {
|
||||
crate::dialogs::variables::open(app, id, None);
|
||||
return;
|
||||
}
|
||||
// Repeat Options: a dialog with the selected repeat's current values.
|
||||
if id == "object.repeat.options"
|
||||
&& p.as_object().is_none_or(|o| o.is_empty())
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ pub mod tabs;
|
|||
pub mod text_styles;
|
||||
pub mod transform;
|
||||
pub mod transparency;
|
||||
pub mod variables;
|
||||
|
||||
use egui::{Rect, Sense, Ui, vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
|
@ -136,6 +137,7 @@ pub fn show_icon_panel(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) {
|
|||
"tabs" => tabs::show(app, ui),
|
||||
flattener_preview::ID => flattener_preview::show(app, ui),
|
||||
"attributes" => attributes::show(app, ui),
|
||||
variables::ID => variables::show(app, ui),
|
||||
"colorThemes" => color_themes::show(app, ui),
|
||||
links::ID => links::show(app, ui),
|
||||
asset_export::ID => asset_export::show(app, ui),
|
||||
|
|
@ -187,6 +189,7 @@ pub fn panel_menu_items(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) -> bool
|
|||
links::ID => links::menu(app, ui),
|
||||
asset_export::ID => asset_export::menu(app, ui),
|
||||
css_properties::ID => css_properties::menu(app, ui),
|
||||
variables::ID => variables::menu(app, ui),
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
|
|
|
|||
|
|
@ -0,0 +1,468 @@
|
|||
//! Variables panel: the document's variables (what they drive, which objects) and its
|
||||
//! datasets. Clicking a dataset applies it; clicking a variable selects what it binds; the
|
||||
//! arrows step to the next or previous one. Everything the reference panel does here runs a
|
||||
//! `variable.*` / `dataset.*` command, so the panel and an agent drive the document the same
|
||||
//! way — the commands are also in Window › Variables, the palette and over MCP.
|
||||
//!
|
||||
//! A row's name goes in italics when an edit has moved the art away from what the row says:
|
||||
//! the document then no longer matches the active dataset, and "Update Data Set" writes the
|
||||
//! art back into it.
|
||||
|
||||
use egui::Ui;
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::{NodeId, NodeKind, VariableKind};
|
||||
use vectorcraft_engine::DocState;
|
||||
|
||||
use crate::VectorcraftApp;
|
||||
use crate::widgets;
|
||||
|
||||
/// The panel id, as the dock, the Window menu and `window.panel` name it.
|
||||
pub const ID: &str = "variables";
|
||||
|
||||
/// One variable row: its kind's icon, its name, and the object it drives.
|
||||
struct VarRow {
|
||||
name: String,
|
||||
kind: VariableKind,
|
||||
bound: Vec<u64>,
|
||||
/// The name of the object it drives, or "Multiple objects" when it drives more than one.
|
||||
object: String,
|
||||
}
|
||||
|
||||
/// One dataset row: its name and how many values it carries.
|
||||
struct SetRow {
|
||||
name: String,
|
||||
values: usize,
|
||||
active: bool,
|
||||
/// False once an edit has moved the art away from what this row says.
|
||||
matches: bool,
|
||||
}
|
||||
|
||||
/// The row the panel acts on: Delete, Options… and Select Bound Object all mean it. A name
|
||||
/// the document no longer defines highlights nothing (deleted from the palette, undone).
|
||||
fn highlighted_row(st: &DocState) -> Option<String> {
|
||||
st.variables_highlight.clone().filter(|h| st.doc.variables.variable(h).is_some())
|
||||
}
|
||||
|
||||
/// What a variable drives, as object ids: what Select Bound Object selects.
|
||||
fn bound_objects(st: &DocState, name: &str) -> Vec<u64> {
|
||||
st.doc.variables.objects_of(name).iter().map(|i| i.0).collect()
|
||||
}
|
||||
|
||||
/// A row click: highlight the row, and select what it drives (its bindings are left alone).
|
||||
pub(crate) fn click_row(app: &mut VectorcraftApp, name: &str, bound: &[u64]) {
|
||||
app.run("variable.highlight", json!({"variable": name})).ok();
|
||||
if !bound.is_empty() {
|
||||
app.run("select.set", json!({"ids": bound})).ok();
|
||||
}
|
||||
}
|
||||
|
||||
/// Select Bound Object: select what the highlighted variable drives; its bindings stay.
|
||||
pub(crate) fn select_bound_object(app: &mut VectorcraftApp) {
|
||||
let ids = app.session.active().map(|st| highlighted_row(st).map(|h| bound_objects(st, &h))).unwrap_or_default().unwrap_or_default();
|
||||
if !ids.is_empty() {
|
||||
app.run("select.set", json!({"ids": ids})).ok();
|
||||
}
|
||||
}
|
||||
|
||||
/// What the selection allows the bottom bar's and the panel menu's commands to do.
|
||||
struct Picked {
|
||||
selected: Vec<u64>,
|
||||
has_selection: bool,
|
||||
/// Unbind needs a bound object under the selection, not just any selection.
|
||||
selection_bound: bool,
|
||||
/// Make Text Dynamic needs exactly one type object: a text variable only drives type.
|
||||
single_type: bool,
|
||||
}
|
||||
|
||||
fn picked(st: &vectorcraft_engine::DocState) -> Picked {
|
||||
let selected: Vec<u64> = st.selection.objects.iter().map(|i| i.0).collect();
|
||||
let selection_bound = selected.iter().any(|id| st.doc.variables.bindings.contains_key(&NodeId(*id)));
|
||||
let single_type = matches!(selected.as_slice(), [one] if st.doc.node(NodeId(*one)).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))));
|
||||
Picked { has_selection: !selected.is_empty(), selected, selection_bound, single_type }
|
||||
}
|
||||
|
||||
/// Delete Variable: the highlighted variable only (the command's default); the rest stay.
|
||||
pub(crate) fn delete_highlighted(app: &mut VectorcraftApp) {
|
||||
app.run("variable.delete", json!({})).ok();
|
||||
}
|
||||
|
||||
/// Delete Data Set: the current one only (the command's default); the rest of the rows stay.
|
||||
pub(crate) fn delete_active_dataset(app: &mut VectorcraftApp) {
|
||||
app.run("dataset.delete", json!({})).ok();
|
||||
}
|
||||
|
||||
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let Some(st) = app.session.active() else {
|
||||
widgets::dim_label(ui, tl!("No document"));
|
||||
return;
|
||||
};
|
||||
// Owned snapshots: the rows below run commands, which borrow the app mutably.
|
||||
let vars: Vec<VarRow> = st
|
||||
.doc
|
||||
.variables
|
||||
.variables
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let bound: Vec<u64> = st.doc.variables.objects_of(&v.name).iter().map(|i| i.0).collect();
|
||||
let object = match bound.as_slice() {
|
||||
[] => String::new(),
|
||||
[one] => st.doc.node(NodeId(*one)).map(|n| n.display_name()).unwrap_or_default(),
|
||||
_ => tl!("Multiple objects").to_string(),
|
||||
};
|
||||
VarRow { name: v.name.clone(), kind: v.kind, bound, object }
|
||||
})
|
||||
.collect();
|
||||
// What the art holds now, read once for every row's drift check.
|
||||
let current = st.doc.captured_values();
|
||||
let sets: Vec<SetRow> = st
|
||||
.doc
|
||||
.variables
|
||||
.datasets
|
||||
.iter()
|
||||
.map(|d| SetRow {
|
||||
name: d.name.clone(),
|
||||
values: d.values.len(),
|
||||
active: st.doc.variables.active_dataset.as_deref() == Some(d.name.as_str()),
|
||||
matches: d.matches(¤t),
|
||||
})
|
||||
.collect();
|
||||
let active = sets.iter().find(|s| s.active);
|
||||
let Picked { selected, has_selection, selection_bound, single_type } = picked(st);
|
||||
let highlighted = highlighted_row(st);
|
||||
|
||||
// The current data set across the top: its name, and the arrows that step through them.
|
||||
ui.horizontal(|ui| {
|
||||
widgets::field_label(ui, tl!("Data Set"));
|
||||
let names: Vec<&str> = sets.iter().map(|s| s.name.as_str()).collect();
|
||||
let current = active.map(|s| s.name.as_str()).unwrap_or("");
|
||||
let picked = widgets::dropdown_names(ui, "variables-active", current, &names, 160.0);
|
||||
if let Some(name) = picked.and_then(|i| names.get(i))
|
||||
&& *name != current
|
||||
{
|
||||
app.run("dataset.select", json!({"name": name})).ok();
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let has = !sets.is_empty();
|
||||
if widgets::icon_button_enabled(ui, "chevron-left", tl!("Previous Data Set"), false, has, 24.0).clicked() {
|
||||
app.run("dataset.prev", json!({})).ok();
|
||||
}
|
||||
if widgets::icon_button_enabled(ui, "chevron-right", tl!("Next Data Set"), false, has, 24.0).clicked() {
|
||||
app.run("dataset.next", json!({})).ok();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
widgets::subheader(ui, tl!("Variables"));
|
||||
if vars.is_empty() {
|
||||
widgets::dim_label(ui, tl!("None"));
|
||||
}
|
||||
for row in &vars {
|
||||
ui.horizontal(|ui| {
|
||||
// The kind's own icon, so a text variable and a visibility one read apart.
|
||||
let icon = match row.kind {
|
||||
VariableKind::Text => "type",
|
||||
VariableKind::Visibility => "eye",
|
||||
};
|
||||
ui.add(egui::Image::new(crate::icons::source(icon)).fit_to_exact_size(egui::vec2(14.0, 14.0)));
|
||||
// The object a variable drives is named beside it, as the reference panel does.
|
||||
let text = if row.object.is_empty() { row.name.clone() } else { format!("{} · {}", row.name, row.object) };
|
||||
let r = ui.selectable_label(highlighted.as_deref() == Some(row.name.as_str()), text);
|
||||
// A click highlights the row and selects what it drives; a double click opens
|
||||
// Variable Options for it.
|
||||
if r.clicked() {
|
||||
click_row(app, &row.name, &row.bound);
|
||||
}
|
||||
if r.double_clicked() {
|
||||
crate::dialogs::variables::open(app, "variable.rename", Some(&row.name));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
widgets::subheader(ui, tl!("Data Sets"));
|
||||
if sets.is_empty() {
|
||||
widgets::dim_label(ui, tl!("None"));
|
||||
}
|
||||
for row in &sets {
|
||||
// The active row is ticked and selected; a row the art has drifted from is in
|
||||
// italics, so an edit that left the document not matching it is visible.
|
||||
let label = format!("{}{} · {}", if row.active { "● " } else { "" }, row.name, row.values);
|
||||
let mut text = egui::RichText::new(label);
|
||||
if !row.matches {
|
||||
text = text.italics();
|
||||
}
|
||||
if ui.selectable_label(row.active, text).clicked() && !row.active {
|
||||
app.run("dataset.select", json!({"name": row.name})).ok();
|
||||
}
|
||||
}
|
||||
|
||||
// The bottom bar: make the selection dynamic, and define and delete what the lists show.
|
||||
ui.separator();
|
||||
ui.horizontal(|ui| {
|
||||
if widgets::icon_button_enabled(ui, "type", tl!("Make Text Dynamic"), false, single_type, 24.0).clicked() {
|
||||
app.run("variable.makeTextDynamic", json!({})).ok();
|
||||
}
|
||||
if widgets::icon_button_enabled(ui, "eye", tl!("Make Visibility Dynamic"), false, has_selection, 24.0).clicked() {
|
||||
app.run("variable.makeVisibilityDynamic", json!({})).ok();
|
||||
}
|
||||
if widgets::icon_button(ui, "plus", tl!("New Variable…"), false, 24.0).clicked() {
|
||||
crate::menus::invoke(app, "variable.define", json!({}));
|
||||
}
|
||||
if widgets::icon_button_enabled(ui, "link-2-off", tl!("Unbind Variable"), false, selection_bound, 24.0).clicked() {
|
||||
app.run("variable.unbind", json!({"ids": selected})).ok();
|
||||
}
|
||||
// The bin deletes the highlighted variable only, as the reference panel's does.
|
||||
if widgets::icon_button_enabled(ui, "trash-2", tl!("Delete Variable"), false, highlighted.is_some(), 24.0).clicked() {
|
||||
delete_highlighted(app);
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
// Capture records what the art holds now as a new row (paste: take it in); Update
|
||||
// writes the art back into the row that is active (save: write it out).
|
||||
if widgets::icon_button(ui, "clipboard-paste", tl!("Capture Data Set"), false, 24.0).clicked() {
|
||||
app.run("dataset.capture", json!({})).ok();
|
||||
}
|
||||
let can_update = active.is_some();
|
||||
if widgets::icon_button_enabled(ui, "save", tl!("Update Data Set"), false, can_update, 24.0).clicked() {
|
||||
app.run("dataset.update", json!({})).ok();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The panel's (≡) menu: the same commands as its bars and lists, for the ones that act on one
|
||||
/// row rather than on the selection.
|
||||
pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let vars: Vec<String> = st.doc.variables.variables.iter().map(|v| v.name.clone()).collect();
|
||||
let sets: Vec<String> = st.doc.variables.datasets.iter().map(|d| d.name.clone()).collect();
|
||||
let active = st.doc.variables.active_dataset.clone();
|
||||
let bound: Vec<u64> = st.doc.variables.bindings.keys().map(|i| i.0).collect();
|
||||
let highlighted = highlighted_row(st);
|
||||
let highlighted_bound: Vec<u64> = highlighted.as_ref().map(|h| bound_objects(st, h)).unwrap_or_default();
|
||||
let Picked { selected, has_selection, selection_bound, single_type } = picked(st);
|
||||
|
||||
if widgets::menu_item(ui, tl!("New Variable…"), true, false) {
|
||||
crate::menus::invoke(app, "variable.define", json!({}));
|
||||
}
|
||||
// Variable Options renames the highlighted variable, else the first.
|
||||
if widgets::menu_item(ui, tl!("Variable Options…"), !vars.is_empty(), false) {
|
||||
let name = highlighted.clone().or_else(|| vars.first().cloned()).unwrap_or_default();
|
||||
crate::dialogs::variables::open(app, "variable.rename", Some(&name));
|
||||
}
|
||||
if widgets::menu_item(ui, tl!("Make Text Dynamic"), single_type, false) {
|
||||
app.run("variable.makeTextDynamic", json!({})).ok();
|
||||
}
|
||||
if widgets::menu_item(ui, tl!("Make Visibility Dynamic"), has_selection, false) {
|
||||
app.run("variable.makeVisibilityDynamic", json!({})).ok();
|
||||
}
|
||||
if widgets::menu_item(ui, tl!("Unbind Variable"), selection_bound, false) {
|
||||
app.run("variable.unbind", json!({"ids": selected})).ok();
|
||||
}
|
||||
if widgets::menu_item(ui, tl!("Delete Variable"), highlighted.is_some(), false) {
|
||||
delete_highlighted(app);
|
||||
}
|
||||
ui.separator();
|
||||
if widgets::menu_item(ui, tl!("Capture Data Set"), true, false) {
|
||||
app.run("dataset.capture", json!({})).ok();
|
||||
}
|
||||
if widgets::menu_item(ui, tl!("Update Data Set"), active.is_some(), false) {
|
||||
app.run("dataset.update", json!({})).ok();
|
||||
}
|
||||
if widgets::menu_item(ui, tl!("New Data Set…"), true, false) {
|
||||
crate::menus::invoke(app, "dataset.new", json!({}));
|
||||
}
|
||||
if widgets::menu_item(ui, tl!("Edit Data Set…"), active.is_some(), false) {
|
||||
crate::menus::invoke(app, "dataset.set", json!({}));
|
||||
}
|
||||
if widgets::menu_item(ui, tl!("Rename Data Set…"), !sets.is_empty(), false) {
|
||||
let name = active.clone().or_else(|| sets.first().cloned()).unwrap_or_default();
|
||||
crate::dialogs::variables::open(app, "dataset.rename", Some(&name));
|
||||
}
|
||||
// Only the current data set goes: the rest of the rows stay.
|
||||
if widgets::menu_item(ui, tl!("Delete Data Set"), active.is_some(), false) {
|
||||
delete_active_dataset(app);
|
||||
}
|
||||
ui.separator();
|
||||
// What the highlighted variable drives, selected — its bindings are left alone.
|
||||
if widgets::menu_item(ui, tl!("Select Bound Object"), !highlighted_bound.is_empty(), false) {
|
||||
select_bound_object(app);
|
||||
}
|
||||
if widgets::menu_item(ui, tl!("Select All Bound Objects"), !bound.is_empty(), false) {
|
||||
app.run("select.set", json!({"ids": bound})).ok();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn app_with_data() -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
|
||||
let t = app.session.execute("text.create", &json!({"x": 10, "y": 40, "text": "Alice"})).unwrap()["id"].as_u64().unwrap();
|
||||
app.session.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
|
||||
app.session.execute("variable.bind", &json!({"variable": "Name", "ids": [t]})).unwrap();
|
||||
app.session.execute("dataset.capture", &json!({})).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draws_headless() {
|
||||
let mut app = app_with_data();
|
||||
let ctx = egui::Context::default();
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut app, ui));
|
||||
out.textures_delta.clear();
|
||||
let text = crate::tests_labels::painted_text(&mut app, show);
|
||||
assert!(text.contains("Name") && text.contains("Data Sets") && text.contains("Data Set 1"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_row_the_art_left_behind_is_in_italics() {
|
||||
let mut app = app_with_data();
|
||||
let t = app.session.doc().unwrap().doc.variables.objects_of("Name")[0];
|
||||
// The document no longer matches the row once the art is edited.
|
||||
app.session.execute("text.editRange", &json!({"id": t.0, "insert": "Bob"})).unwrap();
|
||||
let text = crate::tests_labels::painted_text(&mut app, show);
|
||||
assert!(text.contains("Data Set 1"), "{text}");
|
||||
// …and the row's drift is what `dataset.list` reports, so an agent sees it too.
|
||||
let sets = app.run("dataset.list", json!({})).unwrap();
|
||||
assert_eq!(sets["datasets"][0]["matches"], json!(false));
|
||||
// Updating writes the art back into the row.
|
||||
app.run("dataset.update", json!({})).unwrap();
|
||||
assert_eq!(app.run("dataset.list", json!({})).unwrap()["datasets"][0]["matches"], json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_panel_menu_lists_the_row_commands() {
|
||||
let mut app = app_with_data();
|
||||
let ctx = egui::Context::default();
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| menu(&mut app, ui));
|
||||
out.textures_delta.clear();
|
||||
let text = crate::tests_labels::painted_text(&mut app, menu);
|
||||
for item in [
|
||||
"New Variable…",
|
||||
"Variable Options…",
|
||||
"Capture Data Set",
|
||||
"Update Data Set",
|
||||
"Rename Data Set…",
|
||||
"Delete Data Set",
|
||||
"Select Bound Object",
|
||||
"Select All Bound Objects",
|
||||
] {
|
||||
assert!(text.contains(item), "{item} missing from the panel menu:\n{text}");
|
||||
}
|
||||
}
|
||||
|
||||
/// What the panel acts on is the highlight, and only while it names a variable: a stale one
|
||||
/// (deleted elsewhere, undone) highlights nothing, so Delete and Options… stay disabled.
|
||||
#[test]
|
||||
fn a_highlight_of_a_deleted_variable_highlights_nothing() {
|
||||
let mut app = app_with_data();
|
||||
app.session.execute("variable.highlight", &json!({"variable": "Name"})).unwrap();
|
||||
assert_eq!(highlighted_row(app.session.doc().unwrap()).as_deref(), Some("Name"));
|
||||
assert_eq!(bound_objects(app.session.doc().unwrap(), "Name").len(), 1);
|
||||
app.session.execute("variable.delete", &json!({"names": ["Name"]})).unwrap();
|
||||
assert!(highlighted_row(app.session.doc().unwrap()).is_none());
|
||||
}
|
||||
|
||||
/// The reviewer's regression case, through the calls the panel and its menu make: highlight
|
||||
/// a variable, Select Bound Object, then delete one variable and the current dataset. The
|
||||
/// selection lands on what the row drives with the binding intact, and the others remain.
|
||||
#[test]
|
||||
fn selecting_and_deleting_act_on_the_highlighted_row_only() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
|
||||
let a = app.session.execute("text.create", &json!({"x": 10, "y": 40, "text": "Alice"})).unwrap()["id"].as_u64().unwrap();
|
||||
let b = app.session.execute("text.create", &json!({"x": 10, "y": 80, "text": "Bob"})).unwrap()["id"].as_u64().unwrap();
|
||||
for (name, id) in [("Name", a), ("Other", b)] {
|
||||
app.session.execute("variable.define", &json!({"name": name, "kind": "text"})).unwrap();
|
||||
app.session.execute("variable.bind", &json!({"variable": name, "ids": [id]})).unwrap();
|
||||
}
|
||||
app.session.execute("dataset.capture", &json!({})).unwrap();
|
||||
app.session.execute("dataset.capture", &json!({})).unwrap();
|
||||
app.session.execute("dataset.select", &json!({"name": "Data Set 1"})).unwrap();
|
||||
|
||||
// A row click highlights; Select Bound Object selects what it drives, binding intact.
|
||||
click_row(&mut app, "Name", &[a]);
|
||||
assert_eq!(app.session.doc().unwrap().selection.objects, vec![NodeId(a)]);
|
||||
assert!(!app.session.doc().unwrap().doc.variables.objects_of("Name").is_empty());
|
||||
select_bound_object(&mut app);
|
||||
assert_eq!(app.session.doc().unwrap().selection.objects, vec![NodeId(a)]);
|
||||
assert!(!app.session.doc().unwrap().doc.variables.objects_of("Name").is_empty());
|
||||
|
||||
// Delete Variable deletes the highlighted one; Delete Data Set the current one.
|
||||
delete_highlighted(&mut app);
|
||||
delete_active_dataset(&mut app);
|
||||
let vars = app.run("variable.list", json!({})).unwrap();
|
||||
assert_eq!(vars["variables"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(vars["variables"][0]["name"], "Other");
|
||||
let sets = app.run("dataset.list", json!({})).unwrap();
|
||||
assert_eq!(sets["datasets"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(sets["datasets"][0]["name"], "Data Set 2");
|
||||
|
||||
// The panel and its menu still draw with the highlight gone stale.
|
||||
let text = crate::tests_labels::painted_text(&mut app, show);
|
||||
assert!(text.contains("Other") && text.contains("Data Set 2"), "{text}");
|
||||
let text = crate::tests_labels::painted_text(&mut app, menu);
|
||||
assert!(text.contains("Select Bound Object"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_panel_is_registered_with_its_own_icon() {
|
||||
let row = crate::state::ICON_PANELS.iter().find(|(id, ..)| *id == ID).expect("in the icon column");
|
||||
assert_eq!(row.1, "Variables");
|
||||
assert_ne!(row.2, "dc-list-view", "Document Info's icon, which the panel used to share");
|
||||
assert!(crate::icons::exists(row.2), "{} is not a bundled icon", row.2);
|
||||
}
|
||||
|
||||
/// The collapsed column draws `ICON_PANEL_GROUPS`, not `ICON_PANELS`: a panel registered
|
||||
/// but in no group opens from the Window menu and then has nowhere to collapse to. Both
|
||||
/// lists need an entry, and this is the one that was missing.
|
||||
#[test]
|
||||
fn the_panel_reaches_the_collapsed_icon_column() {
|
||||
let in_column = crate::state::ICON_PANEL_GROUPS.iter().any(|g| g.contains(&ID));
|
||||
assert!(in_column, "{ID} is in ICON_PANELS but in no ICON_PANEL_GROUPS row, so the collapsed column never draws it");
|
||||
// Every id in a group names a panel that is registered with a label and an icon.
|
||||
for group in crate::state::ICON_PANEL_GROUPS {
|
||||
for id in *group {
|
||||
let row = crate::state::ICON_PANELS.iter().find(|p| p.0 == *id).unwrap_or_else(|| panic!("{id} is in a group but not registered"));
|
||||
assert!(!row.1.is_empty() && crate::icons::exists(row.2), "{id}: {} / {}", row.1, row.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Window › Variables opens the panel, as Window's other panel items do; its commands are in
|
||||
/// the panel, its menu and the command palette.
|
||||
#[test]
|
||||
fn window_variables_opens_the_panel() {
|
||||
let app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
let entries = crate::menus::menu_entries(&app);
|
||||
let panel = entries
|
||||
.iter()
|
||||
.find(|e| e.command.as_deref() == Some("window.panel") && e.params.get("panel").and_then(serde_json::Value::as_str) == Some(ID))
|
||||
.expect("the Variables panel is listed");
|
||||
assert_eq!((panel.path.clone(), panel.label.as_str()), (vec!["Window".to_string()], "Variables"));
|
||||
assert!(!entries.iter().any(|e| e.path == ["Window", "Variables"]), "no Variables submenu");
|
||||
}
|
||||
|
||||
/// A menu item that needs a name opens the dialog rather than running the command with
|
||||
/// empty params, which would only say "missing `name`".
|
||||
#[test]
|
||||
fn the_menu_dialogs_open_instead_of_failing() {
|
||||
let mut app = app_with_data();
|
||||
for (cmd, field) in [
|
||||
("variable.define", "kind"),
|
||||
("variable.rename", "newName"),
|
||||
("variable.bind", "variable"),
|
||||
("dataset.new", "name"),
|
||||
("dataset.rename", "newName"),
|
||||
("dataset.select", "name"),
|
||||
] {
|
||||
app.ui.dialog = None;
|
||||
crate::menus::invoke(&mut app, cmd, json!({}));
|
||||
let d = app.ui.dialog.as_ref().unwrap_or_else(|| panic!("{cmd} opened no dialog"));
|
||||
assert_eq!(d.kind, crate::dialogs::variables::KIND, "{cmd}");
|
||||
assert_eq!(d.str("__command"), cmd);
|
||||
assert!(d.fields.contains_key(field), "{cmd}: no `{field}` field");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -138,9 +138,14 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[
|
|||
(crate::panels::links::ID, "Links", "link"),
|
||||
(crate::panels::asset_export::ID, "Asset Export", "share-2"),
|
||||
(crate::panels::css_properties::ID, "CSS Properties", "globe"),
|
||||
(crate::panels::variables::ID, "Variables", "chart-column"),
|
||||
];
|
||||
|
||||
/// Groups of icon panels separated by dividers in the collapsed column.
|
||||
///
|
||||
/// This, not [`ICON_PANELS`], is what the collapsed column draws: a panel listed there but in
|
||||
/// no group can be opened from the Window menu and `window.panel`, but never appears as an
|
||||
/// icon. A panel that belongs in the column needs a row here as well as its entry above.
|
||||
pub const ICON_PANEL_GROUPS: &[&[&str]] = &[
|
||||
&["color", "colorGuide"],
|
||||
&["swatches", "brushes", "symbols", "patternOptions"],
|
||||
|
|
@ -150,6 +155,7 @@ pub const ICON_PANEL_GROUPS: &[&[&str]] = &[
|
|||
&["transform", "align", "pathfinder"],
|
||||
&["character", "paragraph", "glyphs"],
|
||||
&["history", "actions", "info", "navigator"],
|
||||
&["variables"],
|
||||
];
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
|
|
|
|||
Loading…
Reference in New Issue