From 30ce0e3a420f00aff0d6d9ab519bbc33319d10b1 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 14:27:38 +0000 Subject: [PATCH] Threads: a frame left alone by a dropped repeat is flowed again Follow-up to #5303. When deduplication shortens a thread ([[a, b], [b, c]] -> [[a, b], [c]]) and no frame changed, the fast path dropped [c] without clearing its threaded continuation. A thread that lost a listing now counts as changed and is flowed again. --- crates/engine/src/cmd/threads.rs | 26 ++++++++++++++++++-------- crates/engine/src/cmd/threads/tests.rs | 23 +++++++++++++++++++++++ 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/threads.rs b/crates/engine/src/cmd/threads.rs index db7082f05..537252dad 100644 --- a/crates/engine/src/cmd/threads.rs +++ b/crates/engine/src/cmd/threads.rs @@ -97,15 +97,25 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) { // copy its text into the story each time, and write the grown story back for the next thread // to copy again. Only its first place counts. let mut seen = std::collections::HashSet::new(); - let threads: Vec> = - std::mem::take(&mut doc.text_threads).into_iter().map(|t| t.into_iter().filter(|id| seen.insert(*id)).collect()).collect(); + // A thread that lost a frame that way is flowed again: its frames' layout was made for the + // thread as listed (a frame left alone must lose its threaded continuation). + let threads: Vec<(Vec, bool)> = std::mem::take(&mut doc.text_threads) + .into_iter() + .map(|t| { + let listed = t.len(); + let t: Vec = t.into_iter().filter(|id| seen.insert(*id)).collect(); + let lost = t.len() != listed; + (t, lost) + }) + .collect(); let mut kept = vec![]; - for thread in threads { - let changed = thread.iter().any(|id| match (before.node(*id), doc.node(*id)) { - (Some(a), Some(b)) => !std::ptr::eq(a, b), - (None, None) => false, - _ => true, - }); + for (thread, lost) in threads { + let changed = lost + || thread.iter().any(|id| match (before.node(*id), doc.node(*id)) { + (Some(a), Some(b)) => !std::ptr::eq(a, b), + (None, None) => false, + _ => true, + }); // When no frame changed, the thread keeps its area-type frames and their text is not copied. if !changed { let live: Vec = thread.into_iter().filter(|id| text_of(doc, *id).is_some()).collect(); diff --git a/crates/engine/src/cmd/threads/tests.rs b/crates/engine/src/cmd/threads/tests.rs index 2dfb119fe..9d56ca0c9 100644 --- a/crates/engine/src/cmd/threads/tests.rs +++ b/crates/engine/src/cmd/threads/tests.rs @@ -58,6 +58,29 @@ fn frames_listed_twice_flow_the_story_once() { assert_eq!(format!("{}{}", plain(fa), plain(fb)), TEXT.repeat(4)); } +/// A frame left alone when a repeated listing is dropped (`[[a, b], [b, c]]` → `[[a, b], [c]]`) +/// is flowed again as a story of its own, even when no frame changed: it no longer continues a +/// paragraph from the frame before it. +#[test] +fn a_frame_left_alone_by_a_dropped_repeat_stops_continuing() { + let (mut s, a, b) = setup(); + s.execute("text.thread.create", &json!({})).unwrap(); + let c = + s.execute("text.create", &json!({"x": 400, "y": 10, "text": "Tail", "area": {"width": 150, "height": 60}})).unwrap()["id"].as_u64().unwrap(); + let mut d = (*s.doc().unwrap().doc).clone(); + let (fa, fb, fc) = (NodeId(a), NodeId(b), NodeId(c)); + d.text_threads = vec![vec![fa, fb], vec![fb, fc]]; + if let Some(NodeKind::Text(t)) = d.node_mut(fc).map(|n| &mut n.kind) { + t.first_para_continues = true; + } + let before = d.clone(); + reflow(&before, &mut d); + assert_eq!(d.text_threads, vec![vec![fa, fb]]); + let NodeKind::Text(t) = &d.node(fc).unwrap().kind else { panic!("not text") }; + assert!(!t.first_para_continues, "an unthreaded frame continues nothing"); + assert_eq!(t.plain_text(), "Tail"); +} + #[test] fn edits_copy_only_the_stories_of_threads_whose_frames_changed() { let (mut s, a, b) = setup();