Threaded text: Convert To Point Type on a frame no longer duplicates its text

Converting a frame of threaded text to point type left the frame's
text in the point type, and the re-flow also added that text to the
story, as it does for a frame that was deleted. The other frames then
held it a second time.

A frame that is still in the document as point type now leaves its
thread with the text it holds. The story flows through the other
frames without that text, so each character is held once. A thread
left with one frame is no longer a thread, and undo restores the
thread.

Docs: docs/mcp.md, docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-10-24 02:05:18 +07:00
committed by bflatastic
parent 777930fcd2
commit 0083587131
4 changed files with 58 additions and 3 deletions
+53 -1
View File
@@ -81,7 +81,8 @@ thread_local! {
}
/// Re-flow every thread with a frame that changed between `before` and `doc` (structural sharing
/// makes "changed" a pointer comparison). Deleted frames' text flows into the remaining ones.
/// makes "changed" a pointer comparison). Deleted frames' text flows into the remaining ones; a
/// frame that is now point type leaves its thread with the text it holds.
pub(crate) fn reflow(before: &Document, doc: &mut Document) {
let threads = std::mem::take(&mut doc.text_threads);
let mut kept = vec![];
@@ -116,6 +117,8 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
sources.push(t.clone());
live.push(*id);
}
// A frame converted to point type keeps the text it holds and leaves the thread.
(None, Some(_)) if matches!(doc.node(*id).map(|n| &n.kind), Some(NodeKind::Text(_))) => {}
(None, Some(old)) => {
story.extend(old.runs.iter().cloned());
later_wins.push(false);
@@ -714,6 +717,55 @@ mod tests {
assert_eq!(format!("{}{}", text(&s, a), text(&s, b)), TEXT.repeat(4));
}
/// A thread of `n` frames (2 or 3), each showing part of the story: area type `a` of [`setup`],
/// for 3 an area type frame that is empty until it is threaded, then the rectangle `b`.
fn thread_of(n: usize) -> (Session, Vec<u64>) {
let (mut s, a, b) = setup();
let frames = if n == 3 {
let c = new_id(s.execute("text.create", &json!({"x": 10, "y": 100, "text": "", "area": {"width": 150, "height": 40}})).unwrap());
s.execute("text.thread.add", &json!({"from": a, "to": c})).unwrap();
s.execute("text.thread.add", &json!({"from": c, "to": b})).unwrap();
vec![a, c, b]
} else {
s.execute("text.thread.add", &json!({"from": a, "to": b})).unwrap();
vec![a, b]
};
assert_eq!(threads(&s), std::slice::from_ref(&frames));
assert!(frames.iter().all(|id| !text(&s, *id).is_empty()), "each frame shows part of the story");
(s, frames)
}
/// Convert To Point Type on a frame of threaded text takes it out of its thread with the text
/// it holds, and the other frames keep the text they hold: the story holds each character once.
/// A thread left with one frame is no thread. Undo threads the frame again.
#[test]
fn a_frame_converted_to_point_type_leaves_its_thread_with_its_text() {
// Soft wraps of the point type become line breaks, so its characters are compared without
// white space.
let squeeze = |t: &str| t.split_whitespace().collect::<String>();
for n in [2, 3] {
for k in 0..n {
let (mut s, frames) = thread_of(n);
let shown: Vec<String> = frames.iter().map(|id| text(&s, *id)).collect();
let id = frames[k];
s.execute("type.convertToPointType", &json!({"ids": [id]})).unwrap();
let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind else { panic!("not text") };
assert!(matches!(t.kind, TextKind::Point), "{n} frames, frame {k}: point type");
assert_eq!(squeeze(&text(&s, id)), squeeze(&shown[k]), "{n} frames, frame {k}: the point type keeps its text");
for (f, before) in frames.iter().zip(&shown).filter(|(f, _)| **f != id) {
assert_eq!(text(&s, *f), *before, "{n} frames, frame {k}: frame {f} keeps its text");
}
let rest: Vec<u64> = frames.iter().copied().filter(|f| *f != id).collect();
assert_eq!(threads(&s), if rest.len() >= 2 { vec![rest] } else { vec![] }, "{n} frames, frame {k}");
let all: String = frames.iter().map(|f| text(&s, *f)).collect();
assert_eq!(squeeze(&all), squeeze(&TEXT.repeat(4)), "{n} frames, frame {k}: each character once");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(threads(&s), std::slice::from_ref(&frames));
assert_eq!(frames.iter().map(|f| text(&s, *f)).collect::<Vec<_>>(), shown, "{n} frames, frame {k}: undo");
}
}
}
#[test]
fn release_and_remove() {
let (mut s, a, b) = setup();
+1 -1
View File
@@ -43,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Convert To Point Type",
["Type"],
None,
"{ids?|id?} area type → point type; soft line wraps become line breaks → {ids}",
"{ids?|id?} area type → point type; soft line wraps become line breaks, and a frame of threaded text leaves its thread with the text it holds → {ids}",
has_selection,
to_point
),