From f48ac15d08f6878c2eb2785e1685864f6db1851b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 5 Nov 2026 23:51:58 -0800 Subject: [PATCH] Text: a restyled empty frame keeps its style before an unedited empty frame When a frame's text ends with a paragraph break and the next frame is empty, restyling that empty frame was undone on reflow: an unedited empty frame after it gave the story's last paragraph its own style again. A restyled empty frame now keeps its style against unedited empty frames that follow it. Unedited empty frames before it, later frames with text and later restyled frames still give the paragraph their style, as before. --- crates/engine/src/cmd/threads/tests.rs | 21 ++++++++++++++++ crates/text/src/thread.rs | 10 ++++++-- crates/text/src/thread/tests.rs | 33 ++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/threads/tests.rs b/crates/engine/src/cmd/threads/tests.rs index 601ba00ff..71d175b3a 100644 --- a/crates/engine/src/cmd/threads/tests.rs +++ b/crates/engine/src/cmd/threads/tests.rs @@ -39,6 +39,27 @@ fn empty_later_frames_keep_the_first_frames_edited_paragraph_style() { assert_eq!([hyphenates(a), hyphenates(b), hyphenates(c)], [true, true, true]); } +#[test] +fn a_restyled_empty_frame_keeps_its_style_before_an_unedited_empty_frame() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); + let a = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "First\n", "area": {"width": 150, "height": 60}})).unwrap()["id"] + .as_u64() + .unwrap(); + let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 60})).unwrap()["id"].as_u64().unwrap(); + let c = s.execute("shape.rectangle", &json!({"x": 400, "y": 10, "width": 150, "height": 60})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [a, b, c]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + assert_eq!([text(&s, a), text(&s, b), text(&s, c)], ["First\n", "", ""]); + s.execute("text.setFormat", &json!({"ids": [b], "hyphenate": true})).unwrap(); + let hyphenates = |id, para| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.para_at(para).hyphenate, + _ => panic!("not text"), + }; + // The empty paragraph after the break takes the style; the paragraph "First" keeps its own. + assert_eq!([hyphenates(a, 0), hyphenates(a, 1), hyphenates(b, 0), hyphenates(c, 0)], [false, true, true, true]); +} + #[test] fn thread_flows_reflows_on_edit_and_survives_delete() { let (mut s, a, b) = setup(); diff --git a/crates/text/src/thread.rs b/crates/text/src/thread.rs index aacc78b23..191963107 100644 --- a/crates/text/src/thread.rs +++ b/crates/text/src/thread.rs @@ -20,24 +20,30 @@ pub fn story(frames: &[&TextObject]) -> Vec { /// paragraph shares that paragraph with the next frame (its first one): the earlier frame's style /// wins unless `later_wins[k]` says frame `k`'s first paragraph should (it was just restyled), or /// the earlier frame's part of the paragraph is empty (its slice ends with a paragraph break). +/// An empty frame that was just restyled keeps its style against unedited empty frames after it. pub fn story_paras(frames: &[&TextObject], later_wins: &[bool]) -> Vec { let mut out: Vec = vec![]; let mut prev_empty_tail = true; + let mut empty_tail_restyled = false; for (k, f) in frames.iter().enumerate() { + let text = f.plain_text(); + let restyled = later_wins.get(k).copied().unwrap_or(false); let mut styles = f.paragraph_styles().into_iter(); if k > 0 && let Some(first) = styles.next() - && (prev_empty_tail || later_wins.get(k).copied().unwrap_or(false)) + && (restyled || (prev_empty_tail && !(text.is_empty() && empty_tail_restyled))) && let Some(last) = out.last_mut() { *last = first; } out.extend(styles); - let text = f.plain_text(); // An empty frame has no paragraph boundary of its own. Keep the preceding frame's // boundary state so another empty frame cannot replace a paragraph's edited style. if !text.is_empty() { prev_empty_tail = text.ends_with('\n') || out.is_empty(); + empty_tail_restyled = false; + } else if restyled { + empty_tail_restyled = true; } } out diff --git a/crates/text/src/thread/tests.rs b/crates/text/src/thread/tests.rs index 24c1460f0..222df20c2 100644 --- a/crates/text/src/thread/tests.rs +++ b/crates/text/src/thread/tests.rs @@ -163,6 +163,39 @@ fn empty_frames_do_not_replace_an_earlier_paragraph_style() { assert_eq!(story_paras(&[&first, &restyled_middle, &last], &[false, true, false])[0].justify, Justify::Center); } +/// When the earlier frame ends with a paragraph break, a restyled empty frame keeps its style +/// against an unedited empty frame after it. An unedited empty frame before it, a frame with +/// text after it, or a restyled frame after it still gives the paragraph its own style. +#[test] +fn a_restyled_empty_frame_keeps_its_style_after_a_paragraph_break() { + let style = |j| ParaStyle { justify: j, ..Default::default() }; + let mut first = frame(0.0); + first.runs = vec![TextRun { text: "First\n".into(), style: CharStyle::default(), inline: None }]; + first.set_all_paras(style(Justify::Right)); + let mut restyled = frame(200.0); + restyled.set_all_paras(style(Justify::Center)); + let empty = frame(400.0); + let tail_style = |frames: &[&TextObject], wins: &[bool]| story_paras(frames, wins).last().map(|p| p.justify); + + assert_eq!(tail_style(&[&first, &restyled, &empty], &[false, true, false]), Some(Justify::Center)); + assert_eq!(tail_style(&[&first, &restyled, &empty, &empty], &[false, true, false, false]), Some(Justify::Center)); + // An unedited empty frame before the restyled one gives way to it. + assert_eq!(tail_style(&[&first, &empty, &restyled, &empty], &[false, false, true, false]), Some(Justify::Center)); + // A restyled first frame of an empty thread keeps its style as well. + assert_eq!(tail_style(&[&restyled, &empty, &empty], &[true, false, false]), Some(Justify::Center)); + + // Without an edit, the last empty frame still gives the empty paragraph its style. + assert_eq!(tail_style(&[&first, &restyled, &empty], &[false, false, false]), Some(Justify::Left)); + // A later frame with text still gives the paragraph it holds its style. + let mut with_text = empty.clone(); + with_text.runs = vec![TextRun { text: "Tail".into(), style: CharStyle::default(), inline: None }]; + assert_eq!(tail_style(&[&first, &restyled, &with_text], &[false, true, false]), Some(Justify::Left)); + // A later restyled frame wins over the earlier one. + let mut right = empty.clone(); + right.set_all_paras(style(Justify::Right)); + assert_eq!(tail_style(&[&first, &restyled, &right], &[false, true, true]), Some(Justify::Right)); +} + /// Numbered list paragraphs count on from one frame to the next: the second frame's first /// whole paragraph takes the next number. A paragraph split between the frames has its number /// in the first frame only, and the paragraph after it takes the next number.