diff --git a/crates/engine/src/cmd/variablecmds.rs b/crates/engine/src/cmd/variablecmds.rs index a73c16448b..a49800ff32 100644 --- a/crates/engine/src/cmd/variablecmds.rs +++ b/crates/engine/src/cmd/variablecmds.rs @@ -245,39 +245,92 @@ fn define(s: &mut Session, p: &Value) -> Result { Ok(json!({"name": name, "kind": kind.label()})) } +/// Whether the object `id`, bound to the variable `name`, can stay bound to it once the variable +/// is of `kind`: a text variable drives type that holds no other text variable, an image variable +/// drives an image object, a graph variable drives a graph root, and a visibility variable drives +/// any object. Locks and hidden layers don't count, as they don't unbind anything else either. +fn keeps_binding(doc: &Document, id: NodeId, name: &str, kind: VariableKind) -> bool { + let Some(n) = doc.node(id) else { return false }; + match kind { + VariableKind::Text => { + matches!(n.kind, NodeKind::Text(_)) + && !doc.variables.bindings.get(&id).is_some_and(|bound| { + doc.variables.variables.iter().any(|v| v.kind == VariableKind::Text && v.name != name && bound.contains(&v.name)) + }) + } + VariableKind::Visibility => true, + VariableKind::Image => matches!(n.kind, NodeKind::Image(_)), + VariableKind::Graph => n.extras().graph.is_some(), + VariableKind::Unknown => false, + } +} + +/// Variable Options: rename a variable (`newName`), change its kind (`kind`), or both, in one undo +/// step, carrying its bindings and its values in every data set to the new name. A kind change +/// unbinds the objects that can't hold the new kind ([`keeps_binding`]) and drops the variable's +/// value in every data set. A call that changes nothing adds no undo step. fn rename(s: &mut Session, p: &Value) -> Result { const C: &str = "variable.rename"; let from = var_name(p, C)?; - let to = named(p.get("newName").and_then(Value::as_str), "newName", C)?; - let st = s.doc()?; - if st.doc.variables.variable(&from).is_none() { - return Err(bad(C, format!("no variable named `{from}`"))); + // An omitted key is no change; a null, like any other value that isn't a name or a kind, is refused. + let to = match p.get("newName") { + None => None, + Some(v) => Some(named(v.as_str(), "newName", C)?), + }; + let kind = match p.get("kind") { + None => None, + Some(v) => Some(v.as_str().and_then(VariableKind::parse).ok_or_else(|| bad(C, "`kind` must be `text`, `visibility`, `image` or `graph`"))?), + }; + if to.is_none() && kind.is_none() { + return Err(bad(C, "missing `newName` or `kind`")); } - if st.doc.variables.variable(&to).is_some() { + let doc = &s.doc()?.doc; + let Some(old) = doc.variables.variable(&from) else { + return Err(bad(C, format!("no variable named `{from}`"))); + }; + let to = to.unwrap_or_else(|| from.clone()); + if to != from && 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}`")))?; + let kind = kind.unwrap_or(old.kind); + let retyped = kind != old.kind; + let unbound: Vec = + if retyped { doc.variables.objects_of(&from).into_iter().filter(|id| !keeps_binding(doc, *id, &from, kind)).collect() } else { Vec::new() }; + let label = if retyped { "Variable Options" } else { "Rename Variable" }; + let dropped = edit_variables(s, label, |vars| { + let v = vars.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() { + v.kind = kind; + for id in &unbound { + vars.unbind(*id, &from); + } + for bound in vars.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); + let mut dropped = 0; + for ds in &mut vars.datasets { + match ds.values.remove(&from) { + // A rename alone carries every value, one that doesn't fit the kind included; a kind + // change drops them all, as none was given for the new kind. + Some(value) if !retyped => { + ds.values.insert(to.clone(), value); + } + Some(_) => dropped += 1, + None => {} } } - Ok(()) + Ok(dropped) })?; - // The highlight names a variable, so it follows the rename rather than going stale. + // The highlight names a variable, so it follows the rename rather than going stale. A call + // that leaves the name as it was leaves the revision alone too. let st = s.doc_mut()?; - if st.variables_highlight.as_deref() == Some(from.as_str()) { + if to != from && st.variables_highlight.as_deref() == Some(from.as_str()) { st.variables_highlight = Some(to.clone()); + st.revision += 1; } - st.revision += 1; - Ok(json!({"from": from, "name": to})) + Ok(json!({"from": from, "name": to, "kind": kind.label(), "unbound": unbound.iter().map(|i| i.0).collect::>(), "droppedValues": dropped})) } /// Make Text / Visibility / Image / Graph Dynamic: define a variable of that kind and bind the @@ -722,7 +775,7 @@ pub fn specs() -> Vec { "Rename Variable", ["Window", "Variables"], None, - "{name, newName} rename a variable, with its bindings and its values in every dataset; refused while the variables are locked", + "{name, newName?, kind?: text|visibility|image|graph} Variable Options: rename a variable, with its bindings and its values in every dataset, change its kind, or both, in one undo step (none when the name and the kind are the ones it has). A kind change unbinds every object that can't hold the new kind (a text variable keeps type that has no other text variable, an image variable keeps image objects, a graph variable keeps graph roots, a visibility variable keeps every object) and drops the variable's values in every dataset; locked and hidden objects keep their binding when they can hold it. Needs `newName` or `kind`, and a null or empty one is refused; refused while the variables are locked → {from, name, kind, unbound: [id…], droppedValues}", variables_unlocked, rename ), diff --git a/crates/engine/src/tests/variables.rs b/crates/engine/src/tests/variables.rs index bb5b73b2f2..622c5e3809 100644 --- a/crates/engine/src/tests/variables.rs +++ b/crates/engine/src/tests/variables.rs @@ -1063,6 +1063,110 @@ fn renaming_a_missing_name_onto_a_taken_one_reports_the_missing_name() { assert!(e.contains("no dataset named `Nope`"), "{e}"); } +/// Variable Options changes a variable's kind: the objects that can't hold the new kind are +/// unbound, the ones that can (locked ones too) stay bound, every value of the old kind goes, and +/// the change is one undo step. +#[test] +fn variable_options_changes_the_kind_and_unbinds_what_cant_hold_it() { + let mut s = session(); + let t = text(&mut s, "Alice"); + let r = rect(&mut s); + let other = text(&mut s, "Held"); + s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap(); + s.execute("variable.define", &json!({"name": "Title", "kind": "text"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Show", "ids": [t.0, r.0, other.0]})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Title", "ids": [other.0]})).unwrap(); + s.execute("dataset.new", &json!({"name": "A", "values": {"Show": false, "Title": "Bob"}})).unwrap(); + s.execute("dataset.new", &json!({"name": "B", "values": {"Show": true}})).unwrap(); + s.execute("select.set", &json!({"ids": [t.0]})).unwrap(); + s.execute("object.lock", &json!({})).unwrap(); + let steps = |s: &Session| s.doc().unwrap().history.undo.len(); + let before = steps(&s); + + let v = s.execute("variable.rename", &json!({"name": "Show", "kind": "text"})).unwrap(); + // The rectangle isn't type, and the other type object already has the text variable Title. + assert_eq!(v["unbound"], json!([r.0, other.0])); + assert_eq!((v["name"].clone(), v["kind"].clone(), v["droppedValues"].clone()), (json!("Show"), json!("text"), json!(2))); + assert_eq!(steps(&s), before + 1, "one undo step"); + let vars = &s.doc().unwrap().doc.variables; + assert_eq!(vars.objects_of("Show"), [t], "the locked type object stays bound"); + assert_eq!(vars.objects_of("Title"), [other], "the other variable keeps its binding"); + let sets = s.execute("dataset.list", &json!({})).unwrap(); + assert_eq!((sets["datasets"][0]["values"].clone(), sets["datasets"][1]["values"].clone()), (json!({"Title": {"text": "Bob"}}), json!({}))); + + s.execute("edit.undo", &json!({})).unwrap(); + let vars = &s.doc().unwrap().doc.variables; + assert_eq!(vars.variable("Show").map(|v| v.kind), Some(vectorcraft_doc::VariableKind::Visibility)); + assert_eq!(vars.objects_of("Show"), [t, r, other]); + assert_eq!(vars.dataset("A").and_then(|d| d.values.get("Show")), Some(&vectorcraft_doc::DataValue::Visible(false))); + + // A new name and a new kind together are one undo step, and every visibility binding stays. + s.execute("variable.rename", &json!({"name": "Title", "newName": "Heading", "kind": "visibility"})).unwrap(); + assert_eq!(steps(&s), before + 1, "one step after the undo"); + let vars = &s.doc().unwrap().doc.variables; + assert_eq!( + (vars.variable("Heading").map(|v| v.kind), vars.objects_of("Heading")), + (Some(vectorcraft_doc::VariableKind::Visibility), vec![other]) + ); + // An image variable keeps only image objects. + let v = s.execute("variable.rename", &json!({"name": "Show", "kind": "image"})).unwrap(); + assert_eq!(v["unbound"], json!([t.0, r.0, other.0])); +} + +/// Variable Options with the variable's own name and kind changes nothing and adds no undo step; +/// a bad kind, a taken name, no change asked for and locked variables are refused. +#[test] +fn variable_options_refuses_bad_params_and_skips_a_change_that_changes_nothing() { + let mut s = session(); + let r = rect(&mut s); + s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap(); + s.execute("variable.define", &json!({"name": "Other", "kind": "text"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Show", "ids": [r.0]})).unwrap(); + let steps = |s: &Session| s.doc().unwrap().history.undo.len(); + let before = steps(&s); + for p in [ + json!({"name": "Show", "newName": "Show"}), + json!({"name": "Show", "kind": "visibility"}), + json!({"name": "Show", "newName": "Show", "kind": "visible"}), + ] { + let v = s.execute("variable.rename", &p).unwrap(); + assert_eq!((v["unbound"].clone(), v["droppedValues"].clone()), (json!([]), json!(0)), "{p}"); + assert_eq!(steps(&s), before, "{p}: no undo step"); + } + for (p, want) in [ + (json!({"name": "Show", "kind": "unknown"}), "`kind` must be"), + (json!({"name": "Show", "kind": "number"}), "`kind` must be"), + (json!({"name": "Show", "kind": 1}), "`kind` must be"), + (json!({"name": "Show", "newName": "New", "kind": null}), "`kind` must be"), + (json!({"name": "Show", "newName": null, "kind": "text"}), "missing `newName`"), + (json!({"name": "Show", "newName": " ", "kind": "text"}), "missing `newName`"), + (json!({"name": "Show"}), "missing `newName` or `kind`"), + (json!({"name": "Show", "newName": "Other", "kind": "text"}), "already exists"), + (json!({"name": "Ghost", "kind": "text"}), "no variable named `Ghost`"), + ] { + let e = s.execute("variable.rename", &p).unwrap_err().to_string(); + assert!(e.contains(want), "{p}: {e}"); + } + // A rename alone carries a value that doesn't fit the kind, as a file may hold one. + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc) + .variables + .datasets + .push(vectorcraft_doc::DataSet { name: "Odd".into(), values: [("Show".to_string(), vectorcraft_doc::DataValue::Text("x".into()))].into() }); + s.execute("variable.rename", &json!({"name": "Show", "newName": "Shown"})).unwrap(); + let odd = s.doc().unwrap().doc.variables.dataset("Odd").and_then(|d| d.values.get("Shown")).cloned(); + assert_eq!(odd, Some(vectorcraft_doc::DataValue::Text("x".into()))); + s.execute("edit.undo", &json!({})).unwrap(); + // A kind change drops it, though it fits the new kind: nobody gave it for that kind. + let v = s.execute("variable.rename", &json!({"name": "Show", "kind": "text"})).unwrap(); + assert_eq!(v["droppedValues"], 1); + assert!(s.doc().unwrap().doc.variables.dataset("Odd").is_some_and(|d| d.values.is_empty())); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("variable.lock", &json!({"locked": true})).unwrap(); + assert!(s.execute("variable.rename", &json!({"name": "Show", "kind": "text"})).is_err(), "refused while the variables are locked"); + let vars = &s.doc().unwrap().doc.variables; + assert_eq!((vars.variable("Show").map(|v| v.kind), vars.objects_of("Show")), (Some(vectorcraft_doc::VariableKind::Visibility), vec![r])); +} + /// `dataset.set` is how a value changes after the row exists: replacing it must not need the /// dataset to be deleted and rebuilt (which loses its place in the list and the active one). #[test] diff --git a/crates/ui-egui/src/dialogs/variables.rs b/crates/ui-egui/src/dialogs/variables.rs index a18fc8597c..dad6e3f43b 100644 --- a/crates/ui-egui/src/dialogs/variables.rs +++ b/crates/ui-egui/src/dialogs/variables.rs @@ -1,5 +1,7 @@ -//! The Variables dialogs (`variable.define`, `variable.bind`, `dataset.new`, `dataset.set` -//! and `dataset.select`), opened from Window › Variables or the command palette. +//! The Variables dialogs (`variable.define`, `variable.rename` as Variable Options, `variable.bind`, +//! `dataset.new`, `dataset.set`, `dataset.rename` and `dataset.select`), opened from Window › +//! Variables or the command palette. Variable Options shows the variable's name and kind, and OK +//! changes either or both. //! //! 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 @@ -24,6 +26,10 @@ pub const KIND: &str = "variables"; /// renames. const EDITED: &str = "__dataset"; +/// The field of Variable Options that names the variable its Name and Kind fields belong to. +/// When the Variable field names another one, the next frame fills them from that one. +const FILLED: &str = "__filled"; + /// The kinds a variable can have, as `variable.define` names them. const KINDS: [&str; 4] = ["text", "visibility", "image", "graph"]; @@ -50,7 +56,7 @@ fn opened(id: &str) -> Option<&'static str> { } /// 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. +/// one. Variable Options edits the one the panel has highlighted, so it starts there. fn rename_of(id: &str) -> Option<&'static str> { Some(match id { "variable.rename" => "variable", @@ -116,8 +122,14 @@ pub fn open(app: &mut VectorcraftApp, id: &str, preselect: Option<&str>) -> bool 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!("")); + let row = preselect.filter(|p| names.contains(p)).or(names.first().copied()).unwrap_or_default(); + fields.insert(key.into(), json!(row)); + if key == "variable" { + // Variable Options starts on the variable's own name and kind. + fill_variable_options(&vars, row, &mut fields); + } else { + fields.insert("newName".into(), json!("")); + } } } fields.insert("__command".into(), json!(id)); @@ -126,6 +138,16 @@ pub fn open(app: &mut VectorcraftApp, id: &str, preselect: Option<&str>) -> bool true } +/// Variable Options' Name and Kind fields for the variable `name`: its name, and its kind as +/// `variable.rename` takes it (empty for a variable of unknown kind, which OK leaves as it is). +/// [`FILLED`] records which variable they were filled for. +fn fill_variable_options(vars: &Variables, name: &str, fields: &mut serde_json::Map) { + let kind = vars.variable(name).map(|v| v.kind).filter(|k| *k != VariableKind::Unknown).map(VariableKind::label).unwrap_or_default(); + fields.insert("newName".into(), json!(name)); + fields.insert("kind".into(), json!(kind)); + fields.insert(FILLED.into(), json!(name)); +} + /// Whether `id` opens one of these dialogs. pub fn opens(id: &str) -> bool { opened(id).is_some() @@ -181,13 +203,25 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { 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)); + kind_dropdown(ui, d); + ui.end_row(); + } + // Variable Options: which variable, and its name and kind; picking another variable + // starts its fields over from that one. + "variable.rename" => { + label(ui, tl!("Variable")); + dropdown(ui, d, "variable", &variable_names, 220.0); + let picked = d.str("variable"); + if picked != d.str(FILLED) { + fill_variable_options(vars, &picked, &mut d.fields); } ui.end_row(); + label(ui, tl!("Name")); + text(ui, d, "newName", 220.0); + ui.end_row(); + label(ui, tl!("Kind")); + kind_dropdown(ui, d); + ui.end_row(); } "variable.bind" => { label(ui, tl!("Variable")); @@ -199,15 +233,10 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { dropdown(ui, d, "name", &dataset_names, 220.0); ui.end_row(); } - // A rename: which row, and its new name. - "variable.rename" | "dataset.rename" => { - let (key, label_text, names) = if d.str("__command") == "variable.rename" { - ("variable", tl!("Variable").to_string(), &variable_names) - } else { - ("name", tl!("Data Set").to_string(), &dataset_names) - }; - label(ui, &label_text); - dropdown(ui, d, key, names, 220.0); + // Rename Data Set: which row, and its new name. + "dataset.rename" => { + label(ui, tl!("Data Set")); + dropdown(ui, d, "name", &dataset_names, 220.0); ui.end_row(); label(ui, tl!("New Name")); text(ui, d, "newName", 220.0); @@ -245,6 +274,16 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { false } +/// The Kind dropdown: the kinds by their translated names, while the `kind` field keeps the +/// command's own word. An empty field (a variable of unknown kind) shows no kind. +fn kind_dropdown(ui: &mut egui::Ui, d: &mut Dialog) { + 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)); + } +} + /// 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); @@ -263,8 +302,26 @@ fn set_text(d: &mut Dialog, key: &str, s: String) { /// Records that `field` was typed in or set by `ui.dialog.set`: it no longer holds the row's /// empty text value, so emptying it unsets the variable. +/// +/// In Variable Options, setting the Variable field clears Name and Kind, which OK reads as the +/// variable's own name and kind, until a frame fills them from it; setting Name or Kind ties them +/// to the variable already named, so no frame fills them over. pub(super) fn note_set(d: &mut Dialog, field: &str) { d.fields.remove(&empty_marker(field)); + if d.str("__command") == "variable.rename" { + match field { + // Null rather than removed, so `ui.dialog.set` can still set them. + "variable" => { + d.fields.insert("newName".into(), Value::Null); + d.fields.insert("kind".into(), Value::Null); + } + "newName" | "kind" => { + let row = d.str("variable"); + d.fields.insert(FILLED.into(), json!(row)); + } + _ => {} + } + } } /// A checkbox for `key`, written back as it is toggled, which screen readers call `name`. @@ -329,7 +386,18 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { let p = match cmd.as_str() { "variable.define" => json!({"name": name, "kind": d.str("kind")}), "variable.bind" => json!({"variable": d.str("variable")}), - "variable.rename" => json!({"name": d.str("variable"), "newName": d.str("newName")}), + // A Name the Variable field just cleared keeps the variable's name (an emptied one is sent, + // and refused), and a cleared or empty Kind (a variable of unknown kind) keeps its kind. + "variable.rename" => { + let variable = d.str("variable"); + let new_name = if matches!(d.fields.get("newName"), Some(Value::Null) | None) { variable.clone() } else { d.str("newName") }; + // Any other kind value goes to the command, which refuses one it doesn't know. + match d.fields.get("kind") { + None | Some(Value::Null) => json!({"name": variable, "newName": new_name}), + Some(Value::String(k)) if k.is_empty() => json!({"name": variable, "newName": new_name}), + Some(k) => json!({"name": variable, "newName": new_name, "kind": k}), + } + } "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!({})); @@ -550,4 +618,55 @@ mod tests { open(&mut app, "dataset.rename", Some("Row 1")); assert_eq!(app.ui.dialog.as_ref().unwrap().str("name"), "Row 1"); } + + #[test] + fn variable_options_shows_the_name_and_kind_and_changes_either() { + let mut app = app_with_variables(); + let kind_of = |app: &VectorcraftApp, name: &str| app.session.active().and_then(|st| st.doc.variables.variable(name).map(|v| v.kind)); + open(&mut app, "variable.rename", Some("Badge")); + { + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("newName"), d.str("kind")), ("Badge".to_string(), "visibility".to_string())); + } + app.ui.dialog.as_mut().unwrap().fields.insert("kind".into(), json!("text")); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(kind_of(&app, "Badge"), Some(VariableKind::Text)); + + // OK with nothing changed adds no undo step, and an emptied Name is refused. + let undo = app.session.active().unwrap().history.undo.len(); + open(&mut app, "variable.rename", Some("Title")); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo); + open(&mut app, "variable.rename", Some("Title")); + crate::dialogs::set_field(app.ui.dialog.as_mut().unwrap(), "newName".into(), json!("")); + assert!(crate::dialogs::confirm(&mut app).is_err()); + assert!(kind_of(&app, "Title").is_some(), "Title keeps its name"); + + // Setting the Variable field keeps that variable's own name and kind until Name or Kind is + // set, and a frame fills them from it. + open(&mut app, "variable.rename", Some("Title")); + crate::dialogs::set_field(app.ui.dialog.as_mut().unwrap(), "variable".into(), json!("Badge")); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!((kind_of(&app, "Title"), kind_of(&app, "Badge")), (Some(VariableKind::Text), Some(VariableKind::Text))); + open(&mut app, "variable.rename", Some("Title")); + { + let d = app.ui.dialog.as_mut().unwrap(); + crate::dialogs::set_field(d, "variable".into(), json!("Badge")); + assert!(crate::dialogs::accepts_field(d, "newName") && crate::dialogs::accepts_field(d, "kind"), "Name and Kind stay settable"); + crate::dialogs::set_field(d, "newName".into(), json!("Flag")); + crate::dialogs::set_field(d, "kind".into(), json!("visibility")); + } + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!((kind_of(&app, "Flag"), kind_of(&app, "Title")), (Some(VariableKind::Visibility), Some(VariableKind::Text))); + + // A frame after the Variable field changes fills Name and Kind from that variable. + open(&mut app, "variable.rename", Some("Title")); + crate::dialogs::set_field(app.ui.dialog.as_mut().unwrap(), "variable".into(), json!("Flag")); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| crate::dialogs::show(&mut app, ui.ctx())); + out.textures_delta.clear(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("newName"), d.str("kind")), ("Flag".to_string(), "visibility".to_string())); + } } diff --git a/crates/ui-egui/src/panels/variables.rs b/crates/ui-egui/src/panels/variables.rs index 3ab5fc2484..ac3254048a 100644 --- a/crates/ui-egui/src/panels/variables.rs +++ b/crates/ui-egui/src/panels/variables.rs @@ -272,7 +272,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { if widgets::menu_item(ui, tl!("New Variable…"), !locked, false) { crate::menus::invoke(app, "variable.define", json!({})); } - // Variable Options renames the highlighted variable, else the first. + // Variable Options edits the name and kind of the highlighted variable, else the first. if widgets::menu_item(ui, tl!("Variable Options…"), !vars.is_empty() && !locked, false) { let name = highlighted.clone().or_else(|| vars.first().cloned()).unwrap_or_default(); crate::dialogs::variables::open(app, "variable.rename", Some(&name)); diff --git a/docs/mcp.md b/docs/mcp.md index 8ca4121032..67150a63ed 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3331,7 +3331,12 @@ already walks Image Trace. Short agent entry points: `variable.makeTextDynamic`, `variable.makeVisibilityDynamic`, `variable.makeImageDynamic`, `variable.makeGraphDynamic` (`variable.bind` and `variable.makeTextDynamic` refuse type that already has another text variable and name it), `variable.unbind`, `variable.rename`, `variable.delete`, `variable.highlight` (image values are file paths, graph - values CSV as `graph.setData`). `variable.lock {locked?}` (Lock Variables) locks or unlocks the variables; + values CSV as `graph.setData`). `variable.rename {name, newName?, kind?}` is Variable Options: it renames a + variable, changes its kind, or both, in one undo step, and adds none when the name and kind are the ones it has. + A kind change unbinds each object that can't hold the new kind (a text variable keeps type with no other text + variable, an image variable keeps image objects, a graph variable keeps graph roots, a visibility variable keeps + every object; locks and hidden layers don't unbind anything) and drops the variable's values in every data set. + That rule is VectorCraft's own. `variable.lock {locked?}` (Lock Variables) locks or unlocks the variables; without `locked` it toggles, and the lock is saved with the document. While the variables are locked, `variable.define`, `variable.delete`, `variable.rename`, the four `variable.make…Dynamic` commands and a `variable.loadLibrary` that would delete or define a variable (below) fail with "variables are locked"; diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f4424700be..08c8102458 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -824,6 +824,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Variables: Import Data Sets… (`dataset.import`) defines the variables a file's header names that the document lacks, unbound. `#Shown` defines a visibility variable, `@Photo` defines an image variable, and a header without a prefix defines a text variable. A header that is a variable's exact name keeps naming it, and any other `%` header (graph data files) is refused. - Variables: Load Variable Library… in the Variables panel menu (`variable.loadLibrary`) replaces the document's variables and data sets with a variable library's, read from its XML file, in one undo step: every data set is deleted, and every variable is deleted with its bindings and values, except a variable the library also declares with the same kind, which stays bound so the library's data sets change its objects (#4136); then the library's other variables are defined, unbound: text, visibility, image (linked file) and graph variables, and variables without a known `trait` as unknown. Its data sets take text values from each value's `p` elements, one paragraph each, visibility values from `true` or `false`, image values from a file's absolute path or `file:` URL, and graph values from a `data` element of series names and rows. - Variables: Save Variable Library… in the Variables panel menu (`variable.saveLibrary`) writes the document's variables of every kind and its data sets as a variable library's XML file, which Load Variable Library reads back with the same variables, kinds, data set names and values. + - Variables: Variable Options… in the Variables panel menu, or a double click on a variable, shows the variable's name and kind, and OK changes either or both in one undo step (`variable.rename {name, newName?, kind?}`), as the [Work with variables](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/work-with-variables.html) page describes. VectorCraft's own rule for a kind change: objects that can't hold the new kind are unbound, and the variable's values in every data set are dropped. - JSON-lines control channel with real egui pointer and keyboard injection. - Variables: a type object holds one text variable. `variable.bind` and Make Text Dynamic refuse a second one and name the text variable the object has; a visibility variable can still share the object, and a document saved with two text variables on one object opens with both. - Synthetic input validates complete gestures before queue mutation: at most 256 drag steps, 16 clicks and 1,024 pending events; up to 64 control requests per frame, with later requests retained for the next frame (#1709).