diff --git a/crates/doc/src/variables.rs b/crates/doc/src/variables.rs index 96bccbd0a..ca0b2ea17 100644 --- a/crates/doc/src/variables.rs +++ b/crates/doc/src/variables.rs @@ -6,7 +6,7 @@ use std::collections::{BTreeMap, BTreeSet}; use serde::{Deserialize, Serialize}; -use super::{Document, NodeId, NodeKind}; +use super::{Document, NodeId, NodeKind, TextKind}; /// What a variable drives. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -202,17 +202,46 @@ impl Document { self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| !n.locked))) } - /// What the object `id` holds for the variable of `kind`: its characters, 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 or in a locked layer or group (the - /// document's value is whatever it was left at). + /// The type objects of the story that the type object `id` belongs to, in thread order: the + /// frames of its threaded text (area type and type on a path), else `id` alone. + pub fn story_of(&self, id: NodeId) -> Vec { + let frame = |f: &NodeId| matches!(self.node(*f).map(|n| &n.kind), Some(NodeKind::Text(t)) if !matches!(t.kind, TextKind::Point)); + match self.text_threads.iter().find(|thread| thread.contains(&id)) { + Some(thread) if frame(&id) => thread.iter().filter(|f| frame(f)).copied().collect(), + _ => vec![id], + } + } + + /// The characters of the story that the type object `id` belongs to ([`Document::story_of`]): + /// every frame's text joined in thread order. + pub fn story_text(&self, id: NodeId) -> String { + self.story_of(id) + .into_iter() + .filter_map(|f| match self.node(f).map(|n| &n.kind) { + Some(NodeKind::Text(t)) => Some(t.plain_text()), + _ => None, + }) + .collect() + } + + /// Whether a data set reads and rewrites the text of the type object `id`: every frame of its + /// story ([`Document::story_of`]) is a [`Document::dataset_target`]. + pub fn story_target(&self, id: NodeId) -> bool { + self.story_of(id).into_iter().all(|f| self.dataset_target(f)) + } + + /// 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 + /// or in a locked layer or group, 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 { if !self.dataset_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::Text, NodeKind::Text(_)) if self.story_target(id) => Some(DataValue::Text(self.story_text(id))), (VariableKind::Visibility, _) => Some(DataValue::Visible(node.visible)), (VariableKind::Image, NodeKind::Image(im)) => im.link.as_ref().map(|l| DataValue::Image(l.path.clone())), (VariableKind::Graph, _) => node.extras().graph.as_ref().map(|g| DataValue::Graph(g.to_csv())), diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index c028e29b1..88f1b6a59 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -186,6 +186,48 @@ pub(crate) fn set_plain_text(d: &mut vectorcraft_doc::Document, id: NodeId, text Ok(()) } +/// Replace the whole story of the type object `id` as [`set_plain_text`] replaces one type +/// object's content: for a frame of threaded text, every frame's text. The new text goes into the +/// thread's first frame and the other frames are emptied; the edit's reflow then pours it through +/// them. → the story's last frame, which holds any text that does not fit. +pub(crate) fn set_story_text(d: &mut vectorcraft_doc::Document, id: NodeId, text: &str) -> Result { + let story = d.story_of(id); + let (Some((&first, rest)), Some(&last)) = (story.split_first(), story.last()) else { + return Err(EngineError::NoNode(id)); + }; + if rest.is_empty() { + set_plain_text(d, id, text)?; + return Ok(id); + } + let frames: Vec = story + .iter() + .filter_map(|f| match d.node(*f).map(|n| &n.kind) { + Some(NodeKind::Text(t)) => Some((**t).clone()), + _ => None, + }) + .collect(); + let refs: Vec<&TextObject> = frames.iter().collect(); + // The first frame takes the whole story first, so the replace keeps the story's first style + // and paragraph styles follow as for one type object. + let t = text_mut(d, first).ok_or(EngineError::NoNode(first))?; + t.runs = vectorcraft_text::thread::story(&refs); + t.set_paragraph_styles(vectorcraft_text::thread::story_paras(&refs, &[])); + set_plain_text(d, first, text)?; + // An emptied frame's paragraph takes the new text's last paragraph style, so it does not + // restyle that paragraph when the story is joined again. + let tail = match d.node(first).map(|n| &n.kind) { + Some(NodeKind::Text(t)) => t.paragraph_styles().last().cloned().unwrap_or_default(), + _ => Default::default(), + }; + for f in rest { + let t = text_mut(d, *f).ok_or(EngineError::NoNode(*f))?; + t.runs.clear(); + t.set_paragraph_styles(vec![tail.clone()]); + refresh_bounds(t); + } + Ok(last) +} + /// The byte offset that `k` holds, read with [`index`]. fn byte_param(p: &Value, k: &str) -> Option { p.get(k).and_then(Value::as_u64).map(index) diff --git a/crates/engine/src/cmd/variablecmds.rs b/crates/engine/src/cmd/variablecmds.rs index 116149ec7..adbfe6aa0 100644 --- a/crates/engine/src/cmd/variablecmds.rs +++ b/crates/engine/src/cmd/variablecmds.rs @@ -18,7 +18,7 @@ use vectorcraft_doc::{DataSet, DataValue, NodeId, NodeKind, Variable, VariableKi use super::edit::selected_roots; use super::fileio::{absolute_path, read_file_head, write_file}; -use super::textedit::set_plain_text; +use super::textedit::set_story_text; use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, checked_ids_or_selection, cmd, has_doc, has_selection, links, str_param}; mod csv; @@ -487,16 +487,20 @@ fn apply_dataset(s: &mut Session, name: &str) -> Result { }; // Locked art, art in a locked layer or group, 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. - if !d.dataset_target(id) { + // 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. + let target = if kind == VariableKind::Text { d.story_target(id) } else { d.dataset_target(id) }; + if !target { 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_and(|()| { - typed.push(id); + // laid out exactly as typing it would be. A frame of threaded text has its whole + // story replaced, the value capture reads from it; the story's last frame is the + // one that overflows. + (VariableKind::Text, DataValue::Text(text)) => set_story_text(d, id, text).is_ok_and(|last| { + typed.push(last); true }), (VariableKind::Visibility, DataValue::Visible(show)) => d.node_mut(id).is_some_and(|n| { @@ -670,7 +674,7 @@ pub fn specs() -> Vec { "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", + "{name?} record what the bound objects hold right now as a new dataset (Data Set 1…), and make it the active one; a frame of threaded text holds its whole story, every frame's text in thread order", has_doc, dataset_capture ), @@ -706,7 +710,7 @@ pub fn specs() -> Vec { "Select Data Set", ["Window", "Variables"], None, - "{name} apply a dataset in one undo step, deselecting every selected object left hidden or locked (the key object too) → {applied, skipped, overflow?: [id…] (area type and type on a path whose text it rewrote and that no longer fits; left out when all fits), warning? (when no object is bound to a variable that the dataset has a value for, so nothing was applied)}", + "{name} apply a dataset in one undo step, deselecting every selected object left hidden or locked (the key object too); a text value bound to a frame of threaded text replaces the whole story → {applied, skipped, overflow?: [id…] (area type and type on a path whose text it rewrote and that no longer fits, the story's last frame for threaded text; left out when all fits), warning? (when no object is bound to a variable that the dataset has a value for, so nothing was applied)}", has_doc, dataset_select ), diff --git a/crates/engine/src/tests/variables.rs b/crates/engine/src/tests/variables.rs index bdacd0e1b..738f183ae 100644 --- a/crates/engine/src/tests/variables.rs +++ b/crates/engine/src/tests/variables.rs @@ -114,6 +114,113 @@ fn applying_a_dataset_reports_the_area_type_it_overflows() { assert_eq!((&v["name"], &v["overflow"]), (&json!("Long"), &json!([a.0])), "{v}"); } +/// Area type threaded into a rectangle → (session, first frame, second frame, the story). +fn threaded(second_height: u32) -> (Session, NodeId, NodeId, String) { + let mut s = session(); + let story = "The quick brown fox jumps over the lazy dog and keeps running. ".repeat(3) + "\nSecond paragraph.\nThird."; + let r = s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "area": {"width": 150, "height": 60}})).unwrap(); + let a = NodeId(r["id"].as_u64().unwrap()); + let r = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": second_height})).unwrap(); + let b = NodeId(r["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + (s, a, b, story) +} + +/// Whether `dataset.list` says the data set `name` matches the art. +fn matches(s: &mut Session, name: &str) -> bool { + let sets = s.execute("dataset.list", &json!({})).unwrap(); + let set = sets["datasets"].as_array().unwrap().iter().find(|d| d["name"] == name).unwrap().clone(); + set["matches"].as_bool().unwrap() +} + +#[test] +fn a_text_value_on_a_frame_of_threaded_type_replaces_the_whole_story() { + let (mut s, a, b, story) = threaded(200); + assert!(!plain(&s, a).is_empty() && !plain(&s, b).is_empty()); + s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Name", "ids": [a.0]})).unwrap(); + // A capture reads the whole story, every frame's text in thread order. + s.execute("dataset.capture", &json!({"name": "Orig"})).unwrap(); + let orig = s.doc().unwrap().doc.variables.dataset("Orig").unwrap().values.get("Name").cloned(); + assert_eq!(orig, Some(vectorcraft_doc::DataValue::Text(story.clone()))); + s.execute("dataset.new", &json!({"name": "B", "values": {"Name": "Bob"}})).unwrap(); + + let v = s.execute("dataset.select", &json!({"name": "B"})).unwrap(); + assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64()), (Some(1), Some(0))); + assert_eq!((plain(&s, a), plain(&s, b)), ("Bob".to_string(), String::new())); + assert!(matches(&mut s, "B")); + // The captured row applied back gives the story back, every time. + for _ in 0..2 { + s.execute("dataset.select", &json!({"name": "Orig"})).unwrap(); + assert_eq!(format!("{}{}", plain(&s, a), plain(&s, b)), story); + assert!(!plain(&s, b).is_empty()); + assert!(matches(&mut s, "Orig")); + s.execute("dataset.select", &json!({"name": "B"})).unwrap(); + assert_eq!(format!("{}{}", plain(&s, a), plain(&s, b)), "Bob"); + } +} + +#[test] +fn a_text_value_on_a_later_frame_of_threaded_type_replaces_the_whole_story() { + let (mut s, a, b, story) = threaded(200); + s.execute("variable.define", &json!({"name": "Tail", "kind": "text"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Tail", "ids": [b.0]})).unwrap(); + s.execute("dataset.capture", &json!({"name": "Orig"})).unwrap(); + s.execute("dataset.new", &json!({"name": "T", "values": {"Tail": "END"}})).unwrap(); + + let v = s.execute("dataset.select", &json!({"name": "T"})).unwrap(); + assert_eq!(v["applied"].as_u64(), Some(1)); + assert_eq!((plain(&s, a), plain(&s, b)), ("END".to_string(), String::new())); + assert!(matches(&mut s, "T")); + s.execute("dataset.select", &json!({"name": "Orig"})).unwrap(); + assert_eq!(format!("{}{}", plain(&s, a), plain(&s, b)), story); + assert!(matches(&mut s, "Orig")); +} + +#[test] +fn a_story_with_a_locked_frame_is_neither_read_nor_rewritten() { + let (mut s, a, b, story) = threaded(200); + s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Name", "ids": [a.0]})).unwrap(); + s.execute("dataset.new", &json!({"name": "B", "values": {"Name": "Bob"}})).unwrap(); + s.execute("select.set", &json!({"ids": [b.0]})).unwrap(); + s.execute("object.lock", &json!({})).unwrap(); + + let v = s.execute("dataset.select", &json!({"name": "B"})).unwrap(); + assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64()), (Some(0), Some(1))); + assert_eq!(format!("{}{}", plain(&s, a), plain(&s, b)), story); + assert!(s.doc().unwrap().doc.captured_values().is_empty()); +} + +#[test] +fn two_text_variables_in_one_story_apply_in_definition_order() { + let (mut s, a, b, _) = threaded(200); + s.execute("variable.define", &json!({"name": "Head", "kind": "text"})).unwrap(); + s.execute("variable.define", &json!({"name": "Tail", "kind": "text"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Tail", "ids": [a.0]})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Head", "ids": [b.0]})).unwrap(); + s.execute("dataset.new", &json!({"name": "R", "values": {"Head": "one", "Tail": "two"}})).unwrap(); + + let v = s.execute("dataset.select", &json!({"name": "R"})).unwrap(); + assert_eq!(v["applied"].as_u64(), Some(2)); + assert_eq!(format!("{}{}", plain(&s, a), plain(&s, b)), "two"); +} + +#[test] +fn applying_a_dataset_to_threaded_type_reports_overflow_on_the_last_frame() { + let (mut s, a, b, _) = threaded(40); + s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Name", "ids": [a.0]})).unwrap(); + s.execute("dataset.new", &json!({"name": "Long", "values": {"Name": "word ".repeat(120)}})).unwrap(); + s.execute("dataset.new", &json!({"name": "Short", "values": {"Name": "Ann"}})).unwrap(); + + let v = s.execute("dataset.select", &json!({"name": "Long"})).unwrap(); + assert_eq!(&v["overflow"], &json!([b.0]), "{v}"); + let v = s.execute("dataset.select", &json!({"name": "Short"})).unwrap(); + assert!(v.get("overflow").is_none(), "{v}"); +} + #[test] fn mismatched_and_missing_bindings_are_skipped_and_counted() { let mut s = session(); diff --git a/docs/mcp.md b/docs/mcp.md index fa8e05fab..6ef4071d8 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1747,6 +1747,13 @@ layer is shown again, its type holds the data set's text. They apply the values 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. +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 +`dataset.list` read such a frame as the whole story, every frame's text joined in thread order. When any frame of +the story is locked or inside a locked layer or group, the text value is skipped and counted in `skipped`, and the +story is not read. Two text variables +bound in one story apply in the order they are defined, and the one defined last wins. + ```json {"name":"run_command","arguments":{"command":"symbol.list","params":{}}} {"name":"run_command","arguments":{"command":"brush.list","params":{}}}