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.