Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 88 additions & 7 deletions crates/gpui_ce_parley/src/text_system.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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);
Expand All @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<ParleyBrush>, 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<ParleyBrush>,
available_width: Option<Pixels>,
) -> 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<Range<usize>> {
let mut start = 0;

Expand Down
190 changes: 190 additions & 0 deletions crates/gpui_ce_parley/src/text_system/tests/inline_reflow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self>) -> 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<Pixels>| {
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<Pixels>, line_clamp: Option<usize>| {
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();
Expand Down
Loading