From 46fb9ff09bf4175b68d66c4b4d82183e32d1f3f3 Mon Sep 17 00:00:00 2001 From: Linus Behrbohm Date: Tue, 26 Oct 2021 19:55:42 +0200 Subject: [PATCH] Context menus (#543) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Main usage: `response.context_menu(…)` and `ui.menu_button` --- egui/src/context.rs | 16 + egui/src/menu.rs | 574 ++++++++++++++++-- egui/src/response.rs | 5 + egui/src/ui.rs | 48 +- egui_demo_lib/src/apps/demo/context_menu.rs | 174 ++++++ .../src/apps/demo/demo_app_windows.rs | 5 +- egui_demo_lib/src/apps/demo/drag_and_drop.rs | 35 +- egui_demo_lib/src/apps/demo/mod.rs | 1 + egui_demo_lib/src/apps/demo/widget_gallery.rs | 7 +- egui_demo_lib/src/apps/demo/window_options.rs | 1 - 10 files changed, 792 insertions(+), 74 deletions(-) create mode 100644 egui_demo_lib/src/apps/demo/context_menu.rs diff --git a/egui/src/context.rs b/egui/src/context.rs index 6b9130c1..fb666604 100644 --- a/egui/src/context.rs +++ b/egui/src/context.rs @@ -11,6 +11,7 @@ use crate::{ frame_state::FrameState, input_state::*, layers::GraphicLayers, + menu::ContextMenuSystem, mutex::{Mutex, MutexGuard}, *, }; @@ -303,6 +304,15 @@ impl CtxRef { pub fn debug_painter(&self) -> Painter { Self::layer_painter(self, LayerId::debug()) } + + pub(crate) fn show_context_menu( + &self, + response: &Response, + add_contents: impl FnOnce(&mut Ui), + ) { + self.context_menu_system() + .context_menu(response, add_contents); + } } // ---------------------------------------------------------------------------- @@ -329,6 +339,7 @@ pub struct Context { fonts: Option>, memory: Arc>, animation_manager: Arc>, + context_menu_system: Arc>, input: InputState, @@ -357,6 +368,7 @@ impl Clone for Context { output: self.output.clone(), paint_stats: self.paint_stats.clone(), repaint_requests: self.repaint_requests.load(SeqCst).into(), + context_menu_system: self.context_menu_system.clone(), } } } @@ -375,6 +387,10 @@ impl Context { self.memory.lock() } + pub(crate) fn context_menu_system(&self) -> MutexGuard<'_, ContextMenuSystem> { + self.context_menu_system.lock() + } + pub(crate) fn graphics(&self) -> MutexGuard<'_, GraphicLayers> { self.graphics.lock() } diff --git a/egui/src/menu.rs b/egui/src/menu.rs index 0ecd0f99..227ca3ba 100644 --- a/egui/src/menu.rs +++ b/egui/src/menu.rs @@ -6,7 +6,7 @@ //! use egui::{menu, Button}; //! //! menu::bar(ui, |ui| { -//! menu::menu(ui, "File", |ui| { +//! ui.menu_button("File", |ui| { //! if ui.button("Open").clicked() { //! // … //! } @@ -15,25 +15,53 @@ //! } //! ``` +use super::{ + style::{Spacing, WidgetVisuals}, + Align, CtxRef, Id, InnerResponse, PointerState, Pos2, Rect, Response, Sense, Style, TextStyle, + Ui, Vec2, +}; use crate::{widgets::*, *}; -use epaint::Stroke; +use epaint::{mutex::RwLock, Stroke}; +use std::sync::Arc; /// What is saved between frames. -#[derive(Clone, Copy, Debug, Default)] -#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] -#[cfg_attr(feature = "serde", serde(default))] +#[derive(Clone, Default)] pub(crate) struct BarState { - open_menu: Option, + open_menu: MenuRootManager, } impl BarState { fn load(ctx: &Context, bar_id: &Id) -> Self { - *ctx.memory().id_data_temp.get_or_default(*bar_id) + ctx.memory() + .id_data_temp + .get_or_default::(*bar_id) + .clone() } fn save(self, ctx: &Context, bar_id: Id) { ctx.memory().id_data_temp.insert(bar_id, self); } + /// Show a menu at pointer if right-clicked response. + /// Should be called from [`Context`] on a [`Response`] + pub fn bar_menu( + &mut self, + response: &Response, + add_contents: impl FnOnce(&mut Ui) -> R, + ) -> Option> { + MenuRoot::stationary_click_interaction(response, &mut self.open_menu, response.id); + self.open_menu.show(response, add_contents) + } +} +impl std::ops::Deref for BarState { + type Target = MenuRootManager; + fn deref(&self) -> &Self::Target { + &self.open_menu + } +} +impl std::ops::DerefMut for BarState { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.open_menu + } } /// The menu bar goes well in a [`TopBottomPanel::top`], @@ -58,58 +86,77 @@ pub fn bar(ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) -> InnerResp add_contents(ui) }) } - /// Construct a top level menu in a menu bar. This would be e.g. "File", "Edit" etc. /// /// Returns `None` if the menu is not open. -pub fn menu( +pub fn menu_button( ui: &mut Ui, title: impl ToString, add_contents: impl FnOnce(&mut Ui) -> R, -) -> Option { - menu_impl(ui, title, Box::new(add_contents)) +) -> InnerResponse> { + stationary_menu_impl(ui, title, Box::new(add_contents)) +} +/// Construct a nested sub menu in another menu. +/// +/// Returns `None` if the menu is not open. +pub(crate) fn submenu_button( + ui: &mut Ui, + parent_state: Arc>, + title: impl ToString, + add_contents: impl FnOnce(&mut Ui) -> R, +) -> InnerResponse> { + SubMenu::new(parent_state, title).show(ui, add_contents) } +/// wrapper for the contents of every menu. +#[allow(clippy::needless_pass_by_value)] pub(crate) fn menu_ui<'c, R>( ctx: &CtxRef, menu_id: impl std::hash::Hash, - pos: Pos2, + menu_state_arc: Arc>, mut style: Style, add_contents: impl FnOnce(&mut Ui) -> R + 'c, ) -> InnerResponse { + let pos = { + let mut menu_state = menu_state_arc.write(); + menu_state.entry_count = 0; + menu_state.rect.min + }; + // style.visuals.widgets.active.bg_fill = Color32::TRANSPARENT; + style.visuals.widgets.active.bg_stroke = Stroke::none(); + // style.visuals.widgets.hovered.bg_fill = Color32::TRANSPARENT; + style.visuals.widgets.hovered.bg_stroke = Stroke::none(); + style.visuals.widgets.inactive.bg_fill = Color32::TRANSPARENT; + style.visuals.widgets.inactive.bg_stroke = Stroke::none(); let area = Area::new(menu_id) .order(Order::Foreground) .fixed_pos(pos) .interactable(false) .drag_bounds(Rect::EVERYTHING); let frame = Frame::menu(&style); - - area.show(ctx, |ui| { + let inner_response = area.show(ctx, |ui| { frame .show(ui, |ui| { const DEFAULT_MENU_WIDTH: f32 = 150.0; // TODO: add to ui.spacing ui.set_max_width(DEFAULT_MENU_WIDTH); - - // style.visuals.widgets.active.bg_fill = Color32::TRANSPARENT; - style.visuals.widgets.active.bg_stroke = Stroke::none(); - // style.visuals.widgets.hovered.bg_fill = Color32::TRANSPARENT; - style.visuals.widgets.hovered.bg_stroke = Stroke::none(); - style.visuals.widgets.inactive.bg_fill = Color32::TRANSPARENT; - style.visuals.widgets.inactive.bg_stroke = Stroke::none(); ui.set_style(style); + ui.set_menu_state(Some(menu_state_arc.clone())); ui.with_layout(Layout::top_down_justified(Align::LEFT), add_contents) .inner }) .inner - }) + }); + menu_state_arc.write().rect = inner_response.response.rect; + inner_response } +/// build a top level menu with a button #[allow(clippy::needless_pass_by_value)] -fn menu_impl<'c, R>( +fn stationary_menu_impl<'c, R>( ui: &mut Ui, title: impl ToString, add_contents: Box R + 'c>, -) -> Option { +) -> InnerResponse> { let title = title.to_string(); let bar_id = ui.id(); let menu_id = bar_id.with(&title); @@ -118,43 +165,458 @@ fn menu_impl<'c, R>( let mut button = Button::new(title); - if bar_state.open_menu == Some(menu_id) { + if bar_state.open_menu.is_menu_open(menu_id) { button = button.fill(ui.visuals().widgets.open.bg_fill); button = button.stroke(ui.visuals().widgets.open.bg_stroke); } let button_response = ui.add(button); - if button_response.clicked() { - // Toggle - if bar_state.open_menu == Some(menu_id) { - bar_state.open_menu = None; - } else { - bar_state.open_menu = Some(menu_id); - } - } else if button_response.hovered() && bar_state.open_menu.is_some() { - bar_state.open_menu = Some(menu_id); - } - - let inner = if bar_state.open_menu == Some(menu_id) || ui.ctx().memory().everything_is_visible() - { - let inner = menu_ui( - ui.ctx(), - menu_id, - button_response.rect.left_bottom(), - ui.style().as_ref().clone(), - add_contents, - ) - .inner; - - // TODO: this prevents sub-menus in menus. We should fix that. - if ui.input().key_pressed(Key::Escape) || button_response.clicked_elsewhere() { - bar_state.open_menu = None; - } - Some(inner) - } else { - None - }; + let inner = bar_state.bar_menu(&button_response, add_contents); bar_state.save(ui.ctx(), bar_id); - inner + InnerResponse::new(inner.map(|r| r.inner), button_response) +} + +/// Stores the state for the context menu. +#[derive(Default)] +pub(crate) struct ContextMenuSystem { + root: MenuRootManager, +} +impl ContextMenuSystem { + /// Show a menu at pointer if right-clicked response. + /// Should be called from [`Context`] on a [`Response`] + pub fn context_menu( + &mut self, + response: &Response, + add_contents: impl FnOnce(&mut Ui), + ) -> Option> { + MenuRoot::context_click_interaction(response, &mut self.root, response.id); + self.root.show(response, add_contents) + } +} +impl std::ops::Deref for ContextMenuSystem { + type Target = MenuRootManager; + fn deref(&self) -> &Self::Target { + &self.root + } +} +impl std::ops::DerefMut for ContextMenuSystem { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.root + } +} + +/// Stores the state for the context menu. +#[derive(Clone, Default)] +pub(crate) struct MenuRootManager { + inner: Option, +} +impl MenuRootManager { + /// Show a menu at pointer if right-clicked response. + /// Should be called from [`Context`] on a [`Response`] + pub fn show( + &mut self, + response: &Response, + add_contents: impl FnOnce(&mut Ui) -> R, + ) -> Option> { + if let Some(root) = self.inner.as_mut() { + let (menu_response, inner_response) = root.show(response, add_contents); + if let MenuResponse::Close = menu_response { + self.inner = None; + } + inner_response + } else { + None + } + } + fn is_menu_open(&self, id: Id) -> bool { + self.inner.as_ref().map(|m| m.id) == Some(id) + } +} +impl std::ops::Deref for MenuRootManager { + type Target = Option; + fn deref(&self) -> &Self::Target { + &self.inner + } +} +impl std::ops::DerefMut for MenuRootManager { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.inner + } +} + +/// Menu root associated with an Id from a Response +#[derive(Clone)] +pub(crate) struct MenuRoot { + pub menu_state: Arc>, + pub id: Id, +} + +impl MenuRoot { + pub fn new(position: Pos2, id: Id) -> Self { + Self { + menu_state: Arc::new(RwLock::new(MenuState::new(position))), + id, + } + } + pub fn show( + &mut self, + response: &Response, + add_contents: impl FnOnce(&mut Ui) -> R, + ) -> (MenuResponse, Option>) { + if self.id == response.id { + let inner_response = + MenuState::show(&response.ctx, &self.menu_state, self.id, add_contents); + let mut menu_state = self.menu_state.write(); + menu_state.rect = inner_response.response.rect; + + if menu_state.response.is_close() { + return (MenuResponse::Close, Some(inner_response)); + } + } + (MenuResponse::Stay, None) + } + /// interaction with a stationary menu, i.e. fixed in another Ui + fn stationary_interaction( + response: &Response, + root: &mut MenuRootManager, + id: Id, + ) -> MenuResponse { + let pointer = &response.ctx.input().pointer; + if (response.clicked() && root.is_menu_open(id)) + || response.ctx.input().key_pressed(Key::Escape) + { + // menu open and button clicked or esc pressed + return MenuResponse::Close; + } else if (response.clicked() && !root.is_menu_open(id)) + || (response.hovered() && root.is_some()) + { + // menu not open and button clicked + // or button hovered while other menu is open + let pos = response.rect.left_bottom(); + return MenuResponse::Create(pos, id); + } else if pointer.any_pressed() && pointer.primary_down() { + if let Some(pos) = pointer.interact_pos() { + if let Some(root) = root.inner.as_mut() { + if root.id == id { + // pressed somewhere while this menu is open + let menu_state = root.menu_state.read(); + let in_menu = menu_state.area_contains(pos); + if !in_menu { + return MenuResponse::Close; + } + } + } + } + } + MenuResponse::Stay + } + /// interaction with a context menu + fn context_interaction( + response: &Response, + root: &mut Option, + id: Id, + ) -> MenuResponse { + let response = response.interact(Sense::click()); + let pointer = &response.ctx.input().pointer; + if pointer.any_pressed() { + if let Some(pos) = pointer.interact_pos() { + let mut destroy = false; + let mut in_old_menu = false; + if let Some(root) = root { + let menu_state = root.menu_state.read(); + in_old_menu = menu_state.area_contains(pos); + destroy = root.id == response.id; + } + if !in_old_menu { + let in_target = response.rect.contains(pos); + if in_target && pointer.secondary_down() { + return MenuResponse::Create(pos, id); + } else if (in_target && pointer.primary_down()) || destroy { + return MenuResponse::Close; + } + } + } + } + MenuResponse::Stay + } + fn handle_menu_response(root: &mut MenuRootManager, menu_response: MenuResponse) { + match menu_response { + MenuResponse::Create(pos, id) => { + root.inner = Some(MenuRoot::new(pos, id)); + } + MenuResponse::Close => root.inner = None, + MenuResponse::Stay => {} + } + } + pub fn context_click_interaction(response: &Response, root: &mut MenuRootManager, id: Id) { + let menu_response = Self::context_interaction(response, root, id); + Self::handle_menu_response(root, menu_response); + } + pub fn stationary_click_interaction(response: &Response, root: &mut MenuRootManager, id: Id) { + let menu_response = Self::stationary_interaction(response, root, id); + Self::handle_menu_response(root, menu_response); + } +} +#[derive(Copy, Clone, PartialEq)] +pub(crate) enum MenuResponse { + Close, + Stay, + Create(Pos2, Id), +} +impl MenuResponse { + pub fn is_close(&self) -> bool { + *self == Self::Close + } +} +pub struct SubMenuButton { + text: String, + icon: String, + index: usize, +} +impl SubMenuButton { + /// The `icon` can be an emoji (e.g. `⏵` right arrow), shown right of the label + #[allow(clippy::needless_pass_by_value)] + fn new(text: impl ToString, icon: impl ToString, index: usize) -> Self { + Self { + text: text.to_string(), + icon: icon.to_string(), + index, + } + } + fn visuals<'a>( + ui: &'a Ui, + response: &'_ Response, + menu_state: &'_ MenuState, + sub_id: Id, + ) -> &'a WidgetVisuals { + if menu_state.is_open(sub_id) { + &ui.style().visuals.widgets.hovered + } else { + ui.style().interact(response) + } + } + #[allow(clippy::needless_pass_by_value)] + pub fn icon(mut self, icon: impl ToString) -> Self { + self.icon = icon.to_string(); + self + } + pub(crate) fn show(self, ui: &mut Ui, menu_state: &MenuState, sub_id: Id) -> Response { + let SubMenuButton { text, icon, .. } = self; + + let text_style = TextStyle::Button; + let sense = Sense::click(); + + let button_padding = ui.spacing().button_padding; + let total_extra = button_padding + button_padding; + let text_available_width = ui.available_width() - total_extra.x; + let text_galley = ui + .fonts() + .layout_delayed_color(text, text_style, text_available_width); + + let icon_available_width = text_available_width - text_galley.size().x; + let icon_galley = ui + .fonts() + .layout_delayed_color(icon, text_style, icon_available_width); + let text_and_icon_size = Vec2::new( + text_galley.size().x + icon_galley.size().x, + text_galley.size().y.max(icon_galley.size().y), + ); + let desired_size = text_and_icon_size + 2.0 * button_padding; + + let (rect, response) = ui.allocate_at_least(desired_size, sense); + response.widget_info(|| { + crate::WidgetInfo::labeled(crate::WidgetType::Button, &text_galley.text()) + }); + + if ui.clip_rect().intersects(rect) { + let visuals = Self::visuals(ui, &response, menu_state, sub_id); + let text_pos = Align2::LEFT_CENTER + .align_size_within_rect(text_galley.size(), rect.shrink2(button_padding)) + .min; + let icon_pos = Align2::RIGHT_CENTER + .align_size_within_rect(icon_galley.size(), rect.shrink2(button_padding)) + .min; + + ui.painter().rect_filled( + rect.expand(visuals.expansion), + visuals.corner_radius, + visuals.bg_fill, + ); + + let text_color = visuals.text_color(); + ui.painter() + .galley_with_color(text_pos, text_galley, text_color); + ui.painter() + .galley_with_color(icon_pos, icon_galley, text_color); + } + response + } +} +pub struct SubMenu { + button: SubMenuButton, + parent_state: Arc>, +} +impl SubMenu { + #[allow(clippy::needless_pass_by_value)] + fn new(parent_state: Arc>, text: impl ToString) -> Self { + let index = parent_state.write().next_entry_index(); + Self { + button: SubMenuButton::new(text, "⏵", index), + parent_state, + } + } + pub fn show( + self, + ui: &mut Ui, + add_contents: impl FnOnce(&mut Ui) -> R, + ) -> InnerResponse> { + let sub_id = ui.id().with(self.button.index); + let button = self.button.show(ui, &*self.parent_state.read(), sub_id); + self.parent_state + .write() + .submenu_button_interaction(ui, sub_id, &button); + let inner = self + .parent_state + .write() + .show_submenu(ui.ctx(), sub_id, add_contents); + InnerResponse::new(inner, button) + } +} +pub(crate) struct MenuState { + /// The opened sub-menu and its `Id` + sub_menu: Option<(Id, Arc>)>, + /// Bounding box of this menu (without the sub-menu) + pub rect: Rect, + /// Used to check if any menu in the tree wants to close + pub response: MenuResponse, + /// Used to hash different `Id`s for sub-menus + entry_count: usize, +} +impl MenuState { + pub fn new(position: Pos2) -> Self { + Self { + rect: Rect::from_min_size(position, Vec2::ZERO), + sub_menu: None, + response: MenuResponse::Stay, + entry_count: 0, + } + } + /// Close menu hierarchy. + pub fn close(&mut self) { + self.response = MenuResponse::Close; + } + pub fn show( + ctx: &CtxRef, + menu_state: &Arc>, + id: Id, + add_contents: impl FnOnce(&mut Ui) -> R, + ) -> InnerResponse { + let style = Style { + spacing: Spacing { + item_spacing: Vec2::ZERO, + button_padding: crate::vec2(2.0, 0.0), + ..Default::default() + }, + ..Default::default() + }; + let menu_state_arc = menu_state.clone(); + crate::menu::menu_ui(ctx, id, menu_state_arc, style, add_contents) + } + fn show_submenu( + &mut self, + ctx: &CtxRef, + id: Id, + add_contents: impl FnOnce(&mut Ui) -> R, + ) -> Option { + let (sub_response, response) = self.get_submenu(id).map(|sub| { + let inner_response = Self::show(ctx, sub, id, add_contents); + (sub.read().response, inner_response.inner) + })?; + self.cascade_close_response(sub_response); + Some(response) + } + /// Check if position is in the menu hierarchy's area. + pub fn area_contains(&self, pos: Pos2) -> bool { + self.rect.contains(pos) + || self + .sub_menu + .as_ref() + .map_or(false, |(_, sub)| sub.read().area_contains(pos)) + } + fn next_entry_index(&mut self) -> usize { + self.entry_count += 1; + self.entry_count - 1 + } + /// Sense button interaction opening and closing submenu. + fn submenu_button_interaction(&mut self, ui: &mut Ui, sub_id: Id, button: &Response) { + let pointer = &ui.input().pointer.clone(); + let open = self.is_open(sub_id); + if self.moving_towards_current_submenu(pointer) { + // ensure to repaint once even when pointer is not moving + ui.ctx().request_repaint(); + } else if !open && button.hovered() { + let pos = button.rect.right_top(); + self.open_submenu(sub_id, pos); + } else if open && !button.hovered() && !self.hovering_current_submenu(pointer) { + self.close_submenu(); + } + } + /// Check if `dir` points from `pos` towards left side of `rect`. + fn points_at_left_of_rect(pos: Pos2, dir: Vec2, rect: Rect) -> bool { + let vel_a = dir.angle(); + let top_a = (rect.left_top() - pos).angle(); + let bottom_a = (rect.left_bottom() - pos).angle(); + bottom_a - vel_a >= 0.0 && top_a - vel_a <= 0.0 + } + /// Check if pointer is moving towards current submenu. + fn moving_towards_current_submenu(&self, pointer: &PointerState) -> bool { + if pointer.is_still() { + return false; + } + if let Some(sub_menu) = self.get_current_submenu() { + if let Some(pos) = pointer.hover_pos() { + return Self::points_at_left_of_rect(pos, pointer.velocity(), sub_menu.read().rect); + } + } + false + } + /// Check if pointer is hovering current submenu. + fn hovering_current_submenu(&self, pointer: &PointerState) -> bool { + if let Some(sub_menu) = self.get_current_submenu() { + if let Some(pos) = pointer.hover_pos() { + return sub_menu.read().area_contains(pos); + } + } + false + } + /// Cascade close response to menu root. + fn cascade_close_response(&mut self, response: MenuResponse) { + if response.is_close() { + self.response = response; + } + } + fn is_open(&self, id: Id) -> bool { + self.get_sub_id() == Some(id) + } + fn get_sub_id(&self) -> Option { + self.sub_menu.as_ref().map(|(id, _)| *id) + } + fn get_current_submenu(&self) -> Option<&Arc>> { + self.sub_menu.as_ref().map(|(_, sub)| sub) + } + fn get_submenu(&mut self, id: Id) -> Option<&Arc>> { + self.sub_menu + .as_ref() + .and_then(|(k, sub)| if id == *k { Some(sub) } else { None }) + } + /// Open submenu at position, if not already open. + fn open_submenu(&mut self, id: Id, pos: Pos2) { + if !self.is_open(id) { + self.sub_menu = Some((id, Arc::new(RwLock::new(MenuState::new(pos))))); + } + } + fn close_submenu(&mut self) { + self.sub_menu = None; + } } diff --git a/egui/src/response.rs b/egui/src/response.rs index 0a345f6e..8d8e0031 100644 --- a/egui/src/response.rs +++ b/egui/src/response.rs @@ -470,6 +470,11 @@ impl Response { self.ctx.output().events.push(event); } } + + pub fn context_menu(&self, add_contents: impl FnOnce(&mut Ui)) -> &Self { + self.ctx.show_context_menu(self, add_contents); + self + } } impl Response { diff --git a/egui/src/ui.rs b/egui/src/ui.rs index acf6d4d0..833785dd 100644 --- a/egui/src/ui.rs +++ b/egui/src/ui.rs @@ -1,10 +1,12 @@ // #![warn(missing_docs)] +use epaint::mutex::RwLock; use std::hash::Hash; +use std::sync::Arc; use crate::{ - color::*, containers::*, epaint::text::Fonts, layout::*, mutex::MutexGuard, placer::Placer, - widgets::*, *, + color::*, containers::*, epaint::text::Fonts, layout::*, menu::MenuState, mutex::MutexGuard, + placer::Placer, widgets::*, *, }; // ---------------------------------------------------------------------------- @@ -54,6 +56,9 @@ pub struct Ui { /// If false we are unresponsive to input, /// and all widgets will assume a gray style. enabled: bool, + + /// Indicates whether this Ui belongs to a Menu. + menu_state: Option>>, } impl Ui { @@ -73,6 +78,7 @@ impl Ui { style, placer: Placer::new(max_rect, Layout::default()), enabled: true, + menu_state: None, } } @@ -91,7 +97,7 @@ impl Ui { crate::egui_assert!(!max_rect.any_nan()); let next_auto_id_source = Id::new(self.next_auto_id_source).with("child").value(); self.next_auto_id_source = self.next_auto_id_source.wrapping_add(1); - + let menu_state = self.get_menu_state(); Ui { id: self.id.with(id_source), next_auto_id_source, @@ -99,6 +105,7 @@ impl Ui { style: self.style.clone(), placer: Placer::new(max_rect, layout), enabled: self.enabled, + menu_state, } } @@ -1730,6 +1737,41 @@ impl Ui { self.advance_cursor_after_rect(Rect::from_min_size(top_left, size)); result } + /// Close menu (with submenus), if any. + pub fn close_menu(&mut self) { + if let Some(menu_state) = &mut self.menu_state { + menu_state.write().close(); + } + self.menu_state = None; + } + pub(crate) fn get_menu_state(&self) -> Option>> { + self.menu_state.clone() + } + pub(crate) fn set_menu_state(&mut self, menu_state: Option>>) { + self.menu_state = menu_state; + } + #[inline(always)] + /// Create a menu button. Creates a button for a sub-menu when the `Ui` is inside a menu. + /// + /// ``` + /// # let mut ui = egui::Ui::__test(); + /// ui.menu_button("My menu", |ui| { + /// ui.menu_button("My sub-menu", |ui| { + /// ui.label("Item"); + /// }); + /// }); + /// ``` + pub fn menu_button( + &mut self, + title: impl ToString, + add_contents: impl FnOnce(&mut Ui) -> R, + ) -> InnerResponse> { + if let Some(menu_state) = self.menu_state.clone() { + menu::submenu_button(self, menu_state, title, add_contents) + } else { + menu::menu_button(self, title, add_contents) + } + } } // ---------------------------------------------------------------------------- diff --git a/egui_demo_lib/src/apps/demo/context_menu.rs b/egui_demo_lib/src/apps/demo/context_menu.rs new file mode 100644 index 00000000..d06e6fd3 --- /dev/null +++ b/egui_demo_lib/src/apps/demo/context_menu.rs @@ -0,0 +1,174 @@ +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "persistence", derive(serde::Deserialize, serde::Serialize))] +enum Plot { + Sin, + Bell, + Sigmoid, +} + +fn gaussian(x: f64) -> f64 { + let var: f64 = 2.0; + f64::exp(-(x / var).powi(2)) / (var * f64::sqrt(std::f64::consts::TAU)) +} +fn sigmoid(x: f64) -> f64 { + -1.0 + 2.0 / (1.0 + f64::exp(-x)) +} + +#[derive(Clone, PartialEq)] +#[cfg_attr(feature = "persistence", derive(serde::Deserialize, serde::Serialize))] +pub struct ContextMenus { + title: String, + plot: Plot, + show_axes: [bool; 2], + allow_drag: bool, + allow_zoom: bool, + center_x_axis: bool, + center_y_axis: bool, + width: f32, + height: f32, +} + +impl ContextMenus { + fn example_plot(&self) -> egui::plot::Plot { + use egui::plot::{Line, Value, Values}; + let n = 128; + let line = Line::new(Values::from_values_iter((0..=n).map(|i| { + use std::f64::consts::TAU; + let x = egui::remap(i as f64, 0.0..=n as f64, -TAU..=TAU); + match self.plot { + Plot::Sin => Value::new(x, x.sin()), + Plot::Bell => Value::new(x, 10.0 * gaussian(x)), + Plot::Sigmoid => Value::new(x, sigmoid(x)), + } + }))); + egui::plot::Plot::new("example_plot") + .show_axes(self.show_axes) + .allow_drag(self.allow_drag) + .allow_zoom(self.allow_zoom) + .center_x_axis(self.center_x_axis) + .center_x_axis(self.center_y_axis) + .line(line) + .width(self.width) + .height(self.height) + .data_aspect(1.0) + } + fn nested_menus(ui: &mut egui::Ui) { + if ui.button("Open...").clicked() { + ui.close_menu(); + } + ui.menu_button("SubMenu", |ui| { + ui.menu_button("SubMenu", |ui| { + if ui.button("Open...").clicked() { + ui.close_menu(); + } + let _ = ui.button("Item"); + }); + ui.menu_button("SubMenu", |ui| { + if ui.button("Open...").clicked() { + ui.close_menu(); + } + let _ = ui.button("Item"); + }); + let _ = ui.button("Item"); + if ui.button("Open...").clicked() { + ui.close_menu(); + } + }); + 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_menu(); + } + }); + let _ = ui.button("Very long text for this item"); + } +} + +const DEFAULT_TITLE: &str = "☰ Context Menus"; + +impl Default for ContextMenus { + fn default() -> Self { + Self { + title: DEFAULT_TITLE.to_owned(), + plot: Plot::Sin, + show_axes: [true, true], + allow_drag: true, + allow_zoom: true, + center_x_axis: false, + center_y_axis: false, + width: 400.0, + height: 200.0, + } + } +} +impl super::Demo for ContextMenus { + fn name(&self) -> &'static str { + DEFAULT_TITLE + } + + fn show(&mut self, ctx: &egui::CtxRef, open: &mut bool) { + let Self { title, .. } = self.clone(); + + use super::View; + let window = egui::Window::new(title) + .id(egui::Id::new("demo_context_menus")) // required since we change the title + .vscroll(false) + .open(open); + window.show(ctx, |ui| self.ui(ui)); + } +} + +impl super::View for ContextMenus { + fn ui(&mut self, ui: &mut egui::Ui) { + ui.horizontal(|ui| ui.text_edit_singleline(&mut self.title)); + ui.horizontal(|ui| { + ui.add(self.example_plot()) + .on_hover_text("Right click for options") + .context_menu(|ui| { + ui.menu_button("Plot", |ui| { + if ui.radio_value(&mut self.plot, Plot::Sin, "Sin").clicked() + || ui + .radio_value(&mut self.plot, Plot::Bell, "Gaussian") + .clicked() + || ui + .radio_value(&mut self.plot, Plot::Sigmoid, "Sigmoid") + .clicked() + { + ui.close_menu(); + } + }); + egui::Grid::new("button_grid").show(ui, |ui| { + ui.add( + egui::DragValue::new(&mut self.width) + .speed(1.0) + .prefix("Width:"), + ); + ui.add( + egui::DragValue::new(&mut self.height) + .speed(1.0) + .prefix("Height:"), + ); + ui.end_row(); + ui.checkbox(&mut self.show_axes[0], "x-Axis"); + ui.checkbox(&mut self.show_axes[1], "y-Axis"); + ui.end_row(); + if ui.checkbox(&mut self.allow_drag, "Drag").changed() + || ui.checkbox(&mut self.allow_zoom, "Zoom").changed() + { + ui.close_menu(); + } + }); + }); + }); + ui.label("Right-click plot to edit it!"); + ui.separator(); + ui.horizontal(|ui| { + ui.menu_button("Click for menu", Self::nested_menus); + ui.button("Right-click for menu") + .context_menu(Self::nested_menus); + }); + } +} diff --git a/egui_demo_lib/src/apps/demo/demo_app_windows.rs b/egui_demo_lib/src/apps/demo/demo_app_windows.rs index 586e1b2c..437e2278 100644 --- a/egui_demo_lib/src/apps/demo/demo_app_windows.rs +++ b/egui_demo_lib/src/apps/demo/demo_app_windows.rs @@ -18,6 +18,7 @@ impl Default for Demos { Self::from_demos(vec![ Box::new(super::code_editor::CodeEditor::default()), Box::new(super::code_example::CodeExample::default()), + Box::new(super::context_menu::ContextMenus::default()), Box::new(super::dancing_strings::DancingStrings::default()), Box::new(super::drag_and_drop::DragAndDropDemo::default()), Box::new(super::font_book::FontBook::default()), @@ -227,9 +228,10 @@ fn show_menu_bar(ui: &mut Ui) { use egui::*; menu::bar(ui, |ui| { - menu::menu(ui, "File", |ui| { + ui.menu_button("File", |ui| { if ui.button("Organize windows").clicked() { ui.ctx().memory().reset_areas(); + ui.close_menu(); } if ui .button("Reset egui memory") @@ -237,6 +239,7 @@ fn show_menu_bar(ui: &mut Ui) { .clicked() { *ui.ctx().memory() = Default::default(); + ui.close_menu(); } }); }); diff --git a/egui_demo_lib/src/apps/demo/drag_and_drop.rs b/egui_demo_lib/src/apps/demo/drag_and_drop.rs index 2d272511..cb44e2ff 100644 --- a/egui_demo_lib/src/apps/demo/drag_and_drop.rs +++ b/egui_demo_lib/src/apps/demo/drag_and_drop.rs @@ -75,12 +75,12 @@ pub fn drop_target( InnerResponse::new(ret, response) } - +#[derive(Clone, PartialEq)] +#[cfg_attr(feature = "persistence", derive(serde::Deserialize, serde::Serialize))] pub struct DragAndDropDemo { /// columns with items - columns: Vec>, + columns: Vec>, } - impl Default for DragAndDropDemo { fn default() -> Self { Self { @@ -88,7 +88,10 @@ impl Default for DragAndDropDemo { vec!["Item A", "Item B", "Item C"], vec!["Item D", "Item E"], vec!["Item F", "Item G", "Item H"], - ], + ] + .into_iter() + .map(|v| v.into_iter().map(ToString::to_string).collect()) + .collect(), } } } @@ -114,20 +117,25 @@ impl super::View for DragAndDropDemo { ui.label("This is a proof-of-concept of drag-and-drop in egui."); ui.label("Drag items between columns."); + let id_source = "my_drag_and_drop_demo"; let mut source_col_row = None; let mut drop_col = None; - ui.columns(self.columns.len(), |uis| { - for (col_idx, column) in self.columns.iter().enumerate() { + for (col_idx, column) in self.columns.clone().into_iter().enumerate() { let ui = &mut uis[col_idx]; let can_accept_what_is_being_dragged = true; // We accept anything being dragged (for now) ¯\_(ツ)_/¯ let response = drop_target(ui, can_accept_what_is_being_dragged, |ui| { ui.set_min_size(vec2(64.0, 100.0)); - - for (row_idx, &item) in column.iter().enumerate() { - let item_id = Id::new("item").with(col_idx).with(row_idx); + for (row_idx, item) in column.iter().enumerate() { + let item_id = Id::new(id_source).with(col_idx).with(row_idx); drag_source(ui, item_id, |ui| { - ui.label(item); + let response = ui.add(Label::new(item).sense(Sense::click())); + response.context_menu(|ui| { + if ui.button("Remove").clicked() { + self.columns[col_idx].remove(row_idx); + ui.close_menu(); + } + }); }); if ui.memory().is_being_dragged(item_id) { @@ -137,6 +145,13 @@ impl super::View for DragAndDropDemo { }) .response; + response.context_menu(|ui| { + if ui.button("New Item").clicked() { + self.columns[col_idx].push("New Item".to_string()); + ui.close_menu(); + } + }); + let is_being_dragged = ui.memory().is_anything_being_dragged(); if is_being_dragged && can_accept_what_is_being_dragged && response.hovered() { drop_col = Some(col_idx); diff --git a/egui_demo_lib/src/apps/demo/mod.rs b/egui_demo_lib/src/apps/demo/mod.rs index f519aeec..e8f1a688 100644 --- a/egui_demo_lib/src/apps/demo/mod.rs +++ b/egui_demo_lib/src/apps/demo/mod.rs @@ -7,6 +7,7 @@ mod app; pub mod code_editor; pub mod code_example; +pub mod context_menu; pub mod dancing_strings; pub mod demo_app_windows; pub mod drag_and_drop; diff --git a/egui_demo_lib/src/apps/demo/widget_gallery.rs b/egui_demo_lib/src/apps/demo/widget_gallery.rs index 5d280a94..c15da2df 100644 --- a/egui_demo_lib/src/apps/demo/widget_gallery.rs +++ b/egui_demo_lib/src/apps/demo/widget_gallery.rs @@ -212,6 +212,7 @@ impl WidgetGallery { ui.add(doc_link_label("Plot", "plot")); ui.add(example_plot()); + ui.end_row(); ui.hyperlink_to( @@ -227,14 +228,14 @@ impl WidgetGallery { } fn example_plot() -> egui::plot::Plot { - use egui::plot::{Line, Plot, Value, Values}; + use egui::plot::{Line, Value, Values}; let n = 128; let line = Line::new(Values::from_values_iter((0..=n).map(|i| { use std::f64::consts::TAU; - let x = egui::remap(i as f64, 0.0..=(n as f64), -TAU..=TAU); + let x = egui::remap(i as f64, 0.0..=n as f64, -TAU..=TAU); Value::new(x, x.sin()) }))); - Plot::new("example_plot") + egui::plot::Plot::new("example_plot") .line(line) .height(32.0) .data_aspect(1.0) diff --git a/egui_demo_lib/src/apps/demo/window_options.rs b/egui_demo_lib/src/apps/demo/window_options.rs index 8bc919ec..6759fa5b 100644 --- a/egui_demo_lib/src/apps/demo/window_options.rs +++ b/egui_demo_lib/src/apps/demo/window_options.rs @@ -87,7 +87,6 @@ impl super::View for WindowOptions { anchor, anchor_offset, } = self; - ui.horizontal(|ui| { ui.label("title:"); ui.text_edit_singleline(title);