Merge pull request #7259 from dotCooCoo/fix/threaded-text-one-pass

Threaded Text: thread edits find frames and threads in one pass
This commit is contained in:
BFlat
2026-11-14 04:30:36 +07:00
committed by GitHub
2 changed files with 340 additions and 42 deletions
+100 -42
View File
@@ -68,11 +68,8 @@ pub fn specs() -> Vec<CommandSpec> {
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<usize> = 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<usize> = const { std::cell::Cell::new(0) };
/// Tests: how many lookups and insertions [`Ids`] made on this thread.
static ID_LOOKS: std::cell::Cell<usize> = 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<NodeId>);
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<NodeId> for Ids {
fn from_iter<I: IntoIterator<Item = NodeId>>(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<bool> {
#[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<NodeId> {
/// `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<NodeId> {
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<NodeId>)> =
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<NodeId, (&TextObject, Option<NodeId>)> =
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<NodeId> = 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<NodeId, bool> = 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<NodeId> = 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<Value> {
*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<Option<Vec<NodeId>>> = std::mem::take(&mut d.text_threads).into_iter().map(Some).collect();
let mut listing: std::collections::HashMap<NodeId, Vec<usize>> = 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<NodeId> = 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<Value> {
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<NodeId>, Vec<NodeId>) = 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<NodeId>, Vec<NodeId>) = thread.iter().partition(|id| ids.contains(id));
if out.is_empty() {
continue;
}
@@ -750,7 +809,6 @@ fn release(s: &mut Session, _: &Value) -> Result<Value> {
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<Value> {
}
fn remove(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let ids: Ids = selected_roots(s)?.into_iter().collect();
s.edit("Remove Threading", |d, _| {
let (gone, kept): (Vec<Vec<NodeId>>, Vec<Vec<NodeId>>) =
std::mem::take(&mut d.text_threads).into_iter().partition(|t| t.iter().any(|i| ids.contains(i)));
+240
View File
@@ -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<bool> {
(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::<Vec<_>>(), 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<NodeId> = 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::<Vec<_>>(), 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<Vec<NodeId>>) {
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<NodeId>]) {
let ids: Vec<u64> = 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<T>(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<u64> = 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<NodeId> = 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<std::sync::Arc<Node>> = 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<Vec<NodeId>> = 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<u64> = 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.