diff --git a/parley/src/layout/alignment.rs b/parley/src/layout/alignment.rs index 26aa1144c..1c41b7292 100644 --- a/parley/src/layout/alignment.rs +++ b/parley/src/layout/alignment.rs @@ -114,9 +114,33 @@ fn align_impl( } // 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 +167,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 +190,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 +212,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 f97869d60..032ef7ba8 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 a8a060626..1792688e6 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, @@ -645,17 +644,19 @@ impl<'a, B: Brush> BreakLines<'a, B> { else { // Case: cluster is a space character (and wrapping is enabled) // - // We hang any overflowing whitespace and then line-break. + // Hang the overflowing whitespace by appending it to the current + // line and continuing without committing a line break. + // + // The space is allowed to overflow; the actual break will occur when + // the next non-space character triggers a break opportunity. + // Multiple, consecutive trailing spaces are hanged with this approach. if is_space && text_wrap_mode == TextWrapMode::Wrap { if max_height_exceeded { 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.cluster_idx += 1; - return self.start_new_line(BreakReason::Regular); - } + self.state.line.num_spaces += 1; + self.state.cluster_idx += 1; } // Case: we have previously encountered a REGULAR line-breaking opportunity in the current line // @@ -1029,31 +1030,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 +1152,7 @@ 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; } + } impl Drop for BreakLines<'_, B> { @@ -1365,26 +1370,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/src/shape/mod.rs b/parley/src/shape/mod.rs index cd451001e..2415400a2 100644 --- a/parley/src/shape/mod.rs +++ b/parley/src/shape/mod.rs @@ -251,6 +251,8 @@ fn fill_cluster_in_place( // regional indicators, subdivision flag tag sequences, skin tone modifiers // See also: https://github.com/google/emoji-segmenter + // ======== DISABLED: START ======== + // If the color emoji has a non-printing variation selector, ignore the variation selector. // Its presentation depends on the platform and font. // @@ -259,11 +261,15 @@ fn fill_cluster_in_place( // - `U+270C + U+FE0F`: `✌️`, force emoji presentation // // - let is_emoji_with_non_printing_variation_selector = + /*let is_emoji_with_non_printing_variation_selector = is_emoji_or_pictograph && info.is_variation_selector(); let contributes_to_shaping = - info.contributes_to_shaping() && !is_emoji_with_non_printing_variation_selector; + info.contributes_to_shaping(); && !is_emoji_with_non_printing_variation_selector;*/ + + // ======== DISABLED: END ======== + + let contributes_to_shaping = info.contributes_to_shaping(); if contributes_to_shaping { map_len += 1; } diff --git a/parley_tests/snapshots/base_level_alignment_ltr-justify.png b/parley_tests/snapshots/base_level_alignment_ltr-justify.png index 5b4dd5454..0abfc4bfd 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 8180499e8..6a409b897 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 a51ddfa11..feabbe591 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_consumes_all_trailing_spaces-0.png b/parley_tests/snapshots/hanging_whitespace_consumes_all_trailing_spaces-0.png new file mode 100644 index 000000000..7d2ae4b8a Binary files /dev/null and b/parley_tests/snapshots/hanging_whitespace_consumes_all_trailing_spaces-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 000000000..9096433de 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 000000000..7b36d9e93 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 000000000..4c56fa1d8 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 000000000..4c56fa1d8 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 000000000..e00c6030a 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 000000000..39ee71cc5 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 000000000..afb2472c1 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 000000000..9096433de 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 000000000..0cf9c0460 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 000000000..7b36d9e93 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 000000000..0cf9c0460 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 83b159f79..cddf3c5fc 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 000000000..0cf9c0460 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 5b4dd5454..0abfc4bfd 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 c3c27ccab..3f3bc4a8d 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 7c241b32e..96ba887a7 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 1c861fd7b..164e31d21 100644 --- a/parley_tests/tests/basic.rs +++ b/parley_tests/tests/basic.rs @@ -661,6 +661,223 @@ 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); +} + +/// When multiple consecutive spaces overflow the line width mid-paragraph, +/// ALL of them should hang on the current line (not just the first overflowing one). +/// Line 2 should start with the visible content, not leftover spaces. +#[test] +fn hanging_whitespace_consumes_all_trailing_spaces() { + let mut env = TestEnv::new(test_name!(), Size::new(200.0, 350.0)); + + // Use a large font so that "A" + a few spaces overflow quickly. + // At 100px font size, "A" is ~60px and each space is ~25px in Roboto. + // With max_advance=100, "A " fits (~85px) but "A " overflows (~110px). + // Text: "A B\n" — three spaces between A and B. + // Expected: Line 1 = "A " (all spaces hung), Line 2 = "B\n" + let text = "A B\n"; + let mut builder = env.tree_builder(); + builder.set_white_space_mode(WhiteSpaceCollapse::Preserve); + builder.push_style_modification_span(&[StyleProperty::FontSize(100.0)]); + builder.push_text(text); + let (mut layout, _) = builder.build(); + layout.break_all_lines(Some(100.0)); + layout.align(Alignment::Start, AlignmentOptions::default()); + + env.check_layout_snapshot(&layout); +} + #[test] fn layout_impl_send_sync() { fn assert_send_sync() {}