From 6d98e1dccbe0954932a88416c4c1c031506fb499 Mon Sep 17 00:00:00 2001 From: Vitaly Kravchenko Date: Tue, 11 Aug 2026 12:54:44 +0100 Subject: [PATCH] Allow explicit popup sizing passes (#8407) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [x] I have followed the instructions in the PR template ## Summary - Add an opt-in `Popup::sizing_pass(bool)` builder for remeasuring a popup whose contents change while it remains open. - Preserve the automatic first-open/reopen sizing pass and all existing default behavior. - Add a headless regression covering growth to a capped scroll viewport with overflowing content remaining scrollable. ## Why this is necessary A continuously open popup can first shrink around a short result set and later receive more content, such as an autocomplete after its query changes or a “show more” action. The cached `Area` height constrains the `ScrollArea` input size, so `ScrollArea::max_height` can cap the viewport but cannot make the containing popup grow again. PR #8315 taught `Popup` to rerun its sizing pass after closing and reopening. That fixes the same cached-size feedback loop when `was_open_last_frame` is false, but a continuously open popup keeps that value true while its contents change. In that case the caller is the component that knows the cached natural size is stale. This API exposes the existing one-frame `Area` sizing mechanism through `Popup`. It is additive, defaults to false, and combines with the automatic reopen pass, so unrelated popups, menus, tooltips, and areas keep their current behavior. --- crates/egui/src/containers/popup.rs | 18 ++++++- crates/egui_kittest/tests/popup.rs | 79 +++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 1 deletion(-) diff --git a/crates/egui/src/containers/popup.rs b/crates/egui/src/containers/popup.rs index 8cd545dd0..beee9f900 100644 --- a/crates/egui/src/containers/popup.rs +++ b/crates/egui/src/containers/popup.rs @@ -179,6 +179,7 @@ pub struct Popup<'a> { /// Default width passed to the Area width: Option, + sizing_pass: bool, sense: Sense, interactable: bool, layout: Layout, @@ -202,6 +203,7 @@ impl<'a> Popup<'a> { alternative_aligns: None, gap: 0.0, width: None, + sizing_pass: false, sense: Sense::click(), interactable: true, layout: Layout::default(), @@ -401,6 +403,19 @@ impl<'a> Popup<'a> { self } + /// Force the popup's underlying [`Area`] to run an invisible sizing pass. + /// + /// Popups automatically run a sizing pass when they open or reopen. Set this to `true` for + /// one frame when the contents of an already open popup change and its cached size may no + /// longer fit. Do not leave it enabled continuously, because the popup would remain invisible. + /// + /// Default: `false`. + #[inline] + pub fn sizing_pass(mut self, sizing_pass: bool) -> Self { + self.sizing_pass = sizing_pass; + self + } + /// Set the id of the Area. #[inline] pub fn id(mut self, id: Id) -> Self { @@ -556,6 +571,7 @@ impl<'a> Popup<'a> { alternative_aligns: _, gap, width, + sizing_pass, sense, interactable, layout, @@ -584,7 +600,7 @@ impl<'a> Popup<'a> { .sense(sense) .interactable(interactable) .layout(layout) - .sizing_pass(!was_open_last_frame) + .sizing_pass(sizing_pass || !was_open_last_frame) .info(info.unwrap_or_else(|| { UiStackInfo::new(kind.into()).with_tag_value( MenuConfig::MENU_CONFIG_TAG, diff --git a/crates/egui_kittest/tests/popup.rs b/crates/egui_kittest/tests/popup.rs index 4dbe33909..9d9dba654 100644 --- a/crates/egui_kittest/tests/popup.rs +++ b/crates/egui_kittest/tests/popup.rs @@ -67,6 +67,85 @@ fn reopened_popup_resizes_for_wider_items() { ); } +#[test] +fn open_popup_resizes_after_explicit_sizing_pass() { + const POPUP_BUTTON: &str = "Growing popup"; + const MAX_HEIGHT: f32 = 100.0; + + struct State { + item_count: usize, + needs_sizing_pass: bool, + popup_height: f32, + viewport_height: f32, + content_height: f32, + } + + let mut harness = Harness::builder() + .with_size(egui::Vec2::new(500.0, 300.0)) + .build_ui_state( + |ui, state| { + let response = ui.button(POPUP_BUTTON); + let needs_sizing_pass = core::mem::take(&mut state.needs_sizing_pass); + let item_count = state.item_count; + + if let Some(popup) = Popup::from_response(&response) + .sizing_pass(needs_sizing_pass) + .show(|ui| { + egui::ScrollArea::vertical() + .max_height(MAX_HEIGHT) + .show(ui, |ui| { + for index in 0..item_count { + ui.label(format!("Item {index}")); + } + }) + }) + { + state.popup_height = popup.response.rect.height(); + state.viewport_height = popup.inner.inner_rect.height(); + state.content_height = popup.inner.content_size.y; + } + }, + State { + item_count: 2, + needs_sizing_pass: false, + popup_height: 0.0, + viewport_height: 0.0, + content_height: 0.0, + }, + ); + + harness.run(); + let initial_popup_height = harness.state().popup_height; + + harness.state_mut().item_count = 20; + harness.run(); + let stale_viewport_height = harness.state().viewport_height; + assert!( + stale_viewport_height < MAX_HEIGHT, + "viewport unexpectedly reached its maximum without a sizing pass" + ); + + harness.state_mut().needs_sizing_pass = true; + harness.run(); + + assert!( + harness.state().popup_height > initial_popup_height, + "popup did not grow after an explicit sizing pass" + ); + assert!( + harness.state().viewport_height > stale_viewport_height, + "scroll viewport did not grow after an explicit sizing pass" + ); + assert!( + (harness.state().viewport_height - MAX_HEIGHT).abs() <= 0.5, + "scroll viewport did not stop at its maximum height" + ); + assert!( + harness.state().content_height > harness.state().viewport_height, + "popup contents did not remain scrollable at the maximum height" + ); +} + #[test] fn test_interactive_tooltip() { struct State {