diff --git a/Cargo.lock b/Cargo.lock index 4956e143..c320169c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1393,6 +1393,7 @@ dependencies = [ "eframe", "egui", "egui-wgpu", + "egui_extras", "image", "kittest", "pollster 0.4.0", diff --git a/crates/egui/src/containers/area.rs b/crates/egui/src/containers/area.rs index bf4c63d0..55cdb075 100644 --- a/crates/egui/src/containers/area.rs +++ b/crates/egui/src/containers/area.rs @@ -103,10 +103,10 @@ impl AreaState { /// /// The previous rectangle used by this area can be obtained through [`crate::Memory::area_rect()`]. #[must_use = "You should call .show()"] -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Debug)] pub struct Area { pub(crate) id: Id, - kind: UiKind, + info: UiStackInfo, sense: Option, movable: bool, interactable: bool, @@ -132,7 +132,7 @@ impl Area { pub fn new(id: Id) -> Self { Self { id, - kind: UiKind::GenericArea, + info: UiStackInfo::new(UiKind::GenericArea), sense: None, movable: true, interactable: true, @@ -164,7 +164,16 @@ impl Area { /// Default to [`UiKind::GenericArea`]. #[inline] 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 } @@ -351,7 +360,7 @@ impl Area { } pub(crate) struct Prepared { - kind: UiKind, + info: Option, layer_id: LayerId, state: AreaState, move_response: Response, @@ -376,7 +385,7 @@ impl Area { ctx: &Context, add_contents: impl FnOnce(&mut Ui) -> R, ) -> InnerResponse { - let prepared = self.begin(ctx); + let mut prepared = self.begin(ctx); let mut content_ui = prepared.content_ui(ctx); let inner = add_contents(&mut content_ui); let response = prepared.end(ctx, content_ui); @@ -386,7 +395,7 @@ impl Area { pub(crate) fn begin(self, ctx: &Context) -> Prepared { let Self { id, - kind, + info, sense, movable, order, @@ -518,7 +527,7 @@ impl Area { move_response.interact_rect = state.rect(); Prepared { - kind, + info: Some(info), layer_id, state, move_response, @@ -549,11 +558,11 @@ impl Prepared { 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 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) .max_rect(max_rect) .layout(self.layout) @@ -596,7 +605,7 @@ impl Prepared { #[allow(clippy::needless_pass_by_value)] // intentional to swallow up `content_ui`. pub(crate) fn end(self, ctx: &Context, content_ui: Ui) -> Response { let Self { - kind: _, + info: _, layer_id, mut state, move_response: mut response, diff --git a/crates/egui/src/containers/combo_box.rs b/crates/egui/src/containers/combo_box.rs index 884a9c36..7a5a160f 100644 --- a/crates/egui/src/containers/combo_box.rs +++ b/crates/egui/src/containers/combo_box.rs @@ -1,9 +1,9 @@ use epaint::Shape; use crate::{ - epaint, style::WidgetVisuals, vec2, Align2, Context, Id, InnerResponse, NumExt, Painter, Popup, - PopupCloseBehavior, Rect, Response, ScrollArea, Sense, Stroke, TextStyle, TextWrapMode, Ui, - UiBuilder, Vec2, WidgetInfo, WidgetText, WidgetType, + epaint, style::StyleModifier, style::WidgetVisuals, vec2, Align2, Context, Id, InnerResponse, + NumExt, Painter, Popup, PopupCloseBehavior, Rect, Response, ScrollArea, Sense, Stroke, + TextStyle, TextWrapMode, Ui, UiBuilder, Vec2, WidgetInfo, WidgetText, WidgetType, }; #[allow(unused_imports)] // Documentation @@ -374,6 +374,7 @@ fn combo_box_dyn<'c, R>( let inner = Popup::menu(&button_response) .id(popup_id) + .style(StyleModifier::default()) .width(button_response.rect.width()) .close_behavior(close_behavior) .show(|ui| { diff --git a/crates/egui/src/containers/menu.rs b/crates/egui/src/containers/menu.rs new file mode 100644 index 00000000..26e2fb79 --- /dev/null +++ b/crates/egui/src/containers/menu.rs @@ -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::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) -> 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, + 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(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(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) -> 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(self, ui: &mut Ui, content: impl FnOnce(&mut Ui) -> R) -> InnerResponse { + 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, +} + +impl<'a> MenuButton<'a> { + pub fn new(text: impl Into) -> 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( + self, + ui: &mut Ui, + content: impl FnOnce(&mut Ui) -> R, + ) -> (Response, Option>) { + 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) -> 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( + self, + ui: &mut Ui, + content: impl FnOnce(&mut Ui) -> R, + ) -> (Response, Option>) { + 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, +} + +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( + self, + ui: &Ui, + button_response: &Response, + content: impl FnOnce(&mut Ui) -> R, + ) -> Option> { + 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::()); + } + } + + 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 + } +} diff --git a/crates/egui/src/containers/mod.rs b/crates/egui/src/containers/mod.rs index 8dce87e1..3bd89e0d 100644 --- a/crates/egui/src/containers/mod.rs +++ b/crates/egui/src/containers/mod.rs @@ -7,6 +7,7 @@ pub mod close_tag; pub mod collapsing_header; mod combo_box; pub mod frame; +pub mod menu; pub mod modal; pub mod old_popup; pub mod panel; diff --git a/crates/egui/src/containers/old_popup.rs b/crates/egui/src/containers/old_popup.rs index c803ecf4..fc4da4e2 100644 --- a/crates/egui/src/containers/old_popup.rs +++ b/crates/egui/src/containers/old_popup.rs @@ -196,7 +196,8 @@ pub fn popup_above_or_below_widget( ) -> Option { let response = Popup::from_response(widget_response) .layout(Layout::top_down_justified(Align::LEFT)) - .open_memory(None, close_behavior) + .open_memory(None) + .close_behavior(close_behavior) .id(popup_id) .align(match above_or_below { AboveOrBelow::Above => RectAlign::TOP_START, diff --git a/crates/egui/src/containers/popup.rs b/crates/egui/src/containers/popup.rs index 4cb41b93..4a457bf0 100644 --- a/crates/egui/src/containers/popup.rs +++ b/crates/egui/src/containers/popup.rs @@ -1,6 +1,8 @@ +use crate::containers::menu::{menu_style, MenuConfig, MenuState}; +use crate::style::StyleModifier; use crate::{ 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 std::iter::once; @@ -44,7 +46,8 @@ impl From for PopupAnchor { impl From<&Response> for PopupAnchor { 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) { widget_rect = to_global * widget_rect; } @@ -69,11 +72,12 @@ impl PopupAnchor { } /// Determines popup's close behavior -#[derive(Clone, Copy, PartialEq, Eq)] +#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)] pub enum PopupCloseBehavior { /// 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, /// Popup will be closed if the click happened somewhere else @@ -109,13 +113,10 @@ enum OpenKind<'a> { Closed, /// Open if the bool is true - Bool(&'a mut bool, PopupCloseBehavior), + Bool(&'a mut bool), /// Store the open state via [`crate::Memory`] - Memory { - set: Option, - close_behavior: PopupCloseBehavior, - }, + Memory { set: Option }, } impl OpenKind<'_> { @@ -124,7 +125,7 @@ impl OpenKind<'_> { match self { OpenKind::Open => true, OpenKind::Closed => false, - OpenKind::Bool(open, _) => **open, + OpenKind::Bool(open) => **open, OpenKind::Memory { .. } => ctx.memory(|mem| mem.is_popup_open(id)), } } @@ -138,6 +139,26 @@ pub enum PopupKind { 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 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> { id: Id, ctx: Context, @@ -146,6 +167,8 @@ pub struct Popup<'a> { alternative_aligns: Option<&'a [RectAlign]>, layer_id: LayerId, open_kind: OpenKind<'a>, + close_behavior: PopupCloseBehavior, + info: Option, kind: PopupKind, /// Gap between the anchor and the popup @@ -159,6 +182,7 @@ pub struct Popup<'a> { sense: Sense, layout: Layout, frame: Option, + style: StyleModifier, } impl<'a> Popup<'a> { @@ -169,6 +193,8 @@ impl<'a> Popup<'a> { ctx, anchor: anchor.into(), open_kind: OpenKind::Open, + close_behavior: PopupCloseBehavior::default(), + info: None, kind: PopupKind::Popup, layer_id, rect_align: RectAlign::BOTTOM_START, @@ -179,6 +205,7 @@ impl<'a> Popup<'a> { sense: Sense::click(), layout: Layout::default(), frame: None, + style: StyleModifier::default(), } } @@ -189,6 +216,13 @@ impl<'a> Popup<'a> { 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`]. /// This is the default position, and will be used if it fits. /// 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)`. pub fn menu(response: &Response) -> Self { Self::from_response(response) - .open_memory( - if response.clicked() { - Some(SetOpenCommand::Toggle) - } else { - None - }, - PopupCloseBehavior::CloseOnClick, - ) + .open_memory(response.clicked().then_some(SetOpenCommand::Toggle)) + .kind(PopupKind::Menu) .layout(Layout::top_down_justified(Align::Min)) + .style(menu_style) + .gap(0.0) } /// Show a context menu when the widget was secondary clicked. /// Sets the layout to `Layout::top_down_justified(Align::Min)`. /// In contrast to [`Self::menu`], this will open at the pointer position. pub fn context_menu(response: &Response) -> Self { - Self::from_response(response) + Self::menu(response) .open_memory( response .secondary_clicked() .then_some(SetOpenCommand::Bool(true)), - PopupCloseBehavior::CloseOnClick, ) - .layout(Layout::top_down_justified(Align::Min)) .at_pointer_fixed() } @@ -266,22 +294,17 @@ impl<'a> Popup<'a> { /// Store the open state via [`crate::Memory`]. /// You can set the state via the first [`SetOpenCommand`] param. #[inline] - pub fn open_memory( - mut self, - set_state: impl Into>, - close_behavior: PopupCloseBehavior, - ) -> Self { + pub fn open_memory(mut self, set_state: impl Into>) -> Self { self.open_kind = OpenKind::Memory { set: set_state.into(), - close_behavior, }; self } /// Store the open state via a mutable bool. #[inline] - pub fn open_bool(mut self, open: &'a mut bool, close_behavior: PopupCloseBehavior) -> Self { - self.open_kind = OpenKind::Bool(open, close_behavior); + pub fn open_bool(mut self, open: &'a mut bool) -> Self { + self.open_kind = OpenKind::Bool(open); self } @@ -290,16 +313,7 @@ impl<'a> Popup<'a> { /// This will do nothing if [`Popup::open`] was called. #[inline] pub fn close_behavior(mut self, close_behavior: PopupCloseBehavior) -> Self { - match &mut self.open_kind { - OpenKind::Memory { - close_behavior: behavior, - .. - } - | OpenKind::Bool(_, behavior) => { - *behavior = close_behavior; - } - _ => {} - } + self.close_behavior = close_behavior; self } @@ -339,6 +353,13 @@ impl<'a> Popup<'a> { 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. #[inline] pub fn sense(mut self, sense: Sense) -> Self { @@ -367,6 +388,17 @@ impl<'a> Popup<'a> { 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) -> Self { + self.style = style.into(); + self + } + /// Get the [`Context`] pub fn ctx(&self) -> &Context { &self.ctx @@ -408,11 +440,12 @@ impl<'a> Popup<'a> { match &self.open_kind { OpenKind::Open => true, OpenKind::Closed => false, - OpenKind::Bool(open, _) => **open, + OpenKind::Bool(open) => **open, 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 { AreaState::load(&self.ctx, self.id).and_then(|area| area.size) } @@ -459,7 +492,9 @@ impl<'a> Popup<'a> { ctx, anchor, open_kind, + close_behavior, kind, + info, layer_id, rect_align: _, alternative_aligns: _, @@ -469,6 +504,7 @@ impl<'a> Popup<'a> { sense, layout, frame, + style, } = self; let hover_pos = ctx.pointer_hover_pos(); @@ -497,13 +533,7 @@ impl<'a> Popup<'a> { return None; } - let (ui_kind, order) = match kind { - PopupKind::Popup => (UiKind::Popup, Order::Foreground), - PopupKind::Tooltip => (UiKind::Tooltip, Order::Tooltip), - PopupKind::Menu => (UiKind::Menu, Order::Foreground), - }; - - if kind == PopupKind::Popup { + if kind != PopupKind::Tooltip { ctx.pass_state_mut(|fs| { fs.layers .entry(layer_id) @@ -518,12 +548,17 @@ impl<'a> Popup<'a> { let (pivot, anchor) = best_align.pivot_pos(&anchor_rect, gap); let mut area = Area::new(id) - .order(order) - .kind(ui_kind) + .order(kind.order()) .pivot(pivot) .fixed_pos(anchor) .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 { area = area.default_width(width); @@ -531,31 +566,39 @@ impl<'a> Popup<'a> { 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 should_close = match close_behavior { - PopupCloseBehavior::CloseOnClick => widget_clicked_elsewhere, - PopupCloseBehavior::CloseOnClickOutside => { - widget_clicked_elsewhere && response.response.clicked_elsewhere() - } - PopupCloseBehavior::IgnoreClicks => false, - }; - - should_close - || ctx.input(|i| i.key_pressed(Key::Escape)) - || response.response.should_close() + let closed_by_click = match close_behavior { + PopupCloseBehavior::CloseOnClick => widget_clicked_elsewhere, + PopupCloseBehavior::CloseOnClickOutside => { + widget_clicked_elsewhere && response.response.clicked_elsewhere() + } + PopupCloseBehavior::IgnoreClicks => false, }; + // 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 { OpenKind::Open | OpenKind::Closed => {} - OpenKind::Bool(open, close_behavior) => { - if should_close(close_behavior) { + OpenKind::Bool(open) => { + if should_close { *open = false; } } - OpenKind::Memory { close_behavior, .. } => { - if should_close(close_behavior) { + OpenKind::Memory { .. } => { + if should_close { ctx.memory_mut(|mem| mem.close_popup()); } } diff --git a/crates/egui/src/context.rs b/crates/egui/src/context.rs index aa4b5340..0953573c 100644 --- a/crates/egui/src/context.rs +++ b/crates/egui/src/context.rs @@ -26,7 +26,6 @@ use crate::{ load, load::{Bytes, Loaders, SizedTexture}, memory::{Options, Theme}, - menu, os::OperatingSystem, output::FullOutput, 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. /// /// 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 { self.write(|ctx| { let viewport = ctx.viewport(); - viewport + let widget_rect = viewport .this_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)) } @@ -2626,12 +2637,27 @@ impl Context { } /// 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 { self.data(|d| { - d.get_temp::(menu::CONTEXT_MENU_ID_STR.into()) + d.get_temp::(crate::menu::CONTEXT_MENU_ID_STR.into()) .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 @@ -2676,7 +2702,8 @@ impl Context { /// /// 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) { self.memory_mut(|m| { if transform == TSTransform::IDENTITY { @@ -3166,13 +3193,14 @@ impl Context { } }); + #[allow(deprecated)] ui.horizontal(|ui| { ui.label(format!( "{} menu bars", - self.data(|d| d.count::()) + self.data(|d| d.count::()) )); if ui.button("Reset").clicked() { - self.data_mut(|d| d.remove_by_type::()); + self.data_mut(|d| d.remove_by_type::()); } }); diff --git a/crates/egui/src/gui_zoom.rs b/crates/egui/src/gui_zoom.rs index b0da1d1b..5e24ad2d 100644 --- a/crates/egui/src/gui_zoom.rs +++ b/crates/egui/src/gui_zoom.rs @@ -88,7 +88,6 @@ pub fn zoom_menu_buttons(ui: &mut Ui) { .clicked() { zoom_in(ui.ctx()); - ui.close(); } if ui @@ -99,7 +98,6 @@ pub fn zoom_menu_buttons(ui: &mut Ui) { .clicked() { zoom_out(ui.ctx()); - ui.close(); } if ui @@ -110,6 +108,5 @@ pub fn zoom_menu_buttons(ui: &mut Ui) { .clicked() { ui.ctx().set_zoom_factor(1.0); - ui.close(); } } diff --git a/crates/egui/src/input_state/mod.rs b/crates/egui/src/input_state/mod.rs index 2ccdc8b5..7864ce62 100644 --- a/crates/egui/src/input_state/mod.rs +++ b/crates/egui/src/input_state/mod.rs @@ -1333,6 +1333,18 @@ impl PointerState { pub fn middle_down(&self) -> bool { 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 { diff --git a/crates/egui/src/lib.rs b/crates/egui/src/lib.rs index 82b37c50..a6f46123 100644 --- a/crates/egui/src/lib.rs +++ b/crates/egui/src/lib.rs @@ -422,6 +422,7 @@ pub mod layers; mod layout; pub mod load; mod memory; +#[deprecated = "Use `egui::containers::menu` instead"] pub mod menu; pub mod os; mod painter; diff --git a/crates/egui/src/menu.rs b/crates/egui/src/menu.rs index 25cd213e..fa99cebb 100644 --- a/crates/egui/src/menu.rs +++ b/crates/egui/src/menu.rs @@ -1,3 +1,4 @@ +#![allow(deprecated)] //! Menu bar functionality (very basic so far). //! //! Usage: @@ -146,7 +147,7 @@ pub fn menu_image_button( /// Opens on hover. /// /// Returns `None` if the menu is not open. -pub(crate) fn submenu_button( +pub fn submenu_button( ui: &mut Ui, parent_state: Arc>, title: impl Into, @@ -267,7 +268,7 @@ fn stationary_menu_button_impl<'c, R>( pub(crate) const CONTEXT_MENU_ID_STR: &str = "__egui::context_menu"; /// Response to secondary clicks (right-clicks) by showing the given menu. -pub(crate) fn context_menu( +pub fn context_menu( response: &Response, add_contents: impl FnOnce(&mut Ui), ) -> Option> { @@ -282,7 +283,7 @@ pub(crate) fn context_menu( } /// 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 bar_state = BarState::load(&response.ctx, menu_id); bar_state.is_menu_open(response.id) diff --git a/crates/egui/src/response.rs b/crates/egui/src/response.rs index 863533d1..c3767f8a 100644 --- a/crates/egui/src/response.rs +++ b/crates/egui/src/response.rs @@ -2,8 +2,8 @@ use std::{any::Any, sync::Arc}; use crate::{ emath::{Align, Pos2, Rect, Vec2}, - menu, pass_state, Context, CursorIcon, Id, LayerId, PointerButton, Popup, PopupKind, Sense, - Tooltip, Ui, WidgetRect, WidgetText, + pass_state, Context, CursorIcon, Id, LayerId, PointerButton, Popup, PopupKind, Sense, Tooltip, + Ui, WidgetRect, WidgetText, }; // ---------------------------------------------------------------------------- @@ -935,14 +935,14 @@ impl Response { /// /// See also: [`Ui::menu_button`] and [`Ui::close`]. pub fn context_menu(&self, add_contents: impl FnOnce(&mut Ui)) -> Option> { - menu::context_menu(self, add_contents) + Popup::context_menu(self).show(add_contents) } /// Returns whether a context menu is currently open for this widget. /// /// See [`Self::context_menu`]. 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 diff --git a/crates/egui/src/style.rs b/crates/egui/src/style.rs index c84f2dc8..4d227f63 100644 --- a/crates/egui/src/style.rs +++ b/crates/egui/src/style.rs @@ -2,10 +2,9 @@ #![allow(clippy::if_same_then_else)] -use std::{collections::BTreeMap, ops::RangeInclusive, sync::Arc}; - use emath::Align; use epaint::{text::FontTweak, CornerRadius, Shadow, Stroke}; +use std::{collections::BTreeMap, ops::RangeInclusive, sync::Arc}; use crate::{ ecolor::Color32, @@ -174,6 +173,49 @@ impl From 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>); + +impl std::fmt::Debug for StyleModifier { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("StyleModifier") + } +} + +impl From for StyleModifier +where + T: Fn(&mut Style) + Send + Sync + 'static, +{ + fn from(f: T) -> Self { + Self(Some(Arc::new(f))) + } +} + +impl From