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.