diff --git a/crates/doc/src/variables.rs b/crates/doc/src/variables.rs index de37364fa..96c793496 100644 --- a/crates/doc/src/variables.rs +++ b/crates/doc/src/variables.rs @@ -95,8 +95,9 @@ pub struct DataSet { 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. + /// says. A value the art can't report (its object is gone, or is locked or in a locked layer + /// or group and the variable isn't a visibility variable) isn't drift: the row keeps it, and + /// applying it again would skip it too. pub fn matches(&self, current: &BTreeMap) -> bool { self.values.iter().all(|(k, v)| current.get(k).is_none_or(|c| c == v)) } @@ -193,33 +194,35 @@ impl Document { self.variables.bindings = bindings; } - /// Whether a variable may be bound to the object `id`: it is in the document, neither it nor - /// anything above it is locked, nothing above it is hidden, and it is not on a locked artboard - /// ([`Document::on_locked_artboard`]). Its own visibility doesn't count, as that is what a - /// visibility variable drives. - pub fn variable_target(&self, id: NodeId) -> bool { - self.variable_target_with(&self.locked_artboards(), id) + /// Whether a variable of `kind` may be bound to the object `id`: it is in the document, + /// nothing above it is hidden, it is not on a locked artboard ([`Document::on_locked_artboard`]), + /// and, unless `kind` is visibility, neither it nor anything above it is locked. Its own + /// visibility doesn't count, as that is what a visibility variable drives. + pub fn variable_target(&self, id: NodeId, kind: VariableKind) -> bool { + self.variable_target_with(&self.locked_artboards(), id, kind) } /// [`Document::variable_target`] with the locked artboards `locks` of this document, measured /// once for many ids. - pub fn variable_target_with(&self, locks: &LockedArtboards, 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)))) + pub fn variable_target_with(&self, locks: &LockedArtboards, id: NodeId, kind: VariableKind) -> bool { + let locking = kind != VariableKind::Visibility; + self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| !(locking && n.locked) && (n.visible || *i == id)))) && !locks.holds_id(id) } - /// Whether a data set reads and rewrites the bound object `id`: it is in the document, neither - /// it nor anything above it is locked, and it is not on a locked artboard - /// ([`Document::on_locked_artboard`]). Hidden objects count, and so do objects in a hidden - /// layer or group. - pub fn dataset_target(&self, id: NodeId) -> bool { - self.dataset_target_with(&self.locked_artboards(), id) + /// Whether a data set reads and rewrites the bound object `id` for a variable of `kind`: it + /// is in the document, it is not on a locked artboard ([`Document::on_locked_artboard`]) and, + /// unless `kind` is visibility, neither it nor anything above it is locked. Hidden objects + /// count, and so do objects in a hidden layer or group. + pub fn dataset_target(&self, id: NodeId, kind: VariableKind) -> bool { + self.dataset_target_with(&self.locked_artboards(), id, kind) } /// [`Document::dataset_target`] with the locked artboards `locks` of this document, measured /// once for many ids. - pub fn dataset_target_with(&self, locks: &LockedArtboards, id: NodeId) -> bool { - self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| !n.locked))) && !locks.holds_id(id) + pub fn dataset_target_with(&self, locks: &LockedArtboards, id: NodeId, kind: VariableKind) -> bool { + let locking = kind != VariableKind::Visibility; + self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| !(locking && n.locked)))) && !locks.holds_id(id) } /// The type objects of the story that the type object `id` belongs to, in thread order: the @@ -253,21 +256,21 @@ impl Document { /// [`Document::story_target`] with the locked artboards `locks` of this document, measured /// once for many ids. pub fn story_target_with(&self, locks: &LockedArtboards, id: NodeId) -> bool { - self.story_of(id).into_iter().all(|f| self.dataset_target_with(locks, f)) + self.story_of(id).into_iter().all(|f| self.dataset_target_with(locks, f, VariableKind::Text)) } /// What the object `id` holds for the variable of `kind`: its characters (the whole story /// for a frame of threaded text), whether it shows, or a linked image's path. `None` when the - /// object is gone, is not of that kind, has no link path yet (an embedded image), or is locked, - /// in a locked layer or group, or on a locked artboard, or for text when a frame of its story - /// is (the document's value is whatever it was left at). + /// object is gone, is not of that kind, has no link path yet (an embedded image), or, unless + /// `kind` is visibility, is locked or in a locked layer or group, or is on a locked artboard, + /// or for text when a frame of its story is (the document's value is whatever it was left at). pub fn variable_value(&self, id: NodeId, kind: VariableKind) -> Option { self.variable_value_with(&self.locked_artboards(), id, kind) } /// [`Document::variable_value`] with the locked artboards `locks` of this document. fn variable_value_with(&self, locks: &LockedArtboards, id: NodeId, kind: VariableKind) -> Option { - if !self.dataset_target_with(locks, id) { + if !self.dataset_target_with(locks, id, kind) { return None; } let node = self.node(id)?; diff --git a/crates/engine/src/cmd/variablecmds.rs b/crates/engine/src/cmd/variablecmds.rs index 16762c1e5..ad70f047b 100644 --- a/crates/engine/src/cmd/variablecmds.rs +++ b/crates/engine/src/cmd/variablecmds.rs @@ -138,9 +138,10 @@ fn names_param(p: &Value, cmd: &str, fallback: Option, nothing: &str) -> } } -/// Refuse what a variable of `kind` can't be bound to: art that is locked, on a hidden or locked -/// layer, or on a locked artboard; a text variable only drives type; an image variable only drives -/// an image object; a variable of unknown kind drives nothing. +/// Refuse what a variable of `kind` can't be bound to: art on a hidden layer or in a hidden group, +/// art on a locked artboard, and, unless `kind` is visibility, art that is locked or on a locked +/// layer or in a locked group; a text variable only drives type; an image variable only drives an +/// image object; a variable of unknown kind drives nothing. fn check_bindable(doc: &vectorcraft_doc::Document, ids: &[NodeId], kind: VariableKind, cmd: &str) -> Result<()> { if kind == VariableKind::Unknown { return Err(bad(cmd, "a variable of unknown kind can't be bound to objects")); @@ -149,7 +150,7 @@ fn check_bindable(doc: &vectorcraft_doc::Document, ids: &[NodeId], kind: Variabl for id in ids { match doc.node(*id) { None => return Err(EngineError::NoNode(*id)), - Some(_) if !doc.variable_target_with(&locks, *id) => return Err(bad(cmd, not_a_target(doc, *id))), + Some(_) if !doc.variable_target_with(&locks, *id, kind) => return Err(bad(cmd, not_a_target(doc, *id, kind))), Some(n) if kind == VariableKind::Text && !matches!(n.kind, NodeKind::Text(_)) => { return Err(bad(cmd, format!("node {} is not type", id.0))); } @@ -165,14 +166,16 @@ fn check_bindable(doc: &vectorcraft_doc::Document, ids: &[NodeId], kind: Variabl Ok(()) } -/// Why `variable_target` refuses `id`: the object is locked, a group or layer that holds it is -/// locked, one that holds it is hidden, or it is on a locked artboard, checked in that order. -fn not_a_target(doc: &vectorcraft_doc::Document, id: NodeId) -> String { +/// Why `variable_target` refuses `id` for a variable of `kind`: the object is locked, a group or +/// layer that holds it is locked, one that holds it is hidden, or it is on a locked artboard, +/// checked in that order. For a visibility variable, the object and group lock checks are skipped. +fn not_a_target(doc: &vectorcraft_doc::Document, id: NodeId, kind: VariableKind) -> String { let holders: Vec<&vectorcraft_doc::Node> = doc.ancestry(id).unwrap_or_default().into_iter().filter(|i| *i != id).filter_map(|i| doc.node(i)).collect(); - if doc.node(id).is_some_and(|n| n.locked) { + let locks = kind != VariableKind::Visibility; + if locks && doc.node(id).is_some_and(|n| n.locked) { format!("node {} is locked", id.0) - } else if holders.iter().any(|n| n.locked) { + } else if locks && holders.iter().any(|n| n.locked) { format!("node {} is in a locked group or layer", id.0) } else if holders.iter().any(|n| !n.visible) { format!("node {} is in a hidden group or layer", id.0) @@ -511,8 +514,9 @@ fn dataset_capture(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 locked -/// layer or group, on a locked artboard, or gone) keeps the row's value. With `all`, every other variable the +/// variable the row holds. A variable whose objects report no value (gone, or, for a variable +/// other than a visibility variable, locked or inside a locked layer or group, or, for any +/// variable, on a locked artboard) 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. With every bound object locked, no value is /// written; an update that writes no value adds no undo step. @@ -588,14 +592,16 @@ fn apply_dataset(s: &mut Session, name: &str) -> Result { } // Which bound objects the row may rewrite, measured once before any of them changes, so // text that grows onto a locked artboard part way through is still rewritten. Each id is - // keyed by whether a text value is meant for it, which checks every frame of its story. - let targets: BTreeSet<(NodeId, bool)> = { + // keyed by whether a text value is meant for it, which checks every frame of its story, + // and by whether a visibility value is, which ignores locks. + let targets: BTreeSet<(NodeId, bool, bool)> = { let locks = d.locked_artboards(); let mut out = BTreeSet::new(); for (id, var) in &pairs { - let text = d.variables.variable(var).is_some_and(|v| v.kind == VariableKind::Text); - if if text { d.story_target_with(&locks, *id) } else { d.dataset_target_with(&locks, *id) } { - out.insert((*id, text)); + let kind = d.variables.variable(var).map_or(VariableKind::Unknown, |v| v.kind); + let text = kind == VariableKind::Text; + if if text { d.story_target_with(&locks, *id) } else { d.dataset_target_with(&locks, *id, kind) } { + out.insert((*id, text, kind == VariableKind::Visibility)); } } out @@ -617,12 +623,13 @@ fn apply_dataset(s: &mut Session, name: &str) -> Result { continue; } }; - // Locked art, art in a locked layer or group or on a locked artboard, and text whose - // object is gone, can't be rewritten: skipped and counted, like the rest. Hidden art - // is rewritten, in a hidden layer or group too, and stays hidden unless a visibility - // value shows it. A text value rewrites the whole story of threaded text, so it skips - // when any frame of it is locked or on a locked artboard. - if !targets.contains(&(id, kind == VariableKind::Text)) { + // Art that is gone, art on a locked artboard, and art that is locked or in a locked + // layer or group, is skipped and counted, like the rest, except that a visibility value + // shows and hides locked art off a locked artboard. Hidden art is rewritten, in a hidden layer or group too, + // and stays hidden unless a visibility value shows it. A text value rewrites the whole + // story of threaded text, so it skips when any frame of it is locked or on a locked + // artboard. + if !targets.contains(&(id, kind == VariableKind::Text, kind == VariableKind::Visibility)) { skipped += 1; continue; } @@ -759,7 +766,7 @@ pub fn specs() -> Vec { "Bind Variable", ["Window", "Variables"], None, - "{variable, ids?} bind objects (default: the selection) to a variable; text needs type, image needs an image object, graph needs a graph root, locked art is refused. A type object holds one text variable: binding it to another text variable is refused, and the error names the one it has. Variables of other kinds can share an object, and binding an object to a variable it already has changes nothing → {bound, already}", + "{variable, ids?} bind objects (default: the selection) to a variable; text needs type, image needs an image object, graph needs a graph root, art on a locked artboard is refused, and so are locked art and art in a locked layer or group except by a visibility variable. A type object holds one text variable: binding it to another text variable is refused, and the error names the one it has. Variables of other kinds can share an object, and binding an object to a variable it already has changes nothing → {bound, already}", has_doc, bind ), @@ -815,7 +822,7 @@ pub fn specs() -> Vec { "Update Data Set", ["Window", "Variables"], None, - "{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 locked layer or group, on a locked artboard, 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}", + "{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 (gone, on a locked artboard, or, for a variable other than a visibility variable, locked or inside a locked layer or group) 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 5566f1158..4b773c889 100644 --- a/crates/engine/src/tests/variables.rs +++ b/crates/engine/src/tests/variables.rs @@ -516,7 +516,7 @@ fn unbind_reports_what_it_actually_removed() { 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. +/// Locked type takes no new text variable binding, and a row leaves its text alone. #[test] fn locked_art_is_neither_bound_nor_rewritten() { let mut s = session(); @@ -634,6 +634,90 @@ fn binding_errors_say_whether_a_layer_or_group_is_hidden_or_locked() { assert!(e.contains(&format!("node {} is locked", t.0)), "{e}"); } +/// A visibility variable binds a locked layer and a locked object inside a locked group, and a row +/// hides and shows them in one undo step without unlocking them. List, Capture and Update Data Set +/// read whether they show. +#[test] +fn a_visibility_variable_shows_and_hides_locked_art() { + let mut s = session(); + let badge = rect(&mut s); + let other = rect(&mut s); + let g = group(&mut s, &[badge, other]); + let layer = NodeId(s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap()); + rect(&mut s); + s.execute("object.setProps", &json!({"ids": [badge.0], "locked": true})).unwrap(); + s.execute("object.setProps", &json!({"ids": [g.0], "locked": true})).unwrap(); + s.execute("layer.setProps", &json!({"id": layer.0, "locked": true})).unwrap(); + s.execute("variable.define", &json!({"name": "Layer", "kind": "visibility"})).unwrap(); + s.execute("variable.define", &json!({"name": "Badge", "kind": "visibility"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Layer", "ids": [layer.0]})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Badge", "ids": [badge.0]})).unwrap(); + s.execute("dataset.new", &json!({"name": "Shown", "values": {"Layer": true, "Badge": true}})).unwrap(); + s.execute("dataset.new", &json!({"name": "Hidden", "values": {"Layer": false, "Badge": false}})).unwrap(); + let shows = |s: &Session, id: NodeId| s.doc().unwrap().doc.node(id).unwrap().visible; + let locked = |s: &Session| [layer, g, badge].iter().all(|id| s.doc().unwrap().doc.node(*id).unwrap().locked); + + let v = s.execute("dataset.select", &json!({"name": "Hidden"})).unwrap(); + assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64()), (Some(2), Some(0)), "{v}"); + assert!(!shows(&s, layer) && !shows(&s, badge), "the row hides the locked layer and the locked object"); + assert!(locked(&s), "the row leaves the locks as they were"); + let sets = s.execute("dataset.list", &json!({})).unwrap(); + let matching: Vec<&Value> = sets["datasets"].as_array().unwrap().iter().filter(|d| d["matches"] == json!(true)).map(|d| &d["name"]).collect(); + assert_eq!(matching, [&json!("Hidden")], "List reads the locked art: {sets}"); + assert_eq!(s.execute("dataset.capture", &json!({"name": "Captured"})).unwrap()["values"], 2); + let sets = s.execute("dataset.list", &json!({})).unwrap(); + assert_eq!(sets["datasets"][2]["values"], json!({"Badge": {"visible": false}, "Layer": {"visible": false}}), "{sets}"); + + // Undo puts back what the row hid. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(shows(&s, layer) && shows(&s, badge), "undo shows them again"); + + let v = s.execute("dataset.select", &json!({"name": "Hidden"})).unwrap(); + assert_eq!(v["applied"], 2, "{v}"); + let v = s.execute("dataset.select", &json!({"name": "Shown"})).unwrap(); + assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64()), (Some(2), Some(0)), "{v}"); + assert!(shows(&s, layer) && shows(&s, badge), "the row shows them"); + assert!(locked(&s)); + + // Update writes the locked layer's visibility into the active row. + s.execute("layer.setProps", &json!({"id": layer.0, "visible": false})).unwrap(); + let v = s.execute("dataset.update", &json!({})).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"], json!({"Badge": {"visible": true}, "Layer": {"visible": false}}), "{sets}"); +} + +/// Text and graph variables refuse locked art and art in a locked layer. A visibility variable is +/// refused for art on a hidden layer that is also locked, and the error says the art is in a hidden +/// group or layer. +#[test] +fn locked_art_refuses_text_and_graph_variables_and_a_hidden_layer_refuses_visibility() { + let mut s = session(); + let layer = NodeId(s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap()); + let t = text(&mut s, "x"); + let r = rect(&mut s); + s.execute("object.setProps", &json!({"ids": [r.0], "locked": true})).unwrap(); + s.execute("layer.setProps", &json!({"id": layer.0, "locked": true})).unwrap(); + s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + s.execute("variable.define", &json!({"name": "Chart", "kind": "graph"})).unwrap(); + s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap(); + let bind = |s: &mut Session, var: &str, id: NodeId| s.execute("variable.bind", &json!({"variable": var, "ids": [id.0]})); + + let e = bind(&mut s, "Name", t).unwrap_err().to_string(); + assert!(e.contains(&format!("node {} is in a locked group or layer", t.0)), "{e}"); + let e = bind(&mut s, "Chart", r).unwrap_err().to_string(); + assert!(e.contains(&format!("node {} is locked", r.0)), "{e}"); + bind(&mut s, "Show", t).unwrap(); + assert!(s.doc().unwrap().doc.variables.objects_of("Name").is_empty()); + + s.execute("layer.setProps", &json!({"id": layer.0, "visible": false})).unwrap(); + let e = bind(&mut s, "Show", r).unwrap_err().to_string(); + assert!(e.contains(&format!("node {} is in a hidden group or layer", r.0)), "{e}"); + // The hidden layer's own visibility is what the variable drives. + bind(&mut s, "Show", layer).unwrap(); +} + /// 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] @@ -848,7 +932,7 @@ fn updating_writes_only_the_variables_the_row_holds() { 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 +/// A text 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() { @@ -1201,8 +1285,8 @@ fn a_hidden_object_comes_back_and_is_captured_hidden() { } /// A row selected while a layer is hidden rewrites the type on it and applies its other values, -/// and leaves the layer hidden: showing the layer shows that row's text. A layer that is locked -/// as well as hidden is still left alone. +/// and leaves the layer hidden: showing the layer shows that row's text. On a layer that is locked +/// as well as hidden, the type keeps its text and the row's visibility value applies. #[test] fn a_row_rewrites_type_on_a_hidden_layer_but_not_on_a_locked_one() { let mut s = session(); @@ -1230,12 +1314,12 @@ fn a_row_rewrites_type_on_a_hidden_layer_but_not_on_a_locked_one() { s.execute("layer.setProps", &json!({"id": layer.0, "visible": true})).unwrap(); assert_eq!(plain(&s, t), "Bob", "showing the layer shows the row's text"); - // Locked and hidden: the row skips both objects and the type keeps its text. + // Locked and hidden: the row skips the type, which keeps its text, and shows the rectangle. s.execute("layer.setProps", &json!({"id": layer.0, "visible": false, "locked": true})).unwrap(); let v = s.execute("dataset.select", &json!({"name": "A"})).unwrap(); - assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64()), (Some(0), Some(2)), "{v}"); + assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64()), (Some(1), Some(1)), "{v}"); assert_eq!(plain(&s, t), "Bob", "the type on the locked layer keeps its text"); - assert!(!s.doc().unwrap().doc.node(r).unwrap().visible); + assert!(s.doc().unwrap().doc.node(r).unwrap().visible, "the row's visibility value applies on the locked layer"); } /// Type inside a hidden group is rewritten too, and List and Capture Data Set read it. diff --git a/docs/mcp.md b/docs/mcp.md index ea8f91260..cb976bff3 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2210,11 +2210,16 @@ document stays unmodified. `dataset.select`, `dataset.next` and `dataset.prev` reply `{name, applied, skipped}`. They add `overflow: [id…]` when area type or type on a path whose text they rewrote doesn't fit, as `text.setText` does (see [Fitting area type](#fitting-area-type-auto-size-shrink-text-to-fit-overflow)). They skip an object that is -locked, inside a locked layer or group, or on a locked artboard, and count it in `skipped`. They rewrite hidden objects and objects inside +on a locked artboard, locked, or inside a locked layer or group, and count it in `skipped`, unless the value is a +visibility value, which shows or hides an object that is locked or inside a locked layer or group but not one on a +locked artboard. They rewrite hidden objects and objects inside a hidden layer or group, which stay hidden unless one of the data set's visibility values shows them. When such a layer is shown again, its type holds the data set's text. They apply the values in the order the variables are defined. Of two variables of the same kind on one object, the one defined last wins. `dataset.list`, -`dataset.capture` and `dataset.update` read hidden objects and objects inside a hidden layer or group as well. +`dataset.capture` and `dataset.update` read hidden objects and objects inside a hidden layer or group as well, and +read whether a locked object, or one inside a locked layer or group, shows. `variable.bind` binds a visibility +variable to a locked layer or object and to objects inside a locked layer or group, and refuses them for a text, +image or graph variable. A text value bound to any frame of threaded text replaces the whole story, and the text flows through the frames again; `overflow` names the story's last frame when the text does not fit. `dataset.capture`, `dataset.update` and @@ -2244,8 +2249,9 @@ A `dataset.import` error about a value says why it was refused: a visibility val or no, or a graph value longer than 262,144 bytes. `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 locked layer or group, -on a locked artboard, or deleted, keeps the data set's value. With `all: true` it also adds every other variable that the +data set holds. A variable whose objects report no value, because they are deleted or, for a text, image or graph +variable, locked or inside a locked layer or group, or for any variable on a locked artboard, 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. When every bound object is locked, it writes no value and adds no undo step, and `dataset.capture` returns the error "no bound object reports a value (locked objects and embedded images report none)". diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 619cdbef9..5a294fb68 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -651,8 +651,9 @@ 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 locked layer or group; `dataset.update {all: true}` also adds the other variables that the art reports a value for. - - Variables: `dataset.select`, `dataset.next` and `dataset.prev` rewrite type and apply the other values on hidden objects and in hidden layers and groups, which stay hidden unless a visibility value shows them, so a layer shown later holds the selected data set's text; objects that are locked or in a locked layer or group are skipped. + - 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 text, image or graph variable whose objects are locked or inside a locked layer or group; `dataset.update {all: true}` also adds the other variables that the art reports a value for. + - Variables: `dataset.select`, `dataset.next` and `dataset.prev` rewrite type and apply the other values on hidden objects and in hidden layers and groups, which stay hidden unless a visibility value shows them, so a layer shown later holds the selected data set's text; objects that are locked or in a locked layer or group are skipped, except by a visibility value. + - Variables: a visibility variable binds a locked layer, a locked object and objects inside a locked layer or group, and `dataset.select`, `dataset.next` and `dataset.prev` show and hide them without unlocking them; `dataset.list`, `dataset.capture` and `dataset.update` read whether they show. A text, image or graph variable refuses locked art. - Variables: `dataset.select`, `dataset.next` and `dataset.prev` name the area type and type on a path whose text they rewrote and that doesn't fit, in the reply's `overflow` key, as `text.setText` does. - Variables: when no object is bound to a variable that the data set has a value for, for example after Import Data Sets… defines new variables without binding them, choosing the data set in the Variables panel or stepping to it with the panel's arrows shows a status message, and `dataset.select`, `dataset.next` and `dataset.prev` add the message to the reply as `warning`. - Variables: Lock Variables (`variable.lock`, in the Variables panel's bottom bar and menu) stops variables from being defined, deleted or renamed until they are unlocked, while objects can still be bound and unbound and data sets still change and apply; the lock is saved with the document. @@ -677,8 +678,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Export for Screens is laid out as the reference app's dialog: on the left the Artboards and Assets tabs (thumbnails with their checkboxes, number and name, in a grid or a list), Clear Selection and Prefix; on the right Select (All, Range, Include Bleed, Include Artboard Fill, Full Document), Export to, Open Location after Export, Create Sub-folders by Scale or by Format (on by default), Export PDFs As (Single File writes the chosen artboards as the pages of one PDF named after the document, `pdfs: "single"`), and the format rows with the iOS and Android buttons, the format presets (☰) and Format Settings; under them, how many items are selected and how many files the export writes. File name patterns, the Layers and Selection sources and per-item folders stay available through `document.exportForScreens` (scripts and MCP). Tests: `dialogs::tests_screens` (layout, iOS/Android, Clear Selection, sub-folders, Single File) and `fileio::tests_screens::single_file_pdfs_hold_every_chosen_artboard`. - Save for Web: Preview in Browser writes each preview's page and images to a folder of its own inside `VectorCraft Save for Web` in the system's temporary folder. A later preview, from the same app or another one running, does not overwrite an earlier one. Each preview first removes the preview folders there that are an hour old or older and, on Linux, macOS and FreeBSD, belong to the current user; it leaves other files and folders alone. The folders a preview makes there are open to their owner only on those systems. When `VectorCraft Save for Web` is a link or is not a folder, or on those systems belongs to another user or can be written by other users, the preview goes to a new folder of its own in the temporary folder, which a later preview removes once it is an hour old (`file.saveForWeb.browser`). - Export for Screens, Save for Web (slice images, and files named after the document's title) and the Libraries panel name files with the characters Windows does not allow written as `-` and with a `-` after a Windows device name such as `CON`, on every platform (`document.exportForScreens`, `document.exportForWeb`, `library.create`). + - Variables (data merge): text, visibility and image 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/Image Dynamic, Capture/Update Data Set, CSV import/export (`dataset.import` / `dataset.export`), and a row in italics when an edit leaves the art not matching it; an image dataset value is a file path and apply keeps the object's bounds; bindings let go of deleted art and locked art is left alone except by visibility values; every command, like the rest, is drivable over MCP and the CLI. - Export for Screens numbers distinct row files whose names collide, compared without case, as `-2`, `-3`… instead of leaving them out; identical rows still write one file. It puts row folders that differ only in case into one folder (`document.exportForScreens`). - - Variables (data merge): text, visibility and image 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/Image Dynamic, Capture/Update Data Set, CSV import/export (`dataset.import` / `dataset.export`), and a row in italics when an edit leaves the art not matching it; an image dataset value is a file path and apply keeps the object's bounds; bindings let go of deleted art and locked art is left alone; every command, like the rest, is drivable over MCP and the CLI. - Variables: Import Data Sets… (`dataset.import`) reads tab-separated files as well as comma-separated ones. Every header cell is a variable, and the rows replace the document's data sets as Data Set 1, Data Set 2 and so on. An import error about a row's values gives the file line the row starts on. - 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 text variable the library also declares, which stays bound so the library's data sets change its objects (#4136); then the library's other text variables are defined, unbound, and its data sets take the text of each value's `p` elements, one paragraph each. Variables of other kinds are left out with a warning that names their kinds.