From 24a42c5bb07ba1eec80d8cfae8b95c4ef4d23f45 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 15:56:44 -0800 Subject: [PATCH] Type: Tabs indent markers apply to every paragraph when threaded text reflows With mixed indents, the Tabs panel sends one text.setFormat per paragraph, addressed by frame and byte offset. Threaded text reflows after each step, so an indent that made earlier paragraphs wrap onto more lines moved text into the next frame, and the later steps landed on the wrong paragraphs or on none. The steps now run from the story's last paragraph to its first, so each step's offset comes before every change already made. --- crates/ui-egui/src/panels/tabs.rs | 6 +- crates/ui-egui/src/panels/tabs/tests.rs | 74 +++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/tabs.rs b/crates/ui-egui/src/panels/tabs.rs index 67e589104..a1a8c7fb1 100644 --- a/crates/ui-egui/src/panels/tabs.rs +++ b/crates/ui-egui/src/panels/tabs.rs @@ -181,7 +181,9 @@ fn paint_markers(ui: &mut Ui, painter: &egui::Painter, tokens: &Tokens, view: Ma } } -/// Apply an absolute ruler position through paragraph-format commands. +/// Apply an absolute ruler position through paragraph-format commands. Paragraphs that need values +/// of their own get one command each, the story's last paragraph first: threaded text re-flows +/// after each command, and a re-flow moves only text after the paragraph that changed. fn set_marker(app: &mut VectorcraftApp, marker: IndentMarker, target: f64, para: &ParaStyle, span: f64, targets: &[IndentTarget], both: bool) { if targets.is_empty() || !target.is_finite() { return; @@ -196,6 +198,7 @@ fn set_marker(app: &mut VectorcraftApp, marker: IndentMarker, target: f64, para: super::character::end_typing(app); let commands: Vec = targets .iter() + .rev() .map(|p| { json!({"command": "text.setFormat", "params": { "ids": [p.id], "start": p.start, "end": p.start, "firstLineIndent": target - p.left @@ -222,6 +225,7 @@ fn set_marker(app: &mut VectorcraftApp, marker: IndentMarker, target: f64, para: super::character::end_typing(app); let commands: Vec = targets .iter() + .rev() .map(|p| { json!({"command": "text.setFormat", "params": { "ids": [p.id], "start": p.start, "end": p.start, diff --git a/crates/ui-egui/src/panels/tabs/tests.rs b/crates/ui-egui/src/panels/tabs/tests.rs index 42aea4317..3244ba243 100644 --- a/crates/ui-egui/src/panels/tabs/tests.rs +++ b/crates/ui-egui/src/panels/tabs/tests.rs @@ -464,6 +464,80 @@ fn indent_markers_after_select_all_keep_each_story_paragraphs_indents() { } } +/// After Select All in threaded text with mixed indents, a marker change that makes the first +/// frame's paragraphs wrap onto more lines pushes text into the next frame while the change is +/// applied. Every paragraph of the story still gets the new position: the upper marker keeps each +/// left indent, and the lower marker keeps each first-line position. +#[test] +fn indent_markers_after_select_all_follow_text_that_reflows_into_the_next_frame() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 600, "height": 1000})).unwrap(); + let story = (1..=12).map(|i| format!("p{i} aaa bbb ccc")).collect::>().join("\n"); + let a = app.run("text.create", json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 200, "height": 60}})).unwrap()["id"] + .as_u64() + .unwrap(); + let b = app.run("text.thread.add", json!({"from": a, "rect": [300, 10, 200, 950]})).unwrap()["id"].as_u64().unwrap(); + app.run("text.setFormat", json!({"ids": [b], "leftIndent": 10})).unwrap(); + // Each paragraph of the story by its first word, with its left indent and first-line position; + // a paragraph continued from the frame before is counted once. No paragraph of the story is + // empty, so an empty piece (after a frame's last line break) is not one. + let paragraphs = |app: &VectorcraftApp| -> Vec<(String, f64, f64)> { + let st = app.session.active().unwrap(); + [a, b] + .into_iter() + .flat_map(|id| match &st.doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t + .plain_text() + .split('\n') + .enumerate() + .filter(|(i, text)| !text.is_empty() && (*i > 0 || !t.first_para_continues)) + .map(|(i, text)| { + let p = t.para_at(i); + (text.split(' ').next().unwrap_or_default().to_string(), p.left_indent, p.left_indent + p.first_line_indent) + }) + .collect::>(), + _ => panic!("not text"), + }) + .collect() + }; + let in_a = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(a)).unwrap().kind { + NodeKind::Text(t) => t.plain_text().len(), + _ => panic!("not text"), + }; + // Select All in the first frame and move `marker` to `to` → the story's paragraphs before and + // after. + let apply = |app: &mut VectorcraftApp, marker, to| { + let before = paragraphs(app); + assert_eq!(before.len(), 12, "{before:?}"); + let shown = in_a(app); + app.run("select.set", json!({"ids": [a]})).unwrap(); + app.session.select_tool("type", Default::default()).unwrap(); + app.session.set_tool_option("textSelection", &json!({"editing": a, "anchor": 0, "caret": 0})); + app.run("select.all", json!({})).unwrap(); + let (node, para, span) = current_para(app).unwrap(); + let targets = indent_targets(app, &node); + set_marker(app, marker, to, ¶, span, &targets, false); + let after = paragraphs(app); + assert!(in_a(app) < shown, "the first frame holds less text once it wraps: {} of {shown} bytes, {after:?}", in_a(app)); + assert_eq!(after.iter().map(|p| &p.0).collect::>(), before.iter().map(|p| &p.0).collect::>()); + (before, after) + }; + let (before, after) = apply(&mut app, IndentMarker::Upper, 150.0); + assert!(before.iter().any(|p| p.1 == 0.0) && before.iter().any(|p| p.1 == 10.0), "{before:?}"); + for (was, now) in before.iter().zip(&after) { + assert_eq!((now.1, now.2), (was.1, 150.0), "{} keeps its left indent and starts at 150 pt: {after:?}", now.0); + } + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(paragraphs(&app), before); + // First lines at 150 and 160 pt; a left indent of 180 pt leaves room for one word a line. + app.run("text.setFormat", json!({"ids": [a, b], "firstLineIndent": 150})).unwrap(); + let (before, after) = apply(&mut app, IndentMarker::Lower, 180.0); + assert!(before.iter().any(|p| p.2 == 150.0) && before.iter().any(|p| p.2 == 160.0), "{before:?}"); + for (was, now) in before.iter().zip(&after) { + assert_eq!((now.1, now.2), (180.0, was.2), "{} is indented 180 pt and keeps its first-line position: {after:?}", now.0); + } +} + #[test] fn lower_marker_bounds_cover_each_touched_first_line() { let targets = [IndentTarget { id: 1, start: 0, left: 24.0, first_line: -20.0 }, IndentTarget { id: 1, start: 4, left: 24.0, first_line: 280.0 }];