From 7b099b3a63dbc316c0ed1bd848f80402dd88284c Mon Sep 17 00:00:00 2001 From: francois-unity <105223066+francois-unity@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:01:04 -0400 Subject: [PATCH 1/2] Hang the whitespace that ends a paragraph on its last row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parley’s line breaker hangs a space that overflows a row and then breaks, so a space that ends the paragraph leaves an empty row behind, and that row counts in the layout height. A block’s max-content width comes from Layout::width(), which leaves trailing whitespace out, so laying the text out again at exactly that width always wraps it: a box holding "Visit our " measured one row taller than the text it paints. CSS hangs that whitespace instead. After line breaking, a paragraph whose last rows hold only whitespace after a soft break is now broken again: the earlier rows as before, and the last one with no width limit, still aligned within the wrap width. That covers several trailing spaces, whitespace-only text and clamped layouts, and the empty row Parley also leaves after an oversized inline box that ends a paragraph. A forced break (U+2028) still starts its own row, and widths don’t change, since Layout::width() already left the hanging whitespace out. --- crates/gpui_ce_parley/src/text_system.rs | 58 +++++++- .../src/text_system/tests/inline_reflow.rs | 136 ++++++++++++++++++ 2 files changed, 192 insertions(+), 2 deletions(-) diff --git a/crates/gpui_ce_parley/src/text_system.rs b/crates/gpui_ce_parley/src/text_system.rs index fdb61ebf4d..eb9ef59bc1 100644 --- a/crates/gpui_ce_parley/src/text_system.rs +++ b/crates/gpui_ce_parley/src/text_system.rs @@ -17,8 +17,8 @@ use gpui::{ use parking_lot::{Mutex, RwLock}; use parley::setting::Tag; use parley::{ - Affinity, Alignment, AlignmentOptions, CHROMIUM_LINE_BREAK_OVERRIDE, Cluster, Cursor, - FontContext, FontFamily, FontFamilyName, FontFeature, FontFeatures, FontStyle, + Affinity, Alignment, AlignmentOptions, BreakReason, CHROMIUM_LINE_BREAK_OVERRIDE, Cluster, + Cursor, FontContext, FontFamily, FontFamilyName, FontFeature, FontFeatures, FontStyle, FontVariation as ParleyFontVariation, FontWeight, GenericFamily, InlineBox, InlineBoxKind, Layout, LayoutContext, Line, LineHeight, PositionedLayoutItem, Selection, StyleProperty, }; @@ -1982,6 +1982,8 @@ impl ParleyTextSystem { } else { layout.break_all_lines(Some(f32::from(wrap_width))); } + + hang_trailing_whitespace(&mut layout, text, f32::from(wrap_width)); } else if let Some(alignment_width) = alignment_width { let mut breaker = layout.break_lines(); breaker.state_mut().set_layout_max_advance(f32::INFINITY); @@ -2256,6 +2258,58 @@ impl ParleyTextSystem { } } +/// Hangs the whitespace that ends a paragraph on its last row, as CSS does. Parley wraps +/// overflowing trailing whitespace (or the end after an oversized inline box) onto blank rows. +fn hang_trailing_whitespace(layout: &mut Layout, text: &str, wrap_width: f32) { + let is_blank = |line: &Line<'_, ParleyBrush>| { + line.items() + .all(|item| !matches!(item, PositionedLayoutItem::InlineBox(_))) + && text.get(line.text_range()).is_some_and(|row| { + row.chars() + .all(|character| character.is_whitespace() || is_bidi_control(character)) + }) + }; + let last_row = layout + .lines() + .rposition(|line| !is_blank(&line)) + .unwrap_or_default(); + let soft_wrapped = layout.get(last_row).is_some_and(|line| { + matches!( + line.break_reason(), + BreakReason::Regular | BreakReason::Emergency + ) + }); + + if !soft_wrapped { + return; + } + + // Break the earlier rows again as before, then let the last one take the rest. + let mut breaker = layout.break_lines(); + breaker.state_mut().set_layout_max_advance(f32::MAX); + breaker.state_mut().set_line_max_advance(wrap_width); + + for _ in 0..last_row { + breaker.break_next(); + } + + breaker.state_mut().set_line_max_advance(f32::MAX); + + while breaker.break_next().is_some() { + // Still align the row within the wrap width. + breaker.set_prior_line_width(wrap_width); + } + + breaker.finish(); +} + +fn is_bidi_control(character: char) -> bool { + matches!( + character, + '\u{061c}' | '\u{200e}' | '\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}' + ) +} + fn run_ranges(runs: &[TextRun]) -> Vec> { let mut start = 0; diff --git a/crates/gpui_ce_parley/src/text_system/tests/inline_reflow.rs b/crates/gpui_ce_parley/src/text_system/tests/inline_reflow.rs index ef78e8d1d2..ba437e36d6 100644 --- a/crates/gpui_ce_parley/src/text_system/tests/inline_reflow.rs +++ b/crates/gpui_ce_parley/src/text_system/tests/inline_reflow.rs @@ -588,6 +588,142 @@ fn block_line_clamp_ignores_wrapped_trailing_whitespace() { assert_paragraph_fits(text, layout, f32::from(width), 2); } +const TRAILING_SPACE_TEXT: &str = "Can’t find the version you’re looking for? Visit our "; +const WITH_TRAILING_SPACE: &str = "with-trailing-space"; +const WITHOUT_TRAILING_SPACE: &str = "without-trailing-space"; + +struct TrailingSpaceView; + +impl Render for TrailingSpaceView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let paragraph = |text: &'static str, selector: &'static str| { + div() + .text_size(px(13.)) + .line_height(px(20.)) + .debug_selector(move || selector.into()) + .child(text) + }; + + // Start-aligned column items take their max-content width. + div() + .size_full() + .flex() + .flex_col() + .items_start() + .child(paragraph(TRAILING_SPACE_TEXT, WITH_TRAILING_SPACE)) + .child(paragraph( + TRAILING_SPACE_TEXT.trim_end(), + WITHOUT_TRAILING_SPACE, + )) + } +} + +#[test] +fn block_text_ending_in_whitespace_keeps_one_line_at_its_own_width() { + let mut cx = headless(); + let window = cx + .open_window(size(px(900.), px(180.)), |_window, cx| { + cx.new(|_cx| TrailingSpaceView) + }) + .unwrap(); + cx.run_until_parked(); + + let window = window.into(); + let mut debug_bounds = |selector| cx.debug_bounds(window, selector).unwrap().unwrap(); + let with_space = debug_bounds(WITH_TRAILING_SPACE); + let without_space = debug_bounds(WITHOUT_TRAILING_SPACE); + + assert_eq!(without_space.size.height, px(20.)); + assert_eq!(with_space.size, without_space.size); +} + +#[test] +fn trailing_whitespace_hangs_on_the_last_row_at_any_width() { + let system = test_system(); + let layout = |text: &str, wrap_width: Option, line_clamp: Option| { + system.layout_text(TextLayoutRequest { + text, + font_size: px(13.), + runs: &[text_run(text, IBM_PLEX.family)], + options: TextLayoutOptions { + wrap_width, + line_clamp, + text_align: TextAlign::Left, + ..Default::default() + }, + }) + }; + + // A forced break still starts its own row, even when only whitespace follows it. + assert_eq!( + layout("Visit our\u{2028} ", None, None).visual_lines.len(), + 2 + ); + + for text in [ + "Visit our ", + "Visit our ", + " ", + "Visit our \u{2028}", + "Visit our\u{2028} ", + ] { + let unwrapped = layout(text, None, None); + let own_width = unwrapped.platform_layout.size().width; + + for line_clamp in [None, Some(4)] { + let wrapped = layout(text, Some(own_width), line_clamp); + let context = format!("{text:?} at {own_width:?}, clamp {line_clamp:?}"); + + assert_eq!( + wrapped.visual_lines.len(), + unwrapped.visual_lines.len(), + "{context}" + ); + assert_eq!( + wrapped.platform_layout.size(), + unwrapped.platform_layout.size(), + "{context}" + ); + assert_document_contract(text, &wrapped); + } + } +} + +#[test] +fn oversized_inline_box_ending_a_paragraph_leaves_no_blank_row() { + let system = test_system(); + let text = "ab"; + let layout = system.layout_inline(InlineLayoutRequest { + text, + runs: &[text_run(text, IBM_PLEX.family)], + boxes: &[InlineBoxRequest { + id: 1, + index: text.len(), + size: size(px(80.), px(12.)), + vertical_align: VerticalAlign::Baseline, + }], + text_styles: &[], + font_size: px(13.), + line_height: px(20.), + text_metrics: InlineTextMetrics { + ascent: px(10.), + descent: px(3.), + x_height: px(6.), + }, + options: TextLayoutOptions { + wrap_width: Some(px(40.)), + text_align: TextAlign::Left, + ..Default::default() + }, + bidi_scopes: &[], + }); + + assert_eq!(layout.lines.len(), 2); + assert_eq!(layout.boxes[0].line_index, 1); + let last_row = layout.lines[1]; + assert_eq!(layout.size.height, last_row.origin.y + last_row.size.height); +} + #[test] fn block_paragraph_remeasures_nowrap_overflow_and_restores_original_text() { let mut cx = headless(); From 6467dc2387e73b87984089fcaa4d24ffa7cabf82 Mon Sep 17 00:00:00 2001 From: francois-unity <105223066+francois-unity@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:08:42 -0400 Subject: [PATCH 2/2] Count the whitespace that ends a paragraph in its block width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit hangs a paragraph’s closing whitespace on its last row instead of wrapping it onto an empty one. A block still measured its width with Layout::width(), which leaves trailing whitespace out, so a fragment ending in a space now sat flush against the next one: in a sentence built from fragments, "Visit our " followed by a link read "Visit ourdownload archive". A text element’s width (LineLayout::width) already keeps that space. A block’s width now counts the whitespace that ends its paragraph when it fits in the width the paragraph was given, so a block laid out again at its own measured width keeps both its row and its space. When it doesn’t fit, it still hangs and stops counting. Whitespace before a forced break and the spaces a soft wrap hangs still don’t count. The element test now builds that sentence as a baseline-aligned flex row and checks both its row and its space, and a unit test pins when the space counts. --- crates/gpui_ce_parley/src/text_system.rs | 37 ++++++-- .../src/text_system/tests/inline_reflow.rs | 90 +++++++++++++++---- 2 files changed, 104 insertions(+), 23 deletions(-) diff --git a/crates/gpui_ce_parley/src/text_system.rs b/crates/gpui_ce_parley/src/text_system.rs index eb9ef59bc1..7492504972 100644 --- a/crates/gpui_ce_parley/src/text_system.rs +++ b/crates/gpui_ce_parley/src/text_system.rs @@ -1998,13 +1998,15 @@ impl ParleyTextSystem { layout.break_all_lines(None); } + // The width the paragraph was given, if any. + let available_width = alignment_width + .or_else(|| wrap.map(|(width, _max_lines)| width)) + .filter(|width| *width < Pixels::MAX); + // Parley uses an unbounded line width for empty layouts. Align their empty // row here so centered and right-aligned carets stay inside the container. let empty_alignment = (text.is_empty() && inline_boxes.is_empty()).then(|| { - let width = alignment_width - .or_else(|| wrap.map(|(width, _max_lines)| width)) - .filter(|width| *width < Pixels::MAX) - .unwrap_or_default(); + let width = available_width.unwrap_or_default(); match text_align { TextAlign::Start if layout.is_rtl() => width, @@ -2208,7 +2210,10 @@ impl ParleyTextSystem { anyhow::bail!("Parley produced no line"); } - let mut size = size(px(layout.width()), px(layout.height())); + let mut size = size( + px(width_with_closing_whitespace(&layout, available_width)), + px(layout.height()), + ); if empty_alignment.is_some() { size.width = Pixels::ZERO; @@ -2303,6 +2308,28 @@ fn hang_trailing_whitespace(layout: &mut Layout, text: &str, wrap_w breaker.finish(); } +/// The paragraph’s width, counting the whitespace that ends it when it fits, as a text +/// element’s width does. `Layout::width` leaves it out, so a block ending in a space lost it. +fn width_with_closing_whitespace( + layout: &Layout, + available_width: Option, +) -> f32 { + let width = layout.width(); + let Some(last_row) = layout.lines().last() else { + return width; + }; + let metrics = last_row.metrics(); + let closing_end = metrics.inline_min_coord + metrics.advance; + // Slack so a block laid out at its own measured width keeps the space. + let fits = available_width.is_none_or(|available| closing_end <= f32::from(available) + 0.01); + + if metrics.trailing_whitespace > 0. && last_row.break_reason() == BreakReason::None && fits { + width.max(closing_end) + } else { + width + } +} + fn is_bidi_control(character: char) -> bool { matches!( character, diff --git a/crates/gpui_ce_parley/src/text_system/tests/inline_reflow.rs b/crates/gpui_ce_parley/src/text_system/tests/inline_reflow.rs index ba437e36d6..5812888c61 100644 --- a/crates/gpui_ce_parley/src/text_system/tests/inline_reflow.rs +++ b/crates/gpui_ce_parley/src/text_system/tests/inline_reflow.rs @@ -589,19 +589,17 @@ fn block_line_clamp_ignores_wrapped_trailing_whitespace() { } const TRAILING_SPACE_TEXT: &str = "Can’t find the version you’re looking for? Visit our "; -const WITH_TRAILING_SPACE: &str = "with-trailing-space"; -const WITHOUT_TRAILING_SPACE: &str = "without-trailing-space"; +const SENTENCE_ROW: &str = "sentence-row"; +const SENTENCE: &str = "sentence"; +const LINK: &str = "link"; +const TRIMMED_SENTENCE: &str = "trimmed-sentence"; struct TrailingSpaceView; impl Render for TrailingSpaceView { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { - let paragraph = |text: &'static str, selector: &'static str| { - div() - .text_size(px(13.)) - .line_height(px(20.)) - .debug_selector(move || selector.into()) - .child(text) + let block = |text: &'static str, selector: &'static str| { + div().debug_selector(move || selector.into()).child(text) }; // Start-aligned column items take their max-content width. @@ -610,16 +608,24 @@ impl Render for TrailingSpaceView { .flex() .flex_col() .items_start() - .child(paragraph(TRAILING_SPACE_TEXT, WITH_TRAILING_SPACE)) - .child(paragraph( - TRAILING_SPACE_TEXT.trim_end(), - WITHOUT_TRAILING_SPACE, - )) + .text_size(px(13.)) + .line_height(px(20.)) + // A sentence built from fragments, like Unity Hub's Archive footer. + .child( + div() + .flex() + .flex_wrap() + .items_baseline() + .debug_selector(|| SENTENCE_ROW.into()) + .child(block(TRAILING_SPACE_TEXT, SENTENCE)) + .child(block("download archive", LINK)), + ) + .child(block(TRAILING_SPACE_TEXT.trim_end(), TRIMMED_SENTENCE)) } } #[test] -fn block_text_ending_in_whitespace_keeps_one_line_at_its_own_width() { +fn block_text_ending_in_whitespace_keeps_its_line_and_its_space() { let mut cx = headless(); let window = cx .open_window(size(px(900.), px(180.)), |_window, cx| { @@ -630,11 +636,59 @@ fn block_text_ending_in_whitespace_keeps_one_line_at_its_own_width() { let window = window.into(); let mut debug_bounds = |selector| cx.debug_bounds(window, selector).unwrap().unwrap(); - let with_space = debug_bounds(WITH_TRAILING_SPACE); - let without_space = debug_bounds(WITHOUT_TRAILING_SPACE); + let row = debug_bounds(SENTENCE_ROW); + let sentence = debug_bounds(SENTENCE); + let link = debug_bounds(LINK); + let trimmed = debug_bounds(TRIMMED_SENTENCE); + + assert_eq!(sentence.size.height, px(20.)); + assert_eq!(row.size.height, px(20.)); + assert_eq!(link.origin.y, sentence.origin.y); + assert!( + link.origin.x - sentence.origin.x > trimmed.size.width, + "the link must not touch the sentence: {sentence:?}, {link:?}, {trimmed:?}" + ); +} + +#[test] +fn closing_whitespace_counts_in_a_block_width_only_when_it_fits() { + let system = test_system(); + let text = "Visit our "; + let layout = |wrap_width: Option| { + system.layout_inline(InlineLayoutRequest { + text, + runs: &[text_run(text, IBM_PLEX.family)], + boxes: &[], + text_styles: &[], + font_size: px(13.), + line_height: px(20.), + text_metrics: InlineTextMetrics { + ascent: px(10.), + descent: px(3.), + x_height: px(6.), + }, + options: TextLayoutOptions { + wrap_width, + text_align: TextAlign::Left, + ..Default::default() + }, + bidi_scopes: &[], + }) + }; + + let natural = layout(None); + let trimmed_width = natural.layout.platform_layout.size().width; + assert!(natural.size.width > trimmed_width); + + // At its own width the space fits: one row, same size. + let at_own_width = layout(Some(natural.size.width)); + assert_eq!(at_own_width.lines.len(), 1); + assert_eq!(at_own_width.size, natural.size); - assert_eq!(without_space.size.height, px(20.)); - assert_eq!(with_space.size, without_space.size); + // Narrower, the space hangs and no longer counts. + let narrower = layout(Some(trimmed_width)); + assert_eq!(narrower.lines.len(), 1); + assert_eq!(narrower.size.width, trimmed_width); } #[test]