Skip to main content

herogpui_components/
context_menu.rs

1//! `ContextMenu` — a right-click menu over an area (HeroGPUI extension;
2//! HeroUI v3 has no context menu, only the trigger-anchored `Dropdown`).
3//!
4//! It reuses the Dropdown's [`Menu`] panel unchanged — rows, sections,
5//! submenus, typeahead, keyboard navigation, disabled keys, the enter/exit
6//! motion, and the topmost-overlay Escape / outside-press arbitration — and
7//! only replaces the trigger: a secondary-button press inside the wrapped
8//! area opens the menu with its top-left corner at the pointer, flipping to
9//! stay inside the window. Choosing an item, Escape or a press outside the
10//! panel closes it.
11//!
12//! The keyboard opens it too: Shift+F10 or the ContextMenu key (`menu` on
13//! Windows and Linux, `contextmenu` on the web) while the focus is anywhere
14//! inside the area opens the menu below the focused HeroGPUI control when
15//! its bounds were painted, or at the area's top-left corner otherwise. The
16//! first item receives focus, and dismissing the menu returns it to the
17//! previous control. The area owns a focus handle outside the tab order: a
18//! primary press on a part of the area that does not take the focus itself
19//! focuses the area, so an area with no focusable content is reachable too.
20//!
21//! The panel takes the same styling surface as a [`crate::Dropdown`]'s:
22//! [`ContextMenu::recipe`] names a [`herogpui_theme::ComponentThemes::menu`]
23//! overlay (row fill, panel width, padding, radius and the rest of
24//! `MenuStyle`), and [`ContextMenu::item_content`] and
25//! [`ContextMenu::item_start_content`] draw the rows' label and leading
26//! element.
27//!
28//! ```
29//! use herogpui_components::{ContextMenu, MenuItem};
30//! use gpui::{div, prelude::*, px};
31//!
32//! let menu = ContextMenu::new(
33//!     "canvas-menu",
34//!     div().size(px(240.)).child("Right-click here"),
35//!     vec![
36//!         MenuItem::new("copy", "Copy").shortcut("⌘C"),
37//!         MenuItem::new("paste", "Paste").shortcut("⌘V"),
38//!         MenuItem::Separator,
39//!         MenuItem::new("delete", "Delete").danger(),
40//!     ],
41//! )
42//! .on_action(|key, _window, _cx| println!("chose {key}"));
43//! ```
44
45use std::cell::Cell;
46use std::rc::Rc;
47use std::sync::Arc;
48
49use gpui::{
50    px, size, AnyElement, App, Bounds, InteractiveElement, IntoElement, MouseButton, ParentElement,
51    Pixels, RenderOnce, SharedString, Styled, Window,
52};
53use herogpui_core::{element_id, Placement};
54
55use crate::{Menu, MenuItem};
56
57type Callback<T> = Arc<dyn Fn(&T, &mut Window, &mut App) + 'static>;
58type RowContent<T> = Box<dyn Fn(&SharedString, crate::util::InteractiveState) -> T + 'static>;
59
60/// A secondary-click menu over `child`. See the [module docs](self).
61#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
62#[derive(IntoElement)]
63pub struct ContextMenu {
64    id: gpui::ElementId,
65    child: AnyElement,
66    items: Vec<MenuItem>,
67    disabled_keys: Vec<SharedString>,
68    is_disabled: bool,
69    on_action: Option<Callback<SharedString>>,
70    on_open_change: Option<Callback<bool>>,
71    recipes: Vec<SharedString>,
72    item_content: Option<RowContent<AnyElement>>,
73    item_start_content: Option<RowContent<Option<AnyElement>>>,
74}
75
76impl ContextMenu {
77    /// A context menu with `items` over the `child` area. `id` keys the
78    /// menu's state; give every instance its own.
79    pub fn new(
80        id: impl Into<gpui::ElementId>,
81        child: impl IntoElement,
82        items: Vec<MenuItem>,
83    ) -> Self {
84        Self {
85            id: id.into(),
86            child: child.into_any_element(),
87            items,
88            disabled_keys: Vec::new(),
89            is_disabled: false,
90            on_action: None,
91            on_open_change: None,
92            recipes: Vec::new(),
93            item_content: None,
94            item_start_content: None,
95        }
96    }
97
98    /// Named theme overlay from [`herogpui_theme::ComponentThemes::menu`],
99    /// forwarded onto the painted [`Menu`] as [`crate::Dropdown::recipe`]
100    /// forwards it. Stackable; a missing name adds no override.
101    pub fn recipe(mut self, name: impl Into<SharedString>) -> Self {
102        self.recipes.push(name.into());
103        self
104    }
105
106    /// Replaces each item's label, forwarded onto the painted [`Menu`]. See
107    /// [`Menu::item_content`].
108    pub fn item_content(
109        mut self,
110        render: impl Fn(&SharedString, crate::util::InteractiveState) -> AnyElement + 'static,
111    ) -> Self {
112        self.item_content = Some(Box::new(render));
113        self
114    }
115
116    /// The leading element of each row, forwarded onto the painted [`Menu`].
117    /// See [`Menu::item_start_content`].
118    pub fn item_start_content(
119        mut self,
120        render: impl Fn(&SharedString, crate::util::InteractiveState) -> Option<AnyElement> + 'static,
121    ) -> Self {
122        self.item_start_content = Some(Box::new(render));
123        self
124    }
125
126    /// Runs with the chosen item's key; the menu then closes.
127    pub fn on_action(mut self, f: impl Fn(&SharedString, &mut Window, &mut App) + 'static) -> Self {
128        self.on_action = Some(Arc::new(f));
129        self
130    }
131
132    /// Reports every open and close.
133    pub fn on_open_change(mut self, f: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
134        self.on_open_change = Some(Arc::new(f));
135        self
136    }
137
138    /// Items that render dimmed and cannot be chosen or focused.
139    pub fn disabled_keys(
140        mut self,
141        keys: impl IntoIterator<Item = impl Into<SharedString>>,
142    ) -> Self {
143        self.disabled_keys = keys.into_iter().map(Into::into).collect();
144        self
145    }
146
147    /// A disabled context menu ignores secondary clicks; the area still
148    /// renders and receives every other event.
149    pub fn is_disabled(mut self, v: bool) -> Self {
150        self.is_disabled = v;
151        self
152    }
153}
154
155fn set_open(
156    own: &Option<gpui::Entity<bool>>,
157    on_open_change: &Option<Callback<bool>>,
158    open: bool,
159    window: &mut Window,
160    cx: &mut App,
161) {
162    if let Some(held) = own {
163        held.update(cx, |v, cx| {
164            *v = open;
165            cx.notify();
166        });
167    }
168    if let Some(cb) = on_open_change {
169        cb(&open, window, cx);
170    }
171}
172
173/// Shift+F10, or the ContextMenu key, with no other modifier.
174fn opens_context_menu(keystroke: &gpui::Keystroke) -> bool {
175    let m = &keystroke.modifiers;
176    if m.control || m.alt || m.platform || m.function {
177        return false;
178    }
179    match keystroke.key.as_str() {
180        "f10" => m.shift,
181        "menu" | "contextmenu" => !m.shift,
182        _ => false,
183    }
184}
185
186impl RenderOnce for ContextMenu {
187    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
188        let base = self.id.clone();
189        let (is_open, own) =
190            crate::util::controlled(window, cx, element_id::scoped(&base, "open"), None, false);
191        let (phase, overlay_token) = crate::util::overlay_scope(
192            window,
193            cx,
194            element_id::scoped(&base, "phase"),
195            is_open,
196            true,
197        );
198        // The pointer position, as a zero-size anchor the shared popover
199        // positioner places the panel against (offset 0, so the panel's
200        // corner sits on the pointer).
201        let anchor = window
202            .use_keyed_state(element_id::scoped(&base, "anchor"), cx, |_, _| {
203                Rc::new(Cell::new(None::<Bounds<Pixels>>))
204            })
205            .read(cx)
206            .clone();
207        let focus_first =
208            window.use_keyed_state(element_id::scoped(&base, "focus-first"), cx, |_, _| false);
209
210        let area_focus = window
211            .use_keyed_state(element_id::scoped(&base, "area-focus"), cx, |_, cx| {
212                cx.focus_handle().tab_stop(false)
213            })
214            .read(cx)
215            .clone();
216        // The area's own bounds, the fallback keyboard-open anchor.
217        let area_bounds = window
218            .use_keyed_state(element_id::scoped(&base, "area-bounds"), cx, |_, _| {
219                Rc::new(Cell::new(None::<Bounds<Pixels>>))
220            })
221            .read(cx)
222            .clone();
223        // Where the focus was when the keyboard opened the menu.
224        let restore_focus = window
225            .use_keyed_state(element_id::scoped(&base, "restore-focus"), cx, |_, _| {
226                Rc::new(std::cell::RefCell::new(None::<gpui::FocusHandle>))
227            })
228            .read(cx)
229            .clone();
230
231        let bounds_probe = area_bounds.clone();
232        let mut area = gpui::div()
233            .id(element_id::scoped(&base, "area"))
234            .relative()
235            .track_focus(&area_focus)
236            .child(self.child)
237            .child(
238                gpui::canvas(
239                    move |bounds, _, _| bounds_probe.set(Some(bounds)),
240                    |_, _, _, _| {},
241                )
242                .absolute()
243                .inset_0(),
244            );
245        if !self.is_disabled {
246            let focus = area_focus;
247            area = area.on_mouse_down(MouseButton::Left, move |_, window, cx| {
248                if !focus.contains_focused(window, cx) {
249                    window.focus(&focus, cx);
250                }
251            });
252            let anchor_key = anchor.clone();
253            let own_key = own.clone();
254            let on_open_change_key = self.on_open_change.clone();
255            let focus_first_key = focus_first.clone();
256            let restore_key = restore_focus.clone();
257            area = area.on_key_down(move |event, window, cx| {
258                if is_open || !opens_context_menu(&event.keystroke) {
259                    return;
260                }
261                let Some(bounds) = area_bounds.get() else {
262                    return;
263                };
264                let target = crate::util::focused_element_bounds(window, cx)
265                    .filter(|focused| bounds.intersects(focused))
266                    .unwrap_or(Bounds::new(bounds.origin, size(px(0.), px(0.))));
267                anchor_key.set(Some(target));
268                *restore_key.borrow_mut() = window.focused(cx);
269                focus_first_key.update(cx, |focus, _| *focus = true);
270                set_open(&own_key, &on_open_change_key, true, window, cx);
271                cx.stop_propagation();
272            });
273            let anchor = anchor.clone();
274            let own = own.clone();
275            let on_open_change = self.on_open_change.clone();
276            let pointer_focus_first = focus_first.clone();
277            let pointer_restore = restore_focus.clone();
278            area = area.on_mouse_down(MouseButton::Right, move |event, window, cx| {
279                anchor.set(Some(Bounds::new(event.position, size(px(0.), px(0.)))));
280                pointer_focus_first.update(cx, |focus, _| *focus = false);
281                pointer_restore.borrow_mut().take();
282                set_open(&own, &on_open_change, true, window, cx);
283                cx.stop_propagation();
284            });
285        }
286
287        let mut root = gpui::div().relative().child(area);
288        if phase != crate::util::OverlayPhase::Closed {
289            let mut menu = Menu::new(element_id::scoped(&base, "menu-content"), self.items)
290                .id(element_id::scoped(&base, "menu"))
291                .dropdown_composition()
292                .focus_first(focus_first)
293                .exiting(phase == crate::util::OverlayPhase::Exiting)
294                .disabled_keys(self.disabled_keys)
295                .overlay_token(overlay_token);
296            for recipe in self.recipes {
297                menu = menu.recipe(recipe);
298            }
299            if let Some(render) = self.item_content {
300                menu = menu.item_content(render);
301            }
302            if let Some(render) = self.item_start_content {
303                menu = menu.item_start_content(render);
304            }
305            if let Some(on_action) = self.on_action {
306                menu = menu.on_action(move |key, window, cx| on_action(key, window, cx));
307            }
308            let on_open_change = self.on_open_change;
309            menu = menu.on_dismiss(move |_refocus, window, cx| {
310                set_open(&own, &on_open_change, false, window, cx);
311                // A keyboard open took the focus into the menu; hand it back,
312                // an Enter pick included: pinned gpui activates an element on
313                // key up only when the key went down on it too, so the
314                // restored element does not see the pick's release as a press.
315                let restore = restore_focus.borrow_mut().take();
316                if let Some(handle) = restore {
317                    window.focus(&handle, cx);
318                }
319            });
320            root = root.child(crate::util::floating(
321                crate::popover::popover_with_resolved_placement(
322                    anchor,
323                    Placement::BottomStart,
324                    px(0.),
325                    None,
326                    menu.panel_debug_label("context-menu"),
327                ),
328            ));
329        }
330        root
331    }
332}