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//! ```
22//! use herogpui_components::{ContextMenu, MenuItem};
23//! use gpui::{div, prelude::*, px};
24//!
25//! let menu = ContextMenu::new(
26//!     "canvas-menu",
27//!     div().size(px(240.)).child("Right-click here"),
28//!     vec![
29//!         MenuItem::new("copy", "Copy").shortcut("⌘C"),
30//!         MenuItem::new("paste", "Paste").shortcut("⌘V"),
31//!         MenuItem::Separator,
32//!         MenuItem::new("delete", "Delete").danger(),
33//!     ],
34//! )
35//! .on_action(|key, _window, _cx| println!("chose {key}"));
36//! ```
37
38use std::cell::Cell;
39use std::rc::Rc;
40use std::sync::Arc;
41
42use gpui::{
43    px, size, AnyElement, App, Bounds, InteractiveElement, IntoElement, MouseButton, ParentElement,
44    Pixels, RenderOnce, SharedString, Styled, Window,
45};
46use herogpui_core::{element_id, Placement};
47
48use crate::{Menu, MenuItem};
49
50type Callback<T> = Arc<dyn Fn(&T, &mut Window, &mut App) + 'static>;
51
52/// A secondary-click menu over `child`. See the [module docs](self).
53#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
54#[derive(IntoElement)]
55pub struct ContextMenu {
56    id: gpui::ElementId,
57    child: AnyElement,
58    items: Vec<MenuItem>,
59    disabled_keys: Vec<SharedString>,
60    is_disabled: bool,
61    on_action: Option<Callback<SharedString>>,
62    on_open_change: Option<Callback<bool>>,
63}
64
65impl ContextMenu {
66    /// A context menu with `items` over the `child` area. `id` keys the
67    /// menu's state; give every instance its own.
68    pub fn new(
69        id: impl Into<gpui::ElementId>,
70        child: impl IntoElement,
71        items: Vec<MenuItem>,
72    ) -> Self {
73        Self {
74            id: id.into(),
75            child: child.into_any_element(),
76            items,
77            disabled_keys: Vec::new(),
78            is_disabled: false,
79            on_action: None,
80            on_open_change: None,
81        }
82    }
83
84    /// Runs with the chosen item's key; the menu then closes.
85    pub fn on_action(mut self, f: impl Fn(&SharedString, &mut Window, &mut App) + 'static) -> Self {
86        self.on_action = Some(Arc::new(f));
87        self
88    }
89
90    /// Reports every open and close.
91    pub fn on_open_change(mut self, f: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
92        self.on_open_change = Some(Arc::new(f));
93        self
94    }
95
96    /// Items that render dimmed and cannot be chosen or focused.
97    pub fn disabled_keys(
98        mut self,
99        keys: impl IntoIterator<Item = impl Into<SharedString>>,
100    ) -> Self {
101        self.disabled_keys = keys.into_iter().map(Into::into).collect();
102        self
103    }
104
105    /// A disabled context menu ignores secondary clicks; the area still
106    /// renders and receives every other event.
107    pub fn is_disabled(mut self, v: bool) -> Self {
108        self.is_disabled = v;
109        self
110    }
111}
112
113fn set_open(
114    own: &Option<gpui::Entity<bool>>,
115    on_open_change: &Option<Callback<bool>>,
116    open: bool,
117    window: &mut Window,
118    cx: &mut App,
119) {
120    if let Some(held) = own {
121        held.update(cx, |v, cx| {
122            *v = open;
123            cx.notify();
124        });
125    }
126    if let Some(cb) = on_open_change {
127        cb(&open, window, cx);
128    }
129}
130
131/// Shift+F10, or the ContextMenu key, with no other modifier.
132fn opens_context_menu(keystroke: &gpui::Keystroke) -> bool {
133    let m = &keystroke.modifiers;
134    if m.control || m.alt || m.platform || m.function {
135        return false;
136    }
137    match keystroke.key.as_str() {
138        "f10" => m.shift,
139        "menu" | "contextmenu" => !m.shift,
140        _ => false,
141    }
142}
143
144impl RenderOnce for ContextMenu {
145    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
146        let base = self.id.clone();
147        let (is_open, own) =
148            crate::util::controlled(window, cx, element_id::scoped(&base, "open"), None, false);
149        let (phase, overlay_token) = crate::util::overlay_scope(
150            window,
151            cx,
152            element_id::scoped(&base, "phase"),
153            is_open,
154            true,
155        );
156        // The pointer position, as a zero-size anchor the shared popover
157        // positioner places the panel against (offset 0, so the panel's
158        // corner sits on the pointer).
159        let anchor = window
160            .use_keyed_state(element_id::scoped(&base, "anchor"), cx, |_, _| {
161                Rc::new(Cell::new(None::<Bounds<Pixels>>))
162            })
163            .read(cx)
164            .clone();
165        let focus_first =
166            window.use_keyed_state(element_id::scoped(&base, "focus-first"), cx, |_, _| false);
167
168        let area_focus = window
169            .use_keyed_state(element_id::scoped(&base, "area-focus"), cx, |_, cx| {
170                cx.focus_handle().tab_stop(false)
171            })
172            .read(cx)
173            .clone();
174        // The area's own bounds, the fallback keyboard-open anchor.
175        let area_bounds = window
176            .use_keyed_state(element_id::scoped(&base, "area-bounds"), cx, |_, _| {
177                Rc::new(Cell::new(None::<Bounds<Pixels>>))
178            })
179            .read(cx)
180            .clone();
181        // Where the focus was when the keyboard opened the menu.
182        let restore_focus = window
183            .use_keyed_state(element_id::scoped(&base, "restore-focus"), cx, |_, _| {
184                Rc::new(std::cell::RefCell::new(None::<gpui::FocusHandle>))
185            })
186            .read(cx)
187            .clone();
188
189        let bounds_probe = area_bounds.clone();
190        let mut area = gpui::div()
191            .id(element_id::scoped(&base, "area"))
192            .relative()
193            .track_focus(&area_focus)
194            .child(self.child)
195            .child(
196                gpui::canvas(
197                    move |bounds, _, _| bounds_probe.set(Some(bounds)),
198                    |_, _, _, _| {},
199                )
200                .absolute()
201                .inset_0(),
202            );
203        if !self.is_disabled {
204            let focus = area_focus;
205            area = area.on_mouse_down(MouseButton::Left, move |_, window, cx| {
206                if !focus.contains_focused(window, cx) {
207                    window.focus(&focus, cx);
208                }
209            });
210            let anchor_key = anchor.clone();
211            let own_key = own.clone();
212            let on_open_change_key = self.on_open_change.clone();
213            let focus_first_key = focus_first.clone();
214            let restore_key = restore_focus.clone();
215            area = area.on_key_down(move |event, window, cx| {
216                if is_open || !opens_context_menu(&event.keystroke) {
217                    return;
218                }
219                let Some(bounds) = area_bounds.get() else {
220                    return;
221                };
222                let target = crate::util::focused_element_bounds(window, cx)
223                    .filter(|focused| bounds.intersects(focused))
224                    .unwrap_or(Bounds::new(bounds.origin, size(px(0.), px(0.))));
225                anchor_key.set(Some(target));
226                *restore_key.borrow_mut() = window.focused(cx);
227                focus_first_key.update(cx, |focus, _| *focus = true);
228                set_open(&own_key, &on_open_change_key, true, window, cx);
229                cx.stop_propagation();
230            });
231            let anchor = anchor.clone();
232            let own = own.clone();
233            let on_open_change = self.on_open_change.clone();
234            let pointer_focus_first = focus_first.clone();
235            let pointer_restore = restore_focus.clone();
236            area = area.on_mouse_down(MouseButton::Right, move |event, window, cx| {
237                anchor.set(Some(Bounds::new(event.position, size(px(0.), px(0.)))));
238                pointer_focus_first.update(cx, |focus, _| *focus = false);
239                pointer_restore.borrow_mut().take();
240                set_open(&own, &on_open_change, true, window, cx);
241                cx.stop_propagation();
242            });
243        }
244
245        let mut root = gpui::div().relative().child(area);
246        if phase != crate::util::OverlayPhase::Closed {
247            let mut menu = Menu::new(element_id::scoped(&base, "menu-content"), self.items)
248                .id(element_id::scoped(&base, "menu"))
249                .dropdown_composition()
250                .focus_first(focus_first)
251                .exiting(phase == crate::util::OverlayPhase::Exiting)
252                .disabled_keys(self.disabled_keys)
253                .overlay_token(overlay_token);
254            if let Some(on_action) = self.on_action {
255                menu = menu.on_action(move |key, window, cx| on_action(key, window, cx));
256            }
257            let on_open_change = self.on_open_change;
258            menu = menu.on_dismiss(move |_refocus, window, cx| {
259                set_open(&own, &on_open_change, false, window, cx);
260                // A keyboard open took the focus into the menu; hand it back,
261                // an Enter pick included: pinned gpui activates an element on
262                // key up only when the key went down on it too, so the
263                // restored element does not see the pick's release as a press.
264                let restore = restore_focus.borrow_mut().take();
265                if let Some(handle) = restore {
266                    window.focus(&handle, cx);
267                }
268            });
269            root = root.child(crate::util::floating(
270                crate::popover::popover_with_resolved_placement(
271                    anchor,
272                    Placement::BottomStart,
273                    px(0.),
274                    None,
275                    menu.panel_debug_label("context-menu"),
276                ),
277            ));
278        }
279        root
280    }
281}