Skip to main content

gpui_component/menu/
context_menu.rs

1use std::{
2    cell::{Cell, RefCell},
3    rc::Rc,
4};
5
6use gpui::{
7    Anchor, AnyElement, App, Bounds, Context, DismissEvent, Element, ElementId, Entity,
8    FocusHandle, Focusable, GlobalElementId, Hitbox, HitboxBehavior, InspectorElementId,
9    InteractiveElement, IntoElement, LayoutId, MouseButton, MouseDownEvent, ParentElement, Pixels,
10    Point, Position, Style, StyleRefinement, Styled, Subscription, Window, anchored, deferred, div,
11    px,
12};
13
14use crate::menu::PopupMenu;
15
16/// A extension trait for adding a context menu to an element.
17pub trait ContextMenuExt: InteractiveElement + ParentElement + Styled {
18    /// Add a context menu to the element.
19    ///
20    /// This will changed the element to be `relative` positioned, and add a child `ContextMenu` element.
21    /// Because the `ContextMenu` element is positioned `absolute`, it will not affect the layout of the parent element.
22    #[track_caller]
23    fn context_menu(
24        mut self,
25        f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
26    ) -> ContextMenu<Self>
27    where
28        Self: Sized,
29    {
30        // The ID must be stable across renders, otherwise the element state
31        // (open menu) is lost on every re-render.
32        let caller = std::panic::Location::caller();
33        let id = self
34            .interactivity()
35            .element_id
36            .clone()
37            .map(|id| ElementId::Name(format!("context-menu-{:?}", id).into()))
38            .unwrap_or_else(|| ElementId::CodeLocation(*caller));
39        ContextMenu::new(id, self).menu(f)
40    }
41}
42
43impl<E: InteractiveElement + ParentElement + Styled> ContextMenuExt for E {}
44
45/// A context menu that can be shown on right-click.
46pub struct ContextMenu<E: ParentElement + Styled + Sized> {
47    id: ElementId,
48    element: Option<E>,
49    menu: Option<Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu>>,
50    // This is not in use, just for style refinement forwarding.
51    _ignore_style: StyleRefinement,
52    anchor: Anchor,
53}
54
55impl<E: ParentElement + Styled> ContextMenu<E> {
56    /// Create a new context menu with the given ID.
57    pub fn new(id: impl Into<ElementId>, element: E) -> Self {
58        Self {
59            id: id.into(),
60            element: Some(element),
61            menu: None,
62            anchor: Anchor::TopLeft,
63            _ignore_style: StyleRefinement::default(),
64        }
65    }
66
67    /// Build the context menu using the given builder function.
68    #[must_use]
69    fn menu<F>(mut self, builder: F) -> Self
70    where
71        F: Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
72    {
73        self.menu = Some(Rc::new(builder));
74        self
75    }
76
77    fn with_element_state<R>(
78        &mut self,
79        id: &GlobalElementId,
80        window: &mut Window,
81        cx: &mut App,
82        f: impl FnOnce(&mut Self, &mut ContextMenuState, &mut Window, &mut App) -> R,
83    ) -> R {
84        window.with_optional_element_state::<ContextMenuState, _>(
85            Some(id),
86            |element_state, window| {
87                let mut element_state = element_state.unwrap().unwrap_or_default();
88                let result = f(self, &mut element_state, window, cx);
89                (result, Some(element_state))
90            },
91        )
92    }
93}
94
95impl<E: ParentElement + Styled> ParentElement for ContextMenu<E> {
96    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
97        if let Some(element) = &mut self.element {
98            element.extend(elements);
99        }
100    }
101}
102
103impl<E: ParentElement + Styled> Styled for ContextMenu<E> {
104    fn style(&mut self) -> &mut StyleRefinement {
105        if let Some(element) = &mut self.element {
106            element.style()
107        } else {
108            &mut self._ignore_style
109        }
110    }
111}
112
113impl<E: ParentElement + Styled + IntoElement + 'static> IntoElement for ContextMenu<E> {
114    type Element = Self;
115
116    fn into_element(self) -> Self::Element {
117        self
118    }
119}
120
121struct ContextMenuSharedState {
122    menu_view: Option<Entity<PopupMenu>>,
123    open: bool,
124    position: Point<Pixels>,
125    /// Registered on this element's dispatch node every frame and never
126    /// focused, so the menu can resolve its shortcut hints against the
127    /// trigger's key contexts on the frame it opens: GPUI looks a handle up in
128    /// the previously rendered frame, where the menu's own element is not yet.
129    trigger_focus_handle: Option<FocusHandle>,
130    _subscription: Option<Subscription>,
131}
132
133pub struct ContextMenuState {
134    element: Option<AnyElement>,
135    /// Whether this trigger draws the open menu this frame.
136    ///
137    /// Triggers without an `ElementId` fall back to their code location, so
138    /// rows rendered from one call site share the element state and all see
139    /// the menu as open. Only the trigger that was pressed draws it: stacked
140    /// copies of one `PopupMenu` share an item's pending-click state, and the
141    /// covered copies clear it on mouse up before the visible one fires.
142    draws_menu: Rc<Cell<bool>>,
143    shared_state: Rc<RefCell<ContextMenuSharedState>>,
144}
145
146impl Default for ContextMenuState {
147    fn default() -> Self {
148        Self {
149            element: None,
150            draws_menu: Rc::default(),
151            shared_state: Rc::new(RefCell::new(ContextMenuSharedState {
152                menu_view: None,
153                open: false,
154                position: Default::default(),
155                trigger_focus_handle: None,
156                _subscription: None,
157            })),
158        }
159    }
160}
161
162/// The deferred menu layer of one trigger.
163///
164/// Every trigger that shares the open state carries one, but only the trigger
165/// whose bounds contain the press builds and lays out the menu, so an open
166/// menu is rendered once per frame rather than once per row.
167struct DeferredMenu {
168    draws: Rc<Cell<bool>>,
169    menu_view: Entity<PopupMenu>,
170    position: Point<Pixels>,
171    anchor: Anchor,
172    menu: Option<AnyElement>,
173}
174
175impl DeferredMenu {
176    fn build_menu(&self, window: &mut Window, cx: &mut App) -> AnyElement {
177        // Focus the menu, so that can be handle the action.
178        let focus_handle = self.menu_view.focus_handle(cx);
179        if !focus_handle.contains_focused(window, cx) {
180            focus_handle.focus(window, cx);
181        }
182
183        deferred(
184            anchored().child(
185                div()
186                    .w(window.bounds().size.width)
187                    .h(window.bounds().size.height)
188                    .on_scroll_wheel(|_, _, cx| {
189                        cx.stop_propagation();
190                    })
191                    .child(
192                        anchored()
193                            .position(self.position)
194                            .snap_to_window_with_margin(px(8.))
195                            .anchor(self.anchor)
196                            .child(self.menu_view.clone()),
197                    ),
198            ),
199        )
200        .with_priority(gpui_base::POPUP_PRIORITY)
201        .into_any()
202    }
203}
204
205impl IntoElement for DeferredMenu {
206    type Element = Self;
207
208    fn into_element(self) -> Self::Element {
209        self
210    }
211}
212
213impl Element for DeferredMenu {
214    type RequestLayoutState = ();
215    type PrepaintState = ();
216
217    fn id(&self) -> Option<ElementId> {
218        None
219    }
220
221    fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
222        None
223    }
224
225    fn request_layout(
226        &mut self,
227        _: Option<&GlobalElementId>,
228        _: Option<&InspectorElementId>,
229        window: &mut Window,
230        cx: &mut App,
231    ) -> (LayoutId, ()) {
232        // An empty absolute placeholder, where the menu's `anchored` root used
233        // to sit, so the trigger's own layout is unchanged.
234        let style = Style {
235            position: Position::Absolute,
236            ..Style::default()
237        };
238        (window.request_layout(style, None, cx), ())
239    }
240
241    fn prepaint(
242        &mut self,
243        _: Option<&GlobalElementId>,
244        _: Option<&InspectorElementId>,
245        bounds: Bounds<Pixels>,
246        _: &mut (),
247        window: &mut Window,
248        cx: &mut App,
249    ) {
250        if !self.draws.get() {
251            return;
252        }
253        let mut menu = self.build_menu(window, cx);
254        menu.prepaint_as_root(bounds.origin, window.viewport_size().into(), window, cx);
255        self.menu = Some(menu);
256    }
257
258    fn paint(
259        &mut self,
260        _: Option<&GlobalElementId>,
261        _: Option<&InspectorElementId>,
262        _: Bounds<Pixels>,
263        _: &mut (),
264        _: &mut (),
265        window: &mut Window,
266        cx: &mut App,
267    ) {
268        if let Some(menu) = &mut self.menu {
269            menu.paint(window, cx);
270        }
271    }
272}
273
274impl<E: ParentElement + Styled + IntoElement + 'static> Element for ContextMenu<E> {
275    type RequestLayoutState = ContextMenuState;
276    type PrepaintState = Hitbox;
277
278    fn id(&self) -> Option<ElementId> {
279        Some(self.id.clone())
280    }
281
282    fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
283        None
284    }
285
286    fn request_layout(
287        &mut self,
288        id: Option<&gpui::GlobalElementId>,
289        _: Option<&gpui::InspectorElementId>,
290        window: &mut Window,
291        cx: &mut App,
292    ) -> (gpui::LayoutId, Self::RequestLayoutState) {
293        let anchor = self.anchor;
294
295        self.with_element_state(
296            id.unwrap(),
297            window,
298            cx,
299            |this, state: &mut ContextMenuState, window, cx| {
300                let (position, open) = {
301                    let shared_state = state.shared_state.borrow();
302                    (shared_state.position, shared_state.open)
303                };
304                state
305                    .shared_state
306                    .borrow_mut()
307                    .trigger_focus_handle
308                    .get_or_insert_with(|| cx.focus_handle());
309                let menu_view = state.shared_state.borrow().menu_view.clone();
310                let draws_menu = Rc::new(Cell::new(false));
311                let menu_element = menu_view
312                    .filter(|menu| open && !menu.read(cx).is_empty())
313                    .map(|menu_view| DeferredMenu {
314                        draws: draws_menu.clone(),
315                        menu_view,
316                        position,
317                        anchor,
318                        menu: None,
319                    });
320
321                let mut element = this
322                    .element
323                    .take()
324                    .expect("Element should exists.")
325                    .children(menu_element)
326                    .into_any_element();
327
328                let layout_id = element.request_layout(window, cx);
329
330                (
331                    layout_id,
332                    ContextMenuState {
333                        element: Some(element),
334                        draws_menu,
335                        shared_state: state.shared_state.clone(),
336                    },
337                )
338            },
339        )
340    }
341
342    fn prepaint(
343        &mut self,
344        _: Option<&gpui::GlobalElementId>,
345        _: Option<&InspectorElementId>,
346        bounds: gpui::Bounds<gpui::Pixels>,
347        request_layout: &mut Self::RequestLayoutState,
348        window: &mut Window,
349        cx: &mut App,
350    ) -> Self::PrepaintState {
351        if let Some(trigger_focus) = request_layout
352            .shared_state
353            .borrow()
354            .trigger_focus_handle
355            .as_ref()
356        {
357            window.set_focus_handle(trigger_focus, cx);
358        }
359        let position = request_layout.shared_state.borrow().position;
360        request_layout.draws_menu.set(bounds.contains(&position));
361        if let Some(element) = &mut request_layout.element {
362            element.prepaint(window, cx);
363        }
364        window.insert_hitbox(bounds, HitboxBehavior::Normal)
365    }
366
367    fn paint(
368        &mut self,
369        id: Option<&gpui::GlobalElementId>,
370        _: Option<&InspectorElementId>,
371        _: gpui::Bounds<gpui::Pixels>,
372        request_layout: &mut Self::RequestLayoutState,
373        hitbox: &mut Self::PrepaintState,
374        window: &mut Window,
375        cx: &mut App,
376    ) {
377        if let Some(element) = &mut request_layout.element {
378            element.paint(window, cx);
379        }
380
381        // Take the builder before setting up element state to avoid borrow issues
382        let builder = self.menu.clone();
383
384        self.with_element_state(
385            id.unwrap(),
386            window,
387            cx,
388            |_view, state: &mut ContextMenuState, window, _| {
389                let shared_state = state.shared_state.clone();
390
391                let hitbox = hitbox.clone();
392                // When right mouse click, to build content menu, and show it at the mouse position.
393                window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| {
394                    if phase.bubble()
395                        && event.button == MouseButton::Right
396                        && hitbox.is_hovered(window)
397                    {
398                        // Capture the focused element to restore focus to on dismiss.
399                        // If focus is still on the previous menu, keep its captured focus.
400                        let previous_focus_handle = window.focused(cx).and_then(|focused| {
401                            let shared_state = shared_state.borrow();
402                            match shared_state.menu_view.as_ref() {
403                                Some(menu) if menu.read(cx).focus_handle == focused => {
404                                    menu.read(cx).previous_focus_handle.clone()
405                                }
406                                _ => Some(focused),
407                            }
408                        });
409
410                        {
411                            let mut shared_state = shared_state.borrow_mut();
412                            // Clear any existing menu view to allow immediate replacement
413                            // Set the new position and open the menu
414                            shared_state.menu_view = None;
415                            shared_state._subscription = None;
416                            shared_state.position = event.position;
417                            shared_state.open = true;
418                        }
419
420                        // Use defer to build the menu in the next frame, avoiding race conditions
421                        window.defer(cx, {
422                            let shared_state = shared_state.clone();
423                            let builder = builder.clone();
424                            move |window, cx| {
425                                let menu = PopupMenu::build(window, cx, move |menu, window, cx| {
426                                    let Some(build) = &builder else {
427                                        return menu;
428                                    };
429                                    build(menu, window, cx)
430                                });
431                                let trigger_focus_handle =
432                                    shared_state.borrow().trigger_focus_handle.clone();
433                                menu.update(cx, |menu, cx| {
434                                    menu.set_trigger_focus(trigger_focus_handle, cx);
435                                    menu.set_previous_focus(previous_focus_handle, cx);
436                                });
437
438                                // Set up the subscription for dismiss handling.
439                                // Hold a Weak here, not a strong clone: the closure
440                                // would otherwise close the cycle
441                                // `shared_state -> _subscription -> closure ->
442                                // shared_state`, so a menu left open when the window
443                                // closes leaks its PopupMenu entity.
444                                let _subscription = window.subscribe(&menu, cx, {
445                                    let shared_state = Rc::downgrade(&shared_state);
446                                    move |_, _: &DismissEvent, window, _cx| {
447                                        if let Some(shared_state) = shared_state.upgrade() {
448                                            shared_state.borrow_mut().open = false;
449                                            window.refresh();
450                                        }
451                                    }
452                                });
453
454                                // Update the shared state with the built menu and subscription
455                                {
456                                    let mut state = shared_state.borrow_mut();
457                                    state.menu_view = Some(menu.clone());
458                                    state._subscription = Some(_subscription);
459                                    window.refresh();
460                                }
461                            }
462                        });
463                    }
464                });
465            },
466        );
467    }
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473    use crate::menu::PopupMenuItem;
474    use crate::theme::Theme;
475    use gpui::{
476        Context, FocusHandle, IntoElement, KeyBinding, Render, TestAppContext, VisualTestContext,
477        actions, point, px,
478    };
479    use std::cell::Cell;
480
481    actions!(context_menu_test, [RemoveTab, CopyText]);
482
483    /// The regression shape: the action handler lives on the trigger's
484    /// ancestor (like an action bar), which is NOT on the focus path while
485    /// focus is in the content area.
486    struct TestRoot {
487        content_focus: FocusHandle,
488        received: Rc<Cell<bool>>,
489    }
490
491    impl Render for TestRoot {
492        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
493            let received = self.received.clone();
494            div()
495                .size_full()
496                .child(
497                    div()
498                        .id("content")
499                        .h(px(40.))
500                        .track_focus(&self.content_focus),
501                )
502                .child(
503                    div()
504                        .id("action-bar")
505                        .h(px(60.))
506                        .on_action(move |_: &RemoveTab, _, _| received.set(true))
507                        .child(
508                            div()
509                                .id("tab")
510                                .size_full()
511                                .context_menu(|menu, _, _| menu.menu("Close", Box::new(RemoveTab))),
512                        ),
513                )
514        }
515    }
516
517    #[gpui::test]
518    fn action_bubbles_from_trigger_and_focus_restores_on_dismiss(cx: &mut TestAppContext) {
519        cx.update(|cx| {
520            cx.set_global(Theme::default());
521            super::super::popup_menu::init(cx);
522        });
523
524        let received = Rc::new(Cell::new(false));
525        let (root, cx) = cx.add_window_view({
526            let received = received.clone();
527            move |window, cx| {
528                let content_focus = cx.focus_handle();
529                content_focus.focus(window, cx);
530                TestRoot {
531                    content_focus,
532                    received,
533                }
534            }
535        });
536        let content_focus = root.read_with(cx, |root, _| root.content_focus.clone());
537        let cx: &mut VisualTestContext = cx;
538        cx.run_until_parked();
539        cx.update(|window, cx| {
540            _ = window.draw(cx);
541        });
542
543        // Right-click inside the tab to open the context menu.
544        cx.simulate_event(MouseDownEvent {
545            button: MouseButton::Right,
546            position: point(px(50.), px(70.)),
547            modifiers: Default::default(),
548            click_count: 1,
549            first_mouse: false,
550        });
551        // The menu entity is built in a deferred callback, then rendered
552        // (which also focuses it) on the next draw.
553        cx.run_until_parked();
554        cx.update(|window, cx| {
555            _ = window.draw(cx);
556        });
557
558        // Select "Close" and confirm. Keyboard confirm and mouse click share
559        // the same `confirm` path in `PopupMenu`.
560        cx.simulate_keystrokes("down enter");
561        cx.run_until_parked();
562
563        // The action must reach the handler on the trigger's ancestor chain,
564        // even though the action bar was never on the focus path.
565        assert!(received.get());
566        // And dismiss must restore focus to where it was before the menu
567        // opened, keeping the dangling-focus fix (#2614).
568        cx.update(|window, cx| {
569            assert_eq!(window.focused(cx).as_ref(), Some(&content_focus));
570        });
571    }
572
573    const CONTEXT: &str = "context_menu_test";
574
575    /// The story shape: nothing is focused, the key binding lives in the key
576    /// context of the trigger's ancestor, and other content outside that
577    /// context paints after the trigger.
578    struct UnfocusedRoot {
579        frames: Rc<Cell<usize>>,
580    }
581
582    impl Render for UnfocusedRoot {
583        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
584            self.frames.set(self.frames.get() + 1);
585            div()
586                .size_full()
587                .child(
588                    div()
589                        .key_context(CONTEXT)
590                        .on_action(|_: &CopyText, _, _| {})
591                        .child(
592                            div()
593                                .id("tab")
594                                .w(px(100.))
595                                .h(px(30.))
596                                .context_menu(|menu, _, _| menu.menu("Copy", Box::new(CopyText))),
597                        ),
598                )
599                .child(div().child("Status"))
600        }
601    }
602
603    /// The issue shape (#3134): rows rendered from one call site without an
604    /// `ElementId` share the context menu's element state.
605    struct RowsRoot {
606        clicked: Rc<Cell<usize>>,
607    }
608
609    impl Render for RowsRoot {
610        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
611            div().size_full().children((0..3).map(|_| {
612                let clicked = self.clicked.clone();
613                div()
614                    .w(px(100.))
615                    .h(px(30.))
616                    .context_menu(move |menu, _, _| {
617                        let clicked = clicked.clone();
618                        menu.item(
619                            PopupMenuItem::new("Favorite")
620                                .on_click(move |_, _, _| clicked.set(clicked.get() + 1)),
621                        )
622                    })
623            }))
624        }
625    }
626
627    #[gpui::test]
628    fn item_click_fires_once_from_rows_without_an_id(cx: &mut TestAppContext) {
629        cx.update(|cx| crate::init(cx));
630        let clicked = Rc::new(Cell::new(0));
631        let (_, cx) = cx.add_window_view({
632            let clicked = clicked.clone();
633            move |_, _| RowsRoot { clicked }
634        });
635        cx.update(|window, cx| {
636            window.draw(cx).clear(cx);
637        });
638
639        // Right-click the second row; the menu opens at the press position.
640        let press = point(px(10.), px(40.));
641        cx.simulate_mouse_down(press, MouseButton::Right, Default::default());
642        cx.simulate_mouse_up(press, MouseButton::Right, Default::default());
643        cx.run_until_parked();
644        cx.update(|window, cx| {
645            window.draw(cx).clear(cx);
646        });
647
648        // Click the first item, which sits inside the menu's content padding.
649        let item = point(press.x + px(30.), press.y + px(17.));
650        cx.simulate_mouse_move(item, None, Default::default());
651        cx.simulate_click(item, Default::default());
652        cx.run_until_parked();
653
654        assert_eq!(
655            clicked.get(),
656            1,
657            "the item's on_click must fire exactly once"
658        );
659    }
660
661    /// Opening a context menu and closing the window without dismissing the
662    /// menu must release the `PopupMenu` entity (#3223): the dismiss
663    /// subscription used to capture a strong `Rc` clone of `shared_state`,
664    /// and the app-global listener registry kept that clone alive even after
665    /// the window (and its element state) was gone, so the menu entity
666    /// survived the window. The subscription now holds a `Weak` instead.
667    #[gpui::test]
668    fn open_without_dismiss_releases_the_menu_entity(cx: &mut TestAppContext) {
669        cx.update(|cx| crate::init(cx));
670        let before = cx.update(|cx| cx.leak_detector_snapshot());
671
672        {
673            let (_, cx) = cx.add_window_view(|_, _| RowsRoot {
674                clicked: Rc::new(Cell::new(0)),
675            });
676            cx.update(|window, cx| {
677                window.draw(cx).clear(cx);
678            });
679
680            // Right-click the first row; the menu opens and is left open.
681            let press = point(px(10.), px(10.));
682            cx.simulate_mouse_down(press, MouseButton::Right, Default::default());
683            cx.simulate_mouse_up(press, MouseButton::Right, Default::default());
684            cx.run_until_parked();
685            cx.update(|window, cx| {
686                window.draw(cx).clear(cx);
687            });
688
689            // Close the window without dismissing the menu.
690            cx.update(|window, _| window.remove_window());
691            cx.run_until_parked();
692        }
693
694        // The app itself is still alive here, so the global listener registry
695        // (which held the subscription's strong `Rc` clone) is too: any entity
696        // leaked by the old cycle is still reachable and detected.
697        cx.update(|cx| cx.assert_no_new_leaks(&before));
698    }
699
700    #[gpui::test]
701    fn shortcut_hint_is_painted_on_the_frame_the_menu_opens(cx: &mut TestAppContext) {
702        cx.update(|cx| {
703            crate::init(cx);
704            cx.bind_keys([KeyBinding::new("ctrl-c", CopyText, Some(CONTEXT))]);
705        });
706        let frames = Rc::new(Cell::new(0));
707        let (_, cx) = cx.add_window_view({
708            let frames = frames.clone();
709            move |_, _| UnfocusedRoot { frames }
710        });
711        cx.update(|window, cx| {
712            window.draw(cx).clear(cx);
713            assert!(window.focused(cx).is_none());
714        });
715        let frames_before_open = frames.get();
716
717        // Right-click inside the tab; the menu is built in a deferred callback
718        // and drawn on the frame that follows.
719        cx.simulate_mouse_down(
720            point(px(10.), px(10.)),
721            MouseButton::Right,
722            Default::default(),
723        );
724
725        assert_eq!(
726            frames.get(),
727            frames_before_open + 1,
728            "the press must be followed by exactly one frame for this to test the first one"
729        );
730        assert!(
731            cx.debug_bounds("kbd:ctrl-c").is_some(),
732            "the shortcut hint must be painted on the same frame as its item"
733        );
734    }
735}