From d9c1f4335e35f5ad7d993d4d662db02dd32de8a4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 11:40:39 -0800 Subject: [PATCH] Threaded Text: thread edits find frames and threads in one pass Several threaded-text paths in threads.rs searched a list once for each frame, so their work grew with the product of the frame count and the list length: - leave_threads (Create Outlines, Expand Appearance, Rasterize, Flatten Transparency, symbol replacement, opening with text as outlines) searched the threads for each frame that stayed and ran continued over that frame's whole thread again, about n^2 frame visits for a thread of n frames. It now marks each changed thread with one continued call, and keeps the frames that left in a map instead of a list. - has_thread_selection (the availability of Release Selection and Remove Threading), with_threads, keep_shown_text and cut_to_shown, release and remove test ids against a hash set instead of a list. - create finds the thread of each selected frame from a table built once, and each frame still takes in the first thread not yet merged that lists it. - release writes each thread back at its own index instead of searching the thread list for an equal thread. Marks, thread membership, frame order, text and overflow are unchanged. A 256-frame thread that loses one frame visited 65,025 frames before this change and at most 256 now. Six tests on many threads count the set lookups: with a list in place of the set they make 67,650 to 258,226. Another test compares the marks with a scan of the frames before each one. --- crates/engine/src/cmd/threads.rs | 142 ++++++++++----- crates/engine/src/cmd/threads/tests.rs | 240 +++++++++++++++++++++++++ 2 files changed, 340 insertions(+), 42 deletions(-) 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.