Exclude `\n` when splitting `Galley`s (#7316)

* Follow up to #7146 

Previously when galleys were splitted, each exept the last had an extra
empty row that had to be removed when they were concated. This changes
it to remove the `\n` from the layout jobs when splitting.
This commit is contained in:
Lucas Meurer 2025-07-09 14:53:19 +02:00 committed by GitHub
parent a7f14ca176
commit 207e71c2ae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 146 additions and 60 deletions

View File

@ -84,7 +84,7 @@ impl<'a> AtomKind<'a> {
let wrap_mode = wrap_mode.unwrap_or(ui.wrap_mode());
let galley =
text.into_galley(ui, Some(wrap_mode), available_size.x, TextStyle::Button);
(galley.intrinsic_size, SizedAtomKind::Text(galley))
(galley.intrinsic_size(), SizedAtomKind::Text(galley))
}
AtomKind::Image(image) => {
let size = image.load_and_calc_size(ui, available_size);

View File

@ -825,7 +825,7 @@ impl GalleyCache {
let job = Arc::new(job);
if allow_split_paragraphs && should_cache_each_paragraph_individually(&job) {
let (child_galleys, child_hashes) =
self.layout_each_paragraph_individuallly(fonts, &job);
self.layout_each_paragraph_individually(fonts, &job);
debug_assert_eq!(
child_hashes.len(),
child_galleys.len(),
@ -869,7 +869,7 @@ impl GalleyCache {
}
/// Split on `\n` and lay out (and cache) each paragraph individually.
fn layout_each_paragraph_individuallly(
fn layout_each_paragraph_individually(
&mut self,
fonts: &mut FontsImpl,
job: &LayoutJob,
@ -884,9 +884,11 @@ impl GalleyCache {
while start < job.text.len() {
let is_first_paragraph = start == 0;
// `end` will not include the `\n` since we don't want to create an empty row in our
// split galley
let end = job.text[start..]
.find('\n')
.map_or(job.text.len(), |i| start + i + 1);
.map_or(job.text.len(), |i| start + i);
let mut paragraph_job = LayoutJob {
text: job.text[start..end].to_owned(),
@ -920,7 +922,7 @@ impl GalleyCache {
if section_range.end <= start {
// The section is behind us
current_section += 1;
} else if end <= section_range.start {
} else if end < section_range.start {
break; // Haven't reached this one yet.
} else {
// Section range overlaps with paragraph range
@ -953,10 +955,6 @@ impl GalleyCache {
// This will prevent us from invalidating cache entries unnecessarily:
if max_rows_remaining != usize::MAX {
max_rows_remaining -= galley.rows.len();
// Ignore extra trailing row, see merging `Galley::concat` for more details.
if end < job.text.len() && !galley.elided {
max_rows_remaining += 1;
}
}
let elided = galley.elided;
@ -965,7 +963,7 @@ impl GalleyCache {
break;
}
start = end;
start = end + 1;
}
(child_galleys, child_hashes)
@ -1091,6 +1089,29 @@ mod tests {
Color32::WHITE,
f32::INFINITY,
),
{
let mut job = LayoutJob::simple(
"hi".to_owned(),
FontId::default(),
Color32::WHITE,
f32::INFINITY,
);
job.append("\n", 0.0, TextFormat::default());
job.append("\n", 0.0, TextFormat::default());
job.append("world", 0.0, TextFormat::default());
job.wrap.max_rows = 2;
job
},
{
let mut job = LayoutJob::simple(
"Test text with a lot of words\n and a newline.".to_owned(),
FontId::new(14.0, FontFamily::Monospace),
Color32::WHITE,
40.0,
);
job.first_row_min_height = 30.0;
job
},
LayoutJob::simple(
"This some text that may be long.\nDet kanske också finns lite ÅÄÖ här.".to_owned(),
FontId::new(14.0, FontFamily::Proportional),
@ -1213,7 +1234,7 @@ mod tests {
let text = job.text.clone();
let galley_unwrapped = layout(&mut fonts, job.into());
let intrinsic_size = galley_wrapped.intrinsic_size;
let intrinsic_size = galley_wrapped.intrinsic_size();
let unwrapped_size = galley_unwrapped.size();
let difference = (intrinsic_size - unwrapped_size).length().abs();
@ -1232,7 +1253,7 @@ mod tests {
format!("{unwrapped_size:.4?}"),
"Unwrapped galley intrinsic size should exactly match its size. \
{:.8?} vs {:8?}",
galley_unwrapped.intrinsic_size,
galley_unwrapped.intrinsic_size(),
galley_unwrapped.size(),
);
}

View File

@ -204,20 +204,12 @@ fn calculate_intrinsic_size(
) -> Vec2 {
let mut intrinsic_size = Vec2::ZERO;
for (idx, paragraph) in paragraphs.iter().enumerate() {
if paragraph.glyphs.is_empty() {
if idx == 0 {
intrinsic_size.y += point_scale.round_to_pixel(paragraph.empty_paragraph_height);
}
continue;
}
intrinsic_size.x = f32::max(
paragraph
.glyphs
.last()
.map(|l| l.max_x())
.unwrap_or_default(),
intrinsic_size.x,
);
let width = paragraph
.glyphs
.last()
.map(|l| l.max_x())
.unwrap_or_default();
intrinsic_size.x = f32::max(intrinsic_size.x, width);
let mut height = paragraph
.glyphs
@ -253,7 +245,7 @@ fn rows_from_paragraphs(
if paragraph.glyphs.is_empty() {
rows.push(PlacedRow {
pos: Pos2::ZERO,
pos: pos2(0.0, f32::NAN),
row: Arc::new(Row {
section_index_at_start: paragraph.section_index_at_start,
glyphs: vec![],
@ -659,12 +651,12 @@ fn galley_from_rows(
let mut cursor_y = 0.0;
for placed_row in &mut rows {
let mut max_row_height = first_row_min_height.max(placed_row.rect().height());
let mut max_row_height = first_row_min_height.at_least(placed_row.height());
let row = Arc::make_mut(&mut placed_row.row);
first_row_min_height = 0.0;
for glyph in &row.glyphs {
max_row_height = max_row_height.max(glyph.line_height);
max_row_height = max_row_height.at_least(glyph.line_height);
}
max_row_height = point_scale.round_to_pixel(max_row_height);
@ -1212,4 +1204,72 @@ mod tests {
assert_eq!(row.pos, Pos2::ZERO);
assert_eq!(row.rect().max.x, row.glyphs.last().unwrap().max_x());
}
#[test]
fn test_empty_row() {
let mut fonts = FontsImpl::new(
1.0,
1024,
AlphaFromCoverage::default(),
FontDefinitions::default(),
);
let font_id = FontId::default();
let font_height = fonts.font(&font_id).row_height();
let job = LayoutJob::simple(String::new(), font_id, Color32::WHITE, f32::INFINITY);
let galley = layout(&mut fonts, job.into());
assert_eq!(galley.rows.len(), 1, "Expected one row");
assert_eq!(
galley.rows[0].row.glyphs.len(),
0,
"Expected no glyphs in the empty row"
);
assert_eq!(
galley.size(),
Vec2::new(0.0, font_height.round()),
"Unexpected galley size"
);
assert_eq!(
galley.intrinsic_size(),
Vec2::new(0.0, font_height.round()),
"Unexpected intrinsic size"
);
}
#[test]
fn test_end_with_newline() {
let mut fonts = FontsImpl::new(
1.0,
1024,
AlphaFromCoverage::default(),
FontDefinitions::default(),
);
let font_id = FontId::default();
let font_height = fonts.font(&font_id).row_height();
let job = LayoutJob::simple("Hi!\n".to_owned(), font_id, Color32::WHITE, f32::INFINITY);
let galley = layout(&mut fonts, job.into());
assert_eq!(galley.rows.len(), 2, "Expected two rows");
assert_eq!(
galley.rows[1].row.glyphs.len(),
0,
"Expected no glyphs in the empty row"
);
assert_eq!(
galley.size().round(),
Vec2::new(17.0, font_height.round() * 2.0),
"Unexpected galley size"
);
assert_eq!(
galley.intrinsic_size().round(),
Vec2::new(17.0, font_height.round() * 2.0),
"Unexpected intrinsic size"
);
}
}

View File

@ -561,11 +561,7 @@ pub struct Galley {
/// tessellation.
pub pixels_per_point: f32,
/// This is the size that a non-wrapped, non-truncated, non-justified version of the text
/// would have.
///
/// Useful for advanced layouting.
pub intrinsic_size: Vec2,
pub(crate) intrinsic_size: Vec2,
}
#[derive(Clone, Debug, PartialEq)]
@ -801,6 +797,21 @@ impl Galley {
self.rect.size()
}
/// This is the size that a non-wrapped, non-truncated, non-justified version of the text
/// would have.
///
/// Useful for advanced layouting.
#[inline]
pub fn intrinsic_size(&self) -> Vec2 {
// We do the rounding here instead of in `round_output_to_gui` so that rounding
// errors don't accumulate when concatenating multiple galleys.
if self.job.round_output_to_gui {
self.intrinsic_size.round_ui()
} else {
self.intrinsic_size
}
}
pub(crate) fn round_output_to_gui(&mut self) {
for placed_row in &mut self.rows {
// Optimization: only call `make_mut` if necessary (can cause a deep clone)
@ -827,8 +838,6 @@ impl Galley {
.at_most(rect.min.x + self.job.wrap.max_width)
.floor_ui();
}
self.intrinsic_size = self.intrinsic_size.round_ui();
}
/// Append each galley under the previous one.
@ -849,32 +858,28 @@ impl Galley {
for (i, galley) in galleys.iter().enumerate() {
let current_y_offset = merged_galley.rect.height();
let is_last_galley = i + 1 == galleys.len();
let mut rows = galley.rows.iter();
// As documented in `Row::ends_with_newline`, a '\n' will always create a
// new `Row` immediately below the current one. Here it doesn't make sense
// for us to append this new row so we just ignore it.
let is_last_row = i + 1 == galleys.len();
if !is_last_row && !galley.elided {
let popped = rows.next_back();
debug_assert_eq!(popped.unwrap().row.glyphs.len(), 0, "Bug in Galley::concat");
}
merged_galley
.rows
.extend(galley.rows.iter().enumerate().map(|(row_idx, placed_row)| {
let new_pos = placed_row.pos + current_y_offset * Vec2::Y;
let new_pos = new_pos.round_to_pixels(pixels_per_point);
merged_galley.mesh_bounds = merged_galley
.mesh_bounds
.union(placed_row.visuals.mesh_bounds.translate(new_pos.to_vec2()));
merged_galley.rect = merged_galley
.rect
.union(Rect::from_min_size(new_pos, placed_row.size));
merged_galley.rows.extend(rows.map(|placed_row| {
let new_pos = placed_row.pos + current_y_offset * Vec2::Y;
let new_pos = new_pos.round_to_pixels(pixels_per_point);
merged_galley.mesh_bounds = merged_galley
.mesh_bounds
.union(placed_row.visuals.mesh_bounds.translate(new_pos.to_vec2()));
merged_galley.rect = merged_galley
.rect
.union(Rect::from_min_size(new_pos, placed_row.size));
super::PlacedRow {
pos: new_pos,
row: placed_row.row.clone(),
}
}));
let mut row = placed_row.row.clone();
let is_last_row_in_galley = row_idx + 1 == galley.rows.len();
if !is_last_galley && is_last_row_in_galley {
// Since we remove the `\n` when splitting rows, we need to add it back here
Arc::make_mut(&mut row).ends_with_newline = true;
}
super::PlacedRow { pos: new_pos, row }
}));
merged_galley.num_vertices += galley.num_vertices;
merged_galley.num_indices += galley.num_indices;