⚠️ Improved menu based on `egui::Popup` (#5716)

Continuation of #5713

**Silently breaking changes:**
- Menus now close on click by default, this is configurable via
`PopupCloseBehavior`

**Additional additions:**
- `Button::right_text`
- `StyleModifier`

This is a rewrite of the egui menus, with the following goals:
- submenu buttons should work everywhere, in a popup, context menu,
area, in some random Ui
- remove the menu state from Ui
- should work just like the previous menu
- ~proper support for keyboard navigation~
  - It's better now but requires further work to be perfect
- support `PopupCloseBehavior`

* Closes #4607 
* [x] I have followed the instructions in the PR template
This commit is contained in:
lucasmerlin 2025-03-03 14:56:30 +01:00 committed by GitHub
parent 6e3575b4c7
commit cd22517280
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 1289 additions and 368 deletions

View File

@ -1393,6 +1393,7 @@ dependencies = [
"eframe", "eframe",
"egui", "egui",
"egui-wgpu", "egui-wgpu",
"egui_extras",
"image", "image",
"kittest", "kittest",
"pollster 0.4.0", "pollster 0.4.0",

View File

@ -103,10 +103,10 @@ impl AreaState {
/// ///
/// The previous rectangle used by this area can be obtained through [`crate::Memory::area_rect()`]. /// The previous rectangle used by this area can be obtained through [`crate::Memory::area_rect()`].
#[must_use = "You should call .show()"] #[must_use = "You should call .show()"]
#[derive(Clone, Copy, Debug)] #[derive(Clone, Debug)]
pub struct Area { pub struct Area {
pub(crate) id: Id, pub(crate) id: Id,
kind: UiKind, info: UiStackInfo,
sense: Option<Sense>, sense: Option<Sense>,
movable: bool, movable: bool,
interactable: bool, interactable: bool,
@ -132,7 +132,7 @@ impl Area {
pub fn new(id: Id) -> Self { pub fn new(id: Id) -> Self {
Self { Self {
id, id,
kind: UiKind::GenericArea, info: UiStackInfo::new(UiKind::GenericArea),
sense: None, sense: None,
movable: true, movable: true,
interactable: true, interactable: true,
@ -164,7 +164,16 @@ impl Area {
/// Default to [`UiKind::GenericArea`]. /// Default to [`UiKind::GenericArea`].
#[inline] #[inline]
pub fn kind(mut self, kind: UiKind) -> Self { pub fn kind(mut self, kind: UiKind) -> Self {
self.kind = kind; self.info = UiStackInfo::new(kind);
self
}
/// Set the [`UiStackInfo`] of the area's [`Ui`].
///
/// Default to [`UiStackInfo::new(UiKind::GenericArea)`].
#[inline]
pub fn info(mut self, info: UiStackInfo) -> Self {
self.info = info;
self self
} }
@ -351,7 +360,7 @@ impl Area {
} }
pub(crate) struct Prepared { pub(crate) struct Prepared {
kind: UiKind, info: Option<UiStackInfo>,
layer_id: LayerId, layer_id: LayerId,
state: AreaState, state: AreaState,
move_response: Response, move_response: Response,
@ -376,7 +385,7 @@ impl Area {
ctx: &Context, ctx: &Context,
add_contents: impl FnOnce(&mut Ui) -> R, add_contents: impl FnOnce(&mut Ui) -> R,
) -> InnerResponse<R> { ) -> InnerResponse<R> {
let prepared = self.begin(ctx); let mut prepared = self.begin(ctx);
let mut content_ui = prepared.content_ui(ctx); let mut content_ui = prepared.content_ui(ctx);
let inner = add_contents(&mut content_ui); let inner = add_contents(&mut content_ui);
let response = prepared.end(ctx, content_ui); let response = prepared.end(ctx, content_ui);
@ -386,7 +395,7 @@ impl Area {
pub(crate) fn begin(self, ctx: &Context) -> Prepared { pub(crate) fn begin(self, ctx: &Context) -> Prepared {
let Self { let Self {
id, id,
kind, info,
sense, sense,
movable, movable,
order, order,
@ -518,7 +527,7 @@ impl Area {
move_response.interact_rect = state.rect(); move_response.interact_rect = state.rect();
Prepared { Prepared {
kind, info: Some(info),
layer_id, layer_id,
state, state,
move_response, move_response,
@ -549,11 +558,11 @@ impl Prepared {
self.constrain_rect self.constrain_rect
} }
pub(crate) fn content_ui(&self, ctx: &Context) -> Ui { pub(crate) fn content_ui(&mut self, ctx: &Context) -> Ui {
let max_rect = self.state.rect(); let max_rect = self.state.rect();
let mut ui_builder = UiBuilder::new() let mut ui_builder = UiBuilder::new()
.ui_stack_info(UiStackInfo::new(self.kind)) .ui_stack_info(self.info.take().unwrap_or_default())
.layer_id(self.layer_id) .layer_id(self.layer_id)
.max_rect(max_rect) .max_rect(max_rect)
.layout(self.layout) .layout(self.layout)
@ -596,7 +605,7 @@ impl Prepared {
#[allow(clippy::needless_pass_by_value)] // intentional to swallow up `content_ui`. #[allow(clippy::needless_pass_by_value)] // intentional to swallow up `content_ui`.
pub(crate) fn end(self, ctx: &Context, content_ui: Ui) -> Response { pub(crate) fn end(self, ctx: &Context, content_ui: Ui) -> Response {
let Self { let Self {
kind: _, info: _,
layer_id, layer_id,
mut state, mut state,
move_response: mut response, move_response: mut response,

View File

@ -1,9 +1,9 @@
use epaint::Shape; use epaint::Shape;
use crate::{ use crate::{
epaint, style::WidgetVisuals, vec2, Align2, Context, Id, InnerResponse, NumExt, Painter, Popup, epaint, style::StyleModifier, style::WidgetVisuals, vec2, Align2, Context, Id, InnerResponse,
PopupCloseBehavior, Rect, Response, ScrollArea, Sense, Stroke, TextStyle, TextWrapMode, Ui, NumExt, Painter, Popup, PopupCloseBehavior, Rect, Response, ScrollArea, Sense, Stroke,
UiBuilder, Vec2, WidgetInfo, WidgetText, WidgetType, TextStyle, TextWrapMode, Ui, UiBuilder, Vec2, WidgetInfo, WidgetText, WidgetType,
}; };
#[allow(unused_imports)] // Documentation #[allow(unused_imports)] // Documentation
@ -374,6 +374,7 @@ fn combo_box_dyn<'c, R>(
let inner = Popup::menu(&button_response) let inner = Popup::menu(&button_response)
.id(popup_id) .id(popup_id)
.style(StyleModifier::default())
.width(button_response.rect.width()) .width(button_response.rect.width())
.close_behavior(close_behavior) .close_behavior(close_behavior)
.show(|ui| { .show(|ui| {

View File

@ -0,0 +1,528 @@
use crate::style::StyleModifier;
use crate::{
Button, Color32, Context, Frame, Id, InnerResponse, Layout, Popup, PopupCloseBehavior,
Response, Style, Ui, UiBuilder, UiKind, UiStack, UiStackInfo, Widget, WidgetText,
};
use emath::{vec2, Align, RectAlign, Vec2};
use epaint::Stroke;
/// Apply a menu style to the [`Style`].
///
/// Mainly removes the background stroke and the inactive background fill.
pub fn menu_style(style: &mut Style) {
style.spacing.button_padding = vec2(2.0, 0.0);
style.visuals.widgets.active.bg_stroke = Stroke::NONE;
style.visuals.widgets.open.bg_stroke = Stroke::NONE;
style.visuals.widgets.hovered.bg_stroke = Stroke::NONE;
style.visuals.widgets.inactive.weak_bg_fill = Color32::TRANSPARENT;
style.visuals.widgets.inactive.bg_stroke = Stroke::NONE;
}
/// Find the root [`UiStack`] of the menu.
pub fn find_menu_root(ui: &Ui) -> &UiStack {
ui.stack()
.iter()
.find(|stack| {
stack.is_root_ui()
|| [Some(UiKind::Popup), Some(UiKind::Menu)].contains(&stack.kind())
|| stack.info.tags.contains(MenuConfig::MENU_CONFIG_TAG)
})
.expect("We should always find the root")
}
/// Is this Ui part of a menu?
///
/// Returns `false` if this is a menu bar.
/// Should be used to determine if we should show a menu button or submenu button.
pub fn is_in_menu(ui: &Ui) -> bool {
for stack in ui.stack().iter() {
if let Some(config) = stack
.info
.tags
.get_downcast::<MenuConfig>(MenuConfig::MENU_CONFIG_TAG)
{
return !config.bar;
}
if [Some(UiKind::Popup), Some(UiKind::Menu)].contains(&stack.kind()) {
return true;
}
}
false
}
#[derive(Clone, Debug)]
pub struct MenuConfig {
/// Is this a menu bar?
bar: bool,
/// If the user clicks, should we close the menu?
pub close_behavior: PopupCloseBehavior,
/// Override the menu style.
///
/// Default is [`menu_style`].
pub style: StyleModifier,
}
impl Default for MenuConfig {
fn default() -> Self {
Self {
close_behavior: PopupCloseBehavior::default(),
bar: false,
style: menu_style.into(),
}
}
}
impl MenuConfig {
/// The tag used to store the menu config in the [`UiStack`].
pub const MENU_CONFIG_TAG: &'static str = "egui_menu_config";
pub fn new() -> Self {
Self::default()
}
/// If the user clicks, should we close the menu?
#[inline]
pub fn close_behavior(mut self, close_behavior: PopupCloseBehavior) -> Self {
self.close_behavior = close_behavior;
self
}
/// Override the menu style.
///
/// Default is [`menu_style`].
#[inline]
pub fn style(mut self, style: impl Into<StyleModifier>) -> Self {
self.style = style.into();
self
}
fn from_stack(stack: &UiStack) -> Self {
stack
.info
.tags
.get_downcast(Self::MENU_CONFIG_TAG)
.cloned()
.unwrap_or_default()
}
/// Find the config for the current menu.
///
/// Returns the default config if no config is found.
pub fn find(ui: &Ui) -> Self {
find_menu_root(ui)
.info
.tags
.get_downcast(Self::MENU_CONFIG_TAG)
.cloned()
.unwrap_or_default()
}
}
#[derive(Clone)]
pub struct MenuState {
pub open_item: Option<Id>,
last_visible_pass: u64,
}
impl MenuState {
pub const ID: &'static str = "menu_state";
/// Find the root of the menu and get the state
pub fn from_ui<R>(ui: &Ui, f: impl FnOnce(&mut Self, &UiStack) -> R) -> R {
let stack = find_menu_root(ui);
Self::from_id(ui.ctx(), stack.id, |state| f(state, stack))
}
/// Get the state via the menus root [`Ui`] id
pub fn from_id<R>(ctx: &Context, id: Id, f: impl FnOnce(&mut Self) -> R) -> R {
let pass_nr = ctx.cumulative_pass_nr();
ctx.data_mut(|data| {
let state = data.get_temp_mut_or_insert_with(id.with(Self::ID), || Self {
open_item: None,
last_visible_pass: pass_nr,
});
// If the menu was closed for at least a frame, reset the open item
if state.last_visible_pass + 1 < pass_nr {
state.open_item = None;
}
state.last_visible_pass = pass_nr;
f(state)
})
}
/// Is the menu with this id the deepest sub menu? (-> no child sub menu is open)
pub fn is_deepest_sub_menu(ctx: &Context, id: Id) -> bool {
Self::from_id(ctx, id, |state| state.open_item.is_none())
}
}
/// Horizontal menu bar where you can add [`MenuButton`]s.
/// The menu bar goes well in a [`crate::TopBottomPanel::top`],
/// but can also be placed in a [`crate::Window`].
/// In the latter case you may want to wrap it in [`Frame`].
#[derive(Clone, Debug)]
pub struct Bar {
config: MenuConfig,
style: StyleModifier,
}
impl Default for Bar {
fn default() -> Self {
Self {
config: MenuConfig::default(),
style: menu_style.into(),
}
}
}
impl Bar {
pub fn new() -> Self {
Self::default()
}
/// Set the style for buttons in the menu bar.
///
/// Doesn't affect the style of submenus, use [`MenuConfig::style`] for that.
/// Default is [`menu_style`].
#[inline]
pub fn style(mut self, style: impl Into<StyleModifier>) -> Self {
self.style = style.into();
self
}
/// Set the config for submenus.
///
/// Note: The config will only be passed when using [`MenuButton`], not via [`Popup::menu`].
#[inline]
pub fn config(mut self, config: MenuConfig) -> Self {
self.config = config;
self
}
/// Show the menu bar.
#[inline]
pub fn ui<R>(self, ui: &mut Ui, content: impl FnOnce(&mut Ui) -> R) -> InnerResponse<R> {
let Self { mut config, style } = self;
config.bar = true;
// TODO(lucasmerlin): It'd be nice if we had a ui.horizontal_builder or something
// So we don't need the nested scope here
ui.horizontal(|ui| {
ui.scope_builder(
UiBuilder::new()
.layout(Layout::left_to_right(Align::Center))
.ui_stack_info(
UiStackInfo::new(UiKind::Menu)
.with_tag_value(MenuConfig::MENU_CONFIG_TAG, config),
),
|ui| {
style.apply(ui.style_mut());
// Take full width and fixed height:
let height = ui.spacing().interact_size.y;
ui.set_min_size(vec2(ui.available_width(), height));
content(ui)
},
)
.inner
})
}
}
/// A thin wrapper around a [`Button`] that shows a [`Popup::menu`] when clicked.
///
/// The only thing this does is search for the current menu config (if set via [`Bar`]).
/// If your menu button is not in a [`Bar`] it's fine to use [`Ui::button`] and [`Popup::menu`]
/// directly.
pub struct MenuButton<'a> {
pub button: Button<'a>,
pub config: Option<MenuConfig>,
}
impl<'a> MenuButton<'a> {
pub fn new(text: impl Into<WidgetText>) -> Self {
Self::from_button(Button::new(text))
}
/// Set the config for the menu.
#[inline]
pub fn config(mut self, config: MenuConfig) -> Self {
self.config = Some(config);
self
}
/// Create a new menu button from a [`Button`].
#[inline]
pub fn from_button(button: Button<'a>) -> Self {
Self {
button,
config: None,
}
}
/// Show the menu button.
pub fn ui<R>(
self,
ui: &mut Ui,
content: impl FnOnce(&mut Ui) -> R,
) -> (Response, Option<InnerResponse<R>>) {
let response = self.button.ui(ui);
let mut config = self.config.unwrap_or_else(|| MenuConfig::find(ui));
config.bar = false;
let inner = Popup::menu(&response)
.close_behavior(config.close_behavior)
.style(config.style.clone())
.info(
UiStackInfo::new(UiKind::Menu).with_tag_value(MenuConfig::MENU_CONFIG_TAG, config),
)
.show(content);
(response, inner)
}
}
/// A submenu button that shows a [`SubMenu`] if a [`Button`] is hovered.
pub struct SubMenuButton<'a> {
pub button: Button<'a>,
pub sub_menu: SubMenu,
}
impl<'a> SubMenuButton<'a> {
/// The default right arrow symbol: `"⏵"`
pub const RIGHT_ARROW: &'static str = "";
pub fn new(text: impl Into<WidgetText>) -> Self {
Self::from_button(Button::new(text).right_text(""))
}
/// Create a new submenu button from a [`Button`].
///
/// Use [`Button::right_text`] and [`SubMenuButton::RIGHT_ARROW`] to add the default right
/// arrow symbol.
pub fn from_button(button: Button<'a>) -> Self {
Self {
button,
sub_menu: SubMenu::default(),
}
}
/// Set the config for the submenu.
///
/// The close behavior will not affect the current button, but the buttons in the submenu.
#[inline]
pub fn config(mut self, config: MenuConfig) -> Self {
self.sub_menu.config = Some(config);
self
}
/// Show the submenu button.
pub fn ui<R>(
self,
ui: &mut Ui,
content: impl FnOnce(&mut Ui) -> R,
) -> (Response, Option<InnerResponse<R>>) {
let my_id = ui.next_auto_id();
let open = MenuState::from_ui(ui, |state, _| {
state.open_item == Some(SubMenu::id_from_widget_id(my_id))
});
let inactive = ui.style().visuals.widgets.inactive;
// TODO(lucasmerlin) add `open` function to `Button`
if open {
ui.style_mut().visuals.widgets.inactive = ui.style().visuals.widgets.open;
}
let response = self.button.ui(ui);
ui.style_mut().visuals.widgets.inactive = inactive;
let popup_response = self.sub_menu.show(ui, &response, content);
(response, popup_response)
}
}
#[derive(Clone, Debug, Default)]
pub struct SubMenu {
config: Option<MenuConfig>,
}
impl SubMenu {
pub fn new() -> Self {
Self::default()
}
/// Set the config for the submenu.
///
/// The close behavior will not affect the current button, but the buttons in the submenu.
#[inline]
pub fn config(mut self, config: MenuConfig) -> Self {
self.config = Some(config);
self
}
/// Get the id for the submenu from the widget/response id.
pub fn id_from_widget_id(widget_id: Id) -> Id {
widget_id.with("submenu")
}
/// Show the submenu.
pub fn show<R>(
self,
ui: &Ui,
button_response: &Response,
content: impl FnOnce(&mut Ui) -> R,
) -> Option<InnerResponse<R>> {
let frame = Frame::menu(ui.style());
let id = Self::id_from_widget_id(button_response.id);
let (open_item, menu_id, parent_config) = MenuState::from_ui(ui, |state, stack| {
(state.open_item, stack.id, MenuConfig::from_stack(stack))
});
let mut menu_config = self.config.unwrap_or_else(|| parent_config.clone());
menu_config.bar = false;
let menu_root_response = ui
.ctx()
.read_response(menu_id)
// Since we are a child of that ui, this should always exist
.unwrap();
let hover_pos = ui.ctx().pointer_hover_pos();
// We don't care if the user is hovering over the border
let menu_rect = menu_root_response.rect - frame.total_margin();
let is_hovering_menu = hover_pos.is_some_and(|pos| {
ui.ctx().layer_id_at(pos) == Some(menu_root_response.layer_id)
&& menu_rect.contains(pos)
});
let is_any_open = open_item.is_some();
let mut is_open = open_item == Some(id);
let mut set_open = None;
// We expand the button rect so there is no empty space where no menu is shown
// TODO(lucasmerlin): Instead, maybe make item_spacing.y 0.0?
let button_rect = button_response
.rect
.expand2(ui.style().spacing.item_spacing / 2.0);
// In theory some other widget could cover the button and this check would still pass
// But since we check if no other menu is open, nothing should be able to cover the button
let is_hovered = hover_pos.is_some_and(|pos| button_rect.contains(pos));
// The clicked handler is there for accessibility (keyboard navigation)
if (!is_any_open && is_hovered) || button_response.clicked() {
set_open = Some(true);
is_open = true;
// Ensure that all other sub menus are closed when we open the menu
MenuState::from_id(ui.ctx(), id, |state| {
state.open_item = None;
});
}
let gap = frame.total_margin().sum().x / 2.0 + 2.0;
let mut response = button_response.clone();
// Expand the button rect so that the button and the first item in the submenu are aligned
let expand = Vec2::new(0.0, frame.total_margin().sum().y / 2.0);
response.interact_rect = response.interact_rect.expand2(expand);
let popup_response = Popup::from_response(&response)
.id(id)
.open(is_open)
.align(RectAlign::RIGHT_START)
.layout(Layout::top_down_justified(Align::Min))
.gap(gap)
.style(menu_config.style.clone())
.frame(frame)
// The close behavior is handled by the menu (see below)
.close_behavior(PopupCloseBehavior::IgnoreClicks)
.info(
UiStackInfo::new(UiKind::Menu)
.with_tag_value(MenuConfig::MENU_CONFIG_TAG, menu_config.clone()),
)
.show(|ui| {
// Ensure our layer stays on top when the button is clicked
if button_response.clicked() || button_response.is_pointer_button_down_on() {
ui.ctx().move_to_top(ui.layer_id());
}
content(ui)
});
if let Some(popup_response) = &popup_response {
// If no child sub menu is open means we must be the deepest child sub menu.
let is_deepest_submenu = MenuState::is_deepest_sub_menu(ui.ctx(), id);
// If the user clicks and the cursor is not hovering over our menu rect, it's
// safe to assume they clicked outside the menu, so we close everything.
// If they were to hover some other parent submenu we wouldn't be open.
// 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();
// We never automatically close when a submenu button is clicked, (so menus work
// on touch devices)
// Luckily we will always be the deepest submenu when a submenu button is clicked,
// so the following check is enough.
let submenu_button_clicked = button_response.clicked();
let clicked_inside = is_deepest_submenu
&& !submenu_button_clicked
&& response.ctx.input(|i| i.pointer.any_click())
&& hover_pos.is_some_and(|pos| popup_response.response.interact_rect.contains(pos));
let click_close = match menu_config.close_behavior {
PopupCloseBehavior::CloseOnClick => clicked_outside || clicked_inside,
PopupCloseBehavior::CloseOnClickOutside => clicked_outside,
PopupCloseBehavior::IgnoreClicks => false,
};
if click_close {
set_open = Some(false);
ui.close();
}
let is_moving_towards_rect = ui.input(|i| {
i.pointer
.is_moving_towards_rect(&popup_response.response.rect)
});
if is_moving_towards_rect {
// We need to repaint while this is true, so we can detect when
// the pointer is no longer moving towards the rect
ui.ctx().request_repaint();
}
let hovering_other_menu_entry = is_open
&& !is_hovered
&& !popup_response.response.contains_pointer()
&& !is_moving_towards_rect
&& is_hovering_menu;
let close_called = popup_response.response.should_close();
// Close the parent ui to e.g. close the popup from where the submenu was opened
if close_called {
ui.close();
}
if hovering_other_menu_entry {
set_open = Some(false);
}
if ui.will_parent_close() {
ui.data_mut(|data| data.remove_by_type::<MenuState>());
}
}
if let Some(set_open) = set_open {
MenuState::from_id(ui.ctx(), menu_id, |state| {
state.open_item = set_open.then_some(id);
});
}
popup_response
}
}

View File

@ -7,6 +7,7 @@ pub mod close_tag;
pub mod collapsing_header; pub mod collapsing_header;
mod combo_box; mod combo_box;
pub mod frame; pub mod frame;
pub mod menu;
pub mod modal; pub mod modal;
pub mod old_popup; pub mod old_popup;
pub mod panel; pub mod panel;

View File

@ -196,7 +196,8 @@ pub fn popup_above_or_below_widget<R>(
) -> Option<R> { ) -> Option<R> {
let response = Popup::from_response(widget_response) let response = Popup::from_response(widget_response)
.layout(Layout::top_down_justified(Align::LEFT)) .layout(Layout::top_down_justified(Align::LEFT))
.open_memory(None, close_behavior) .open_memory(None)
.close_behavior(close_behavior)
.id(popup_id) .id(popup_id)
.align(match above_or_below { .align(match above_or_below {
AboveOrBelow::Above => RectAlign::TOP_START, AboveOrBelow::Above => RectAlign::TOP_START,

View File

@ -1,6 +1,8 @@
use crate::containers::menu::{menu_style, MenuConfig, MenuState};
use crate::style::StyleModifier;
use crate::{ use crate::{
Area, AreaState, Context, Frame, Id, InnerResponse, Key, LayerId, Layout, Order, Response, Area, AreaState, Context, Frame, Id, InnerResponse, Key, LayerId, Layout, Order, Response,
Sense, Ui, UiKind, Sense, Ui, UiKind, UiStackInfo,
}; };
use emath::{vec2, Align, Pos2, Rect, RectAlign, Vec2}; use emath::{vec2, Align, Pos2, Rect, RectAlign, Vec2};
use std::iter::once; use std::iter::once;
@ -44,7 +46,8 @@ impl From<Pos2> for PopupAnchor {
impl From<&Response> for PopupAnchor { impl From<&Response> for PopupAnchor {
fn from(response: &Response) -> Self { fn from(response: &Response) -> Self {
let mut widget_rect = response.rect; // We use interact_rect so we don't show the popup relative to some clipped point
let mut widget_rect = response.interact_rect;
if let Some(to_global) = response.ctx.layer_transform_to_global(response.layer_id) { if let Some(to_global) = response.ctx.layer_transform_to_global(response.layer_id) {
widget_rect = to_global * widget_rect; widget_rect = to_global * widget_rect;
} }
@ -69,11 +72,12 @@ impl PopupAnchor {
} }
/// Determines popup's close behavior /// Determines popup's close behavior
#[derive(Clone, Copy, PartialEq, Eq)] #[derive(Clone, Copy, PartialEq, Eq, Default, Debug)]
pub enum PopupCloseBehavior { pub enum PopupCloseBehavior {
/// Popup will be closed on click anywhere, inside or outside the popup. /// Popup will be closed on click anywhere, inside or outside the popup.
/// ///
/// It is used in [`crate::ComboBox`]. /// It is used in [`crate::ComboBox`] and in [`crate::containers::menu`]s.
#[default]
CloseOnClick, CloseOnClick,
/// Popup will be closed if the click happened somewhere else /// Popup will be closed if the click happened somewhere else
@ -109,13 +113,10 @@ enum OpenKind<'a> {
Closed, Closed,
/// Open if the bool is true /// Open if the bool is true
Bool(&'a mut bool, PopupCloseBehavior), Bool(&'a mut bool),
/// Store the open state via [`crate::Memory`] /// Store the open state via [`crate::Memory`]
Memory { Memory { set: Option<SetOpenCommand> },
set: Option<SetOpenCommand>,
close_behavior: PopupCloseBehavior,
},
} }
impl OpenKind<'_> { impl OpenKind<'_> {
@ -124,7 +125,7 @@ impl OpenKind<'_> {
match self { match self {
OpenKind::Open => true, OpenKind::Open => true,
OpenKind::Closed => false, OpenKind::Closed => false,
OpenKind::Bool(open, _) => **open, OpenKind::Bool(open) => **open,
OpenKind::Memory { .. } => ctx.memory(|mem| mem.is_popup_open(id)), OpenKind::Memory { .. } => ctx.memory(|mem| mem.is_popup_open(id)),
} }
} }
@ -138,6 +139,26 @@ pub enum PopupKind {
Menu, Menu,
} }
impl PopupKind {
/// Returns the order to be used with this kind.
pub fn order(self) -> Order {
match self {
Self::Tooltip => Order::Tooltip,
Self::Menu | Self::Popup => Order::Foreground,
}
}
}
impl From<PopupKind> for UiKind {
fn from(kind: PopupKind) -> Self {
match kind {
PopupKind::Popup => Self::Popup,
PopupKind::Tooltip => Self::Tooltip,
PopupKind::Menu => Self::Menu,
}
}
}
pub struct Popup<'a> { pub struct Popup<'a> {
id: Id, id: Id,
ctx: Context, ctx: Context,
@ -146,6 +167,8 @@ pub struct Popup<'a> {
alternative_aligns: Option<&'a [RectAlign]>, alternative_aligns: Option<&'a [RectAlign]>,
layer_id: LayerId, layer_id: LayerId,
open_kind: OpenKind<'a>, open_kind: OpenKind<'a>,
close_behavior: PopupCloseBehavior,
info: Option<UiStackInfo>,
kind: PopupKind, kind: PopupKind,
/// Gap between the anchor and the popup /// Gap between the anchor and the popup
@ -159,6 +182,7 @@ pub struct Popup<'a> {
sense: Sense, sense: Sense,
layout: Layout, layout: Layout,
frame: Option<Frame>, frame: Option<Frame>,
style: StyleModifier,
} }
impl<'a> Popup<'a> { impl<'a> Popup<'a> {
@ -169,6 +193,8 @@ impl<'a> Popup<'a> {
ctx, ctx,
anchor: anchor.into(), anchor: anchor.into(),
open_kind: OpenKind::Open, open_kind: OpenKind::Open,
close_behavior: PopupCloseBehavior::default(),
info: None,
kind: PopupKind::Popup, kind: PopupKind::Popup,
layer_id, layer_id,
rect_align: RectAlign::BOTTOM_START, rect_align: RectAlign::BOTTOM_START,
@ -179,6 +205,7 @@ impl<'a> Popup<'a> {
sense: Sense::click(), sense: Sense::click(),
layout: Layout::default(), layout: Layout::default(),
frame: None, frame: None,
style: StyleModifier::default(),
} }
} }
@ -189,6 +216,13 @@ impl<'a> Popup<'a> {
self self
} }
/// Set the [`UiStackInfo`] of the popup's [`Ui`].
#[inline]
pub fn info(mut self, info: UiStackInfo) -> Self {
self.info = Some(info);
self
}
/// Set the [`RectAlign`] of the popup relative to the [`PopupAnchor`]. /// Set the [`RectAlign`] of the popup relative to the [`PopupAnchor`].
/// This is the default position, and will be used if it fits. /// This is the default position, and will be used if it fits.
/// See [`Self::align_alternatives`] for more on this. /// See [`Self::align_alternatives`] for more on this.
@ -226,29 +260,23 @@ impl<'a> Popup<'a> {
/// Sets the layout to `Layout::top_down_justified(Align::Min)`. /// Sets the layout to `Layout::top_down_justified(Align::Min)`.
pub fn menu(response: &Response) -> Self { pub fn menu(response: &Response) -> Self {
Self::from_response(response) Self::from_response(response)
.open_memory( .open_memory(response.clicked().then_some(SetOpenCommand::Toggle))
if response.clicked() { .kind(PopupKind::Menu)
Some(SetOpenCommand::Toggle)
} else {
None
},
PopupCloseBehavior::CloseOnClick,
)
.layout(Layout::top_down_justified(Align::Min)) .layout(Layout::top_down_justified(Align::Min))
.style(menu_style)
.gap(0.0)
} }
/// Show a context menu when the widget was secondary clicked. /// Show a context menu when the widget was secondary clicked.
/// Sets the layout to `Layout::top_down_justified(Align::Min)`. /// Sets the layout to `Layout::top_down_justified(Align::Min)`.
/// In contrast to [`Self::menu`], this will open at the pointer position. /// In contrast to [`Self::menu`], this will open at the pointer position.
pub fn context_menu(response: &Response) -> Self { pub fn context_menu(response: &Response) -> Self {
Self::from_response(response) Self::menu(response)
.open_memory( .open_memory(
response response
.secondary_clicked() .secondary_clicked()
.then_some(SetOpenCommand::Bool(true)), .then_some(SetOpenCommand::Bool(true)),
PopupCloseBehavior::CloseOnClick,
) )
.layout(Layout::top_down_justified(Align::Min))
.at_pointer_fixed() .at_pointer_fixed()
} }
@ -266,22 +294,17 @@ impl<'a> Popup<'a> {
/// Store the open state via [`crate::Memory`]. /// Store the open state via [`crate::Memory`].
/// You can set the state via the first [`SetOpenCommand`] param. /// You can set the state via the first [`SetOpenCommand`] param.
#[inline] #[inline]
pub fn open_memory( pub fn open_memory(mut self, set_state: impl Into<Option<SetOpenCommand>>) -> Self {
mut self,
set_state: impl Into<Option<SetOpenCommand>>,
close_behavior: PopupCloseBehavior,
) -> Self {
self.open_kind = OpenKind::Memory { self.open_kind = OpenKind::Memory {
set: set_state.into(), set: set_state.into(),
close_behavior,
}; };
self self
} }
/// Store the open state via a mutable bool. /// Store the open state via a mutable bool.
#[inline] #[inline]
pub fn open_bool(mut self, open: &'a mut bool, close_behavior: PopupCloseBehavior) -> Self { pub fn open_bool(mut self, open: &'a mut bool) -> Self {
self.open_kind = OpenKind::Bool(open, close_behavior); self.open_kind = OpenKind::Bool(open);
self self
} }
@ -290,16 +313,7 @@ impl<'a> Popup<'a> {
/// This will do nothing if [`Popup::open`] was called. /// This will do nothing if [`Popup::open`] was called.
#[inline] #[inline]
pub fn close_behavior(mut self, close_behavior: PopupCloseBehavior) -> Self { pub fn close_behavior(mut self, close_behavior: PopupCloseBehavior) -> Self {
match &mut self.open_kind { self.close_behavior = close_behavior;
OpenKind::Memory {
close_behavior: behavior,
..
}
| OpenKind::Bool(_, behavior) => {
*behavior = close_behavior;
}
_ => {}
}
self self
} }
@ -339,6 +353,13 @@ impl<'a> Popup<'a> {
self self
} }
/// Set the frame of the popup.
#[inline]
pub fn frame(mut self, frame: Frame) -> Self {
self.frame = Some(frame);
self
}
/// Set the sense of the popup. /// Set the sense of the popup.
#[inline] #[inline]
pub fn sense(mut self, sense: Sense) -> Self { pub fn sense(mut self, sense: Sense) -> Self {
@ -367,6 +388,17 @@ impl<'a> Popup<'a> {
self self
} }
/// Set the style for the popup contents.
///
/// Default:
/// - is [`menu_style`] for [`Self::menu`] and [`Self::context_menu`]
/// - is [`None`] otherwise
#[inline]
pub fn style(mut self, style: impl Into<StyleModifier>) -> Self {
self.style = style.into();
self
}
/// Get the [`Context`] /// Get the [`Context`]
pub fn ctx(&self) -> &Context { pub fn ctx(&self) -> &Context {
&self.ctx &self.ctx
@ -408,11 +440,12 @@ impl<'a> Popup<'a> {
match &self.open_kind { match &self.open_kind {
OpenKind::Open => true, OpenKind::Open => true,
OpenKind::Closed => false, OpenKind::Closed => false,
OpenKind::Bool(open, _) => **open, OpenKind::Bool(open) => **open,
OpenKind::Memory { .. } => self.ctx.memory(|mem| mem.is_popup_open(self.id)), OpenKind::Memory { .. } => self.ctx.memory(|mem| mem.is_popup_open(self.id)),
} }
} }
/// Get the expected size of the popup.
pub fn get_expected_size(&self) -> Option<Vec2> { pub fn get_expected_size(&self) -> Option<Vec2> {
AreaState::load(&self.ctx, self.id).and_then(|area| area.size) AreaState::load(&self.ctx, self.id).and_then(|area| area.size)
} }
@ -459,7 +492,9 @@ impl<'a> Popup<'a> {
ctx, ctx,
anchor, anchor,
open_kind, open_kind,
close_behavior,
kind, kind,
info,
layer_id, layer_id,
rect_align: _, rect_align: _,
alternative_aligns: _, alternative_aligns: _,
@ -469,6 +504,7 @@ impl<'a> Popup<'a> {
sense, sense,
layout, layout,
frame, frame,
style,
} = self; } = self;
let hover_pos = ctx.pointer_hover_pos(); let hover_pos = ctx.pointer_hover_pos();
@ -497,13 +533,7 @@ impl<'a> Popup<'a> {
return None; return None;
} }
let (ui_kind, order) = match kind { if kind != PopupKind::Tooltip {
PopupKind::Popup => (UiKind::Popup, Order::Foreground),
PopupKind::Tooltip => (UiKind::Tooltip, Order::Tooltip),
PopupKind::Menu => (UiKind::Menu, Order::Foreground),
};
if kind == PopupKind::Popup {
ctx.pass_state_mut(|fs| { ctx.pass_state_mut(|fs| {
fs.layers fs.layers
.entry(layer_id) .entry(layer_id)
@ -518,12 +548,17 @@ impl<'a> Popup<'a> {
let (pivot, anchor) = best_align.pivot_pos(&anchor_rect, gap); let (pivot, anchor) = best_align.pivot_pos(&anchor_rect, gap);
let mut area = Area::new(id) let mut area = Area::new(id)
.order(order) .order(kind.order())
.kind(ui_kind)
.pivot(pivot) .pivot(pivot)
.fixed_pos(anchor) .fixed_pos(anchor)
.sense(sense) .sense(sense)
.layout(layout); .layout(layout)
.info(info.unwrap_or_else(|| {
UiStackInfo::new(kind.into()).with_tag_value(
MenuConfig::MENU_CONFIG_TAG,
MenuConfig::new().close_behavior(close_behavior),
)
}));
if let Some(width) = width { if let Some(width) = width {
area = area.default_width(width); area = area.default_width(width);
@ -531,31 +566,39 @@ impl<'a> Popup<'a> {
let frame = frame.unwrap_or_else(|| Frame::popup(&ctx.style())); let frame = frame.unwrap_or_else(|| Frame::popup(&ctx.style()));
let response = area.show(&ctx, |ui| frame.show(ui, content).inner); let mut response = area.show(&ctx, |ui| {
style.apply(ui.style_mut());
frame.show(ui, content).inner
});
let should_close = |close_behavior| { let closed_by_click = match close_behavior {
let should_close = match close_behavior { PopupCloseBehavior::CloseOnClick => widget_clicked_elsewhere,
PopupCloseBehavior::CloseOnClick => widget_clicked_elsewhere, PopupCloseBehavior::CloseOnClickOutside => {
PopupCloseBehavior::CloseOnClickOutside => { widget_clicked_elsewhere && response.response.clicked_elsewhere()
widget_clicked_elsewhere && response.response.clicked_elsewhere() }
} PopupCloseBehavior::IgnoreClicks => false,
PopupCloseBehavior::IgnoreClicks => false,
};
should_close
|| ctx.input(|i| i.key_pressed(Key::Escape))
|| response.response.should_close()
}; };
// If a submenu is open, the CloseBehavior is handled there
let is_any_submenu_open = !MenuState::is_deepest_sub_menu(&response.response.ctx, id);
let should_close = (!is_any_submenu_open && closed_by_click)
|| ctx.input(|i| i.key_pressed(Key::Escape))
|| response.response.should_close();
if should_close {
response.response.set_close();
}
match open_kind { match open_kind {
OpenKind::Open | OpenKind::Closed => {} OpenKind::Open | OpenKind::Closed => {}
OpenKind::Bool(open, close_behavior) => { OpenKind::Bool(open) => {
if should_close(close_behavior) { if should_close {
*open = false; *open = false;
} }
} }
OpenKind::Memory { close_behavior, .. } => { OpenKind::Memory { .. } => {
if should_close(close_behavior) { if should_close {
ctx.memory_mut(|mem| mem.close_popup()); ctx.memory_mut(|mem| mem.close_popup());
} }
} }

View File

@ -26,7 +26,6 @@ use crate::{
load, load,
load::{Bytes, Loaders, SizedTexture}, load::{Bytes, Loaders, SizedTexture},
memory::{Options, Theme}, memory::{Options, Theme},
menu,
os::OperatingSystem, os::OperatingSystem,
output::FullOutput, output::FullOutput,
pass_state::PassState, pass_state::PassState,
@ -1197,15 +1196,27 @@ impl Context {
/// This is because widget interaction happens at the start of the pass, using the widget rects from the previous pass. /// This is because widget interaction happens at the start of the pass, using the widget rects from the previous pass.
/// ///
/// If the widget was not visible the previous pass (or this pass), this will return `None`. /// If the widget was not visible the previous pass (or this pass), this will return `None`.
///
/// If you try to read a [`Ui`]'s response, while still inside, this will return the [`Rect`] from the previous frame.
pub fn read_response(&self, id: Id) -> Option<Response> { pub fn read_response(&self, id: Id) -> Option<Response> {
self.write(|ctx| { self.write(|ctx| {
let viewport = ctx.viewport(); let viewport = ctx.viewport();
viewport let widget_rect = viewport
.this_pass .this_pass
.widgets .widgets
.get(id) .get(id)
.or_else(|| viewport.prev_pass.widgets.get(id)) .or_else(|| viewport.prev_pass.widgets.get(id))
.copied() .copied();
widget_rect.map(|mut rect| {
// If the Rect is invalid the Ui hasn't registered its final Rect yet.
// We return the Rect from last frame instead.
if !(rect.rect.is_positive() && rect.rect.is_finite()) {
if let Some(prev_rect) = viewport.prev_pass.widgets.get(id) {
rect.rect = prev_rect.rect;
}
}
rect
})
}) })
.map(|widget_rect| self.get_response(widget_rect)) .map(|widget_rect| self.get_response(widget_rect))
} }
@ -2626,12 +2637,27 @@ impl Context {
} }
/// Is an egui context menu open? /// Is an egui context menu open?
///
/// This only works with the old, deprecated [`crate::menu`] API.
#[allow(deprecated)]
#[deprecated = "Use `is_popup_open` instead"]
pub fn is_context_menu_open(&self) -> bool { pub fn is_context_menu_open(&self) -> bool {
self.data(|d| { self.data(|d| {
d.get_temp::<crate::menu::BarState>(menu::CONTEXT_MENU_ID_STR.into()) d.get_temp::<crate::menu::BarState>(crate::menu::CONTEXT_MENU_ID_STR.into())
.is_some_and(|state| state.has_root()) .is_some_and(|state| state.has_root())
}) })
} }
/// Is a popup or (context) menu open?
///
/// Will return false for [`crate::Tooltip`]s (which are technically popups as well).
pub fn is_popup_open(&self) -> bool {
self.pass_state_mut(|fs| {
fs.layers
.values()
.any(|layer| !layer.open_popups.is_empty())
})
}
} }
// Ergonomic methods to forward some calls often used in 'if let' without holding the borrow // Ergonomic methods to forward some calls often used in 'if let' without holding the borrow
@ -2676,7 +2702,8 @@ impl Context {
/// ///
/// Can be used to implement pan and zoom (see relevant demo). /// Can be used to implement pan and zoom (see relevant demo).
/// ///
/// For a temporary transform, use [`Self::transform_layer_shapes`] instead. /// For a temporary transform, use [`Self::transform_layer_shapes`] or
/// [`Ui::with_visual_transform`].
pub fn set_transform_layer(&self, layer_id: LayerId, transform: TSTransform) { pub fn set_transform_layer(&self, layer_id: LayerId, transform: TSTransform) {
self.memory_mut(|m| { self.memory_mut(|m| {
if transform == TSTransform::IDENTITY { if transform == TSTransform::IDENTITY {
@ -3166,13 +3193,14 @@ impl Context {
} }
}); });
#[allow(deprecated)]
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.label(format!( ui.label(format!(
"{} menu bars", "{} menu bars",
self.data(|d| d.count::<menu::BarState>()) self.data(|d| d.count::<crate::menu::BarState>())
)); ));
if ui.button("Reset").clicked() { if ui.button("Reset").clicked() {
self.data_mut(|d| d.remove_by_type::<menu::BarState>()); self.data_mut(|d| d.remove_by_type::<crate::menu::BarState>());
} }
}); });

View File

@ -88,7 +88,6 @@ pub fn zoom_menu_buttons(ui: &mut Ui) {
.clicked() .clicked()
{ {
zoom_in(ui.ctx()); zoom_in(ui.ctx());
ui.close();
} }
if ui if ui
@ -99,7 +98,6 @@ pub fn zoom_menu_buttons(ui: &mut Ui) {
.clicked() .clicked()
{ {
zoom_out(ui.ctx()); zoom_out(ui.ctx());
ui.close();
} }
if ui if ui
@ -110,6 +108,5 @@ pub fn zoom_menu_buttons(ui: &mut Ui) {
.clicked() .clicked()
{ {
ui.ctx().set_zoom_factor(1.0); ui.ctx().set_zoom_factor(1.0);
ui.close();
} }
} }

View File

@ -1333,6 +1333,18 @@ impl PointerState {
pub fn middle_down(&self) -> bool { pub fn middle_down(&self) -> bool {
self.button_down(PointerButton::Middle) self.button_down(PointerButton::Middle)
} }
/// Is the mouse moving in the direction of the given rect?
pub fn is_moving_towards_rect(&self, rect: &Rect) -> bool {
if self.is_still() {
return false;
}
if let Some(pos) = self.hover_pos() {
return rect.intersects_ray(pos, self.direction());
}
false
}
} }
impl InputState { impl InputState {

View File

@ -422,6 +422,7 @@ pub mod layers;
mod layout; mod layout;
pub mod load; pub mod load;
mod memory; mod memory;
#[deprecated = "Use `egui::containers::menu` instead"]
pub mod menu; pub mod menu;
pub mod os; pub mod os;
mod painter; mod painter;

View File

@ -1,3 +1,4 @@
#![allow(deprecated)]
//! Menu bar functionality (very basic so far). //! Menu bar functionality (very basic so far).
//! //!
//! Usage: //! Usage:
@ -146,7 +147,7 @@ pub fn menu_image_button<R>(
/// Opens on hover. /// Opens on hover.
/// ///
/// Returns `None` if the menu is not open. /// Returns `None` if the menu is not open.
pub(crate) fn submenu_button<R>( pub fn submenu_button<R>(
ui: &mut Ui, ui: &mut Ui,
parent_state: Arc<RwLock<MenuState>>, parent_state: Arc<RwLock<MenuState>>,
title: impl Into<WidgetText>, title: impl Into<WidgetText>,
@ -267,7 +268,7 @@ fn stationary_menu_button_impl<'c, R>(
pub(crate) const CONTEXT_MENU_ID_STR: &str = "__egui::context_menu"; pub(crate) const CONTEXT_MENU_ID_STR: &str = "__egui::context_menu";
/// Response to secondary clicks (right-clicks) by showing the given menu. /// Response to secondary clicks (right-clicks) by showing the given menu.
pub(crate) fn context_menu( pub fn context_menu(
response: &Response, response: &Response,
add_contents: impl FnOnce(&mut Ui), add_contents: impl FnOnce(&mut Ui),
) -> Option<InnerResponse<()>> { ) -> Option<InnerResponse<()>> {
@ -282,7 +283,7 @@ pub(crate) fn context_menu(
} }
/// Returns `true` if the context menu is opened for this widget. /// Returns `true` if the context menu is opened for this widget.
pub(crate) fn context_menu_opened(response: &Response) -> bool { pub fn context_menu_opened(response: &Response) -> bool {
let menu_id = Id::new(CONTEXT_MENU_ID_STR); let menu_id = Id::new(CONTEXT_MENU_ID_STR);
let bar_state = BarState::load(&response.ctx, menu_id); let bar_state = BarState::load(&response.ctx, menu_id);
bar_state.is_menu_open(response.id) bar_state.is_menu_open(response.id)

View File

@ -2,8 +2,8 @@ use std::{any::Any, sync::Arc};
use crate::{ use crate::{
emath::{Align, Pos2, Rect, Vec2}, emath::{Align, Pos2, Rect, Vec2},
menu, pass_state, Context, CursorIcon, Id, LayerId, PointerButton, Popup, PopupKind, Sense, pass_state, Context, CursorIcon, Id, LayerId, PointerButton, Popup, PopupKind, Sense, Tooltip,
Tooltip, Ui, WidgetRect, WidgetText, Ui, WidgetRect, WidgetText,
}; };
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
@ -935,14 +935,14 @@ impl Response {
/// ///
/// See also: [`Ui::menu_button`] and [`Ui::close`]. /// See also: [`Ui::menu_button`] and [`Ui::close`].
pub fn context_menu(&self, add_contents: impl FnOnce(&mut Ui)) -> Option<InnerResponse<()>> { pub fn context_menu(&self, add_contents: impl FnOnce(&mut Ui)) -> Option<InnerResponse<()>> {
menu::context_menu(self, add_contents) Popup::context_menu(self).show(add_contents)
} }
/// Returns whether a context menu is currently open for this widget. /// Returns whether a context menu is currently open for this widget.
/// ///
/// See [`Self::context_menu`]. /// See [`Self::context_menu`].
pub fn context_menu_opened(&self) -> bool { pub fn context_menu_opened(&self) -> bool {
menu::context_menu_opened(self) Popup::context_menu(self).is_open()
} }
/// Draw a debug rectangle over the response displaying the response's id and whether it is /// Draw a debug rectangle over the response displaying the response's id and whether it is

View File

@ -2,10 +2,9 @@
#![allow(clippy::if_same_then_else)] #![allow(clippy::if_same_then_else)]
use std::{collections::BTreeMap, ops::RangeInclusive, sync::Arc};
use emath::Align; use emath::Align;
use epaint::{text::FontTweak, CornerRadius, Shadow, Stroke}; use epaint::{text::FontTweak, CornerRadius, Shadow, Stroke};
use std::{collections::BTreeMap, ops::RangeInclusive, sync::Arc};
use crate::{ use crate::{
ecolor::Color32, ecolor::Color32,
@ -174,6 +173,49 @@ impl From<TextStyle> for FontSelection {
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
/// Utility to modify a [`Style`] in some way.
/// Constructed via [`StyleModifier::from`] from a `Fn(&mut Style)` or a [`Style`].
#[derive(Clone, Default)]
pub struct StyleModifier(Option<Arc<dyn Fn(&mut Style) + Send + Sync>>);
impl std::fmt::Debug for StyleModifier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("StyleModifier")
}
}
impl<T> From<T> for StyleModifier
where
T: Fn(&mut Style) + Send + Sync + 'static,
{
fn from(f: T) -> Self {
Self(Some(Arc::new(f)))
}
}
impl From<Style> for StyleModifier {
fn from(style: Style) -> Self {
Self(Some(Arc::new(move |s| *s = style.clone())))
}
}
impl StyleModifier {
/// Create a new [`StyleModifier`] from a function.
pub fn new(f: impl Fn(&mut Style) + Send + Sync + 'static) -> Self {
Self::from(f)
}
/// Apply the modification to the given [`Style`].
/// Usually used with [`Ui::style_mut`].
pub fn apply(&self, style: &mut Style) {
if let Some(f) = &self.0 {
f(style);
}
}
}
// ----------------------------------------------------------------------------
/// Specifies the look and feel of egui. /// Specifies the look and feel of egui.
/// ///
/// You can change the visuals of a [`Ui`] with [`Ui::style_mut`] /// You can change the visuals of a [`Ui`] with [`Ui::style_mut`]

View File

@ -6,6 +6,7 @@ use epaint::mutex::RwLock;
use std::{any::Any, hash::Hash, sync::Arc}; use std::{any::Any, hash::Hash, sync::Arc};
use crate::close_tag::ClosableTag; use crate::close_tag::ClosableTag;
use crate::containers::menu;
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
use crate::Stroke; use crate::Stroke;
use crate::{ use crate::{
@ -15,8 +16,6 @@ use crate::{
epaint::text::Fonts, epaint::text::Fonts,
grid, grid,
layout::{Direction, Layout}, layout::{Direction, Layout},
menu,
menu::MenuState,
pass_state, pass_state,
placer::Placer, placer::Placer,
pos2, style, pos2, style,
@ -99,7 +98,8 @@ pub struct Ui {
sizing_pass: bool, sizing_pass: bool,
/// Indicates whether this Ui belongs to a Menu. /// Indicates whether this Ui belongs to a Menu.
menu_state: Option<Arc<RwLock<MenuState>>>, #[allow(deprecated)]
menu_state: Option<Arc<RwLock<crate::menu::MenuState>>>,
/// The [`UiStack`] for this [`Ui`]. /// The [`UiStack`] for this [`Ui`].
stack: Arc<UiStack>, stack: Arc<UiStack>,
@ -1187,6 +1187,14 @@ impl Ui {
/// [`crate::Area`] e.g. will return true from it's [`Response::should_close`] method. /// [`crate::Area`] e.g. will return true from it's [`Response::should_close`] method.
/// ///
/// If you want to close a specific kind of container, use [`Ui::close_kind`] instead. /// If you want to close a specific kind of container, use [`Ui::close_kind`] instead.
///
/// Also note that this won't bubble up across [`crate::Area`]s. If needed, you can check
/// `response.should_close()` and close the parent manually. ([`menu`] does this for example).
///
/// See also:
/// - [`Ui::close_kind`]
/// - [`Ui::should_close`]
/// - [`Ui::will_parent_close`]
pub fn close(&self) { pub fn close(&self) {
let tag = self.stack.iter().find_map(|stack| { let tag = self.stack.iter().find_map(|stack| {
stack stack
@ -1207,6 +1215,11 @@ impl Ui {
/// This is useful if you want to e.g. close a [`crate::Window`]. Since it contains a /// This is useful if you want to e.g. close a [`crate::Window`]. Since it contains a
/// `Collapsible`, [`Ui::close`] would close the `Collapsible` instead. /// `Collapsible`, [`Ui::close`] would close the `Collapsible` instead.
/// You can close the [`crate::Window`] by calling `ui.close_kind(UiKind::Window)`. /// You can close the [`crate::Window`] by calling `ui.close_kind(UiKind::Window)`.
///
/// See also:
/// - [`Ui::close`]
/// - [`Ui::should_close`]
/// - [`Ui::will_parent_close`]
pub fn close_kind(&self, ui_kind: UiKind) { pub fn close_kind(&self, ui_kind: UiKind) {
let tag = self let tag = self
.stack .stack
@ -1230,6 +1243,12 @@ impl Ui {
/// Only works if the [`Ui`] was created with [`UiBuilder::closable`]. /// Only works if the [`Ui`] was created with [`UiBuilder::closable`].
/// ///
/// You can also check via this [`Ui`]'s [`Response::should_close`]. /// You can also check via this [`Ui`]'s [`Response::should_close`].
///
/// See also:
/// - [`Ui::will_parent_close`]
/// - [`Ui::close`]
/// - [`Ui::close_kind`]
/// - [`Response::should_close`]
pub fn should_close(&self) -> bool { pub fn should_close(&self) -> bool {
self.stack self.stack
.info .info
@ -1237,6 +1256,22 @@ impl Ui {
.get_downcast(ClosableTag::NAME) .get_downcast(ClosableTag::NAME)
.is_some_and(|tag: &ClosableTag| tag.should_close()) .is_some_and(|tag: &ClosableTag| tag.should_close())
} }
/// Will this [`Ui`] or any of its parents close this frame?
///
/// See also
/// - [`Ui::should_close`]
/// - [`Ui::close`]
/// - [`Ui::close_kind`]
pub fn will_parent_close(&self) -> bool {
self.stack.iter().any(|stack| {
stack
.info
.tags
.get_downcast::<ClosableTag>(ClosableTag::NAME)
.is_some_and(|tag| tag.should_close())
})
}
} }
/// # Allocating space: where do I put my widgets? /// # Allocating space: where do I put my widgets?
@ -2977,7 +3012,11 @@ impl Ui {
self.close_kind(UiKind::Menu); self.close_kind(UiKind::Menu);
} }
pub(crate) fn set_menu_state(&mut self, menu_state: Option<Arc<RwLock<MenuState>>>) { #[allow(deprecated)]
pub(crate) fn set_menu_state(
&mut self,
menu_state: Option<Arc<RwLock<crate::menu::MenuState>>>,
) {
self.menu_state = menu_state; self.menu_state = menu_state;
} }
@ -3004,11 +3043,12 @@ impl Ui {
title: impl Into<WidgetText>, title: impl Into<WidgetText>,
add_contents: impl FnOnce(&mut Ui) -> R, add_contents: impl FnOnce(&mut Ui) -> R,
) -> InnerResponse<Option<R>> { ) -> InnerResponse<Option<R>> {
if let Some(menu_state) = self.menu_state.clone() { let (response, inner) = if menu::is_in_menu(self) {
menu::submenu_button(self, menu_state, title, add_contents) menu::SubMenuButton::new(title).ui(self, add_contents)
} else { } else {
menu::menu_button(self, title, add_contents) menu::MenuButton::new(title).ui(self, add_contents)
} };
InnerResponse::new(inner.map(|i| i.inner), response)
} }
/// Create a menu button with an image that when clicked will show the given menu. /// Create a menu button with an image that when clicked will show the given menu.
@ -3037,11 +3077,15 @@ impl Ui {
image: impl Into<Image<'a>>, image: impl Into<Image<'a>>,
add_contents: impl FnOnce(&mut Ui) -> R, add_contents: impl FnOnce(&mut Ui) -> R,
) -> InnerResponse<Option<R>> { ) -> InnerResponse<Option<R>> {
if let Some(menu_state) = self.menu_state.clone() { let (response, inner) = if menu::is_in_menu(self) {
menu::submenu_button(self, menu_state, String::new(), add_contents) menu::SubMenuButton::from_button(
Button::image(image).right_text(menu::SubMenuButton::RIGHT_ARROW),
)
.ui(self, add_contents)
} else { } else {
menu::menu_custom_button(self, Button::image(image), add_contents) menu::MenuButton::from_button(Button::image(image)).ui(self, add_contents)
} };
InnerResponse::new(inner.map(|i| i.inner), response)
} }
/// Create a menu button with an image and a text that when clicked will show the given menu. /// Create a menu button with an image and a text that when clicked will show the given menu.
@ -3071,11 +3115,16 @@ impl Ui {
title: impl Into<WidgetText>, title: impl Into<WidgetText>,
add_contents: impl FnOnce(&mut Ui) -> R, add_contents: impl FnOnce(&mut Ui) -> R,
) -> InnerResponse<Option<R>> { ) -> InnerResponse<Option<R>> {
if let Some(menu_state) = self.menu_state.clone() { let (response, inner) = if menu::is_in_menu(self) {
menu::submenu_button(self, menu_state, title, add_contents) menu::SubMenuButton::from_button(
Button::image_and_text(image, title).right_text(menu::SubMenuButton::RIGHT_ARROW),
)
.ui(self, add_contents)
} else { } else {
menu::menu_custom_button(self, Button::image_and_text(image, title), add_contents) menu::MenuButton::from_button(Button::image_and_text(image, title))
} .ui(self, add_contents)
};
InnerResponse::new(inner.map(|i| i.inner), response)
} }
} }

View File

@ -25,7 +25,7 @@ use crate::{
pub struct Button<'a> { pub struct Button<'a> {
image: Option<Image<'a>>, image: Option<Image<'a>>,
text: Option<WidgetText>, text: Option<WidgetText>,
shortcut_text: WidgetText, right_text: WidgetText,
wrap_mode: Option<TextWrapMode>, wrap_mode: Option<TextWrapMode>,
/// None means default for interact /// None means default for interact
@ -61,7 +61,7 @@ impl<'a> Button<'a> {
Self { Self {
text, text,
image, image,
shortcut_text: Default::default(), right_text: Default::default(),
wrap_mode: None, wrap_mode: None,
fill: None, fill: None,
stroke: None, stroke: None,
@ -181,9 +181,18 @@ impl<'a> Button<'a> {
/// Designed for menu buttons, for setting a keyboard shortcut text (e.g. `Ctrl+S`). /// Designed for menu buttons, for setting a keyboard shortcut text (e.g. `Ctrl+S`).
/// ///
/// The text can be created with [`crate::Context::format_shortcut`]. /// The text can be created with [`crate::Context::format_shortcut`].
///
/// See also [`Self::right_text`].
#[inline] #[inline]
pub fn shortcut_text(mut self, shortcut_text: impl Into<WidgetText>) -> Self { pub fn shortcut_text(mut self, shortcut_text: impl Into<WidgetText>) -> Self {
self.shortcut_text = shortcut_text.into(); self.right_text = shortcut_text.into().weak();
self
}
/// Show some text on the right side of the button.
#[inline]
pub fn right_text(mut self, right_text: impl Into<WidgetText>) -> Self {
self.right_text = right_text.into();
self self
} }
@ -200,7 +209,7 @@ impl Widget for Button<'_> {
let Button { let Button {
text, text,
image, image,
shortcut_text, right_text,
wrap_mode, wrap_mode,
fill, fill,
stroke, stroke,
@ -239,16 +248,16 @@ impl Widget for Button<'_> {
Vec2::ZERO Vec2::ZERO
}; };
let gap_before_shortcut_text = ui.spacing().item_spacing.x; let gap_before_right_text = ui.spacing().item_spacing.x;
let mut text_wrap_width = ui.available_width() - 2.0 * button_padding.x; let mut text_wrap_width = ui.available_width() - 2.0 * button_padding.x;
if image.is_some() { if image.is_some() {
text_wrap_width -= image_size.x + ui.spacing().icon_spacing; text_wrap_width -= image_size.x + ui.spacing().icon_spacing;
} }
// Note: we don't wrap the shortcut text // Note: we don't wrap the right text
let shortcut_galley = (!shortcut_text.is_empty()).then(|| { let right_galley = (!right_text.is_empty()).then(|| {
shortcut_text.into_galley( right_text.into_galley(
ui, ui,
Some(TextWrapMode::Extend), Some(TextWrapMode::Extend),
f32::INFINITY, f32::INFINITY,
@ -256,9 +265,9 @@ impl Widget for Button<'_> {
) )
}); });
if let Some(shortcut_galley) = &shortcut_galley { if let Some(right_galley) = &right_galley {
// Leave space for the shortcut text: // Leave space for the right text:
text_wrap_width -= gap_before_shortcut_text + shortcut_galley.size().x; text_wrap_width -= gap_before_right_text + right_galley.size().x;
} }
let galley = let galley =
@ -276,9 +285,9 @@ impl Widget for Button<'_> {
desired_size.x += galley.size().x; desired_size.x += galley.size().x;
desired_size.y = desired_size.y.max(galley.size().y); desired_size.y = desired_size.y.max(galley.size().y);
} }
if let Some(shortcut_galley) = &shortcut_galley { if let Some(right_galley) = &right_galley {
desired_size.x += gap_before_shortcut_text + shortcut_galley.size().x; desired_size.x += gap_before_right_text + right_galley.size().x;
desired_size.y = desired_size.y.max(shortcut_galley.size().y); desired_size.y = desired_size.y.max(right_galley.size().y);
} }
desired_size += 2.0 * button_padding; desired_size += 2.0 * button_padding;
if !small { if !small {
@ -335,7 +344,7 @@ impl Widget for Button<'_> {
.layout() .layout()
.align_size_within_rect(image_size, rect.shrink2(button_padding)) .align_size_within_rect(image_size, rect.shrink2(button_padding))
.min; .min;
if galley.is_some() || shortcut_galley.is_some() { if galley.is_some() || right_galley.is_some() {
image_pos.x = cursor_x; image_pos.x = cursor_x;
} }
let image_rect = Rect::from_min_size(image_pos, image_size); let image_rect = Rect::from_min_size(image_pos, image_size);
@ -369,27 +378,25 @@ impl Widget for Button<'_> {
.layout() .layout()
.align_size_within_rect(galley.size(), rect.shrink2(button_padding)) .align_size_within_rect(galley.size(), rect.shrink2(button_padding))
.min; .min;
if image.is_some() || shortcut_galley.is_some() { if image.is_some() || right_galley.is_some() {
text_pos.x = cursor_x; text_pos.x = cursor_x;
} }
ui.painter().galley(text_pos, galley, visuals.text_color()); ui.painter().galley(text_pos, galley, visuals.text_color());
} }
if let Some(shortcut_galley) = shortcut_galley { if let Some(right_galley) = right_galley {
// Always align to the right // Always align to the right
let layout = if ui.layout().is_horizontal() { let layout = if ui.layout().is_horizontal() {
ui.layout().with_main_align(Align::Max) ui.layout().with_main_align(Align::Max)
} else { } else {
ui.layout().with_cross_align(Align::Max) ui.layout().with_cross_align(Align::Max)
}; };
let shortcut_text_pos = layout let right_text_pos = layout
.align_size_within_rect(shortcut_galley.size(), rect.shrink2(button_padding)) .align_size_within_rect(right_galley.size(), rect.shrink2(button_padding))
.min; .min;
ui.painter().galley(
shortcut_text_pos, ui.painter()
shortcut_galley, .galley(right_text_pos, right_galley, visuals.text_color());
ui.visuals().weak_text_color(),
);
} }
} }

View File

@ -1,94 +0,0 @@
use egui::{ComboBox, Popup};
#[derive(Clone, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub struct ContextMenus {}
impl crate::Demo for ContextMenus {
fn name(&self) -> &'static str {
"☰ Context Menus"
}
fn show(&mut self, ctx: &egui::Context, open: &mut bool) {
use crate::View;
egui::Window::new(self.name())
.vscroll(false)
.resizable(false)
.open(open)
.show(ctx, |ui| self.ui(ui));
}
}
impl crate::View for ContextMenus {
fn ui(&mut self, ui: &mut egui::Ui) {
ui.horizontal(|ui| {
ui.menu_button("Click for menu", Self::nested_menus);
ui.button("Right-click for menu")
.context_menu(Self::nested_menus);
if ui.ctx().is_context_menu_open() {
ui.label("Context menu is open");
} else {
ui.label("Context menu is closed");
}
});
ui.horizontal(|ui| {
let response = ui.button("New menu");
Popup::menu(&response).show(Self::nested_menus);
let response = ui.button("New context menu");
Popup::context_menu(&response).show(Self::nested_menus);
ComboBox::new("Hi", "Hi").show_ui(ui, |ui| {
_ = ui.selectable_label(false, "I have some long text that should be wrapped");
_ = ui.selectable_label(false, "Short");
_ = ui.selectable_label(false, "Medium length");
});
});
ui.vertical_centered(|ui| {
ui.add(crate::egui_github_link_file!());
});
}
}
impl ContextMenus {
fn nested_menus(ui: &mut egui::Ui) {
ui.set_max_width(200.0); // To make sure we wrap long text
if ui.button("Open…").clicked() {
ui.close();
}
ui.menu_button("SubMenu", |ui| {
ui.menu_button("SubMenu", |ui| {
if ui.button("Open…").clicked() {
ui.close();
}
let _ = ui.button("Item");
ui.menu_button("Recursive", Self::nested_menus)
});
ui.menu_button("SubMenu", |ui| {
if ui.button("Open…").clicked() {
ui.close();
}
let _ = ui.button("Item");
});
let _ = ui.button("Item");
if ui.button("Open…").clicked() {
ui.close();
}
});
ui.menu_button("SubMenu", |ui| {
let _ = ui.button("Item1");
let _ = ui.button("Item2");
let _ = ui.button("Item3");
let _ = ui.button("Item4");
if ui.button("Open…").clicked() {
ui.close();
}
});
let _ = ui.button("Very long text for this item that should be wrapped");
}
}

View File

@ -1,12 +1,12 @@
use std::collections::BTreeSet; use std::collections::BTreeSet;
use egui::{Context, Modifiers, ScrollArea, Ui};
use super::About; use super::About;
use crate::is_mobile; use crate::is_mobile;
use crate::Demo; use crate::Demo;
use crate::View; use crate::View;
use egui::containers::menu;
use egui::style::StyleModifier;
use egui::{Context, Modifiers, ScrollArea, Ui};
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
struct DemoGroup { struct DemoGroup {
@ -65,7 +65,6 @@ impl Default for DemoGroups {
Box::<super::paint_bezier::PaintBezier>::default(), Box::<super::paint_bezier::PaintBezier>::default(),
Box::<super::code_editor::CodeEditor>::default(), Box::<super::code_editor::CodeEditor>::default(),
Box::<super::code_example::CodeExample>::default(), Box::<super::code_example::CodeExample>::default(),
Box::<super::context_menu::ContextMenus>::default(),
Box::<super::dancing_strings::DancingStrings>::default(), Box::<super::dancing_strings::DancingStrings>::default(),
Box::<super::drag_and_drop::DragAndDropDemo>::default(), Box::<super::drag_and_drop::DragAndDropDemo>::default(),
Box::<super::extra_viewport::ExtraViewport>::default(), Box::<super::extra_viewport::ExtraViewport>::default(),
@ -227,29 +226,27 @@ impl DemoWindows {
fn mobile_top_bar(&mut self, ctx: &Context) { fn mobile_top_bar(&mut self, ctx: &Context) {
egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| { egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
egui::menu::bar(ui, |ui| { menu::Bar::new()
let font_size = 16.5; .config(menu::MenuConfig::new().style(StyleModifier::default()))
.ui(ui, |ui| {
let font_size = 16.5;
ui.menu_button(egui::RichText::new("⏷ demos").size(font_size), |ui| { ui.menu_button(egui::RichText::new("⏷ demos").size(font_size), |ui| {
ui.set_style(ui.ctx().style()); // ignore the "menu" style set by `menu_button`. self.demo_list_ui(ui);
self.demo_list_ui(ui); });
if ui.ui_contains_pointer() && ui.input(|i| i.pointer.any_click()) {
ui.close();
}
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
use egui::special_emojis::GITHUB; use egui::special_emojis::GITHUB;
ui.hyperlink_to( ui.hyperlink_to(
egui::RichText::new("🦋").size(font_size), egui::RichText::new("🦋").size(font_size),
"https://bsky.app/profile/ernerfeldt.bsky.social", "https://bsky.app/profile/ernerfeldt.bsky.social",
); );
ui.hyperlink_to( ui.hyperlink_to(
egui::RichText::new(GITHUB).size(font_size), egui::RichText::new(GITHUB).size(font_size),
"https://github.com/emilk/egui", "https://github.com/emilk/egui",
); );
});
}); });
});
}); });
} }
@ -282,7 +279,7 @@ impl DemoWindows {
}); });
egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| { egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
egui::menu::bar(ui, |ui| { menu::Bar::new().ui(ui, |ui| {
file_menu_button(ui); file_menu_button(ui);
}); });
}); });
@ -345,7 +342,6 @@ fn file_menu_button(ui: &mut Ui) {
.clicked() .clicked()
{ {
ui.ctx().memory_mut(|mem| mem.reset_areas()); ui.ctx().memory_mut(|mem| mem.reset_areas());
ui.close();
} }
if ui if ui
@ -357,7 +353,6 @@ fn file_menu_button(ui: &mut Ui) {
.clicked() .clicked()
{ {
ui.ctx().memory_mut(|mem| *mem = Default::default()); ui.ctx().memory_mut(|mem| *mem = Default::default());
ui.close();
} }
}); });
} }

View File

@ -7,7 +7,6 @@
pub mod about; pub mod about;
pub mod code_editor; pub mod code_editor;
pub mod code_example; pub mod code_example;
pub mod context_menu;
pub mod dancing_strings; pub mod dancing_strings;
pub mod demo_app_windows; pub mod demo_app_windows;
pub mod drag_and_drop; pub mod drag_and_drop;

View File

@ -1,4 +1,9 @@
use egui::{vec2, Align2, ComboBox, Frame, Id, Popup, PopupCloseBehavior, RectAlign, Tooltip, Ui}; use crate::rust_view_ui;
use egui::containers::menu::{MenuConfig, SubMenuButton};
use egui::{
include_image, Align, Align2, ComboBox, Frame, Id, Layout, Popup, PopupCloseBehavior,
RectAlign, Tooltip, Ui, UiBuilder,
};
/// Showcase [`Popup`]. /// Showcase [`Popup`].
#[derive(Clone, PartialEq)] #[derive(Clone, PartialEq)]
@ -10,6 +15,7 @@ pub struct PopupsDemo {
#[cfg_attr(feature = "serde", serde(skip))] #[cfg_attr(feature = "serde", serde(skip))]
close_behavior: PopupCloseBehavior, close_behavior: PopupCloseBehavior,
popup_open: bool, popup_open: bool,
checked: bool,
} }
impl PopupsDemo { impl PopupsDemo {
@ -28,6 +34,7 @@ impl Default for PopupsDemo {
gap: 4.0, gap: 4.0,
close_behavior: PopupCloseBehavior::CloseOnClick, close_behavior: PopupCloseBehavior::CloseOnClick,
popup_open: false, popup_open: false,
checked: false,
} }
} }
} }
@ -50,120 +57,70 @@ impl crate::Demo for PopupsDemo {
} }
} }
fn nested_menus(ui: &mut egui::Ui, checked: &mut bool) {
ui.set_max_width(200.0); // To make sure we wrap long text
if ui.button("Open…").clicked() {
ui.close();
}
ui.menu_button("Popups can have submenus", |ui| {
ui.menu_button("SubMenu", |ui| {
if ui.button("Open…").clicked() {
ui.close();
}
let _ = ui.button("Item");
ui.menu_button("Recursive", |ui| nested_menus(ui, checked));
});
ui.menu_button("SubMenu", |ui| {
if ui.button("Open…").clicked() {
ui.close();
}
let _ = ui.button("Item");
});
let _ = ui.button("Item");
if ui.button("Open…").clicked() {
ui.close();
}
});
ui.menu_image_text_button(
include_image!("../../data/icon.png"),
"I have an icon!",
|ui| {
let _ = ui.button("Item1");
let _ = ui.button("Item2");
let _ = ui.button("Item3");
let _ = ui.button("Item4");
if ui.button("Open…").clicked() {
ui.close();
}
},
);
let _ = ui.button("Very long text for this item that should be wrapped");
SubMenuButton::new("Always CloseOnClickOutside")
.config(MenuConfig::new().close_behavior(PopupCloseBehavior::CloseOnClickOutside))
.ui(ui, |ui| {
ui.checkbox(checked, "Checkbox");
if ui.button("Open…").clicked() {
ui.close();
}
});
}
impl crate::View for PopupsDemo { impl crate::View for PopupsDemo {
fn ui(&mut self, ui: &mut egui::Ui) { fn ui(&mut self, ui: &mut egui::Ui) {
ui.horizontal(|ui| {
ui.style_mut().spacing.item_spacing.x = 0.0;
let align_combobox = |ui: &mut Ui, label: &str, align: &mut Align2| {
let aligns = [
(Align2::LEFT_TOP, "Left top"),
(Align2::LEFT_CENTER, "Left center"),
(Align2::LEFT_BOTTOM, "Left bottom"),
(Align2::CENTER_TOP, "Center top"),
(Align2::CENTER_CENTER, "Center center"),
(Align2::CENTER_BOTTOM, "Center bottom"),
(Align2::RIGHT_TOP, "Right top"),
(Align2::RIGHT_CENTER, "Right center"),
(Align2::RIGHT_BOTTOM, "Right bottom"),
];
ui.label(label);
ComboBox::new(label, "")
.selected_text(aligns.iter().find(|(a, _)| a == align).unwrap().1)
.show_ui(ui, |ui| {
for (align2, name) in &aligns {
ui.selectable_value(align, *align2, *name);
}
});
};
ui.label("Align4(");
align_combobox(ui, "parent: ", &mut self.align4.parent);
ui.label(", ");
align_combobox(ui, "child: ", &mut self.align4.child);
ui.label(") ");
let presets = [
(RectAlign::TOP_START, "Top start"),
(RectAlign::TOP, "Top"),
(RectAlign::TOP_END, "Top end"),
(RectAlign::RIGHT_START, "Right start"),
(RectAlign::RIGHT, "Right Center"),
(RectAlign::RIGHT_END, "Right end"),
(RectAlign::BOTTOM_START, "Bottom start"),
(RectAlign::BOTTOM, "Bottom"),
(RectAlign::BOTTOM_END, "Bottom end"),
(RectAlign::LEFT_START, "Left start"),
(RectAlign::LEFT, "Left"),
(RectAlign::LEFT_END, "Left end"),
];
ui.label(" Presets: ");
ComboBox::new("Preset", "")
.selected_text(
presets
.iter()
.find(|(a, _)| a == &self.align4)
.map_or("Select", |(_, name)| *name),
)
.show_ui(ui, |ui| {
for (align4, name) in &presets {
ui.selectable_value(&mut self.align4, *align4, *name);
}
});
});
ui.horizontal(|ui| {
ui.label("Gap:");
ui.add(egui::DragValue::new(&mut self.gap));
});
ui.horizontal(|ui| {
ui.label("Close behavior:");
ui.selectable_value(
&mut self.close_behavior,
PopupCloseBehavior::CloseOnClick,
"Close on click",
)
.on_hover_text("Closes when the user clicks anywhere (inside or outside)");
ui.selectable_value(
&mut self.close_behavior,
PopupCloseBehavior::CloseOnClickOutside,
"Close on click outside",
)
.on_hover_text("Closes when the user clicks outside the popup");
ui.selectable_value(
&mut self.close_behavior,
PopupCloseBehavior::IgnoreClicks,
"Ignore clicks",
)
.on_hover_text("Close only when the button is clicked again");
});
ui.checkbox(&mut self.popup_open, "Show popup");
let response = Frame::group(ui.style()) let response = Frame::group(ui.style())
.inner_margin(vec2(0.0, 25.0))
.show(ui, |ui| { .show(ui, |ui| {
ui.set_width(ui.available_width());
ui.vertical_centered(|ui| ui.button("Click, right-click and hover me!")) ui.vertical_centered(|ui| ui.button("Click, right-click and hover me!"))
.inner .inner
}) })
.inner; .inner;
self.apply_options(Popup::menu(&response).id(Id::new("menu"))) self.apply_options(Popup::menu(&response).id(Id::new("menu")))
.show(|ui| { .show(|ui| nested_menus(ui, &mut self.checked));
_ = ui.button("Menu item 1");
_ = ui.button("Menu item 2");
if ui.button("I always close the menu").clicked() {
ui.close();
}
});
self.apply_options(Popup::context_menu(&response).id(Id::new("context_menu"))) self.apply_options(Popup::context_menu(&response).id(Id::new("context_menu")))
.show(|ui| { .show(|ui| nested_menus(ui, &mut self.checked));
_ = ui.button("Context menu item 1");
_ = ui.button("Context menu item 2");
});
if self.popup_open { if self.popup_open {
self.apply_options(Popup::from_response(&response).id(Id::new("popup"))) self.apply_options(Popup::from_response(&response).id(Id::new("popup")))
@ -178,6 +135,148 @@ impl crate::View for PopupsDemo {
ui.label("Tooltips are popups, too!"); ui.label("Tooltips are popups, too!");
}); });
Frame::canvas(ui.style()).show(ui, |ui| {
let mut reset_btn_ui = ui.new_child(
UiBuilder::new()
.max_rect(ui.max_rect())
.layout(Layout::right_to_left(Align::Min)),
);
if reset_btn_ui
.button("")
.on_hover_text("Reset to defaults")
.clicked()
{
*self = Self::default();
}
ui.set_width(ui.available_width());
ui.style_mut().override_text_style = Some(egui::TextStyle::Monospace);
ui.spacing_mut().item_spacing.x = 0.0;
let align_combobox = |ui: &mut Ui, label: &str, align: &mut Align2| {
let aligns = [
(Align2::LEFT_TOP, "LEFT_TOP"),
(Align2::LEFT_CENTER, "LEFT_CENTER"),
(Align2::LEFT_BOTTOM, "LEFT_BOTTOM"),
(Align2::CENTER_TOP, "CENTER_TOP"),
(Align2::CENTER_CENTER, "CENTER_CENTER"),
(Align2::CENTER_BOTTOM, "CENTER_BOTTOM"),
(Align2::RIGHT_TOP, "RIGHT_TOP"),
(Align2::RIGHT_CENTER, "RIGHT_CENTER"),
(Align2::RIGHT_BOTTOM, "RIGHT_BOTTOM"),
];
ComboBox::new(label, "")
.selected_text(aligns.iter().find(|(a, _)| a == align).unwrap().1)
.show_ui(ui, |ui| {
for (align2, name) in &aligns {
ui.selectable_value(align, *align2, *name);
}
});
};
rust_view_ui(ui, "let align = RectAlign {");
ui.horizontal(|ui| {
rust_view_ui(ui, " parent: Align2::");
align_combobox(ui, "parent", &mut self.align4.parent);
rust_view_ui(ui, ",");
});
ui.horizontal(|ui| {
rust_view_ui(ui, " child: Align2::");
align_combobox(ui, "child", &mut self.align4.child);
rust_view_ui(ui, ",");
});
rust_view_ui(ui, "};");
ui.horizontal(|ui| {
rust_view_ui(ui, "let align = RectAlign::");
let presets = [
(RectAlign::TOP_START, "TOP_START"),
(RectAlign::TOP, "TOP"),
(RectAlign::TOP_END, "TOP_END"),
(RectAlign::RIGHT_START, "RIGHT_START"),
(RectAlign::RIGHT, "RIGHT"),
(RectAlign::RIGHT_END, "RIGHT_END"),
(RectAlign::BOTTOM_START, "BOTTOM_START"),
(RectAlign::BOTTOM, "BOTTOM"),
(RectAlign::BOTTOM_END, "BOTTOM_END"),
(RectAlign::LEFT_START, "LEFT_START"),
(RectAlign::LEFT, "LEFT"),
(RectAlign::LEFT_END, "LEFT_END"),
];
ComboBox::new("Preset", "")
.selected_text(
presets
.iter()
.find(|(a, _)| a == &self.align4)
.map_or("<Select Preset>", |(_, name)| *name),
)
.show_ui(ui, |ui| {
for (align4, name) in &presets {
ui.selectable_value(&mut self.align4, *align4, *name);
}
});
rust_view_ui(ui, ";");
});
ui.horizontal(|ui| {
rust_view_ui(ui, "let gap = ");
ui.add(egui::DragValue::new(&mut self.gap));
rust_view_ui(ui, ";");
});
rust_view_ui(ui, "let close_behavior");
ui.horizontal(|ui| {
rust_view_ui(ui, " = PopupCloseBehavior::");
let close_behaviors = [
(
PopupCloseBehavior::CloseOnClick,
"CloseOnClick",
"Closes when the user clicks anywhere (inside or outside)",
),
(
PopupCloseBehavior::CloseOnClickOutside,
"CloseOnClickOutside",
"Closes when the user clicks outside the popup",
),
(
PopupCloseBehavior::IgnoreClicks,
"IgnoreClicks",
"Close only when the button is clicked again",
),
];
ComboBox::new("Close behavior", "")
.selected_text(
close_behaviors
.iter()
.find_map(|(behavior, text, _)| {
(behavior == &self.close_behavior).then_some(*text)
})
.unwrap(),
)
.show_ui(ui, |ui| {
for (close_behavior, name, tooltip) in &close_behaviors {
ui.selectable_value(&mut self.close_behavior, *close_behavior, *name)
.on_hover_text(*tooltip);
}
});
rust_view_ui(ui, ";");
});
ui.horizontal(|ui| {
rust_view_ui(ui, "let popup_open = ");
ui.checkbox(&mut self.popup_open, "");
rust_view_ui(ui, ";");
});
ui.monospace("");
rust_view_ui(ui, "let response = ui.button(\"Click me!\");");
rust_view_ui(ui, "Popup::menu(&response)");
rust_view_ui(ui, " .gap(gap).align(align)");
rust_view_ui(ui, " .close_behavior(close_behavior)");
rust_view_ui(ui, " .show(|ui| { /* menu contents */ });");
});
ui.vertical_centered(|ui| { ui.vertical_centered(|ui| {
ui.add(crate::egui_github_link_file!()); ui.add(crate::egui_github_link_file!());
}); });

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:4806984f9c801a054cea80b89664293680abaa57cf0a95cf9682f111e3794fc1 oid sha256:17dc2a2f98d4cc52f6c6337dcc2e40f22d7310a933de91bb60576b893926193c
size 25080 size 58674

View File

@ -63,6 +63,7 @@ document-features = { workspace = true, optional = true }
[dev-dependencies] [dev-dependencies]
egui = { workspace = true, features = ["default_fonts"] } egui = { workspace = true, features = ["default_fonts"] }
image = { workspace = true, features = ["png"] } image = { workspace = true, features = ["png"] }
egui_extras = { workspace = true, features = ["image"] }
[lints] [lints]
workspace = true workspace = true

View File

@ -0,0 +1,188 @@
use egui::containers::menu::{Bar, MenuConfig, SubMenuButton};
use egui::{include_image, PopupCloseBehavior, Ui};
use egui_kittest::{Harness, SnapshotResults};
use kittest::Queryable;
struct TestMenu {
config: MenuConfig,
checked: bool,
}
impl TestMenu {
fn new(config: MenuConfig) -> Self {
Self {
config,
checked: false,
}
}
fn ui(&mut self, ui: &mut Ui) {
ui.vertical(|ui| {
Bar::new().config(self.config.clone()).ui(ui, |ui| {
egui::Sides::new().show(
ui,
|ui| {
ui.menu_button("Menu A", |ui| {
_ = ui.button("Button in Menu A");
ui.menu_button("Submenu A", |ui| {
for i in 0..4 {
_ = ui.button(format!("Button {i} in Submenu A"));
}
});
ui.menu_image_text_button(
include_image!("../../eframe/data/icon.png"),
"Submenu B with icon",
|ui| {
_ = ui.button("Button in Submenu B");
},
);
SubMenuButton::new("Submenu C (CloseOnClickOutside)")
.config(
MenuConfig::new()
.close_behavior(PopupCloseBehavior::CloseOnClickOutside),
)
.ui(ui, |ui| {
_ = ui.button("Button in Submenu C");
ui.checkbox(&mut self.checked, "Checkbox in Submenu C");
ui.menu_button("Submenu D", |ui| {
if ui
.button("Button in Submenu D (close on click)")
.clicked()
{
ui.close();
};
});
});
});
ui.menu_image_text_button(
include_image!("../../eframe/data/icon.png"),
"Menu B with icon",
|ui| {
_ = ui.button("Button in Menu B");
},
);
_ = ui.button("Menu Button");
ui.menu_button("Menu C", |ui| {
_ = ui.button("Button in Menu C");
});
},
|ui| {
ui.label("Some other label");
},
);
});
});
}
fn into_harness(self) -> Harness<'static, Self> {
Harness::builder()
.with_size(egui::Vec2::new(500.0, 300.0))
.build_ui_state(
|ui, menu| {
egui_extras::install_image_loaders(ui.ctx());
menu.ui(ui);
},
self,
)
}
}
#[test]
fn menu_close_on_click_outside() {
// We're intentionally setting CloseOnClick here so we can test if a submenu can override the
// close behavior. (Note how Submenu C has CloseOnClickOutside set)
let mut harness =
TestMenu::new(MenuConfig::new().close_behavior(PopupCloseBehavior::CloseOnClick))
.into_harness();
harness.get_by_label("Menu A").simulate_click();
harness.run();
harness
.get_by_label("Submenu C (CloseOnClickOutside)")
.hover();
harness.run();
// We should be able to check the checkbox without closing the menu
// Click a couple of times, just in case
for expect_checked in [true, false, true, false] {
harness
.get_by_label("Checkbox in Submenu C")
.simulate_click();
harness.run();
assert_eq!(expect_checked, harness.state().checked);
}
// Hovering outside should not close the menu
harness.get_by_label("Some other label").hover();
harness.run();
assert!(harness.query_by_label("Checkbox in Submenu C").is_some());
// Clicking outside should close the menu
harness.get_by_label("Some other label").simulate_click();
harness.run();
assert!(harness.query_by_label("Checkbox in Submenu C").is_none());
}
#[test]
fn menu_close_on_click() {
let mut harness =
TestMenu::new(MenuConfig::new().close_behavior(PopupCloseBehavior::CloseOnClick))
.into_harness();
harness.get_by_label("Menu A").simulate_click();
harness.run();
harness.get_by_label("Submenu B with icon").hover();
harness.run();
// Clicking the button should close the menu (even if ui.close() is not called by the button)
harness.get_by_label("Button in Submenu B").simulate_click();
harness.run();
assert!(harness.query_by_label("Button in Submenu B").is_none());
}
#[test]
fn clicking_submenu_button_should_never_close_menu() {
// We test for this since otherwise the menu wouldn't work on touch devices
// The other tests use .hover to open submenus, but this test explicitly uses .simulate_click
let mut harness =
TestMenu::new(MenuConfig::new().close_behavior(PopupCloseBehavior::CloseOnClick))
.into_harness();
harness.get_by_label("Menu A").simulate_click();
harness.run();
// Clicking the submenu button should not close the menu
harness.get_by_label("Submenu B with icon").simulate_click();
harness.run();
harness.get_by_label("Button in Submenu B").simulate_click();
harness.run();
assert!(harness.query_by_label("Button in Submenu B").is_none());
}
#[test]
fn menu_snapshots() {
let mut harness = TestMenu::new(MenuConfig::new()).into_harness();
let mut results = SnapshotResults::new();
harness.get_by_label("Menu A").hover();
harness.run();
results.add(harness.try_snapshot("menu/closed_hovered"));
harness.get_by_label("Menu A").simulate_click();
harness.run();
results.add(harness.try_snapshot("menu/opened"));
harness
.get_by_label("Submenu C (CloseOnClickOutside)")
.hover();
harness.run();
results.add(harness.try_snapshot("menu/submenu"));
harness.get_by_label("Submenu D").hover();
harness.run();
results.add(harness.try_snapshot("menu/subsubmenu"));
}

View File

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

View File

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

View File

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

View File

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