diff --git a/crates/engine/src/cmd/threads.rs b/crates/engine/src/cmd/threads.rs index c1c3801e40..1d692ecf38 100644 --- a/crates/engine/src/cmd/threads.rs +++ b/crates/engine/src/cmd/threads.rs @@ -68,11 +68,8 @@ pub fn specs() -> Vec { fn has_thread_selection(s: &Session) -> std::result::Result<(), String> { has_selection(s)?; let st = s.active().ok_or("no document open")?; - if st.selection.objects.iter().any(|id| st.doc.text_threads.iter().any(|t| t.contains(id))) { - Ok(()) - } else { - Err("no threaded text selected".into()) - } + let selected: Ids = st.selection.objects.iter().copied().collect(); + if st.doc.text_threads.iter().flatten().any(|id| selected.contains(id)) { Ok(()) } else { Err("no threaded text selected".into()) } } /// The text object `id` when it can be a frame of a thread: area type or type on a path. @@ -87,6 +84,43 @@ fn text_of(d: &Document, id: NodeId) -> Option<&TextObject> { thread_local! { /// Tests: how many threads' stories [`reflow`] copied on this thread. static COPIED: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// Tests: how many frames [`leave_threads`] passed to [`continued_marks`] on this thread. + static MARK_VISITS: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// Tests: how many lookups and insertions [`Ids`] made on this thread. + static ID_LOOKS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// A set of node ids, for the threaded-text commands to test whether a frame is selected, already +/// listed or in a thread in the same time however many ids the set holds. +#[derive(Clone, Default)] +struct Ids(std::collections::HashSet); + +impl Ids { + fn contains(&self, id: &NodeId) -> bool { + #[cfg(test)] + ID_LOOKS.with(|c| c.set(c.get() + 1)); + self.0.contains(id) + } + + /// Add `id` to the set; whether it was not in it already. + fn insert(&mut self, id: NodeId) -> bool { + #[cfg(test)] + ID_LOOKS.with(|c| c.set(c.get() + 1)); + self.0.insert(id) + } +} + +impl FromIterator for Ids { + fn from_iter>(ids: I) -> Self { + Self(ids.into_iter().collect()) + } +} + +/// [`vectorcraft_text::thread::continued`] for the frames of a thread that [`leave_threads`] marks. +fn continued_marks(frames: &[&TextObject]) -> Vec { + #[cfg(test)] + MARK_VISITS.with(|c| c.set(c.get() + frames.len())); + vectorcraft_text::thread::continued(frames) } /// Re-flow every thread with a frame that changed between `before` and `doc` (structural sharing @@ -371,10 +405,12 @@ pub(crate) fn story_of(d: &Document, id: NodeId) -> Vec { /// `ids` followed by the other frames of the threads they are in, each once, in thread order. pub(crate) fn with_threads(d: &Document, ids: &[NodeId]) -> Vec { + let given: Ids = ids.iter().copied().collect(); + let mut listed = given.clone(); let mut out = ids.to_vec(); - for thread in d.text_threads.iter().filter(|t| t.iter().any(|id| ids.contains(id))) { + for thread in d.text_threads.iter().filter(|t| t.iter().any(|id| given.contains(id))) { for id in thread { - if !out.contains(id) && text_of(d, *id).is_some() { + if text_of(d, *id).is_some() && listed.insert(*id) { out.push(*id); } } @@ -399,13 +435,14 @@ pub(crate) fn frames(d: &Document) -> Vec<(NodeId, TextObject)> { /// threaded and the frames before it end inside one. A threaded frame whose mark changes lays out /// its first line again, so its thread re-flows after the edit. pub(crate) fn leave_threads(d: &mut Document, was: &[(NodeId, TextObject)]) { - let gone: Vec<(NodeId, &TextObject, Option)> = - was.iter().filter(|(id, _)| text_of(d, *id).is_none()).map(|(id, t)| (*id, t, kept_type(d, *id))).collect(); + // Each frame that left, with the type that takes its place. + let gone: std::collections::HashMap)> = + was.iter().filter(|(id, _)| text_of(d, *id).is_none()).map(|(id, t)| (*id, (t, kept_type(d, *id)))).collect(); if gone.is_empty() { return; } - let find = |id: &NodeId| gone.iter().find(|(g, _, _)| g == id); - let mut stayed = vec![]; + let find = |id: &NodeId| gone.get(id); + let mut changed = vec![]; for i in 0..d.text_threads.len() { let thread = d.text_threads[i].clone(); if !thread.iter().any(|id| find(id).is_some()) { @@ -414,28 +451,33 @@ pub(crate) fn leave_threads(d: &mut Document, was: &[(NodeId, TextObject)]) { let keep: Vec = thread .iter() .filter_map(|id| match find(id) { - Some((_, _, kept)) => *kept, + Some((_, kept)) => *kept, None => Some(*id), }) .collect(); - if let (Some((_, last, None)), Some(stay)) = (thread.last().and_then(find), keep.last()) { + if let (Some((last, None)), Some(stay)) = (thread.last().and_then(find), keep.last()) { take_overflow(d, *stay, last); } - stayed.extend(keep.iter().copied()); + changed.push(keep.clone()); d.text_threads[i] = keep; } d.text_threads.retain(|t| t.len() >= 2); - let marks: Vec<(NodeId, bool)> = stayed - .iter() - .map(|id| { - let on = d.text_threads.iter().find(|t| t.contains(id)).is_some_and(|t| { - let live: Vec<(NodeId, &TextObject)> = t.iter().filter_map(|i| Some((*i, text_of(d, *i)?))).collect(); - let frames: Vec<&TextObject> = live.iter().map(|(_, f)| *f).collect(); - live.iter().zip(vectorcraft_text::thread::continued(&frames)).any(|((i, _), on)| on && i == id) - }); - (*id, on) - }) - .collect(); + // One pass over each changed thread: a frame left alone continues nothing, and the area type and + // type on a path of a thread that stays take their marks in thread order. + let mut marks: Vec<(NodeId, bool)> = vec![]; + for keep in &changed { + if keep.len() < 2 { + marks.extend(keep.iter().map(|id| (*id, false))); + continue; + } + let live: Vec<(NodeId, &TextObject)> = keep.iter().filter_map(|i| Some((*i, text_of(d, *i)?))).collect(); + let frames: Vec<&TextObject> = live.iter().map(|(_, f)| *f).collect(); + let mut on: std::collections::HashMap = std::collections::HashMap::new(); + for ((id, _), mark) in live.iter().zip(continued_marks(&frames)) { + *on.entry(*id).or_default() |= mark; + } + marks.extend(keep.iter().map(|id| (*id, on.get(id).copied().unwrap_or(false)))); + } for (id, on) in marks { if text_of(d, id).is_some_and(|t| t.first_para_continues != on) && let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) @@ -450,21 +492,23 @@ pub(crate) fn leave_threads(d: &mut Document, was: &[(NodeId, TextObject)]) { /// thread with frames outside `art`, to the text it shows. When such a frame leaves its thread, /// [`leave_threads`] puts the text it has no room for after the text of the last frame that stays. pub(crate) fn keep_shown_text(d: &Document, art: &mut Node) { - let mut inside = vec![]; - art.walk(&mut |n| inside.push(n.id)); - let last: Vec = d + let mut inside = Ids::default(); + art.walk(&mut |n| { + inside.insert(n.id); + }); + let last: Ids = d .text_threads .iter() .filter(|t| t.iter().any(|id| !inside.contains(id))) .filter_map(|t| t.last().copied().filter(|id| inside.contains(id))) .collect(); - if !last.is_empty() { + if !last.0.is_empty() { cut_to_shown(art, &last); } } /// Cut the area type and type on a path `ids` in `n` to the text each one shows. -fn cut_to_shown(n: &mut Node, ids: &[NodeId]) { +fn cut_to_shown(n: &mut Node, ids: &Ids) { if ids.contains(&n.id) && let NodeKind::Text(t) = &mut n.kind && !matches!(t.kind, TextKind::Point) @@ -567,21 +611,35 @@ fn create(s: &mut Session, _: &Value) -> Result { *d.node_mut(*id).ok_or(EngineError::NoNode(*id))? = f; } } - // Existing threads containing any of these frames merge into the new one, keeping order. + // Existing threads containing any of these frames merge into the new one, keeping order: + // each frame takes in the first thread not yet taken in that lists it. + let mut threads: Vec>> = std::mem::take(&mut d.text_threads).into_iter().map(Some).collect(); + let mut listing: std::collections::HashMap> = std::collections::HashMap::new(); + for (i, t) in threads.iter().enumerate() { + for id in t.iter().flatten() { + listing.entry(*id).or_default().push(i); + } + } let mut order: Vec = vec![]; + let mut listed = Ids::default(); for id in &ids { - match d.text_threads.iter().position(|t| t.contains(id)) { - Some(i) => { - for x in d.text_threads.remove(i) { - if !order.contains(&x) { + let thread = listing.get(id).and_then(|at| at.iter().find_map(|i| threads[*i].take())); + match thread { + Some(thread) => { + for x in thread { + if listed.insert(x) { order.push(x); } } } - None if !order.contains(id) => order.push(*id), - None => {} + None => { + if listed.insert(*id) { + order.push(*id); + } + } } } + d.text_threads = threads.into_iter().flatten().collect(); d.text_threads.push(order); // Force a re-flow of the new thread on this edit. touch(d, ids[0]); @@ -706,11 +764,12 @@ fn touch(d: &mut Document, id: NodeId) { } fn release(s: &mut Session, _: &Value) -> Result { - let ids = selected_roots(s)?; + let ids: Ids = selected_roots(s)?.into_iter().collect(); s.edit("Release Threaded Text", |d, _| { // The released frames' text joins the rest of the story (they become empty). - for thread in d.text_threads.clone() { - let (out, keep): (Vec, Vec) = thread.iter().partition(|i| ids.contains(i)); + for i in 0..d.text_threads.len() { + let thread = d.text_threads[i].clone(); + let (out, keep): (Vec, Vec) = thread.iter().partition(|id| ids.contains(id)); if out.is_empty() { continue; } @@ -750,7 +809,6 @@ fn release(s: &mut Session, _: &Value) -> Result { t.first_para_continues = false; refresh_bounds(t); } - let i = d.text_threads.iter().position(|t| *t == thread).unwrap_or(0); d.text_threads[i] = keep; } d.text_threads.retain(|t| t.len() >= 2); @@ -760,7 +818,7 @@ fn release(s: &mut Session, _: &Value) -> Result { } fn remove(s: &mut Session, _: &Value) -> Result { - let ids = selected_roots(s)?; + let ids: Ids = selected_roots(s)?.into_iter().collect(); s.edit("Remove Threading", |d, _| { let (gone, kept): (Vec>, Vec>) = std::mem::take(&mut d.text_threads).into_iter().partition(|t| t.iter().any(|i| ids.contains(i))); diff --git a/crates/engine/src/cmd/threads/tests.rs b/crates/engine/src/cmd/threads/tests.rs index a622901c01..6d98fcc347 100644 --- a/crates/engine/src/cmd/threads/tests.rs +++ b/crates/engine/src/cmd/threads/tests.rs @@ -1053,6 +1053,246 @@ fn a_frame_that_starts_its_thread_after_outlining_has_its_first_line_indent() { } } +/// Whether frame `id` of `d` is marked as continuing a paragraph. +fn marked(d: &Document, id: NodeId) -> bool { + text_of(d, id).unwrap().first_para_continues +} + +/// The marks of `thread`'s frames, each found from the frames before it: a frame continues a +/// paragraph when the last of them with text does not end with a paragraph break. A frame left +/// alone continues nothing. +fn marks_by_scan(d: &Document, thread: &[NodeId]) -> Vec { + (0..thread.len()) + .map(|k| { + thread.len() >= 2 + && thread[..k] + .iter() + .rev() + .map(|id| text_of(d, *id).unwrap().plain_text()) + .find(|t| !t.is_empty()) + .is_some_and(|t| !t.ends_with('\n')) + }) + .collect() +} + +/// When frames leave their threads (from the start, the middle or the end of a thread, outlined or +/// with their type under a new id, leaving a thread with one frame), each frame that stays takes +/// the mark found from the frames before it, and a thread that lost no frame keeps its marks. +#[test] +fn frames_that_stay_after_others_leave_their_threads_take_the_marks_of_their_place() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + let mut frame = |text: &str| { + NodeId(new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": text, "area": {"width": 150, "height": 60}})).unwrap())) + }; + let [a1, a2, a3, a4, a5] = ["One", "", "Two\n", "Three", "Four"].map(&mut frame); + let [b1, b2] = ["Alpha", "Beta"].map(&mut frame); + let [c1, c2] = ["x", "y"].map(&mut frame); + let [d1, d2, d3, d4] = ["M", "N\n", "O", "P"].map(&mut frame); + let rect = NodeId(new_id(s.execute("shape.rectangle", &json!({"x": 400, "y": 400, "width": 20, "height": 20})).unwrap())); + let mut d = (*s.doc().unwrap().doc).clone(); + d.text_threads = vec![vec![a1, a2, a3, a4, a5], vec![b1, b2], vec![c1, c2], vec![d1, d2, d3, d4]]; + // Every mark is the opposite of what it becomes, or (in the thread that loses no frame) wrong. + for id in [a1, a2, a3, a4, a5, b1, b2, c1, d1, d2, d3, d4] { + if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) { + t.first_para_continues = true; + } + } + let shown: Vec<(NodeId, String)> = d.text_threads.iter().flatten().map(|id| (*id, text_of(&d, *id).unwrap().plain_text())).collect(); + let was = frames(&d); + // a1, b2 and d3 become a rectangle; a4 becomes a group holding its type under a new id. + let outline = d.node(rect).unwrap().kind.clone(); + for id in [a1, b2, d3] { + d.node_mut(id).unwrap().kind = outline.clone(); + } + let mut kept = d.node(a4).unwrap().clone(); + kept.id = d.alloc_id(); + let new_a4 = kept.id; + let layer = d.parent_of(a4); + d.remove(a4).unwrap(); + d.insert(layer, usize::MAX, Node::group(a4, vec![std::sync::Arc::new(kept)])).unwrap(); + leave_threads(&mut d, &was); + assert_eq!(d.text_threads, vec![vec![a2, a3, new_a4, a5], vec![c1, c2], vec![d1, d2, d4]]); + for thread in [&[a2, a3, new_a4, a5][..], &[b1], &[d1, d2, d4]] { + let want = marks_by_scan(&d, thread); + assert_eq!(thread.iter().map(|id| marked(&d, *id)).collect::>(), want, "{thread:?}"); + } + assert_eq!([a2, a3, new_a4, a5, b1, d1, d2, d4].map(|id| marked(&d, id)), [false, false, false, true, false, false, true, false]); + assert_eq!([c1, c2].map(|id| marked(&d, id)), [true, false], "a thread that lost no frame keeps its marks"); + assert!(!matches!(d.node(a4).unwrap().kind, NodeKind::Text(_)), "the group in the frame's place is not type"); + for (id, before) in shown { + let now = if id == a4 { new_a4 } else { id }; + if let Some(t) = text_of(&d, now) { + assert_eq!(t.plain_text(), before, "frame {id:?} keeps its text"); + } + } +} + +/// The frames that stay in a long thread after one leaves are marked in one pass over the thread. +#[test] +fn a_long_thread_is_marked_in_one_pass_when_a_frame_leaves() { + const FRAMES: usize = 256; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); + let first = NodeId(new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": "w0", "area": {"width": 150, "height": 60}})).unwrap())); + let rect = NodeId(new_id(s.execute("shape.rectangle", &json!({"x": 400, "y": 200, "width": 20, "height": 20})).unwrap())); + let mut d = (*s.doc().unwrap().doc).clone(); + let layer = d.parent_of(first); + let model = d.node(first).unwrap().clone(); + let mut thread = vec![first]; + for k in 1..FRAMES { + let mut n = model.clone(); + n.id = d.alloc_id(); + if let NodeKind::Text(t) = &mut n.kind { + t.runs.truncate(1); + t.runs[0].text = format!("w{k}"); + t.first_para_continues = true; + } + thread.push(d.insert(layer, usize::MAX, n).unwrap()); + } + d.text_threads = vec![thread.clone()]; + let was = frames(&d); + let out = thread[FRAMES / 2]; + d.node_mut(out).unwrap().kind = d.node(rect).unwrap().kind.clone(); + MARK_VISITS.with(|c| c.set(0)); + leave_threads(&mut d, &was); + let visits = MARK_VISITS.with(std::cell::Cell::get); + let rest: Vec = thread.iter().copied().filter(|id| *id != out).collect(); + assert_eq!(d.text_threads, vec![rest.clone()]); + assert!(visits <= FRAMES, "{visits} frames visited to mark a thread of {} frames", rest.len()); + assert_eq!(rest.iter().map(|id| marked(&d, *id)).collect::>(), marks_by_scan(&d, &rest)); +} + +/// `count` threads of `per` area type frames each, made in one edit, each frame holding a word. +fn many_threads(count: usize, per: usize) -> (Session, Vec>) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); + let first = NodeId(new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": "w", "area": {"width": 150, "height": 60}})).unwrap())); + let threads = s + .edit("Make", |d, _| { + let layer = d.parent_of(first); + let model = d.node(first).unwrap().clone(); + let mut threads = vec![]; + for _ in 0..count { + let mut thread = vec![]; + for _ in 0..per { + let mut n = model.clone(); + n.id = d.alloc_id(); + thread.push(d.insert(layer, usize::MAX, n).unwrap()); + } + threads.push(thread); + } + d.text_threads = threads.clone(); + Ok(threads) + }) + .unwrap(); + assert_eq!(s.doc().unwrap().doc.text_threads, threads); + (s, threads) +} + +/// Select the first frame of each of `threads`. +fn select_firsts(s: &mut Session, threads: &[Vec]) { + let ids: Vec = threads.iter().map(|t| t[0].0).collect(); + s.execute("select.set", &json!({ "ids": ids })).unwrap(); +} + +/// Reset the count of [`Ids`] lookups, run `f`, and give its result with the lookups it made. +fn looks(f: impl FnOnce() -> T) -> (T, usize) { + ID_LOOKS.with(|c| c.set(0)); + let out = f(); + (out, ID_LOOKS.with(std::cell::Cell::get)) +} + +/// Whether Release Selection and Remove Threading are available is found in one pass over the +/// threads' frames, however many frames are selected. +#[test] +fn thread_commands_are_offered_after_one_pass_over_the_threads() { + let (mut s, made) = many_threads(300, 3); + let (threads, loose) = made.split_at(150); + s.edit("Unthread", |d, _| { + d.text_threads = threads.to_vec(); + Ok(()) + }) + .unwrap(); + // 450 frames out of any thread, then the last frame of the last thread. + let mut ids: Vec = loose.iter().flatten().map(|id| id.0).collect(); + ids.push(threads[149][2].0); + s.execute("select.set", &json!({ "ids": ids })).unwrap(); + let (offered, n) = looks(|| has_thread_selection(&s)); + assert!(offered.is_ok()); + assert!(n <= 2 * (451 + 450), "{n} id lookups"); + s.execute("select.set", &json!({ "ids": ids[..450] })).unwrap(); + assert!(has_thread_selection(&s).is_err(), "frames out of any thread are no threaded text"); + let rect = new_id(s.execute("shape.rectangle", &json!({"x": 400, "y": 200, "width": 20, "height": 20})).unwrap()); + s.execute("select.set", &json!({"ids": [rect]})).unwrap(); + assert!(has_thread_selection(&s).is_err(), "a rectangle is no threaded text"); +} + +/// The frames of the selected frames' threads are listed with one lookup per frame. +#[test] +fn the_frames_threaded_with_a_selection_are_listed_in_one_pass() { + let (s, threads) = many_threads(150, 3); + let firsts: Vec = threads.iter().map(|t| t[0]).collect(); + let d = &s.doc().unwrap().doc; + let (all, n) = looks(|| with_threads(d, &firsts)); + let mut want = firsts.clone(); + want.extend(threads.iter().flat_map(|t| t[1..].iter().copied())); + assert_eq!(all, want); + assert!(n <= 4 * (150 + 450), "{n} id lookups"); +} + +/// The last frames of many threads copied out of them are found with a few lookups per frame and +/// per object copied. +#[test] +fn the_last_frames_copied_out_of_many_threads_are_found_in_one_pass() { + let (s, threads) = many_threads(150, 3); + let d = &s.doc().unwrap().doc; + let lasts: Vec> = threads.iter().map(|t| std::sync::Arc::new(d.node(t[2]).unwrap().clone())).collect(); + let mut art = Node::group(NodeId(u64::MAX), lasts); + let before = art.clone(); + let ((), n) = looks(|| keep_shown_text(d, &mut art)); + assert!(n <= 4 * (151 + 450), "{n} id lookups"); + assert_eq!(art, before, "each copied frame already shows all of its word"); +} + +/// Threading one frame of each of many threads merges them in one pass, in the order of the +/// selection. +#[test] +fn creating_a_thread_from_many_threads_merges_them_in_one_pass() { + let (mut s, threads) = many_threads(150, 3); + select_firsts(&mut s, &threads); + let (made, n) = looks(|| s.execute("text.thread.create", &json!({}))); + made.unwrap(); + assert!(n <= 8 * (150 + 450), "{n} id lookups"); + assert_eq!(s.doc().unwrap().doc.text_threads, vec![threads.concat()]); +} + +/// Release Selection on one frame of each of many threads finds the selected frames with one +/// lookup per frame. +#[test] +fn releasing_frames_of_many_threads_takes_one_pass() { + let (mut s, threads) = many_threads(150, 3); + select_firsts(&mut s, &threads); + let (released, n) = looks(|| s.execute("text.thread.releaseSelection", &json!({}))); + released.unwrap(); + assert!(n <= 2 * 450, "{n} id lookups"); + let rest: Vec> = threads.iter().map(|t| t[1..].to_vec()).collect(); + assert_eq!(s.doc().unwrap().doc.text_threads, rest); +} + +/// Remove Threading on the last frame of each of many threads finds the selected frames with one +/// lookup per frame. +#[test] +fn removing_the_threads_of_many_frames_takes_one_pass() { + let (mut s, threads) = many_threads(150, 3); + let lasts: Vec = threads.iter().map(|t| t[2].0).collect(); + s.execute("select.set", &json!({ "ids": lasts })).unwrap(); + let (removed, n) = looks(|| s.execute("text.thread.remove", &json!({}))); + removed.unwrap(); + assert!(n <= 2 * 450, "{n} id lookups"); + assert!(s.doc().unwrap().doc.text_threads.is_empty()); +} + /// The text that a thread's last frame has no room for is measured as the frame lays it out, /// without the first-line indent of the paragraph it continues: when the frame is outlined, the /// frame before it takes only that text.