1
0
mirror of https://github.com/emilk/egui.git synced 2026-06-26 14:49:06 -04:00

Popup-in-popup (draft)

This commit is contained in:
lucasmerlin
2026-01-09 12:01:37 +01:00
parent f8e763378a
commit 5d2ddbfb15
9 changed files with 201 additions and 78 deletions

View File

@@ -521,8 +521,8 @@ impl SubMenu {
// Only edge case is the user hovering this submenu's button, so we also check
// if we clicked outside the parent menu (which we luckily have access to here).
let clicked_outside = is_deepest_submenu
&& popup_response.response.clicked_elsewhere()
&& menu_root_response.clicked_elsewhere();
&& popup_response.response.clicked_elsewhere_excluding_child_layers()
&& menu_root_response.clicked_elsewhere_excluding_child_layers();
// We never automatically close when a submenu button is clicked, (so menus work
// on touch devices)

View File

@@ -513,19 +513,19 @@ impl<'a> Popup<'a> {
if open {
match self.anchor {
PopupAnchor::PointerFixed => {
self.ctx.memory_mut(|mem| mem.open_popup_at(id, hover_pos));
self.ctx.memory_mut(|mem| mem.open_popup_at(self.layer_id, id, hover_pos));
}
_ => Popup::open_id(&self.ctx, id),
_ => Popup::open_id(&self.ctx, self.layer_id, id),
}
} else {
Self::close_id(&self.ctx, id);
}
}
Some(SetOpenCommand::Toggle) => {
Self::toggle_id(&self.ctx, id);
Self::toggle_id(&self.ctx, self.layer_id, id);
}
None => {
self.ctx.memory_mut(|mem| mem.keep_popup_open(id));
self.ctx.memory_mut(|mem| mem.keep_popup_open(self.layer_id, id));
}
}
}
@@ -570,7 +570,7 @@ impl<'a> Popup<'a> {
let (pivot, anchor) = best_align.pivot_pos(&anchor_rect, gap);
let mut area = Area::new(id)
.order(kind.order())
.order(layer_id.order)
.pivot(pivot)
.fixed_pos(anchor)
.sense(sense)
@@ -589,6 +589,7 @@ impl<'a> Popup<'a> {
}
let mut response = area.show(&ctx, |ui| {
ui.set_sublayer(layer_id, ui.layer_id());
style.apply(ui.style_mut());
let frame = frame.unwrap_or_else(|| Frame::popup(ui.style()));
frame.show(ui, content).inner
@@ -600,7 +601,7 @@ impl<'a> Popup<'a> {
let closed_by_click = match close_behavior {
PopupCloseBehavior::CloseOnClick => close_click,
PopupCloseBehavior::CloseOnClickOutside => {
close_click && response.response.clicked_elsewhere()
close_click && response.response.clicked_elsewhere_excluding_child_layers()
}
PopupCloseBehavior::IgnoreClicks => false,
};
@@ -670,15 +671,15 @@ impl Popup<'_> {
/// If you are NOT using [`Popup::show`], you must
/// also call [`crate::Memory::keep_popup_open`] as long as
/// you're showing the popup.
pub fn open_id(ctx: &Context, popup_id: Id) {
ctx.memory_mut(|mem| mem.open_popup(popup_id));
pub fn open_id(ctx: &Context, layer_id: LayerId, popup_id: Id) {
ctx.memory_mut(|mem| mem.open_popup(layer_id, popup_id));
}
/// Toggle the given popup between closed and open.
///
/// Note: At most, only one popup can be open at a time.
pub fn toggle_id(ctx: &Context, popup_id: Id) {
ctx.memory_mut(|mem| mem.toggle_popup(popup_id));
pub fn toggle_id(ctx: &Context, layer_id: LayerId, popup_id: Id) {
ctx.memory_mut(|mem| mem.toggle_popup(layer_id, popup_id));
}
/// Close all currently open popups.

View File

@@ -6,8 +6,8 @@ use ahash::{HashMap, HashSet};
use epaint::emath::TSTransform;
use crate::{
EventFilter, Id, IdMap, LayerId, Order, Pos2, Rangef, RawInput, Rect, Style, Vec2, ViewportId,
ViewportIdMap, ViewportIdSet, area, vec2,
area, vec2, EventFilter, Id, IdMap, LayerId, Order, Pos2, Rangef, RawInput, Rect, Style,
Vec2, ViewportId, ViewportIdMap, ViewportIdSet,
};
mod theme;
@@ -115,7 +115,7 @@ pub struct Memory {
/// If position is [`None`], the popup position will be calculated based on some configuration
/// (e.g. relative to some other widget).
#[cfg_attr(feature = "persistence", serde(skip))]
popups: ViewportIdMap<OpenPopup>,
popups: ViewportIdMap<HashMap<LayerId, OpenPopup>>,
}
impl Default for Memory {
@@ -743,7 +743,7 @@ impl Memory {
// Cleanup
self.interactions.retain(|id, _| viewports.contains(id));
self.areas.retain(|id, _| viewports.contains(id));
self.popups.retain(|id, _| viewports.contains(id));
// self.popups.retain(|id, _| viewports.contains(id)); TODO
self.areas.entry(self.viewport_id).or_default();
@@ -763,12 +763,12 @@ impl Memory {
self.focus_mut().end_pass(used_ids);
// Clean up abandoned popups.
if let Some(popup) = self.popups.get_mut(&self.viewport_id) {
if popup.open_this_frame {
if let Some(popups) = self.popups.get_mut(&self.viewport_id) {
popups.retain(|_, popup| {
let open_this_frame = popup.open_this_frame;
popup.open_this_frame = false;
} else {
self.popups.remove(&self.viewport_id);
}
open_this_frame
})
}
}
@@ -1013,23 +1013,31 @@ impl Memory {
pub fn is_popup_open(&self, popup_id: Id) -> bool {
self.popups
.get(&self.viewport_id)
.is_some_and(|state| state.id == popup_id)
.is_some_and(|state| {
state.values().any(|popup| popup.id == popup_id)
})
|| self.everything_is_visible()
}
/// Is any popup open?
#[deprecated = "Use Popup::is_any_open instead"]
pub fn any_popup_open(&self) -> bool {
self.popups.contains_key(&self.viewport_id) || self.everything_is_visible()
self.popups
.get(&self.viewport_id)
.is_some_and(|state| !state.is_empty())
|| self.everything_is_visible()
}
/// Open the given popup and close all others.
///
/// Note that you must call `keep_popup_open` on subsequent frames as long as the popup is open.
#[deprecated = "Use Popup::open_id instead"]
pub fn open_popup(&mut self, popup_id: Id) {
pub fn open_popup(&mut self, layer: LayerId, popup_id: Id) {
// TODO: Close non-parent popups
self.popups
.insert(self.viewport_id, OpenPopup::new(popup_id, None));
.entry(self.viewport_id)
.or_default()
.insert(layer, OpenPopup::new(popup_id, None));
}
/// Popups must call this every frame while open.
@@ -1038,26 +1046,34 @@ impl Memory {
/// called. For example, when a context menu is open and the underlying widget stops
/// being rendered.
#[deprecated = "Use Popup::show instead"]
pub fn keep_popup_open(&mut self, popup_id: Id) {
if let Some(state) = self.popups.get_mut(&self.viewport_id)
&& state.id == popup_id
{
state.open_this_frame = true;
pub fn keep_popup_open(&mut self, layer_id: LayerId, popup_id: Id) {
if let Some(state) = self.popups.get_mut(&self.viewport_id) {
if let Some(popup) = state.get_mut(&layer_id) {
if popup.id == popup_id {
popup.open_this_frame = true;
}
}
}
}
/// Open the popup and remember its position.
#[deprecated = "Use Popup with PopupAnchor::Position instead"]
pub fn open_popup_at(&mut self, popup_id: Id, pos: impl Into<Option<Pos2>>) {
pub fn open_popup_at(&mut self, layer_id: LayerId, popup_id: Id, pos: impl Into<Option<Pos2>>) {
self.popups
.insert(self.viewport_id, OpenPopup::new(popup_id, pos.into()));
.entry(self.viewport_id)
.or_default()
.insert(layer_id, OpenPopup::new(popup_id, pos.into()));
}
/// Get the position for this popup.
#[deprecated = "Use Popup::position_of_id instead"]
pub fn popup_position(&self, id: Id) -> Option<Pos2> {
let state = self.popups.get(&self.viewport_id)?;
if state.id == id { state.pos } else { None }
self.popups.get(&self.viewport_id).and_then(|state| {
state
.values()
.find(|popup| popup.id == id)
.and_then(|popup| popup.pos)
})
}
/// Close any currently open popup.
@@ -1071,9 +1087,8 @@ impl Memory {
/// See also [`Self::close_all_popups`] if you want to close any / all currently open popups.
#[deprecated = "Use Popup::close_id instead"]
pub fn close_popup(&mut self, popup_id: Id) {
#[expect(deprecated)]
if self.is_popup_open(popup_id) {
self.popups.remove(&self.viewport_id);
if let Some(state) = self.popups.get_mut(&self.viewport_id) {
state.retain(|_, popup| popup.id != popup_id);
}
}
@@ -1081,12 +1096,11 @@ impl Memory {
///
/// Note: At most, only one popup can be open at a time.
#[deprecated = "Use Popup::toggle_id instead"]
pub fn toggle_popup(&mut self, popup_id: Id) {
#[expect(deprecated)]
pub fn toggle_popup(&mut self, layer_id: LayerId, popup_id: Id) {
if self.is_popup_open(popup_id) {
self.close_popup(popup_id);
} else {
self.open_popup(popup_id);
self.open_popup(layer_id, popup_id);
}
}
}
@@ -1294,6 +1308,17 @@ impl Areas {
self.sublayers.get(&layer_id).into_iter().flatten().copied()
}
pub fn is_child_recursive(&self, parent: LayerId, child: LayerId) -> bool {
if let Some(children) = self.sublayers.get(&parent) {
for &c in children {
if c == child || self.is_child_recursive(c, child) {
return true;
}
}
}
false
}
pub(crate) fn is_sublayer(&self, layer: &LayerId) -> bool {
self.parent_layer(*layer).is_some()
}

View File

@@ -1,10 +1,10 @@
use std::{any::Any, sync::Arc};
use crate::{
Context, CursorIcon, Id, LayerId, PointerButton, Popup, PopupKind, Sense, Tooltip, Ui,
WidgetRect, WidgetText,
emath::{Align, Pos2, Rect, Vec2},
pass_state,
emath::{Align, Pos2, Rect, Vec2}, pass_state, Context, CursorIcon, Id, LayerId, PointerButton, Popup, PopupKind, Sense,
Tooltip, Ui,
WidgetRect,
WidgetText,
};
// ----------------------------------------------------------------------------
@@ -259,6 +259,28 @@ impl Response {
}
}
pub fn clicked_elsewhere_excluding_child_layers(&self) -> bool {
let clicked_elsewhere = self.clicked_elsewhere();
if !clicked_elsewhere {
return false;
}
if let Some(pos) = self.ctx.input(|i| i.pointer.interact_pos()) {
let layer_under_pointer = self.ctx.layer_id_at(pos);
if let Some(layer_under_pointer) = layer_under_pointer {
let child_clicked = self.ctx.memory(|mem| {
mem.areas()
.is_child_recursive(self.layer_id, layer_under_pointer)
});
!child_clicked
} else {
clicked_elsewhere
}
} else {
clicked_elsewhere
}
}
/// Was the widget enabled?
/// If false, there was no interaction attempted
/// and the widget should be drawn in a gray disabled look.

View File

@@ -1,9 +1,9 @@
use crate::rust_view_ui;
use egui::color_picker::{Alpha, color_picker_color32};
use egui::color_picker::{color_picker_color32, Alpha};
use egui::containers::menu::{MenuConfig, SubMenuButton};
use egui::{
Align, Align2, Atom, Button, ComboBox, Frame, Id, Layout, Popup, PopupCloseBehavior, RectAlign,
RichText, Tooltip, Ui, UiBuilder, include_image,
include_image, Align, Align2, Atom, Button, ComboBox, Frame, Id, Layout, Popup, PopupCloseBehavior,
RectAlign, RichText, Tooltip, Ui, UiBuilder,
};
/// Showcase [`Popup`].
@@ -119,6 +119,13 @@ impl PopupsDemo {
if ui.button("Open…").clicked() {
ui.close();
}
ComboBox::new("Combobox in menu", "")
.selected_text(if self.checked { "Option 1" } else { "Option 2" })
.show_ui(ui, |ui| {
ui.selectable_value(&mut self.checked, true, "Option 1");
ui.selectable_value(&mut self.checked, false, "Option 2");
});
});
}
}

View File

@@ -17,6 +17,14 @@ pub struct Node<'tree> {
pub(crate) queue: &'tree EventQueue,
}
impl PartialEq for Node<'_> {
fn eq(&self, other: &Self) -> bool {
self.accesskit_node.id() == other.accesskit_node.id()
}
}
impl Eq for Node<'_> {}
impl Debug for Node<'_> {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
debug_fmt_node(self, f)

View File

@@ -1,31 +0,0 @@
use kittest::Queryable as _;
#[test]
fn test_interactive_tooltip() {
struct State {
link_clicked: bool,
}
let mut harness = egui_kittest::Harness::new_ui_state(
|ui, state| {
ui.label("I have a tooltip").on_hover_ui(|ui| {
if ui.link("link").clicked() {
state.link_clicked = true;
}
});
},
State {
link_clicked: false,
},
);
harness.get_by_label_contains("tooltip").hover();
harness.run();
harness.get_by_label("link").hover();
harness.run();
harness.get_by_label("link").click();
harness.run();
assert!(harness.state().link_clicked);
}

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e8876d1b19c1d8c0cac30edc022913ca88ba7afaedf677e9c5de07c3b45e3b8a
size 10595

View File

@@ -0,0 +1,88 @@
use egui::{ComboBox, Popup, PopupCloseBehavior, Vec2};
use egui_kittest::kittest::Queryable as _;
use egui_kittest::Harness;
#[test]
fn test_interactive_tooltip() {
struct State {
link_clicked: bool,
}
let mut harness = egui_kittest::Harness::new_ui_state(
|ui, state| {
ui.label("I have a tooltip").on_hover_ui(|ui| {
if ui.link("link").clicked() {
state.link_clicked = true;
}
});
},
State {
link_clicked: false,
},
);
harness.get_by_label_contains("tooltip").hover();
harness.run();
harness.get_by_label("link").hover();
harness.run();
harness.get_by_label("link").click();
harness.run();
assert!(harness.state().link_clicked);
}
#[test]
fn test_combobox_in_popup() {
let mut harness = Harness::builder()
.with_size(Vec2::new(200.0, 200.0))
.build_ui_state(
|ui, state| {
let response = ui.button("Open Popup");
Popup::menu(&response)
.close_behavior(PopupCloseBehavior::CloseOnClickOutside)
.show(|ui| {
ui.heading("Popup");
ComboBox::new("combo", "")
.selected_text("Select an option")
.close_behavior(PopupCloseBehavior::CloseOnClickOutside)
.show_ui(ui, |ui| {
ui.selectable_value(state, 0, "Option 0");
ui.selectable_value(state, 1, "Option 1");
});
});
},
0,
);
harness.get_by_label("Open Popup").click();
harness.run();
harness.get_by_value("Select an option").click();
harness.run();
harness.get_by_label("Option 1").click();
harness.run();
assert_eq!(*harness.state(), 1);
// The parent popup should not close when clicking on the child popup
harness.get_by_label("Option 0").click();
harness.run();
assert_eq!(*harness.state(), 0);
harness.snapshot("combobox_in_popup");
// Clicking the parent popup should close the child popup
harness.get_by_label("Popup").click();
harness.run();
assert_eq!(harness.query_by_label("Option 0"), None);
harness.get_by_value("Select an option").click();
harness.run();
assert_eq!(harness.query_by_label("Option 0").is_some(), true);
// Clicking outside should close both popups
harness.get_by_label("Open Popup").click();
harness.run();
assert_eq!(harness.query_by_label("Popup"), None);
}