mirror of
https://github.com/emilk/egui.git
synced 2026-06-26 14:49:06 -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:
@@ -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();
|
||||
|
||||
@@ -726,18 +726,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),
|
||||
|
||||
@@ -707,9 +707,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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user