From 0083587131b975acba95e51e4bfc3c2d5c81fea8 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 23 Oct 2026 04:06:26 -0700 Subject: [PATCH] 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. --- crates/engine/src/cmd/threads.rs | 54 ++++++++++++++++++++++++++++++- crates/engine/src/cmd/typemenu.rs | 2 +- docs/mcp.md | 4 ++- docs/target-app-parity.md | 1 + 4 files changed, 58 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/threads.rs b/crates/engine/src/cmd/threads.rs index 273707a12..1c43e7fe3 100644 --- a/crates/engine/src/cmd/threads.rs +++ b/crates/engine/src/cmd/threads.rs @@ -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) { + 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::(); + for n in [2, 3] { + for k in 0..n { + let (mut s, frames) = thread_of(n); + let shown: Vec = 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 = 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::>(), shown, "{n} frames, frame {k}: undo"); + } + } + } + #[test] fn release_and_remove() { let (mut s, a, b) = setup(); diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index 1eca1b756..a61707b00 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -43,7 +43,7 @@ pub fn specs() -> Vec { "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 ), diff --git a/docs/mcp.md b/docs/mcp.md index f93556839..e5a512b6d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4065,7 +4065,9 @@ the middle of its bounding box's right side, hollow on point type and filled on `type.convertToAreaType` (point type gets a frame around its text, which keeps its place and doesn't rewrap) or `type.convertToPointType` (soft line wraps become line breaks), the same commands as the Type menu's Convert To Area Type and Convert To Point Type. Either keeps the text and its styles and is one undo step; `ids` picks the objects -(default: the selection). +(default: the selection). A frame of threaded text that `type.convertToPointType` converts leaves its thread with the +text it holds, and the story flows through the other frames without that text; a thread left with one frame is no +longer a thread. ```json {"name":"run_command","arguments":{"command":"type.convertToAreaType","params":{"ids":[42]}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ac75ebff1..e45e2bdd6 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -745,6 +745,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur For Auto Size area type in columns, the bisection for the frame's height runs on the lines of the one-column layout, and its result is checked with at most two layouts of the columns. When the text wraps around objects or does not fit the one-column layout, or when the check fails, the columns are laid out at every step of the bisection, as before. Typing at the start of a story of 50,000 characters in 2 columns took 1,670 ms a keystroke and now takes 292 ms (release build, `auto_height_in_columns_typing_timing`). Exports to PDF, EPS, SVG, EMF, WMF and the raster formats warn about the area type and type on a path that they draw without the text that doesn't fit, and give their ids, as Print and Print Preview do. An edit copies a thread's story only when it changes one of the thread's frames: with a story of 50,000 characters threaded through 8 frames with a style change at every word, moving another object took 7.8 ms and now takes 0.013 ms, and a frame of a drag took 7.2 ms and now takes 0.012 ms (release build, `threaded_story_edit_timing`). + Type › Convert To Point Type on a frame of threaded text makes point type of the text that frame holds and takes it out of the thread; the story flows through the other frames without that text, so each character is in the document once. With Optical Margin Alignment on, or Roman Hanging Punctuation on in the first paragraph, the glyphs at the ends of left-to-right type on a path hang outside its brackets, measured along the path. The type hangs at the start bracket when its line is placed there, hangs at the end bracket when the line is placed there, and hangs at both when it is centered. Glyphs that hang past the end bracket do not make the type overflow, and a hang stops at an open path's end. SVG export writes such type as outlines. The frames of threaded text share one Optical Margin Alignment setting: turning it on or off in one frame sets it in every frame, threading frames gives the thread its first frame's setting, and frames taken out of a thread keep it. Type › Optical Margin Alignment (`type.opticalMarginAlignment`) hangs the punctuation and letter edges at the ends of a type object's lines outside its margins: at the left of left-aligned lines, at the right of right-aligned lines, and at both ends of centered and justified lines, in point and area type of left-to-right paragraphs. Quotation marks, hyphens, full stops and commas hang wholly; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half; and A, V, W, Y, v, w and y hang by a tenth of their width. It is off by default and saved with the type object. Justification's Glyph Scaling leaves the glyphs that hang at their width, so they hang exactly their share, and Standard burasagari counts what hangs at a line's start as room before hanging a comma; `crates/text/src/tests_hang.rs` checks whole paragraphs with both composers, ragged, centred and justified, with Line-end Punctuation Half Width and bidirectional lines.