diff --git a/parley/src/layout/alignment.rs b/parley/src/layout/alignment.rs index 26aa1144..14e1ba0f 100644 --- a/parley/src/layout/alignment.rs +++ b/parley/src/layout/alignment.rs @@ -104,19 +104,45 @@ fn align_impl( for line in &mut layout.lines { let indent = line.indent; + // Hanging whitespace is an alignment/positioning concern, orthogonal to text + // directionality. The RTL handling here doesn't interact with bidi reordering: + // it simply shifts the line's origin so that hung trailing whitespace (which + // sits at the *start* edge in visual order for RTL) overflows into the start + // margin rather than displacing visible content. if is_rtl { - // In RTL text, trailing whitespace is on the left. As we hang that whitespace, offset - // the line to the left. Note: indent is not subtracted here because `free_space` below - // already accounts for it. line.metrics.offset = -line.metrics.trailing_whitespace; } else { line.metrics.offset = indent; } // Compute free space. - let line_width = line.metrics.inline_max_coord - line.metrics.inline_min_coord; - let free_space = - line_width - indent - line.metrics.advance + line.metrics.trailing_whitespace; + // `line.metrics.inline_max_coord - line.metrics.inline_min_coord` = width of line + let pre_hang_free_space = line.metrics.inline_max_coord - line.metrics.inline_min_coord - indent - line.metrics.advance; + + // CSS Text 3 conditional vs. unconditional hanging of trailing whitespace. + // + // Per https://www.w3.org/TR/css-text-3/#white-space-phase-2, a sequence of + // preserved trailing spaces at the end of a line is: + // - "unconditionally hung" at soft wrap opportunities — the whitespace is + // excluded from the line's measured width, so alignment/justification + // ignores it (it visually overflows into the end margin). + // - "conditionally hung" at forced line breaks and at the end of the + // block (i.e. the paragraph-final line) — the whitespace is measured + // as normal, and only hangs if it would otherwise overflow the line + // box. In practice this means: include it in alignment calculations, + // and clamp free_space to 0 on overflow so visible content stops at + // the start edge rather than being pushed past it. + // + // `BreakReason::Explicit` covers forced breaks (e.g. `\n`); + // `BreakReason::None` covers the final line of the block. + // Both get the conditional-hanging treatment. + let free_space = if matches!(line.break_reason, BreakReason::Explicit | BreakReason::None) { + // Include trailing whitespace; clamp to 0 when it overflows. + pre_hang_free_space.max(0.0) + } else { + // Non-paragraph-final: unconditional hanging (trailing whitespace excluded) + pre_hang_free_space + line.metrics.trailing_whitespace + }; if !options.align_when_overflowing && free_space <= 0.0 { if is_rtl { @@ -143,12 +169,22 @@ fn align_impl( } // Justified alignment doesn't apply to the last line of a paragraph - // (`BreakReason::None`), (`BreakReason::Explicit`) or if there are no whitespace - // gaps to adjust. In that case, start-align, i.e., left-align for LTR text and - // right-align for RTL text. - if matches!(line.break_reason, BreakReason::None | BreakReason::Explicit) - || line.num_spaces == 0 - { + // (`BreakReason::None`), (`BreakReason::Explicit`). In that case, start-align, + // i.e., left-align for LTR text and right-align for RTL text. + if matches!(line.break_reason, BreakReason::None | BreakReason::Explicit) { + if is_rtl { + line.metrics.offset += free_space; + } + continue; + } + + // Spaces that participate in justification: total spaces on the line minus + // trailing spaces (which are hung and should not receive extra width). + // Both counts are pre-computed during line breaking. + let justifiable_spaces = line.total_spaces + .saturating_sub(line.trailing_space_count); + + if justifiable_spaces == 0 { if is_rtl { line.metrics.offset += free_space; } @@ -156,7 +192,7 @@ fn align_impl( } let adjustment = - free_space / line.num_spaces as f32 * if UNDO_JUSTIFICATION { -1. } else { 1. }; + free_space / justifiable_spaces as f32 * if UNDO_JUSTIFICATION { -1. } else { 1. }; let mut applied = 0; // Iterate over text runs in the line and clusters in the text run // - Iterate forwards for even bidi levels (which represent LTR runs) @@ -178,7 +214,7 @@ fn align_impl( &mut clusters.iter_mut() }; clusters.for_each(|cluster| { - if applied == line.num_spaces { + if applied == justifiable_spaces { return; } if cluster.info.whitespace().is_space_or_nbsp() { diff --git a/parley/src/layout/data.rs b/parley/src/layout/data.rs index f97869d6..032ef7ba 100644 --- a/parley/src/layout/data.rs +++ b/parley/src/layout/data.rs @@ -166,8 +166,14 @@ pub(crate) struct LineData { pub(crate) break_reason: BreakReason, /// Maximum advance for the line. pub(crate) max_advance: f32, - /// Number of justified clusters on the line. - pub(crate) num_spaces: usize, + /// Total number of space/nbsp clusters on the line. + pub(crate) total_spaces: usize, + /// Number of trailing space/nbsp clusters that are hung rather than justified. + /// + /// Per CSS Text 3 §4.1.3 (`white-space` processing, phase 2): trailing preserved spaces + /// are either unconditionally hung (soft-wrapped lines) or conditionally hung (forced + /// breaks / end of block). In both cases they are excluded from justification distribution. + pub(crate) trailing_space_count: usize, /// Text indent applied to this line. pub(crate) indent: f32, } diff --git a/parley/src/layout/line_break.rs b/parley/src/layout/line_break.rs index a8a06062..8b8622d8 100644 --- a/parley/src/layout/line_break.rs +++ b/parley/src/layout/line_break.rs @@ -11,7 +11,6 @@ use core_maths::CoreFloat; use crate::analysis::Boundary; use crate::analysis::cluster::Whitespace; -use crate::data::ClusterData; use crate::layout::{ BreakReason, Layout, LayoutData, LayoutItem, LayoutItemKind, LineData, LineItemData, LineMetrics, Run, @@ -651,10 +650,24 @@ impl<'a, B: Brush> BreakLines<'a, B> { return self.max_height_break_data(line_height); } self.state.append_cluster_to_line(next_x, line_height); - if try_commit_line!(BreakReason::Regular) { - // TODO: can this be hoisted out of the conditional? + self.state.line.num_spaces += 1; + // CSS conditional hanging: trailing whitespace before a forced + // break (or end of text) should not cause line wrapping. Only + // wrap if there is visible content before the next forced break. + if self.has_visible_content_before_forced_break( + self.state.cluster_idx + 1, + self.state.item_idx, + cluster_end, + ) { + // Mid-paragraph: hang overflowing whitespace and wrap + if try_commit_line!(BreakReason::Regular) { + // TODO: can this be hoisted out of the conditional? + self.state.cluster_idx += 1; + return self.start_new_line(BreakReason::Regular); + } + } else { + // Paragraph-final: accumulate space without wrapping self.state.cluster_idx += 1; - return self.start_new_line(BreakReason::Regular); } } // Case: we have previously encountered a REGULAR line-breaking opportunity in the current line @@ -1029,31 +1042,34 @@ impl<'a, B: Brush> BreakLines<'a, B> { reorder_line_items(&mut self.lines.line_items[line.item_range.clone()]); } - // Compute size of line's trailing whitespace. "Trailing" is considered the right edge - // for LTR text and the left edge for RTL text. - let run = if self.layout.is_rtl() { + // Compute trailing whitespace advance and count. + // + // "Trailing" means the line's end edge: last item for LTR base direction, first for RTL. + // Within that item, clusters are iterated from the logical end (`.rev()`), skipping any + // newline cluster (zero-advance forced break) to reach the hangable space/nbsp sequence. + // + // This measurement is direction-independent because UAX#9 assigns trailing whitespace + // the paragraph's base embedding level — trailing spaces always end up in a run matching + // the base direction, so iterating from the logical end of that run is correct regardless + // of whether the base is LTR or RTL. + let trailing_run = if self.layout.is_rtl() { self.lines.line_items[line.item_range.clone()].first() } else { self.lines.line_items[line.item_range.clone()].last() }; - line.metrics.trailing_whitespace = run + let (trailing_ws_advance, trailing_ws_count) = trailing_run .filter(|item| item.is_text_run() && item.has_trailing_whitespace) .map(|run| { - fn whitespace_advance<'c, I: Iterator>(clusters: I) -> f32 { - clusters - .take_while(|cluster| cluster.info.whitespace() != Whitespace::None) - .map(|cluster| cluster.advance) - .sum() - } - - let clusters = &self.layout.data.clusters[run.cluster_range.clone()]; - if run.is_rtl() { - whitespace_advance(clusters.iter()) - } else { - whitespace_advance(clusters.iter().rev()) - } + self.layout.data.clusters[run.cluster_range.clone()] + .iter() + .rev() + .skip_while(|cluster| cluster.info.whitespace() == Whitespace::Newline) + .take_while(|cluster| cluster.info.whitespace().is_space_or_nbsp()) + .fold((0.0_f32, 0_usize), |(adv, n), cluster| (adv + cluster.advance, n + 1)) }) - .unwrap_or(0.0); + .unwrap_or((0.0, 0)); + line.metrics.trailing_whitespace = trailing_ws_advance; + line.trailing_space_count = trailing_ws_count; if !have_metrics { // Line consisting entirely of whitespace? @@ -1148,6 +1164,55 @@ impl<'a, B: Brush> BreakLines<'a, B> { line.metrics.inline_min_coord = self.state.line_x; line.metrics.inline_max_coord = self.state.line_x + self.state.line_max_advance; } + + /// Returns `true` if there is visible (non-whitespace) content between `from_cluster` and + /// the next forced break (or end of text). + /// + /// This distinguishes mid-paragraph trailing whitespace (which is unconditionally hung per + /// CSS Text 3 §4.1.3) from paragraph-final trailing whitespace (conditionally hung). + /// When this returns `false`, the line breaker suppresses the soft wrap so the trailing + /// spaces accumulate on the current line for conditional hanging during alignment. + fn has_visible_content_before_forced_break( + &self, + from_cluster: usize, + current_item_idx: usize, + current_run_cluster_end: usize, + ) -> bool { + let items = &self.layout.data.items; + let clusters = &self.layout.data.clusters; + + // Check remaining clusters in the current text run + for cluster in &clusters[from_cluster..current_run_cluster_end] { + let ws = cluster.info.whitespace(); + if ws == Whitespace::Newline { + return false; + } + if !ws.is_space_or_nbsp() { + return true; + } + } + + // Check subsequent items + for item in &items[(current_item_idx + 1)..] { + match item.kind { + LayoutItemKind::InlineBox => return true, + LayoutItemKind::TextRun => { + let run_data = &self.layout.data.runs[item.index]; + for cluster in &clusters[run_data.cluster_range.clone()] { + let ws = cluster.info.whitespace(); + if ws == Whitespace::Newline { + return false; + } + if !ws.is_space_or_nbsp() { + return true; + } + } + } + } + } + + false // Reached end of text, no visible content + } } impl Drop for BreakLines<'_, B> { @@ -1365,26 +1430,12 @@ fn try_commit_line( // return false; // } - // Exclude the trailing space from justification space count. - // Only subtract if the line actually ends with a space — with - // WordBreak::BreakAll, regular breaks can land between non-space - // characters, in which case there is no trailing space to exclude. - let mut num_spaces = state.num_spaces; - if break_reason == BreakReason::Regular - && state.clusters.start < state.clusters.end - && layout.data.clusters[state.clusters.end - 1] - .info - .whitespace() - .is_space_or_nbsp() - { - num_spaces = num_spaces.saturating_sub(1); - } - lines.lines.push(LineData { item_range: start_item_idx..end_item_idx, max_advance, break_reason, - num_spaces, + total_spaces: state.num_spaces, + trailing_space_count: 0, // computed later in start_new_line indent: line_indent, metrics: LineMetrics { advance: state.x, diff --git a/parley_tests/snapshots/base_level_alignment_ltr-justify.png b/parley_tests/snapshots/base_level_alignment_ltr-justify.png index 5b4dd545..0abfc4bf 100644 Binary files a/parley_tests/snapshots/base_level_alignment_ltr-justify.png and b/parley_tests/snapshots/base_level_alignment_ltr-justify.png differ diff --git a/parley_tests/snapshots/break_by_length_matches_max_advance_with_letter_spacing-by_length.png b/parley_tests/snapshots/break_by_length_matches_max_advance_with_letter_spacing-by_length.png index 8180499e..6a409b89 100644 Binary files a/parley_tests/snapshots/break_by_length_matches_max_advance_with_letter_spacing-by_length.png and b/parley_tests/snapshots/break_by_length_matches_max_advance_with_letter_spacing-by_length.png differ diff --git a/parley_tests/snapshots/custom_break_lines_circle_layout-0.png b/parley_tests/snapshots/custom_break_lines_circle_layout-0.png index a51ddfa1..feabbe59 100644 Binary files a/parley_tests/snapshots/custom_break_lines_circle_layout-0.png and b/parley_tests/snapshots/custom_break_lines_circle_layout-0.png differ diff --git a/parley_tests/snapshots/hanging_whitespace_end_of_text-center.png b/parley_tests/snapshots/hanging_whitespace_end_of_text-center.png new file mode 100644 index 00000000..9096433d Binary files /dev/null and b/parley_tests/snapshots/hanging_whitespace_end_of_text-center.png differ diff --git a/parley_tests/snapshots/hanging_whitespace_end_of_text-end.png b/parley_tests/snapshots/hanging_whitespace_end_of_text-end.png new file mode 100644 index 00000000..7b36d9e9 Binary files /dev/null and b/parley_tests/snapshots/hanging_whitespace_end_of_text-end.png differ diff --git a/parley_tests/snapshots/hanging_whitespace_justify_mid_paragraph-0.png b/parley_tests/snapshots/hanging_whitespace_justify_mid_paragraph-0.png new file mode 100644 index 00000000..4c56fa1d Binary files /dev/null and b/parley_tests/snapshots/hanging_whitespace_justify_mid_paragraph-0.png differ diff --git a/parley_tests/snapshots/hanging_whitespace_justify_paragraph_final-0.png b/parley_tests/snapshots/hanging_whitespace_justify_paragraph_final-0.png new file mode 100644 index 00000000..4c56fa1d Binary files /dev/null and b/parley_tests/snapshots/hanging_whitespace_justify_paragraph_final-0.png differ diff --git a/parley_tests/snapshots/hanging_whitespace_mid_paragraph_center-0.png b/parley_tests/snapshots/hanging_whitespace_mid_paragraph_center-0.png new file mode 100644 index 00000000..e00c6030 Binary files /dev/null and b/parley_tests/snapshots/hanging_whitespace_mid_paragraph_center-0.png differ diff --git a/parley_tests/snapshots/hanging_whitespace_mid_paragraph_end-0.png b/parley_tests/snapshots/hanging_whitespace_mid_paragraph_end-0.png new file mode 100644 index 00000000..39ee71cc Binary files /dev/null and b/parley_tests/snapshots/hanging_whitespace_mid_paragraph_end-0.png differ diff --git a/parley_tests/snapshots/hanging_whitespace_multi_paragraph-0.png b/parley_tests/snapshots/hanging_whitespace_multi_paragraph-0.png new file mode 100644 index 00000000..afb2472c Binary files /dev/null and b/parley_tests/snapshots/hanging_whitespace_multi_paragraph-0.png differ diff --git a/parley_tests/snapshots/hanging_whitespace_paragraph_final_center-fits.png b/parley_tests/snapshots/hanging_whitespace_paragraph_final_center-fits.png new file mode 100644 index 00000000..9096433d Binary files /dev/null and b/parley_tests/snapshots/hanging_whitespace_paragraph_final_center-fits.png differ diff --git a/parley_tests/snapshots/hanging_whitespace_paragraph_final_center-overflows.png b/parley_tests/snapshots/hanging_whitespace_paragraph_final_center-overflows.png new file mode 100644 index 00000000..0cf9c046 Binary files /dev/null and b/parley_tests/snapshots/hanging_whitespace_paragraph_final_center-overflows.png differ diff --git a/parley_tests/snapshots/hanging_whitespace_paragraph_final_end-fits.png b/parley_tests/snapshots/hanging_whitespace_paragraph_final_end-fits.png new file mode 100644 index 00000000..7b36d9e9 Binary files /dev/null and b/parley_tests/snapshots/hanging_whitespace_paragraph_final_end-fits.png differ diff --git a/parley_tests/snapshots/hanging_whitespace_paragraph_final_end-overflows.png b/parley_tests/snapshots/hanging_whitespace_paragraph_final_end-overflows.png new file mode 100644 index 00000000..0cf9c046 Binary files /dev/null and b/parley_tests/snapshots/hanging_whitespace_paragraph_final_end-overflows.png differ diff --git a/parley_tests/snapshots/issue_409-paragraphs.png b/parley_tests/snapshots/issue_409-paragraphs.png index 83b159f7..cddf3c5f 100644 Binary files a/parley_tests/snapshots/issue_409-paragraphs.png and b/parley_tests/snapshots/issue_409-paragraphs.png differ diff --git a/parley_tests/snapshots/no_wrap_on_trailing_whitespace_before_newline-0.png b/parley_tests/snapshots/no_wrap_on_trailing_whitespace_before_newline-0.png new file mode 100644 index 00000000..0cf9c046 Binary files /dev/null and b/parley_tests/snapshots/no_wrap_on_trailing_whitespace_before_newline-0.png differ diff --git a/parley_tests/snapshots/realign-0.png b/parley_tests/snapshots/realign-0.png index 5b4dd545..0abfc4bf 100644 Binary files a/parley_tests/snapshots/realign-0.png and b/parley_tests/snapshots/realign-0.png differ diff --git a/parley_tests/snapshots/text_indent_justify-0.png b/parley_tests/snapshots/text_indent_justify-0.png index c3c27cca..3f3bc4a8 100644 Binary files a/parley_tests/snapshots/text_indent_justify-0.png and b/parley_tests/snapshots/text_indent_justify-0.png differ diff --git a/parley_tests/snapshots/trailing_whitespace_bidi-hard_wrap_rtl_ltr.png b/parley_tests/snapshots/trailing_whitespace_bidi-hard_wrap_rtl_ltr.png index 7c241b32..96ba887a 100644 Binary files a/parley_tests/snapshots/trailing_whitespace_bidi-hard_wrap_rtl_ltr.png and b/parley_tests/snapshots/trailing_whitespace_bidi-hard_wrap_rtl_ltr.png differ diff --git a/parley_tests/tests/basic.rs b/parley_tests/tests/basic.rs index 1c861fd7..aaeed6fa 100644 --- a/parley_tests/tests/basic.rs +++ b/parley_tests/tests/basic.rs @@ -661,6 +661,199 @@ fn realign_all() { } } + +/// Tests that trailing whitespace on paragraph-final lines is included in alignment +/// (CSS conditional hanging). For End alignment, trailing whitespace that +/// fits within the paragraph width pushes visible text toward the start margin. +#[test] +fn hanging_whitespace_paragraph_final_end() { + let mut env = TestEnv::new(test_name!(), Size::new(200.0, 50.0)); + + // Paragraph-final line: trailing whitespace fits → included in alignment, + // pushing "Hello" toward the left. + { + let mut builder = env.tree_builder(); + builder.set_white_space_mode(WhiteSpaceCollapse::Preserve); + builder.push_text("Hello \n"); + let (mut layout, _) = builder.build(); + layout.break_all_lines(Some(200.0)); + layout.align(Alignment::End, AlignmentOptions::default()); + env.with_name("fits").check_layout_snapshot(&layout); + } + + // Paragraph-final line: trailing whitespace overflows → free_space clamped to 0, + // visible text pinned at the start margin. + { + let mut builder = env.tree_builder(); + builder.set_white_space_mode(WhiteSpaceCollapse::Preserve); + builder.push_text("Hello \n"); + let (mut layout, _) = builder.build(); + layout.break_all_lines(Some(200.0)); + layout.align(Alignment::End, AlignmentOptions::default()); + env.with_name("overflows").check_layout_snapshot(&layout); + } +} + +/// Same as above but for Center alignment. +#[test] +fn hanging_whitespace_paragraph_final_center() { + let mut env = TestEnv::new(test_name!(), Size::new(200.0, 50.0)); + + // Trailing whitespace fits → shifts visible content toward start. + { + let mut builder = env.tree_builder(); + builder.set_white_space_mode(WhiteSpaceCollapse::Preserve); + builder.push_text("Hello \n"); + let (mut layout, _) = builder.build(); + layout.break_all_lines(Some(200.0)); + layout.align(Alignment::Center, AlignmentOptions::default()); + env.with_name("fits").check_layout_snapshot(&layout); + } + + // Trailing whitespace overflows → visible text pinned at start. + { + let mut builder = env.tree_builder(); + builder.set_white_space_mode(WhiteSpaceCollapse::Preserve); + builder.push_text("Hello \n"); + let (mut layout, _) = builder.build(); + layout.break_all_lines(Some(200.0)); + layout.align(Alignment::Center, AlignmentOptions::default()); + env.with_name("overflows").check_layout_snapshot(&layout); + } +} + +/// Tests that trailing whitespace on mid-paragraph (soft-wrapped) lines is unconditionally +/// hung — excluded from alignment width for End and Center alignment. +#[test] +fn hanging_whitespace_mid_paragraph_end() { + let mut env = TestEnv::new(test_name!(), Size::new(200.0, 60.0)); + + // Two words separated by lots of whitespace, forced to wrap. + // The first line's trailing whitespace should be hung (ignored for alignment), + // so "Hello" should be End-aligned as if it has no trailing whitespace. + let mut builder = env.tree_builder(); + builder.set_white_space_mode(WhiteSpaceCollapse::Preserve); + builder.push_text("Hello world\n"); + let (mut layout, _) = builder.build(); + layout.break_all_lines(Some(200.0)); + layout.align(Alignment::End, AlignmentOptions::default()); + env.check_layout_snapshot(&layout); +} + +/// Same as above but for Center alignment. +#[test] +fn hanging_whitespace_mid_paragraph_center() { + let mut env = TestEnv::new(test_name!(), Size::new(200.0, 60.0)); + + let mut builder = env.tree_builder(); + builder.set_white_space_mode(WhiteSpaceCollapse::Preserve); + builder.push_text("Hello world\n"); + let (mut layout, _) = builder.build(); + layout.break_all_lines(Some(200.0)); + layout.align(Alignment::Center, AlignmentOptions::default()); + env.check_layout_snapshot(&layout); +} + +/// Tests justified alignment with trailing whitespace on non-final lines. +/// Trailing whitespace spaces should NOT participate in justification distribution; +/// only inter-word spaces in visible content should be expanded. +#[test] +fn hanging_whitespace_justify_mid_paragraph() { + let mut env = TestEnv::new(test_name!(), Size::new(200.0, 60.0)); + + // "Hello world" followed by lots of trailing whitespace, then "foo" on next line. + // The trailing whitespace on line 1 should be hung, and justification should only + // distribute free_space across the single inter-word space between "Hello" and "world". + let mut builder = env.tree_builder(); + builder.set_white_space_mode(WhiteSpaceCollapse::Preserve); + builder.push_text("Hello world foo\n"); + let (mut layout, _) = builder.build(); + layout.break_all_lines(Some(200.0)); + layout.align(Alignment::Justify, AlignmentOptions::default()); + env.check_layout_snapshot(&layout); +} + +/// Tests that justify on paragraph-final lines falls back to Start alignment +/// (per CSS spec: last line of a justified paragraph is start-aligned). +/// Trailing whitespace on the final line should still be included per conditional hanging. +#[test] +fn hanging_whitespace_justify_paragraph_final() { + let mut env = TestEnv::new(test_name!(), Size::new(200.0, 60.0)); + + // Multi-line: first line justified, last line start-aligned with trailing ws included. + let mut builder = env.tree_builder(); + builder.set_white_space_mode(WhiteSpaceCollapse::Preserve); + builder.push_text("Hello world foo \n"); + let (mut layout, _) = builder.build(); + layout.break_all_lines(Some(200.0)); + layout.align(Alignment::Justify, AlignmentOptions::default()); + env.check_layout_snapshot(&layout); +} + +/// Tests trailing whitespace behaviour on the very last line (BreakReason::None) +/// which is the end-of-text case rather than an explicit newline. +#[test] +fn hanging_whitespace_end_of_text() { + let mut env = TestEnv::new(test_name!(), Size::new(200.0, 50.0)); + + for (alignment, name) in [ + (Alignment::End, "end"), + (Alignment::Center, "center"), + ] { + // No trailing \n — the line ends due to running out of text (BreakReason::None). + // Trailing whitespace should still be conditionally included. + let mut builder = env.tree_builder(); + builder.set_white_space_mode(WhiteSpaceCollapse::Preserve); + builder.push_text("Hello "); + let (mut layout, _) = builder.build(); + layout.break_all_lines(Some(200.0)); + layout.align(alignment, AlignmentOptions::default()); + env.with_name(name).check_layout_snapshot(&layout); + } +} + +/// Tests the line-breaker behaviour: trailing whitespace before a forced newline +/// should NOT cause a soft wrap, even if it overflows the line width. +/// The whitespace should accumulate on the same line as the preceding text. +#[test] +fn no_wrap_on_trailing_whitespace_before_newline() { + let mut env = TestEnv::new(test_name!(), Size::new(200.0, 50.0)); + + // Enough trailing whitespace to overflow the 200px line width, followed by \n. + // Should produce exactly 2 lines: "Hello...spaces...\n" and "" (empty final line). + let mut builder = env.tree_builder(); + builder.set_white_space_mode(WhiteSpaceCollapse::Preserve); + builder.push_text("Hello \n"); + let (mut layout, _) = builder.build(); + layout.break_all_lines(Some(200.0)); + layout.align(Alignment::Start, AlignmentOptions::default()); + + assert_eq!( + layout.len(), + 2, + "Trailing whitespace before \\n should not cause an extra line wrap" + ); + env.check_layout_snapshot(&layout); +} + +/// Tests multi-paragraph handling: each paragraph has different trailing whitespace, +/// and alignment is applied consistently per the paragraph-final vs mid-paragraph rules. +#[test] +fn hanging_whitespace_multi_paragraph() { + let mut env = TestEnv::new(test_name!(), Size::new(200.0, 80.0)); + + // Two paragraphs: first ends with \n (Explicit), second ends at end-of-text (None). + // Both final lines should get conditional hanging. The soft-wrapped mid-paragraph + // line gets unconditional hanging. + let mut builder = env.tree_builder(); + builder.set_white_space_mode(WhiteSpaceCollapse::Preserve); + builder.push_text("Hello world end of para one \nSecond paragraph "); + let (mut layout, _) = builder.build(); + layout.break_all_lines(Some(200.0)); + layout.align(Alignment::End, AlignmentOptions::default()); + env.check_layout_snapshot(&layout); +} + #[test] fn layout_impl_send_sync() { fn assert_send_sync() {}