diff --git a/crates/egui_kittest/tests/regression_tests.rs b/crates/egui_kittest/tests/regression_tests.rs index 94617ff8b..2cb656b25 100644 --- a/crates/egui_kittest/tests/regression_tests.rs +++ b/crates/egui_kittest/tests/regression_tests.rs @@ -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(); diff --git a/crates/epaint/src/text/text_layout.rs b/crates/epaint/src/text/text_layout.rs index 44ee3577f..35859b095 100644 --- a/crates/epaint/src/text/text_layout.rs +++ b/crates/epaint/src/text/text_layout.rs @@ -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 = 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), diff --git a/crates/epaint/src/text/text_layout_types.rs b/crates/epaint/src/text/text_layout_types.rs index e15028fec..bce2fa48f 100644 --- a/crates/epaint/src/text/text_layout_types.rs +++ b/crates/epaint/src/text/text_layout_types.rs @@ -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), + ) } }