Skip to main content

elegance/
context_menu.rs

1//! Right-click popup menu — elegance-styled context menu.
2//!
3//! [`ContextMenu`] opens a themed popup at the cursor position when its
4//! anchor [`Response`] is secondary-clicked. The popup hosts the same
5//! [`MenuItem`](crate::MenuItem), [`MenuSection`](crate::MenuSection),
6//! and [`SubMenuItem`](crate::SubMenuItem) widgets as the rest of the
7//! menu family, so the visual treatment matches both [`Menu`](crate::Menu)
8//! popups and [`MenuBar`](crate::MenuBar) dropdowns.
9//!
10//! The target [`Response`] must have a click sense for egui to register
11//! the secondary click — most interactive widgets (buttons, list rows
12//! sensed via `Sense::click()`) already do; for plain labels or custom
13//! regions, allocate the rect with `Sense::click()` first.
14//!
15//! ```no_run
16//! # use elegance::{ContextMenu, MenuItem, MenuSection, SubMenuItem};
17//! # egui::__run_test_ui(|ui| {
18//! let row = ui.add(egui::Label::new("theme.rs").sense(egui::Sense::click()));
19//! ContextMenu::new("file_row").show(&row, |ui| {
20//!     ui.add(MenuItem::new("Open").shortcut("\u{21B5}"));
21//!     ui.add(MenuItem::new("Open in new split").shortcut("\u{2318}\u{21E7}\u{21B5}"));
22//!     SubMenuItem::new("Open with").show(ui, |ui| {
23//!         ui.add(MenuItem::new("Source editor"));
24//!         ui.add(MenuItem::new("Preview"));
25//!     });
26//!     ui.separator();
27//!     ui.add(MenuSection::new("Edit"));
28//!     ui.add(MenuItem::new("Copy").shortcut("\u{2318}C"));
29//!     ui.add(MenuItem::new("Rename\u{2026}").shortcut("F2"));
30//!     ui.separator();
31//!     ui.add(MenuItem::new("Delete").danger().shortcut("\u{232B}"));
32//! });
33//! # });
34//! ```
35//!
36//! The popup is dismissed by clicking any item, clicking outside, or
37//! pressing `Esc`.
38
39use std::hash::Hash;
40
41use egui::{CornerRadius, Frame, Id, Margin, Popup, PopupCloseBehavior, Response, Stroke, Ui};
42
43use crate::theme::Theme;
44
45/// A right-click-anchored popup menu attached to a [`Response`].
46///
47/// See the module-level docs for usage. The menu opens at the cursor
48/// position when `target` is secondary-clicked.
49#[derive(Debug, Clone)]
50#[must_use = "Call `.show(&target, |ui| ...)` to render the context menu."]
51pub struct ContextMenu {
52    id_salt: Id,
53    min_width: f32,
54}
55
56impl ContextMenu {
57    /// Create a context menu keyed by `id_salt`. The salt scopes the
58    /// popup's open/closed state in egui memory and must be stable for
59    /// the target it's attached to.
60    pub fn new(id_salt: impl Hash) -> Self {
61        Self {
62            id_salt: Id::new(("elegance::context_menu", Id::new(id_salt))),
63            min_width: 200.0,
64        }
65    }
66
67    /// Minimum width of the popup in points. Default: 200.
68    #[inline]
69    pub fn min_width(mut self, min_width: f32) -> Self {
70        self.min_width = min_width;
71        self
72    }
73
74    /// Render the context menu attached to `target`. The popup opens on
75    /// secondary-click on `target` at the pointer position; clicking
76    /// inside an item, clicking outside, or pressing `Esc` closes it.
77    ///
78    /// Returns `Some(R)` with the body closure's return value while the
79    /// menu is open, `None` while closed.
80    pub fn show<R>(self, target: &Response, add_contents: impl FnOnce(&mut Ui) -> R) -> Option<R> {
81        let theme = Theme::current(&target.ctx);
82        let p = &theme.palette;
83        let r = theme.card_radius as u8;
84        let frame = Frame::new()
85            .fill(p.card)
86            .stroke(Stroke::new(1.0, p.border))
87            .corner_radius(CornerRadius::same(r))
88            .inner_margin(Margin::same(4));
89
90        Popup::context_menu(target)
91            .id(self.id_salt)
92            .frame(frame)
93            .close_behavior(PopupCloseBehavior::CloseOnClick)
94            .show(|ui| {
95                ui.set_min_width(self.min_width);
96                ui.spacing_mut().item_spacing.y = 2.0;
97                add_contents(ui)
98            })
99            .map(|r| r.inner)
100    }
101}