mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 18:54:57 +00:00
Merge pull request #5138 from dotCooCoo/fix/vars-p6
Variables: a text value on threaded type replaces the whole story
This commit is contained in:
@@ -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)]
|
||||
@@ -203,17 +203,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<NodeId> {
|
||||
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<DataValue> {
|
||||
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())),
|
||||
|
||||
@@ -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<NodeId> {
|
||||
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<TextObject> = 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<usize> {
|
||||
p.get(k).and_then(Value::as_u64).map(index)
|
||||
|
||||
@@ -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;
|
||||
@@ -493,16 +493,20 @@ fn apply_dataset(s: &mut Session, name: &str) -> Result<Value> {
|
||||
};
|
||||
// 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| {
|
||||
@@ -676,7 +680,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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
|
||||
),
|
||||
@@ -712,7 +716,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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
|
||||
),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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":{}}}
|
||||
|
||||
Reference in New Issue
Block a user