Skip to content

Commit fdcc6dc

Browse files
committed
Properly support setting a different PopupCloseBehavior in a submenu. Never close menu when clicking submenu buttons. Add tests.
1 parent ab0f83d commit fdcc6dc

File tree

11 files changed

+307
-61
lines changed

11 files changed

+307
-61
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1393,6 +1393,7 @@ dependencies = [
13931393
"eframe",
13941394
"egui",
13951395
"egui-wgpu",
1396+
"egui_extras",
13961397
"image",
13971398
"kittest",
13981399
"pollster 0.4.0",

crates/egui/src/containers/menu.rs

Lines changed: 83 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ pub struct MenuConfig {
6666
impl Default for MenuConfig {
6767
fn default() -> Self {
6868
Self {
69-
close_behavior: PopupCloseBehavior::CloseOnClickOutside,
69+
close_behavior: PopupCloseBehavior::default(),
7070
bar: false,
7171
style: Some(menu_style),
7272
}
@@ -206,25 +206,30 @@ impl Bar {
206206
pub fn ui<R>(self, ui: &mut Ui, content: impl FnOnce(&mut Ui) -> R) -> InnerResponse<R> {
207207
let Self { mut config, style } = self;
208208
config.bar = true;
209-
ui.scope_builder(
210-
UiBuilder::new()
211-
.layout(Layout::left_to_right(Align::Center))
212-
.ui_stack_info(
213-
UiStackInfo::new(UiKind::Menu)
214-
.with_tag_value(MenuConfig::MENU_CONFIG_TAG, config),
215-
),
216-
|ui| {
217-
if let Some(style) = style {
218-
style(ui.style_mut());
219-
}
220-
221-
// Take full width and fixed height:
222-
let height = ui.spacing().interact_size.y;
223-
ui.set_min_size(vec2(ui.available_width(), height));
224-
225-
content(ui)
226-
},
227-
)
209+
// TODO(lucasmerlin): It'd be nice if we had a ui.horizontal_builder or something
210+
// So we don't need the nested scope here
211+
ui.horizontal(|ui| {
212+
ui.scope_builder(
213+
UiBuilder::new()
214+
.layout(Layout::left_to_right(Align::Center))
215+
.ui_stack_info(
216+
UiStackInfo::new(UiKind::Menu)
217+
.with_tag_value(MenuConfig::MENU_CONFIG_TAG, config),
218+
),
219+
|ui| {
220+
if let Some(style) = style {
221+
style(ui.style_mut());
222+
}
223+
224+
// Take full width and fixed height:
225+
let height = ui.spacing().interact_size.y;
226+
ui.set_min_size(vec2(ui.available_width(), height));
227+
228+
content(ui)
229+
},
230+
)
231+
.inner
232+
})
228233
}
229234
}
230235

@@ -266,9 +271,11 @@ impl<'a> MenuButton<'a> {
266271
content: impl FnOnce(&mut Ui) -> R,
267272
) -> (Response, Option<InnerResponse<R>>) {
268273
let response = self.button.ui(ui);
269-
let config = self.config.unwrap_or_else(|| MenuConfig::find(ui));
274+
let mut config = self.config.unwrap_or_else(|| MenuConfig::find(ui));
275+
config.bar = false;
270276
let inner = Popup::menu(&response)
271277
.close_behavior(config.close_behavior)
278+
.style(config.style)
272279
.info(
273280
UiStackInfo::new(UiKind::Menu).with_tag_value(MenuConfig::MENU_CONFIG_TAG, config),
274281
)
@@ -362,12 +369,12 @@ impl SubMenu {
362369
pub fn show<R>(
363370
self,
364371
ui: &Ui,
365-
response: &Response,
372+
button_response: &Response,
366373
content: impl FnOnce(&mut Ui) -> R,
367374
) -> Option<InnerResponse<R>> {
368375
let frame = Frame::menu(ui.style());
369376

370-
let id = Self::id_from_widget_id(response.id);
377+
let id = Self::id_from_widget_id(button_response.id);
371378

372379
let (open_item, menu_id, parent_config) = MenuState::from_ui(ui, |state, stack| {
373380
(state.open_item, stack.id, MenuConfig::from_stack(stack))
@@ -394,11 +401,19 @@ impl SubMenu {
394401
let is_any_open = open_item.is_some();
395402
let mut is_open = open_item == Some(id);
396403
let mut set_open = None;
397-
let button_rect = response.rect.expand2(ui.style().spacing.item_spacing / 2.0);
404+
405+
// We expand the button rect so there is no empty space where no menu is shown
406+
// TODO(lucasmerlin): Instead, maybe make item_spacing.y 0.0?
407+
let button_rect = button_response
408+
.rect
409+
.expand2(ui.style().spacing.item_spacing / 2.0);
410+
411+
// In theory some other widget could cover the button and this check would still pass
412+
// But since we check if no other menu is open, nothing should be able to cover the button
398413
let is_hovered = hover_pos.is_some_and(|pos| button_rect.contains(pos));
399414

400415
// The clicked handler is there for accessibility (keyboard navigation)
401-
if (!is_any_open && is_hovered) || response.clicked() {
416+
if (!is_any_open && is_hovered) || button_response.clicked() {
402417
set_open = Some(true);
403418
is_open = true;
404419
// Ensure that all other sub menus are closed when we open the menu
@@ -409,7 +424,7 @@ impl SubMenu {
409424

410425
let gap = frame.total_margin().sum().x / 2.0 + 2.0;
411426

412-
let mut response = response.clone();
427+
let mut response = button_response.clone();
413428
// Expand the button rect so that the button and the first item in the submenu are aligned
414429
response.rect = response
415430
.rect
@@ -423,33 +438,53 @@ impl SubMenu {
423438
.gap(gap)
424439
.style(menu_config.style)
425440
.frame(frame)
426-
.close_behavior(match menu_config.close_behavior {
427-
// We ignore ClickOutside because it is handled by the menu (see below)
428-
PopupCloseBehavior::CloseOnClickOutside => PopupCloseBehavior::IgnoreClicks,
429-
behavior => behavior,
430-
})
441+
// The close behavior is handled by the menu (see below)
442+
.close_behavior(PopupCloseBehavior::IgnoreClicks)
431443
.info(
432444
UiStackInfo::new(UiKind::Menu)
433-
.with_tag_value(MenuConfig::MENU_CONFIG_TAG, menu_config),
445+
.with_tag_value(MenuConfig::MENU_CONFIG_TAG, menu_config.clone()),
434446
)
435-
.show(content);
447+
.show(|ui| {
448+
// Ensure our layer stays on top when the button is clicked
449+
if button_response.clicked() || button_response.is_pointer_button_down_on() {
450+
ui.ctx().move_to_top(ui.layer_id());
451+
}
452+
content(ui)
453+
});
436454

437455
if let Some(popup_response) = &popup_response {
438-
// The other close behaviors are handled by the popup
439-
if parent_config.close_behavior == PopupCloseBehavior::CloseOnClickOutside {
440-
let is_deepest_submenu = MenuState::is_deepest_sub_menu(ui.ctx(), id);
441-
// If no child sub menu is open means we must be the deepest child sub menu.
442-
// If the user clicks and the cursor is not hovering over our menu rect, it's
443-
// safe to assume they clicked outside the menu, so we close everything.
444-
// If they were to hover some other parent submenu we wouldn't be open.
445-
// Only edge case is the user hovering this submenu's button, so we also check
446-
// if we clicked outside the parent menu (which we luckily have access to here).
447-
let clicked_outside = is_deepest_submenu
448-
&& popup_response.response.clicked_elsewhere()
449-
&& menu_root_response.clicked_elsewhere();
450-
if clicked_outside {
451-
ui.close();
452-
}
456+
// If no child sub menu is open means we must be the deepest child sub menu.
457+
let is_deepest_submenu = MenuState::is_deepest_sub_menu(ui.ctx(), id);
458+
459+
// If the user clicks and the cursor is not hovering over our menu rect, it's
460+
// safe to assume they clicked outside the menu, so we close everything.
461+
// If they were to hover some other parent submenu we wouldn't be open.
462+
// Only edge case is the user hovering this submenu's button, so we also check
463+
// if we clicked outside the parent menu (which we luckily have access to here).
464+
let clicked_outside = is_deepest_submenu
465+
&& popup_response.response.clicked_elsewhere()
466+
&& menu_root_response.clicked_elsewhere();
467+
468+
// We never automatically close when a submenu button is clicked, (so menus work
469+
// on touch devices)
470+
// Luckily we will always be the deepest submenu when a submenu button is clicked,
471+
// so the following check is enough.
472+
let submenu_button_clicked = button_response.clicked();
473+
474+
let clicked_inside = is_deepest_submenu
475+
&& !submenu_button_clicked
476+
&& response.ctx.input(|i| i.pointer.any_click())
477+
&& hover_pos.is_some_and(|pos| popup_response.response.interact_rect.contains(pos));
478+
479+
let click_close = match menu_config.close_behavior {
480+
PopupCloseBehavior::CloseOnClick => clicked_outside || clicked_inside,
481+
PopupCloseBehavior::CloseOnClickOutside => clicked_outside,
482+
PopupCloseBehavior::IgnoreClicks => false,
483+
};
484+
485+
if click_close {
486+
set_open = Some(false);
487+
ui.close();
453488
}
454489

455490
let is_moving_towards_rect = ui.input(|i| {

crates/egui/src/containers/popup.rs

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -578,18 +578,15 @@ impl<'a> Popup<'a> {
578578
let closed_by_click = match close_behavior {
579579
PopupCloseBehavior::CloseOnClick => widget_clicked_elsewhere,
580580
PopupCloseBehavior::CloseOnClickOutside => {
581-
// If a submenu is open, the ClickOutside behavior is handled there
582-
let is_any_submenu_open =
583-
!MenuState::is_deepest_sub_menu(&response.response.ctx, id);
584-
585-
!is_any_submenu_open
586-
&& widget_clicked_elsewhere
587-
&& response.response.clicked_elsewhere()
581+
widget_clicked_elsewhere && response.response.clicked_elsewhere()
588582
}
589583
PopupCloseBehavior::IgnoreClicks => false,
590584
};
591585

592-
let should_close = closed_by_click
586+
// If a submenu is open, the CloseBehavior is handled there
587+
let is_any_submenu_open = !MenuState::is_deepest_sub_menu(&response.response.ctx, id);
588+
589+
let should_close = (!is_any_submenu_open && closed_by_click)
593590
|| ctx.input(|i| i.key_pressed(Key::Escape))
594591
|| response.response.should_close();
595592

crates/egui/src/context.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2696,7 +2696,8 @@ impl Context {
26962696
///
26972697
/// Can be used to implement pan and zoom (see relevant demo).
26982698
///
2699-
/// For a temporary transform, use [`Self::transform_layer_shapes`] instead.
2699+
/// For a temporary transform, use [`Self::transform_layer_shapes`] or
2700+
/// [`Ui::with_visual_transform`].
27002701
pub fn set_transform_layer(&self, layer_id: LayerId, transform: TSTransform) {
27012702
self.memory_mut(|m| {
27022703
if transform == TSTransform::IDENTITY {

crates/egui_demo_lib/src/demo/popups.rs

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use crate::rust_view_ui;
2+
use egui::containers::menu::{MenuConfig, SubMenuButton};
23
use egui::{
34
include_image, Align2, ComboBox, Frame, Id, Popup, PopupCloseBehavior, RectAlign, Tooltip, Ui,
45
};
@@ -13,6 +14,7 @@ pub struct PopupsDemo {
1314
#[cfg_attr(feature = "serde", serde(skip))]
1415
close_behavior: PopupCloseBehavior,
1516
popup_open: bool,
17+
checked: bool,
1618
}
1719

1820
impl PopupsDemo {
@@ -31,6 +33,7 @@ impl Default for PopupsDemo {
3133
gap: 4.0,
3234
close_behavior: PopupCloseBehavior::CloseOnClick,
3335
popup_open: false,
36+
checked: false,
3437
}
3538
}
3639
}
@@ -53,7 +56,7 @@ impl crate::Demo for PopupsDemo {
5356
}
5457
}
5558

56-
fn nested_menus(ui: &mut egui::Ui) {
59+
fn nested_menus(ui: &mut egui::Ui, checked: &mut bool) {
5760
ui.set_max_width(200.0); // To make sure we wrap long text
5861

5962
if ui.button("Open…").clicked() {
@@ -65,7 +68,7 @@ fn nested_menus(ui: &mut egui::Ui) {
6568
ui.close();
6669
}
6770
let _ = ui.button("Item");
68-
ui.menu_button("Recursive", nested_menus)
71+
ui.menu_button("Recursive", |ui| nested_menus(ui, checked));
6972
});
7073
ui.menu_button("SubMenu", |ui| {
7174
if ui.button("Open…").clicked() {
@@ -92,6 +95,14 @@ fn nested_menus(ui: &mut egui::Ui) {
9295
},
9396
);
9497
let _ = ui.button("Very long text for this item that should be wrapped");
98+
SubMenuButton::new("Always CloseOnClickOutside")
99+
.config(MenuConfig::new().close_behavior(PopupCloseBehavior::CloseOnClickOutside))
100+
.ui(ui, |ui| {
101+
ui.checkbox(checked, "Checkbox");
102+
if ui.button("Open…").clicked() {
103+
ui.close();
104+
}
105+
});
95106
}
96107

97108
impl crate::View for PopupsDemo {
@@ -105,10 +116,10 @@ impl crate::View for PopupsDemo {
105116
.inner;
106117

107118
self.apply_options(Popup::menu(&response).id(Id::new("menu")))
108-
.show(nested_menus);
119+
.show(|ui| nested_menus(ui, &mut self.checked));
109120

110121
self.apply_options(Popup::context_menu(&response).id(Id::new("context_menu")))
111-
.show(nested_menus);
122+
.show(|ui| nested_menus(ui, &mut self.checked));
112123

113124
if self.popup_open {
114125
self.apply_options(Popup::from_response(&response).id(Id::new("popup")))

crates/egui_kittest/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ document-features = { workspace = true, optional = true }
6363
[dev-dependencies]
6464
egui = { workspace = true, features = ["default_fonts"] }
6565
image = { workspace = true, features = ["png"] }
66+
egui_extras = { workspace = true, features = ["image"] }
6667

6768
[lints]
6869
workspace = true

0 commit comments

Comments
 (0)