diff --git a/crates/egui/src/containers/resize.rs b/crates/egui/src/containers/resize.rs index 8dcce6b20..c537ac140 100644 --- a/crates/egui/src/containers/resize.rs +++ b/crates/egui/src/containers/resize.rs @@ -17,6 +17,14 @@ pub(crate) struct State { /// Externally requested size (e.g. by Window) for the next frame pub(crate) requested_size: Option, + + /// Minimum content width observed during the current interactive resize. + /// `None` until content overflows the offered rect. At that point we record + /// `last_content_size.x` here and clamp `desired_size.x` against it for the + /// rest of the drag, so wrapping/centered widgets see the same width the window will + /// end up clamped to. Reset to `None` whenever a drag is not in progress. + #[cfg_attr(feature = "serde", serde(default))] + observed_min_content_width: Option, } impl State { @@ -221,6 +229,7 @@ impl Resize { desired_size: default_size, last_content_size: vec2(0.0, 0.0), requested_size: None, + observed_min_content_width: None, } }); @@ -242,13 +251,20 @@ impl Resize { user_requested_size = Some(pointer_pos - position + 0.5 * corner_response.rect.size()); } - if let Some(user_requested_size) = user_requested_size { + if let Some(mut user_requested_size) = user_requested_size { + // We know the minimum width from a previous frame, so lets not shrink past that + if let Some(observed_min) = state.observed_min_content_width { + user_requested_size.x = user_requested_size.x.at_least(observed_min); + } state.desired_size = user_requested_size; } else { // We are not being actively resized, so auto-expand to include size of last frame. // This prevents auto-shrinking if the contents contain width-filling widgets (separators etc) // but it makes a lot of interactions with [`Window`]s nicer. state.desired_size = state.desired_size.max(state.last_content_size); + // No active drag, discard the observed min width, we'll rediscover on next drag, + // in case content changed. + state.observed_min_content_width = None; } state.desired_size = state @@ -305,6 +321,15 @@ impl Resize { state.last_content_size = content_ui.min_size(); + // The content overflowed the rect we provided. This means last_content_size.x is now at the + // very minimum size the content will allow to shrink to. We remember this so we can prevent + // any previous wrapping content from shrinking further. + // 4.0 is a bit of safety margin to ensure we don't prevent shrinking on rounding errors. + let overflowed_x = state.last_content_size.x > state.desired_size.x + 4.0; + if overflowed_x && state.observed_min_content_width.is_none() { + state.observed_min_content_width = Some(state.last_content_size.x); + } + // ------------------------------ let mut size = state.last_content_size; diff --git a/crates/egui_kittest/tests/regression_tests.rs b/crates/egui_kittest/tests/regression_tests.rs index 2cb656b25..e6f056da4 100644 --- a/crates/egui_kittest/tests/regression_tests.rs +++ b/crates/egui_kittest/tests/regression_tests.rs @@ -1,5 +1,7 @@ use egui::accesskit::{self, Role}; -use egui::{Button, ComboBox, Image, Modifiers, Popup, Rect, Vec2, Widget as _}; +use egui::{ + Button, ComboBox, Image, Label, Modifiers, Popup, Pos2, Rect, Vec2, Widget as _, Window, +}; #[cfg(all(feature = "wgpu", feature = "snapshot"))] use egui_kittest::SnapshotResults; use egui_kittest::{Harness, kittest::Queryable as _}; @@ -454,3 +456,58 @@ pub fn pointer_click_on_open_submenu_button_should_not_close_it() { "Expected submenu to remain open on repeated pointer click" ); } + +/// This test checks if we correctly handle wrapping content proceeding non-wrapping content +/// during window resize. When the window is resized past non-wrapping content, the wrapping content +/// above should stay at that non wrapping width and not wrap any further. +#[test] +fn window_resize_wraps_to_content_min_width() { + let wrap_text = "This label should wrap as the window is narrowed. \ + It should not shrink smaller than the bottom labels width though."; + let non_wrap_text = "This is the bottom non-wrapping label which is wider."; + + let window_title = "resize_wrap_regression"; + let mut harness = Harness::builder() + .with_size(Vec2::new(800.0, 600.0)) + .build_ui(move |ui| { + Window::new(window_title) + .default_pos([20.0, 20.0]) + .default_size([400.0, 200.0]) + .show(ui.ctx(), |ui| { + ui.add(Label::new(wrap_text).wrap()); + ui.add(Label::new(non_wrap_text).extend()); + }); + }); + + harness.run(); + + let window_rect = harness + .get_by_role_and_label(Role::Window, window_title) + .rect(); + + // Drag the right edge inward, well past the non-wrapping label's natural + // width, so the non-wrapping label pins the window's minimum width while + // the wrapping label would (without the fix) keep shrinking. + let grab = Pos2::new(window_rect.right(), window_rect.center().y); + let target = Pos2::new(window_rect.left() + 80.0, window_rect.center().y); + + harness.drag_at(grab); + harness.run(); + harness.hover_at(target); + + harness.run(); + + let wrap_width = harness.get_by_label(wrap_text).rect().width(); + let non_wrap_width = harness.get_by_label(non_wrap_text).rect().width(); + + // Wrapped text won't perfectly fill the available width — each line ends + // wherever the next word stops fitting. The tolerance absorbs that + // word-break slack while still catching the bug, where the wrap label + // would be substantially narrower than the non-wrapping label. + assert!( + non_wrap_width - wrap_width < 40.0, + "wrapping label width ({wrap_width}) is much narrower than the \ + non-wrapping label width ({non_wrap_width}) after shrinking the \ + window past the non-wrapping label's natural width" + ); +}