Remove Breaks: a partly selected story's frames are gone through once

When only some frames of a threaded story are selected, Remove Line
Breaks and Remove Paragraph Breaks built the story's frame list again
for every selected frame of it, so the work grew with the square of the
frame count. The command now notes a thread once its story is found not
to be selected in full, and reads that thread's other selected frames
on their own without going through the thread again. Each selected
frame still loses its own breaks, in the order selected, as one undo
step.

A test-only counter in threads.rs counts story_in calls, and
remove_breaks_goes_through_a_partly_named_story_once checks both break
kinds with frames named out of order and twice.
This commit is contained in:
dotCooCoo
2026-11-14 08:58:57 -08:00
parent 676f0524cc
commit ae39914b9f
4 changed files with 51 additions and 2 deletions
+8 -2
View File
@@ -871,13 +871,17 @@ fn remove_breaks(s: &mut Session, p: &Value) -> Result<Value> {
let named: std::collections::HashSet<NodeId> = ids.iter().copied().collect();
let threads = thread_index(d);
let mut done = std::collections::HashSet::new();
// The threads already found not to be a story whose frames are all among them: their
// other frames are read one at a time without going through the thread again.
let mut partial = std::collections::HashSet::new();
let mut out: Vec<(NodeId, Vec<(usize, bool)>)> = vec![];
for &id in &ids {
if !done.insert(id) {
continue;
}
let story = match threads.get(&id).and_then(|&i| d.text_threads.get(i)) {
Some(thread) => crate::cmd::threads::story_in(d, thread, id),
let thread = threads.get(&id).copied();
let story = match thread.filter(|i| !partial.contains(i)).and_then(|i| d.text_threads.get(i)) {
Some(frames) => crate::cmd::threads::story_in(d, frames, id),
None => vec![],
};
if !story.is_empty() && story.iter().all(|f| named.contains(f)) {
@@ -885,6 +889,8 @@ fn remove_breaks(s: &mut Session, p: &Value) -> Result<Value> {
// Every frame is among them, so each one keeps its breaks.
out.extend(story_cuts(d, &story, brk, true));
} else if let Some(t) = text_of(d, id) {
// `id` is type, so the story of its thread is the same for each of its frames.
partial.extend(thread);
out.push((id, break_cuts(&t.plain_text(), 0, usize::MAX, brk)));
}
}
@@ -379,6 +379,38 @@ fn remove_breaks_reads_the_threads_once() {
}
}
/// Remove Breaks reads each named frame of a story that is not all named on its own, in the order
/// named and each once, as one undo step, and goes through that story's frames once for all of them.
#[test]
fn remove_breaks_goes_through_a_partly_named_story_once() {
for (kind, brk) in [("line", '\u{2028}'), ("paragraph", '\n')] {
let mut s = session();
let a = text(&mut s, json!({"x": 10, "y": 20, "text": "a\u{2028}b\n".repeat(40), "size": 12, "area": {"width": 150, "height": 60}}));
let mut roots = vec![a];
for i in 0..4 {
let r = s.execute("shape.rectangle", &json!({"x": 10, "y": 100 + 70 * i, "width": 150, "height": 60})).unwrap();
roots.push(NodeId(r["id"].as_u64().unwrap()));
}
select(&mut s, &roots);
s.execute("text.thread.create", &json!({})).unwrap();
let f = s.doc().unwrap().doc.text_threads[0].clone();
assert_eq!(f.len(), 5);
let named = [f[4], f[1], f[3]];
let breaks: Vec<usize> = named.iter().map(|id| obj(&s, *id).plain_text().matches(brk).count()).collect();
assert!(breaks.iter().all(|n| *n > 0), "{kind}: each named frame holds breaks: {breaks:?}");
let before = doc_json(&s);
let steps = undo_steps(&s);
let builds = crate::cmd::threads::story_builds();
let r = s.execute("addon.cleanup.removeBreaks", &json!({"kind": kind, "ids": [f[4].0, f[1].0, f[4].0, f[3].0]})).unwrap();
assert_eq!(crate::cmd::threads::story_builds() - builds, 1, "{kind}: the story's frames are gone through once");
assert_eq!(r["removed"].as_u64(), Some(breaks.iter().sum::<usize>() as u64), "{kind}");
assert_eq!(ids(&r), named, "{kind}");
assert_eq!(undo_steps(&s), steps + 1, "{kind}");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(doc_json(&s), before, "{kind}");
}
}
/// Lock `id` (`locked`), else hide it, as an undo step of its own.
fn lock_or_hide(s: &mut Session, id: NodeId, locked: bool) {
s.edit("Lock or Hide", |d, _| {
+10
View File
@@ -400,6 +400,8 @@ thread_local! {
/// Tests: how many times [`story_of`] and [`ThreadIndex::of`] went through the document's
/// threads on this thread.
static THREAD_SCANS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
/// Tests: how many times [`story_in`] went through a thread's frames on this thread.
static STORY_BUILDS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// Tests: how many times [`story_of`] and [`ThreadIndex::of`] have gone through the document's
@@ -409,6 +411,12 @@ pub(crate) fn thread_scans() -> usize {
THREAD_SCANS.with(std::cell::Cell::get)
}
/// Tests: how many times [`story_in`] has gone through a thread's frames on this thread.
#[cfg(test)]
pub(crate) fn story_builds() -> usize {
STORY_BUILDS.with(std::cell::Cell::get)
}
/// The frames of the thread that `id` is a frame of, in thread order, each area type or type on a
/// path; empty when `id` is in no thread or the thread has fewer than two such frames.
pub(crate) fn story_of(d: &Document, id: NodeId) -> Vec<NodeId> {
@@ -420,6 +428,8 @@ pub(crate) fn story_of(d: &Document, id: NodeId) -> Vec<NodeId> {
/// [`story_of`] for `id` in `thread`, the first of the document's threads that lists it.
pub(crate) fn story_in(d: &Document, thread: &[NodeId], id: NodeId) -> Vec<NodeId> {
#[cfg(test)]
STORY_BUILDS.with(|c| c.set(c.get() + 1));
let frames: Vec<NodeId> = thread.iter().copied().filter(|f| text_of(d, *f).is_some()).collect();
if frames.len() >= 2 && frames.contains(&id) { frames } else { vec![] }
}
+1
View File
@@ -1022,6 +1022,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too, and elsewhere the in-window menus' do: the field that had the keyboard when the menu opened gets them, with its selection, and the keyboard back (#516). Not yet: Select All across threaded frames (one frame's text is selected).
- Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too, and elsewhere the in-window menus' do: the field that had the keyboard when the menu opened gets them, with its selection, and the keyboard back (#516). In a frame of threaded text, Select All selects the whole story, highlighted in every frame: Copy and Cut take all of it, typing or Delete replaces it as one undo step, and a fill, a size step or a Character or Paragraph panel change applies to every frame's text (VectorCraft's own behavior; `textedit::select_all_in_a_threaded_frame_selects_the_whole_story`, `textedit::typing_over_the_whole_story_replaces_every_frames_text_in_one_step`).
- With the whole story of threaded text selected by Select All, Remove Line Breaks, Remove Paragraph Breaks and Type › Bullets and Numbering › Convert to Text act on the text of every frame as one undo step, and the story stays selected after Convert to Text; a selection inside one frame changes only that frame (VectorCraft's own behavior; `textedit::remove_breaks_takes_the_whole_story_that_select_all_selected`, `textedit::convert_to_text_takes_the_whole_story_that_select_all_selected`).
- When only some frames of a threaded story are selected, Remove Line Breaks and Remove Paragraph Breaks go through that story's frames once for all of them, not once per selected frame; each selected frame still loses its own breaks, in the order selected, as one undo step (`addons::cleanup::text::tests::remove_breaks_goes_through_a_partly_named_story_once`).
- With the whole story of threaded text selected by Select All, the Character, Paragraph, OpenType, Tabs and Text Styles panels, the Justification dialog and the font menu change the frames that are in the edited frame's thread at the time of the change, locked and hidden frames included. A frame that has left the thread is not changed, and a frame added to the thread is (VectorCraft's own behavior; `panels::character::tests::story_changes_follow_the_edited_frames_thread_after_the_first_saved_frame_leaves`, `story_changes_leave_out_a_frame_that_left_and_take_in_a_frame_that_joined`, `story_changes_stay_in_the_edited_frame_once_its_thread_is_removed`, `story_changes_take_in_locked_and_hidden_frames`, `panels::tabs::tests::indent_markers_after_select_all_keep_each_story_paragraphs_indents`, `dialogs::justification::tests::justification_dialog_follows_the_story_thread_at_ok`).
- With the whole story of threaded text selected by Select All, the Character, Paragraph, OpenType, Tabs and Text Styles panels, the Justification dialog and the font menu change the frames that are in the edited frame's thread at the time of the change, locked and hidden frames included. A frame that has left the thread is not changed, and a frame added to the thread is (VectorCraft's own behavior; `panels::character::tests::story_changes_follow_the_edited_frames_thread_after_the_first_saved_frame_leaves`, `story_changes_leave_out_a_frame_that_left_and_take_in_a_frame_that_joined`, `story_changes_stay_in_the_edited_frame_once_its_thread_is_removed`, `story_changes_take_in_locked_and_hidden_frames`).
- With the Selection tool, a Character or Paragraph panel change to a selected frame of threaded text changes every frame of its story, locked and hidden frames included, as one undo step. The anti-aliasing method, Variable Font axes, the Tabs panel's tab stops and both panels' Reset Panel change the whole story too, and the panels show a value only when every frame of the story has it. A story selected through several of its frames or through a group is changed once, and other type keeps its formatting (VectorCraft's own behavior; `panels::character::tests::selection_tool_changes_reach_every_frame_of_the_story`, `selection_tool_fields_read_every_frame_of_the_story`, `selected_stories_list_each_frame_once`, `panels::paragraph::tests::selection_tool_paragraph_changes_reach_every_frame_of_the_story`, `panels::tabs::tests::selection_tool_tab_stops_reach_every_frame_of_the_story`).