diff --git a/crates/gpui_ce_parley/src/text_system.rs b/crates/gpui_ce_parley/src/text_system.rs index fdb61ebf4d..7492504972 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); @@ -1996,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, @@ -2206,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; @@ -2256,6 +2263,80 @@ 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(); +} + +/// 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, + '\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..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 @@ -588,6 +588,196 @@ 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 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 block = |text: &'static str, selector: &'static str| { + div().debug_selector(move || selector.into()).child(text) + }; + + // Start-aligned column items take their max-content width. + div() + .size_full() + .flex() + .flex_col() + .items_start() + .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_its_line_and_its_space() { + 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 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); + + // 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] +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();