Skip to main content

gpui_component/menu/
context_menu.rs

1use std::{cell::RefCell, rc::Rc};
2
3use gpui::{
4    Anchor, AnyElement, App, Context, DismissEvent, Element, ElementId, Entity, FocusHandle,
5    Focusable, GlobalElementId, Hitbox, HitboxBehavior, InspectorElementId, InteractiveElement,
6    IntoElement, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, StyleRefinement,
7    Styled, Subscription, Window, anchored, deferred, div, prelude::FluentBuilder, px,
8};
9
10use crate::menu::PopupMenu;
11
12/// A extension trait for adding a context menu to an element.
13pub trait ContextMenuExt: InteractiveElement + ParentElement + Styled {
14    /// Add a context menu to the element.
15    ///
16    /// This will changed the element to be `relative` positioned, and add a child `ContextMenu` element.
17    /// Because the `ContextMenu` element is positioned `absolute`, it will not affect the layout of the parent element.
18    #[track_caller]
19    fn context_menu(
20        mut self,
21        f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
22    ) -> ContextMenu<Self>
23    where
24        Self: Sized,
25    {
26        // The ID must be stable across renders, otherwise the element state
27        // (open menu) is lost on every re-render.
28        let caller = std::panic::Location::caller();
29        let id = self
30            .interactivity()
31            .element_id
32            .clone()
33            .map(|id| ElementId::Name(format!("context-menu-{:?}", id).into()))
34            .unwrap_or_else(|| ElementId::CodeLocation(*caller));
35        ContextMenu::new(id, self).menu(f)
36    }
37}
38
39impl<E: InteractiveElement + ParentElement + Styled> ContextMenuExt for E {}
40
41/// A context menu that can be shown on right-click.
42pub struct ContextMenu<E: ParentElement + Styled + Sized> {
43    id: ElementId,
44    element: Option<E>,
45    menu: Option<Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu>>,
46    // This is not in use, just for style refinement forwarding.
47    _ignore_style: StyleRefinement,
48    anchor: Anchor,
49}
50
51impl<E: ParentElement + Styled> ContextMenu<E> {
52    /// Create a new context menu with the given ID.
53    pub fn new(id: impl Into<ElementId>, element: E) -> Self {
54        Self {
55            id: id.into(),
56            element: Some(element),
57            menu: None,
58            anchor: Anchor::TopLeft,
59            _ignore_style: StyleRefinement::default(),
60        }
61    }
62
63    /// Build the context menu using the given builder function.
64    #[must_use]
65    fn menu<F>(mut self, builder: F) -> Self
66    where
67        F: Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
68    {
69        self.menu = Some(Rc::new(builder));
70        self
71    }
72
73    fn with_element_state<R>(
74        &mut self,
75        id: &GlobalElementId,
76        window: &mut Window,
77        cx: &mut App,
78        f: impl FnOnce(&mut Self, &mut ContextMenuState, &mut Window, &mut App) -> R,
79    ) -> R {
80        window.with_optional_element_state::<ContextMenuState, _>(
81            Some(id),
82            |element_state, window| {
83                let mut element_state = element_state.unwrap().unwrap_or_default();
84                let result = f(self, &mut element_state, window, cx);
85                (result, Some(element_state))
86            },
87        )
88    }
89}
90
91impl<E: ParentElement + Styled> ParentElement for ContextMenu<E> {
92    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
93        if let Some(element) = &mut self.element {
94            element.extend(elements);
95        }
96    }
97}
98
99impl<E: ParentElement + Styled> Styled for ContextMenu<E> {
100    fn style(&mut self) -> &mut StyleRefinement {
101        if let Some(element) = &mut self.element {
102            element.style()
103        } else {
104            &mut self._ignore_style
105        }
106    }
107}
108
109impl<E: ParentElement + Styled + IntoElement + 'static> IntoElement for ContextMenu<E> {
110    type Element = Self;
111
112    fn into_element(self) -> Self::Element {
113        self
114    }
115}
116
117struct ContextMenuSharedState {
118    menu_view: Option<Entity<PopupMenu>>,
119    open: bool,
120    position: Point<Pixels>,
121    /// Registered on this element's dispatch node every frame and never
122    /// focused, so the menu can resolve its shortcut hints against the
123    /// trigger's key contexts on the frame it opens: GPUI looks a handle up in
124    /// the previously rendered frame, where the menu's own element is not yet.
125    trigger_focus_handle: Option<FocusHandle>,
126    _subscription: Option<Subscription>,
127}
128
129pub struct ContextMenuState {
130    element: Option<AnyElement>,
131    shared_state: Rc<RefCell<ContextMenuSharedState>>,
132}
133
134impl Default for ContextMenuState {
135    fn default() -> Self {
136        Self {
137            element: None,
138            shared_state: Rc::new(RefCell::new(ContextMenuSharedState {
139                menu_view: None,
140                open: false,
141                position: Default::default(),
142                trigger_focus_handle: None,
143                _subscription: None,
144            })),
145        }
146    }
147}
148
149impl<E: ParentElement + Styled + IntoElement + 'static> Element for ContextMenu<E> {
150    type RequestLayoutState = ContextMenuState;
151    type PrepaintState = Hitbox;
152
153    fn id(&self) -> Option<ElementId> {
154        Some(self.id.clone())
155    }
156
157    fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
158        None
159    }
160
161    fn request_layout(
162        &mut self,
163        id: Option<&gpui::GlobalElementId>,
164        _: Option<&gpui::InspectorElementId>,
165        window: &mut Window,
166        cx: &mut App,
167    ) -> (gpui::LayoutId, Self::RequestLayoutState) {
168        let anchor = self.anchor;
169
170        self.with_element_state(
171            id.unwrap(),
172            window,
173            cx,
174            |this, state: &mut ContextMenuState, window, cx| {
175                let (position, open) = {
176                    let shared_state = state.shared_state.borrow();
177                    (shared_state.position, shared_state.open)
178                };
179                state
180                    .shared_state
181                    .borrow_mut()
182                    .trigger_focus_handle
183                    .get_or_insert_with(|| cx.focus_handle());
184                let menu_view = state.shared_state.borrow().menu_view.clone();
185                let mut menu_element = None;
186                if open {
187                    let has_menu_item = menu_view
188                        .as_ref()
189                        .map(|menu| !menu.read(cx).is_empty())
190                        .unwrap_or(false);
191
192                    if has_menu_item {
193                        menu_element = Some(
194                            deferred(
195                                anchored().child(
196                                    div()
197                                        .w(window.bounds().size.width)
198                                        .h(window.bounds().size.height)
199                                        .on_scroll_wheel(|_, _, cx| {
200                                            cx.stop_propagation();
201                                        })
202                                        .child(
203                                            anchored()
204                                                .position(position)
205                                                .snap_to_window_with_margin(px(8.))
206                                                .anchor(anchor)
207                                                .when_some(menu_view, |this, menu| {
208                                                    // Focus the menu, so that can be handle the action.
209                                                    if !menu
210                                                        .focus_handle(cx)
211                                                        .contains_focused(window, cx)
212                                                    {
213                                                        menu.focus_handle(cx).focus(window, cx);
214                                                    }
215
216                                                    this.child(menu.clone())
217                                                }),
218                                        ),
219                                ),
220                            )
221                            .with_priority(gpui_base::POPUP_PRIORITY)
222                            .into_any(),
223                        );
224                    }
225                }
226
227                let mut element = this
228                    .element
229                    .take()
230                    .expect("Element should exists.")
231                    .children(menu_element)
232                    .into_any_element();
233
234                let layout_id = element.request_layout(window, cx);
235
236                (
237                    layout_id,
238                    ContextMenuState {
239                        element: Some(element),
240                        shared_state: state.shared_state.clone(),
241                    },
242                )
243            },
244        )
245    }
246
247    fn prepaint(
248        &mut self,
249        _: Option<&gpui::GlobalElementId>,
250        _: Option<&InspectorElementId>,
251        bounds: gpui::Bounds<gpui::Pixels>,
252        request_layout: &mut Self::RequestLayoutState,
253        window: &mut Window,
254        cx: &mut App,
255    ) -> Self::PrepaintState {
256        if let Some(trigger_focus) = request_layout
257            .shared_state
258            .borrow()
259            .trigger_focus_handle
260            .as_ref()
261        {
262            window.set_focus_handle(trigger_focus, cx);
263        }
264        if let Some(element) = &mut request_layout.element {
265            element.prepaint(window, cx);
266        }
267        window.insert_hitbox(bounds, HitboxBehavior::Normal)
268    }
269
270    fn paint(
271        &mut self,
272        id: Option<&gpui::GlobalElementId>,
273        _: Option<&InspectorElementId>,
274        _: gpui::Bounds<gpui::Pixels>,
275        request_layout: &mut Self::RequestLayoutState,
276        hitbox: &mut Self::PrepaintState,
277        window: &mut Window,
278        cx: &mut App,
279    ) {
280        if let Some(element) = &mut request_layout.element {
281            element.paint(window, cx);
282        }
283
284        // Take the builder before setting up element state to avoid borrow issues
285        let builder = self.menu.clone();
286
287        self.with_element_state(
288            id.unwrap(),
289            window,
290            cx,
291            |_view, state: &mut ContextMenuState, window, _| {
292                let shared_state = state.shared_state.clone();
293
294                let hitbox = hitbox.clone();
295                // When right mouse click, to build content menu, and show it at the mouse position.
296                window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| {
297                    if phase.bubble()
298                        && event.button == MouseButton::Right
299                        && hitbox.is_hovered(window)
300                    {
301                        // Capture the focused element to restore focus to on dismiss.
302                        // If focus is still on the previous menu, keep its captured focus.
303                        let previous_focus_handle = window.focused(cx).and_then(|focused| {
304                            let shared_state = shared_state.borrow();
305                            match shared_state.menu_view.as_ref() {
306                                Some(menu) if menu.read(cx).focus_handle == focused => {
307                                    menu.read(cx).previous_focus_handle.clone()
308                                }
309                                _ => Some(focused),
310                            }
311                        });
312
313                        {
314                            let mut shared_state = shared_state.borrow_mut();
315                            // Clear any existing menu view to allow immediate replacement
316                            // Set the new position and open the menu
317                            shared_state.menu_view = None;
318                            shared_state._subscription = None;
319                            shared_state.position = event.position;
320                            shared_state.open = true;
321                        }
322
323                        // Use defer to build the menu in the next frame, avoiding race conditions
324                        window.defer(cx, {
325                            let shared_state = shared_state.clone();
326                            let builder = builder.clone();
327                            move |window, cx| {
328                                let menu = PopupMenu::build(window, cx, move |menu, window, cx| {
329                                    let Some(build) = &builder else {
330                                        return menu;
331                                    };
332                                    build(menu, window, cx)
333                                });
334                                let trigger_focus_handle =
335                                    shared_state.borrow().trigger_focus_handle.clone();
336                                menu.update(cx, |menu, cx| {
337                                    menu.set_trigger_focus(trigger_focus_handle, cx);
338                                    menu.set_previous_focus(previous_focus_handle, cx);
339                                });
340
341                                // Set up the subscription for dismiss handling
342                                let _subscription = window.subscribe(&menu, cx, {
343                                    let shared_state = shared_state.clone();
344                                    move |_, _: &DismissEvent, window, _cx| {
345                                        shared_state.borrow_mut().open = false;
346                                        window.refresh();
347                                    }
348                                });
349
350                                // Update the shared state with the built menu and subscription
351                                {
352                                    let mut state = shared_state.borrow_mut();
353                                    state.menu_view = Some(menu.clone());
354                                    state._subscription = Some(_subscription);
355                                    window.refresh();
356                                }
357                            }
358                        });
359                    }
360                });
361            },
362        );
363    }
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369    use crate::theme::Theme;
370    use gpui::{
371        Context, FocusHandle, IntoElement, KeyBinding, Render, TestAppContext, VisualTestContext,
372        actions, point, px,
373    };
374    use std::cell::Cell;
375
376    actions!(context_menu_test, [RemoveTab, CopyText]);
377
378    /// The regression shape: the action handler lives on the trigger's
379    /// ancestor (like an action bar), which is NOT on the focus path while
380    /// focus is in the content area.
381    struct TestRoot {
382        content_focus: FocusHandle,
383        received: Rc<Cell<bool>>,
384    }
385
386    impl Render for TestRoot {
387        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
388            let received = self.received.clone();
389            div()
390                .size_full()
391                .child(
392                    div()
393                        .id("content")
394                        .h(px(40.))
395                        .track_focus(&self.content_focus),
396                )
397                .child(
398                    div()
399                        .id("action-bar")
400                        .h(px(60.))
401                        .on_action(move |_: &RemoveTab, _, _| received.set(true))
402                        .child(
403                            div()
404                                .id("tab")
405                                .size_full()
406                                .context_menu(|menu, _, _| menu.menu("Close", Box::new(RemoveTab))),
407                        ),
408                )
409        }
410    }
411
412    #[gpui::test]
413    fn action_bubbles_from_trigger_and_focus_restores_on_dismiss(cx: &mut TestAppContext) {
414        cx.update(|cx| {
415            cx.set_global(Theme::default());
416            super::super::popup_menu::init(cx);
417        });
418
419        let received = Rc::new(Cell::new(false));
420        let (root, cx) = cx.add_window_view({
421            let received = received.clone();
422            move |window, cx| {
423                let content_focus = cx.focus_handle();
424                content_focus.focus(window, cx);
425                TestRoot {
426                    content_focus,
427                    received,
428                }
429            }
430        });
431        let content_focus = root.read_with(cx, |root, _| root.content_focus.clone());
432        let cx: &mut VisualTestContext = cx;
433        cx.run_until_parked();
434        cx.update(|window, cx| {
435            _ = window.draw(cx);
436        });
437
438        // Right-click inside the tab to open the context menu.
439        cx.simulate_event(MouseDownEvent {
440            button: MouseButton::Right,
441            position: point(px(50.), px(70.)),
442            modifiers: Default::default(),
443            click_count: 1,
444            first_mouse: false,
445        });
446        // The menu entity is built in a deferred callback, then rendered
447        // (which also focuses it) on the next draw.
448        cx.run_until_parked();
449        cx.update(|window, cx| {
450            _ = window.draw(cx);
451        });
452
453        // Select "Close" and confirm. Keyboard confirm and mouse click share
454        // the same `confirm` path in `PopupMenu`.
455        cx.simulate_keystrokes("down enter");
456        cx.run_until_parked();
457
458        // The action must reach the handler on the trigger's ancestor chain,
459        // even though the action bar was never on the focus path.
460        assert!(received.get());
461        // And dismiss must restore focus to where it was before the menu
462        // opened, keeping the dangling-focus fix (#2614).
463        cx.update(|window, cx| {
464            assert_eq!(window.focused(cx).as_ref(), Some(&content_focus));
465        });
466    }
467
468    const CONTEXT: &str = "context_menu_test";
469
470    /// The story shape: nothing is focused, the key binding lives in the key
471    /// context of the trigger's ancestor, and other content outside that
472    /// context paints after the trigger.
473    struct UnfocusedRoot {
474        frames: Rc<Cell<usize>>,
475    }
476
477    impl Render for UnfocusedRoot {
478        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
479            self.frames.set(self.frames.get() + 1);
480            div()
481                .size_full()
482                .child(
483                    div()
484                        .key_context(CONTEXT)
485                        .on_action(|_: &CopyText, _, _| {})
486                        .child(
487                            div()
488                                .id("tab")
489                                .w(px(100.))
490                                .h(px(30.))
491                                .context_menu(|menu, _, _| menu.menu("Copy", Box::new(CopyText))),
492                        ),
493                )
494                .child(div().child("Status"))
495        }
496    }
497
498    #[gpui::test]
499    fn shortcut_hint_is_painted_on_the_frame_the_menu_opens(cx: &mut TestAppContext) {
500        cx.update(|cx| {
501            crate::init(cx);
502            cx.bind_keys([KeyBinding::new("ctrl-c", CopyText, Some(CONTEXT))]);
503        });
504        let frames = Rc::new(Cell::new(0));
505        let (_, cx) = cx.add_window_view({
506            let frames = frames.clone();
507            move |_, _| UnfocusedRoot { frames }
508        });
509        cx.update(|window, cx| {
510            window.draw(cx).clear(cx);
511            assert!(window.focused(cx).is_none());
512        });
513        let frames_before_open = frames.get();
514
515        // Right-click inside the tab; the menu is built in a deferred callback
516        // and drawn on the frame that follows.
517        cx.simulate_mouse_down(
518            point(px(10.), px(10.)),
519            MouseButton::Right,
520            Default::default(),
521        );
522
523        assert_eq!(
524            frames.get(),
525            frames_before_open + 1,
526            "the press must be followed by exactly one frame for this to test the first one"
527        );
528        assert!(
529            cx.debug_bounds("kbd:ctrl-c").is_some(),
530            "the shortcut hint must be painted on the same frame as its item"
531        );
532    }
533}