From 00b02c1bdb1dc72104a8fd2835ed7d2ca8e25700 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 15 Oct 2026 07:20:13 -0700 Subject: [PATCH] Variables: Update Data Set writes only the variables the active data set holds A data set can hold values for only some variables: `dataset.new` and `dataset.set` store only the values they are given, and selecting the data set leaves the other variables alone. Update Data Set replaced the active data set's values with the values the art reports for every variable. After one Update, a data set that set only a visibility variable also held the current text of the bound type, and selecting it again wrote that text back. Update also dropped a variable's value when its objects were locked or inside a hidden or locked layer or group, because such objects report no value. `dataset.update` now writes the art's current value for each variable the active data set holds, and keeps the data set's value when the art reports none. A variable that the data set does not hold stays out of it, including one defined after the data set was captured; `dataset.update {all: true}` also adds the other variables that the art reports a value for. The reply adds `left`, the variables it did not write. The Variables panel's Update Data Set button and menu item run it without `all`. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/doc/src/variables.rs | 5 +- crates/engine/src/cmd/variablecmds.rs | 29 ++++++---- crates/engine/src/tests_variables.rs | 76 +++++++++++++++++++++++++++ docs/mcp.md | 6 +++ docs/target-app-parity.md | 1 + 5 files changed, 106 insertions(+), 11 deletions(-) diff --git a/crates/doc/src/variables.rs b/crates/doc/src/variables.rs index 45e3a37a7..276567b12 100644 --- a/crates/doc/src/variables.rs +++ b/crates/doc/src/variables.rs @@ -190,8 +190,9 @@ impl Document { /// 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. + /// `dataset.update` into the active one, for the variables that row holds (`all: true` adds + /// the rest); the Variables panel compares the active row with them to tell whether the art + /// still matches what it says. pub fn captured_values(&self) -> BTreeMap { let mut out = BTreeMap::new(); for v in &self.variables.variables { diff --git a/crates/engine/src/cmd/variablecmds.rs b/crates/engine/src/cmd/variablecmds.rs index 8060d35c5..46361c851 100644 --- a/crates/engine/src/cmd/variablecmds.rs +++ b/crates/engine/src/cmd/variablecmds.rs @@ -5,7 +5,7 @@ //! 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 std::collections::{BTreeMap, BTreeSet}; use serde_json::{Value, json}; use vectorcraft_doc::{DataSet, DataValue, NodeId, NodeKind, Variable, VariableKind, Variables}; @@ -327,10 +327,14 @@ fn dataset_capture(s: &mut Session, p: &Value) -> Result { 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 { +/// Update Data Set: write what the bound objects hold now into the row that is active, for each +/// variable the row holds. A variable whose objects report no value (locked, inside a hidden or +/// locked layer or group, or gone) keeps the row's value. With `all`, every other variable the +/// objects report a value for is added to the row. Without an active row there is nothing to +/// update, so say so rather than picking one. +fn dataset_update(s: &mut Session, p: &Value) -> Result { const C: &str = "dataset.update"; + let all = bool_or(p, "all", false); 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")); @@ -338,12 +342,19 @@ fn dataset_update(s: &mut Session, _p: &Value) -> Result { if values.is_empty() { return Err(bad(C, "bind something to a variable first")); } - s.edit("Update Data Set", |d, _| { + let (written, left) = 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(()) + let mut written = BTreeSet::new(); + for (var, value) in &values { + if all || ds.values.contains_key(var) { + ds.values.insert(var.clone(), value.clone()); + written.insert(var.as_str()); + } + } + let left: Vec = d.variables.variables.iter().filter(|v| !written.contains(v.name.as_str())).map(|v| v.name.clone()).collect(); + Ok((written.len(), left)) })?; - Ok(json!({"name": name, "values": values.len()})) + Ok(json!({"name": name, "values": written, "left": left})) } fn dataset_delete(s: &mut Session, p: &Value) -> Result { @@ -544,7 +555,7 @@ pub fn specs() -> Vec { "Update Data Set", ["Window", "Variables"], None, - "{} write what the bound objects hold now into the active dataset", + "{all?: bool} write what the bound objects hold now into the active dataset, for each variable the dataset holds; a variable whose objects report no value (locked, inside a hidden or locked layer or group, or gone) keeps the dataset's value. `all: true` also adds every other variable the objects report a value for; `left` names the variables it didn't write → {values, left}", has_doc, dataset_update ), diff --git a/crates/engine/src/tests_variables.rs b/crates/engine/src/tests_variables.rs index ed9a321d6..dbc81c0ea 100644 --- a/crates/engine/src/tests_variables.rs +++ b/crates/engine/src/tests_variables.rs @@ -344,6 +344,82 @@ fn updating_writes_the_art_into_the_active_row() { assert_eq!(plain(&s, t), "Caz"); } +/// Update Data Set writes only the variables the active row holds, so a row that sets some of +/// them still leaves the others alone when it is selected again. `all: true` adds the rest. +#[test] +fn updating_writes_only_the_variables_the_row_holds() { + let mut s = session(); + let t = text(&mut s, "Alice"); + let r = rect(&mut s); + s.execute("variable.define", &json!({"name": "Client", "kind": "text"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Client", "ids": [t.0]})).unwrap(); + s.execute("variable.define", &json!({"name": "Badge", "kind": "visibility"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Badge", "ids": [r.0]})).unwrap(); + s.execute("dataset.new", &json!({"name": "Badge off", "values": {"Badge": false}})).unwrap(); + s.execute("dataset.select", &json!({"name": "Badge off"})).unwrap(); + + s.execute("text.editRange", &json!({"id": t.0, "insert": "Typed by hand"})).unwrap(); + s.execute("object.setProps", &json!({"ids": [r.0], "visible": true})).unwrap(); + let v = s.execute("dataset.update", &json!({})).unwrap(); + assert_eq!((v["values"].clone(), v["left"].clone()), (json!(1), json!(["Client"])), "{v}"); + let sets = s.execute("dataset.list", &json!({})).unwrap(); + assert_eq!(sets["datasets"][0]["values"], json!({"Badge": {"visible": true}}), "{sets}"); + + // `all: true` adds every variable the art reports a value for. + let v = s.execute("dataset.update", &json!({"all": true})).unwrap(); + assert_eq!((v["values"].clone(), v["left"].clone()), (json!(2), json!([])), "{v}"); + let sets = s.execute("dataset.list", &json!({})).unwrap(); + assert_eq!(sets["datasets"][0]["values"]["Client"], json!({"text": "Typed by hand"}), "{sets}"); +} + +/// A variable whose object is locked reports no value, so Update keeps the row's value for it +/// rather than dropping it from the row. +#[test] +fn updating_keeps_a_value_the_art_does_not_report() { + let mut s = session(); + let t = text(&mut s, "First"); + let r = rect(&mut s); + s.execute("variable.define", &json!({"name": "Label", "kind": "text"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Label", "ids": [t.0]})).unwrap(); + s.execute("variable.define", &json!({"name": "Mark", "kind": "visibility"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Mark", "ids": [r.0]})).unwrap(); + s.execute("dataset.new", &json!({"name": "Row", "values": {"Label": "Second", "Mark": true}})).unwrap(); + s.execute("dataset.select", &json!({"name": "Row"})).unwrap(); + + s.execute("object.setProps", &json!({"ids": [t.0], "locked": true})).unwrap(); + s.execute("object.setProps", &json!({"ids": [r.0], "visible": false})).unwrap(); + for params in [json!({}), json!({"all": true})] { + let v = s.execute("dataset.update", ¶ms).unwrap(); + assert_eq!((v["values"].clone(), v["left"].clone()), (json!(1), json!(["Label"])), "{params}: {v}"); + let sets = s.execute("dataset.list", &json!({})).unwrap(); + assert_eq!(sets["datasets"][0]["values"], json!({"Label": {"text": "Second"}, "Mark": {"visible": false}}), "{params}: {sets}"); + } +} + +/// A variable defined after a row was captured stays out of that row on Update, and joins it +/// with `all: true`. +#[test] +fn a_variable_defined_after_a_row_joins_it_only_with_all() { + let mut s = session(); + let name = text(&mut s, "First job"); + let note = text(&mut s, "First note"); + s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Name", "ids": [name.0]})).unwrap(); + s.execute("dataset.capture", &json!({"name": "Job 1"})).unwrap(); + s.execute("variable.define", &json!({"name": "Note", "kind": "text"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Note", "ids": [note.0]})).unwrap(); + + s.execute("text.editRange", &json!({"id": name.0, "insert": "Second job"})).unwrap(); + let v = s.execute("dataset.update", &json!({})).unwrap(); + assert_eq!((v["values"].clone(), v["left"].clone()), (json!(1), json!(["Note"])), "{v}"); + let sets = s.execute("dataset.list", &json!({})).unwrap(); + assert_eq!(sets["datasets"][0]["values"], json!({"Name": {"text": "Second job"}}), "{sets}"); + + s.execute("dataset.update", &json!({"all": true})).unwrap(); + let sets = s.execute("dataset.list", &json!({})).unwrap(); + assert_eq!(sets["datasets"][0]["values"], json!({"Name": {"text": "Second job"}, "Note": {"text": "First note"}}), "{sets}"); +} + /// 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] diff --git a/docs/mcp.md b/docs/mcp.md index 1a29c0c34..693fb2159 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -972,6 +972,12 @@ already walks Image Trace. Short agent entry points: {"name":"run_command","arguments":{"command":"imageTrace.presets","params":{}}} ``` +`dataset.update {all?}` writes what the bound objects hold now into the active data set, for each variable that the +data set holds. A variable whose objects report no value, because they are locked, inside a hidden or locked layer +or group, or deleted, keeps the data set's value. With `all: true` it also adds every other variable that the +objects report a value for. The reply gives `values`, the number of values it wrote, and `left`, the variables it +did not write. + ## Graphic styles A graphic style holds an appearance (fills, strokes, effects) plus opacity, blend mode, isolate and knockout. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d99a85046..722e6bbdf 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -396,6 +396,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Architecture:** 19+ crates with enforced layering (`cargo xtask layers`). Every action is a command (~400 engine + ~50 UI). Undo is unlimited via structural sharing. `command.batch` runs several commands as one transaction. - **Automation:** + - Variables: Update Data Set writes what the art holds into the active data set for the variables that data set holds, and keeps the data set's value for a variable whose objects are locked or inside a hidden or locked layer or group; `dataset.update {all: true}` also adds the other variables that the art reports a value for. - Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command. - Variables (data merge): text and visibility variables bound to objects (an object may carry several), dataset rows applied in one undo step (`variable.*`, `dataset.*`) from a Variables panel and Window › Variables, with dialogs to define, bind and edit, Make Text/Visibility Dynamic, Capture/Update Data Set, and a row in italics when an edit leaves the art not matching it; bindings let go of deleted art and locked art is left alone; every command, like the rest, is drivable over MCP and the CLI. - JSON-lines control channel with real egui pointer and keyboard injection.