1
0
mirror of https://github.com/emilk/egui.git synced 2026-06-26 22:53:14 -04:00

Fix text layout bugs in wrapped texts (#8137)

Fixes some bugs that happen randomly when resizing horizontal_wrapped
texts:


https://github.com/user-attachments/assets/141392d2-0239-465a-ba7b-c864f7823319

Adds regression tests (I enjoy using claude to fix these bugs, first
have it create a minimal repro test case, then fix the bug by iterating
until it figures out a fix).
This commit is contained in:
Lucas Meurer
2026-05-04 13:45:50 +02:00
committed by lucasmerlin
parent 27aa63a520
commit e06edc127d
3 changed files with 105 additions and 7 deletions

View File

@@ -1,5 +1,5 @@
use egui::accesskit::{self, Role};
use egui::{Button, ComboBox, Image, Modifiers, Popup, Vec2, Widget as _};
use egui::{Button, ComboBox, Image, Modifiers, Popup, Rect, Vec2, Widget as _};
#[cfg(all(feature = "wgpu", feature = "snapshot"))]
use egui_kittest::SnapshotResults;
use egui_kittest::{Harness, kittest::Queryable as _};
@@ -337,6 +337,100 @@ pub fn keyboard_should_close_nested_submenu_with_second_enter() {
);
}
/// Regression test for a bug in `horizontal_wrapped` layouts where text wraps but does not
/// move to the next line, causing overlapping text.
///
/// Sweeps the available width from 200 down to 50 (one frame per width) and asserts that no
/// two `TextRun` accesskit nodes (one per laid-out row) have overlapping bounds, and that
/// all accesskit text runs and painted text shapes stay within the `horizontal_wrapped` rect.
#[test]
pub fn horizontal_wrapped_text_should_not_overlap() {
struct State {
width: f32,
rect: egui::Rect,
}
let mut harness = Harness::builder()
.with_size(Vec2::new(300.0, 400.0))
.build_ui_state(
|ui, state: &mut State| {
ui.set_width(state.width);
state.rect = egui::Frame::popup(ui.style())
.show(ui, |ui| {
ui.horizontal_wrapped(|ui| {
ui.set_width(ui.available_width());
for i in 0..20 {
ui.label(format!("Hello{i}"));
}
})
.response
.rect
})
.inner;
},
State {
width: 200.0,
rect: Rect::NAN,
},
);
let min_width = 50.0;
loop {
let width = harness.state().width - 1.0;
if width < min_width {
break;
}
harness.state_mut().width = width;
harness.step();
let container_rect = harness.state().rect.expand(1.0);
let runs: Vec<_> = harness
.query_all_by_role(accesskit::Role::TextRun)
.map(|node| (node.rect(), node.value().unwrap_or_default()))
.collect();
for (rect, text) in &runs {
assert!(
container_rect.contains_rect(*rect),
"TextRun rect at available width = {width} is outside horizontal_wrapped rect: \
{text:?} {rect:?} outside {container_rect:?}"
);
}
for clipped in &harness.output().shapes {
if let egui::epaint::Shape::Text(text_shape) = &clipped.shape {
let shape_rect = text_shape.visual_bounding_rect();
assert!(
container_rect.contains_rect(shape_rect),
"TextShape rect at available width = {width} is outside horizontal_wrapped rect: \
{:?} {shape_rect:?} outside {container_rect:?}",
text_shape.galley.text()
);
}
}
for i in 0..runs.len() {
for j in (i + 1)..runs.len() {
let (a, ta) = &runs[i];
let (b, tb) = &runs[j];
let inter = a.intersect(*b);
// Allow tiny floating-point slop for rects that just touch.
let overlaps = inter.width() > 0.5 && inter.height() > 0.5;
assert!(
!overlaps,
"TextRun rects overlap at available width = {width}: \
{ta:?} {a:?} vs {tb:?} {b:?} \
(overlap = {}x{})",
inter.width(),
inter.height()
);
}
}
}
}
#[test]
pub fn pointer_click_on_open_submenu_button_should_not_close_it() {
let mut harness = keyboard_submenu_harness();

View File

@@ -420,18 +420,19 @@ fn line_break(
if job.wrap.max_rows <= out_rows.len() {
*elided = true; // can't fit another row
} else {
let paragraph_min_x = paragraph.glyphs[row_start_idx].pos.x - row_start_x;
let paragraph_max_x = paragraph.glyphs.last().unwrap().max_x() - row_start_x;
let glyphs: Vec<Glyph> = paragraph.glyphs[row_start_idx..]
.iter()
.copied()
.map(|mut glyph| {
glyph.pos.x -= row_start_x;
glyph.pos.x -= row_start_x + paragraph_min_x;
glyph
})
.collect();
let section_index_at_start = glyphs[0].section_index;
let paragraph_min_x = glyphs[0].pos.x;
let paragraph_max_x = glyphs.last().unwrap().max_x();
out_rows.push(PlacedRow {
pos: pos2(paragraph_min_x, 0.0),

View File

@@ -696,9 +696,12 @@ impl PlacedRow {
/// Same as [`Self::rect`] but excluding the `LayoutSection::leading_space`.
pub fn rect_without_leading_space(&self) -> Rect {
let x = self.glyphs.first().map_or(self.pos.x, |g| g.pos.x);
let size_x = self.size.x - x;
Rect::from_min_size(Pos2::new(x, self.pos.y), Vec2::new(size_x, self.size.y))
let x = self.pos.x + self.glyphs.first().map_or(0.0, |g| g.pos.x);
let right = self.pos.x + self.size.x;
Rect::from_min_max(
Pos2::new(x, self.pos.y),
Pos2::new(right, self.pos.y + self.size.y),
)
}
}