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, Context, DismissEvent, Element, ElementId, Entity, FocusHandle,
8    Focusable, GlobalElementId, Hitbox, HitboxBehavior, InspectorElementId, InteractiveElement,
9    IntoElement, LayoutId, MouseButton, MouseDownEvent, ParentElement, Pixels, Point,
10    StyleRefinement, Styled, Subscription, Window, anchored, deferred, div, prelude::FluentBuilder,
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, laid out by every trigger that shares the open
163/// state but drawn only by the one whose bounds contain the press.
164struct DeferredMenu {
165    draws: Rc<Cell<bool>>,
166    menu: Option<AnyElement>,
167}
168
169impl IntoElement for DeferredMenu {
170    type Element = Self;
171
172    fn into_element(self) -> Self::Element {
173        self
174    }
175}
176
177impl Element for DeferredMenu {
178    type RequestLayoutState = ();
179    type PrepaintState = ();
180
181    fn id(&self) -> Option<ElementId> {
182        None
183    }
184
185    fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
186        None
187    }
188
189    fn request_layout(
190        &mut self,
191        _: Option<&GlobalElementId>,
192        _: Option<&InspectorElementId>,
193        window: &mut Window,
194        cx: &mut App,
195    ) -> (LayoutId, ()) {
196        let menu = self.menu.as_mut().expect("menu should exist");
197        (menu.request_layout(window, cx), ())
198    }
199
200    fn prepaint(
201        &mut self,
202        _: Option<&GlobalElementId>,
203        _: Option<&InspectorElementId>,
204        _: gpui::Bounds<Pixels>,
205        _: &mut (),
206        window: &mut Window,
207        cx: &mut App,
208    ) {
209        if !self.draws.get() {
210            return;
211        }
212        if let Some(menu) = &mut self.menu {
213            menu.prepaint(window, cx);
214        }
215    }
216
217    fn paint(
218        &mut self,
219        _: Option<&GlobalElementId>,
220        _: Option<&InspectorElementId>,
221        _: gpui::Bounds<Pixels>,
222        _: &mut (),
223        _: &mut (),
224        window: &mut Window,
225        cx: &mut App,
226    ) {
227        if !self.draws.get() {
228            return;
229        }
230        if let Some(menu) = &mut self.menu {
231            menu.paint(window, cx);
232        }
233    }
234}
235
236impl<E: ParentElement + Styled + IntoElement + 'static> Element for ContextMenu<E> {
237    type RequestLayoutState = ContextMenuState;
238    type PrepaintState = Hitbox;
239
240    fn id(&self) -> Option<ElementId> {
241        Some(self.id.clone())
242    }
243
244    fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
245        None
246    }
247
248    fn request_layout(
249        &mut self,
250        id: Option<&gpui::GlobalElementId>,
251        _: Option<&gpui::InspectorElementId>,
252        window: &mut Window,
253        cx: &mut App,
254    ) -> (gpui::LayoutId, Self::RequestLayoutState) {
255        let anchor = self.anchor;
256
257        self.with_element_state(
258            id.unwrap(),
259            window,
260            cx,
261            |this, state: &mut ContextMenuState, window, cx| {
262                let (position, open) = {
263                    let shared_state = state.shared_state.borrow();
264                    (shared_state.position, shared_state.open)
265                };
266                state
267                    .shared_state
268                    .borrow_mut()
269                    .trigger_focus_handle
270                    .get_or_insert_with(|| cx.focus_handle());
271                let menu_view = state.shared_state.borrow().menu_view.clone();
272                let draws_menu = Rc::new(Cell::new(false));
273                let mut menu_element = None;
274                if open {
275                    let has_menu_item = menu_view
276                        .as_ref()
277                        .map(|menu| !menu.read(cx).is_empty())
278                        .unwrap_or(false);
279
280                    if has_menu_item {
281                        menu_element = Some(
282                            deferred(
283                                anchored().child(
284                                    div()
285                                        .w(window.bounds().size.width)
286                                        .h(window.bounds().size.height)
287                                        .on_scroll_wheel(|_, _, cx| {
288                                            cx.stop_propagation();
289                                        })
290                                        .child(
291                                            anchored()
292                                                .position(position)
293                                                .snap_to_window_with_margin(px(8.))
294                                                .anchor(anchor)
295                                                .when_some(menu_view, |this, menu| {
296                                                    // Focus the menu, so that can be handle the action.
297                                                    if !menu
298                                                        .focus_handle(cx)
299                                                        .contains_focused(window, cx)
300                                                    {
301                                                        menu.focus_handle(cx).focus(window, cx);
302                                                    }
303
304                                                    this.child(menu.clone())
305                                                }),
306                                        ),
307                                ),
308                            )
309                            .with_priority(gpui_base::POPUP_PRIORITY)
310                            .into_any(),
311                        );
312                    }
313                }
314                let menu_element = menu_element.map(|menu| DeferredMenu {
315                    draws: draws_menu.clone(),
316                    menu: Some(menu),
317                });
318
319                let mut element = this
320                    .element
321                    .take()
322                    .expect("Element should exists.")
323                    .children(menu_element)
324                    .into_any_element();
325
326                let layout_id = element.request_layout(window, cx);
327
328                (
329                    layout_id,
330                    ContextMenuState {
331                        element: Some(element),
332                        draws_menu,
333                        shared_state: state.shared_state.clone(),
334                    },
335                )
336            },
337        )
338    }
339
340    fn prepaint(
341        &mut self,
342        _: Option<&gpui::GlobalElementId>,
343        _: Option<&InspectorElementId>,
344        bounds: gpui::Bounds<gpui::Pixels>,
345        request_layout: &mut Self::RequestLayoutState,
346        window: &mut Window,
347        cx: &mut App,
348    ) -> Self::PrepaintState {
349        if let Some(trigger_focus) = request_layout
350            .shared_state
351            .borrow()
352            .trigger_focus_handle
353            .as_ref()
354        {
355            window.set_focus_handle(trigger_focus, cx);
356        }
357        let position = request_layout.shared_state.borrow().position;
358        request_layout.draws_menu.set(bounds.contains(&position));
359        if let Some(element) = &mut request_layout.element {
360            element.prepaint(window, cx);
361        }
362        window.insert_hitbox(bounds, HitboxBehavior::Normal)
363    }
364
365    fn paint(
366        &mut self,
367        id: Option<&gpui::GlobalElementId>,
368        _: Option<&InspectorElementId>,
369        _: gpui::Bounds<gpui::Pixels>,
370        request_layout: &mut Self::RequestLayoutState,
371        hitbox: &mut Self::PrepaintState,
372        window: &mut Window,
373        cx: &mut App,
374    ) {
375        if let Some(element) = &mut request_layout.element {
376            element.paint(window, cx);
377        }
378
379        // Take the builder before setting up element state to avoid borrow issues
380        let builder = self.menu.clone();
381
382        self.with_element_state(
383            id.unwrap(),
384            window,
385            cx,
386            |_view, state: &mut ContextMenuState, window, _| {
387                let shared_state = state.shared_state.clone();
388
389                let hitbox = hitbox.clone();
390                // When right mouse click, to build content menu, and show it at the mouse position.
391                window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| {
392                    if phase.bubble()
393                        && event.button == MouseButton::Right
394                        && hitbox.is_hovered(window)
395                    {
396                        // Capture the focused element to restore focus to on dismiss.
397                        // If focus is still on the previous menu, keep its captured focus.
398                        let previous_focus_handle = window.focused(cx).and_then(|focused| {
399                            let shared_state = shared_state.borrow();
400                            match shared_state.menu_view.as_ref() {
401                                Some(menu) if menu.read(cx).focus_handle == focused => {
402                                    menu.read(cx).previous_focus_handle.clone()
403                                }
404                                _ => Some(focused),
405                            }
406                        });
407
408                        {
409                            let mut shared_state = shared_state.borrow_mut();
410                            // Clear any existing menu view to allow immediate replacement
411                            // Set the new position and open the menu
412                            shared_state.menu_view = None;
413                            shared_state._subscription = None;
414                            shared_state.position = event.position;
415                            shared_state.open = true;
416                        }
417
418                        // Use defer to build the menu in the next frame, avoiding race conditions
419                        window.defer(cx, {
420                            let shared_state = shared_state.clone();
421                            let builder = builder.clone();
422                            move |window, cx| {
423                                let menu = PopupMenu::build(window, cx, move |menu, window, cx| {
424                                    let Some(build) = &builder else {
425                                        return menu;
426                                    };
427                                    build(menu, window, cx)
428                                });
429                                let trigger_focus_handle =
430                                    shared_state.borrow().trigger_focus_handle.clone();
431                                menu.update(cx, |menu, cx| {
432                                    menu.set_trigger_focus(trigger_focus_handle, cx);
433                                    menu.set_previous_focus(previous_focus_handle, cx);
434                                });
435
436                                // Set up the subscription for dismiss handling
437                                let _subscription = window.subscribe(&menu, cx, {
438                                    let shared_state = shared_state.clone();
439                                    move |_, _: &DismissEvent, window, _cx| {
440                                        shared_state.borrow_mut().open = false;
441                                        window.refresh();
442                                    }
443                                });
444
445                                // Update the shared state with the built menu and subscription
446                                {
447                                    let mut state = shared_state.borrow_mut();
448                                    state.menu_view = Some(menu.clone());
449                                    state._subscription = Some(_subscription);
450                                    window.refresh();
451                                }
452                            }
453                        });
454                    }
455                });
456            },
457        );
458    }
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464    use crate::menu::PopupMenuItem;
465    use crate::theme::Theme;
466    use gpui::{
467        Context, FocusHandle, IntoElement, KeyBinding, Render, TestAppContext, VisualTestContext,
468        actions, point, px,
469    };
470    use std::cell::Cell;
471
472    actions!(context_menu_test, [RemoveTab, CopyText]);
473
474    /// The regression shape: the action handler lives on the trigger's
475    /// ancestor (like an action bar), which is NOT on the focus path while
476    /// focus is in the content area.
477    struct TestRoot {
478        content_focus: FocusHandle,
479        received: Rc<Cell<bool>>,
480    }
481
482    impl Render for TestRoot {
483        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
484            let received = self.received.clone();
485            div()
486                .size_full()
487                .child(
488                    div()
489                        .id("content")
490                        .h(px(40.))
491                        .track_focus(&self.content_focus),
492                )
493                .child(
494                    div()
495                        .id("action-bar")
496                        .h(px(60.))
497                        .on_action(move |_: &RemoveTab, _, _| received.set(true))
498                        .child(
499                            div()
500                                .id("tab")
501                                .size_full()
502                                .context_menu(|menu, _, _| menu.menu("Close", Box::new(RemoveTab))),
503                        ),
504                )
505        }
506    }
507
508    #[gpui::test]
509    fn action_bubbles_from_trigger_and_focus_restores_on_dismiss(cx: &mut TestAppContext) {
510        cx.update(|cx| {
511            cx.set_global(Theme::default());
512            super::super::popup_menu::init(cx);
513        });
514
515        let received = Rc::new(Cell::new(false));
516        let (root, cx) = cx.add_window_view({
517            let received = received.clone();
518            move |window, cx| {
519                let content_focus = cx.focus_handle();
520                content_focus.focus(window, cx);
521                TestRoot {
522                    content_focus,
523                    received,
524                }
525            }
526        });
527        let content_focus = root.read_with(cx, |root, _| root.content_focus.clone());
528        let cx: &mut VisualTestContext = cx;
529        cx.run_until_parked();
530        cx.update(|window, cx| {
531            _ = window.draw(cx);
532        });
533
534        // Right-click inside the tab to open the context menu.
535        cx.simulate_event(MouseDownEvent {
536            button: MouseButton::Right,
537            position: point(px(50.), px(70.)),
538            modifiers: Default::default(),
539            click_count: 1,
540            first_mouse: false,
541        });
542        // The menu entity is built in a deferred callback, then rendered
543        // (which also focuses it) on the next draw.
544        cx.run_until_parked();
545        cx.update(|window, cx| {
546            _ = window.draw(cx);
547        });
548
549        // Select "Close" and confirm. Keyboard confirm and mouse click share
550        // the same `confirm` path in `PopupMenu`.
551        cx.simulate_keystrokes("down enter");
552        cx.run_until_parked();
553
554        // The action must reach the handler on the trigger's ancestor chain,
555        // even though the action bar was never on the focus path.
556        assert!(received.get());
557        // And dismiss must restore focus to where it was before the menu
558        // opened, keeping the dangling-focus fix (#2614).
559        cx.update(|window, cx| {
560            assert_eq!(window.focused(cx).as_ref(), Some(&content_focus));
561        });
562    }
563
564    const CONTEXT: &str = "context_menu_test";
565
566    /// The story shape: nothing is focused, the key binding lives in the key
567    /// context of the trigger's ancestor, and other content outside that
568    /// context paints after the trigger.
569    struct UnfocusedRoot {
570        frames: Rc<Cell<usize>>,
571    }
572
573    impl Render for UnfocusedRoot {
574        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
575            self.frames.set(self.frames.get() + 1);
576            div()
577                .size_full()
578                .child(
579                    div()
580                        .key_context(CONTEXT)
581                        .on_action(|_: &CopyText, _, _| {})
582                        .child(
583                            div()
584                                .id("tab")
585                                .w(px(100.))
586                                .h(px(30.))
587                                .context_menu(|menu, _, _| menu.menu("Copy", Box::new(CopyText))),
588                        ),
589                )
590                .child(div().child("Status"))
591        }
592    }
593
594    /// The issue shape (#3134): rows rendered from one call site without an
595    /// `ElementId` share the context menu's element state.
596    struct RowsRoot {
597        clicked: Rc<Cell<usize>>,
598    }
599
600    impl Render for RowsRoot {
601        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
602            div().size_full().children((0..3).map(|_| {
603                let clicked = self.clicked.clone();
604                div()
605                    .w(px(100.))
606                    .h(px(30.))
607                    .context_menu(move |menu, _, _| {
608                        let clicked = clicked.clone();
609                        menu.item(
610                            PopupMenuItem::new("Favorite")
611                                .on_click(move |_, _, _| clicked.set(clicked.get() + 1)),
612                        )
613                    })
614            }))
615        }
616    }
617
618    #[gpui::test]
619    fn item_click_fires_once_from_rows_without_an_id(cx: &mut TestAppContext) {
620        cx.update(|cx| crate::init(cx));
621        let clicked = Rc::new(Cell::new(0));
622        let (_, cx) = cx.add_window_view({
623            let clicked = clicked.clone();
624            move |_, _| RowsRoot { clicked }
625        });
626        cx.update(|window, cx| {
627            window.draw(cx).clear(cx);
628        });
629
630        // Right-click the second row; the menu opens at the press position.
631        let press = point(px(10.), px(40.));
632        cx.simulate_mouse_down(press, MouseButton::Right, Default::default());
633        cx.simulate_mouse_up(press, MouseButton::Right, Default::default());
634        cx.run_until_parked();
635        cx.update(|window, cx| {
636            window.draw(cx).clear(cx);
637        });
638
639        // Click the first item, which sits inside the menu's content padding.
640        let item = point(press.x + px(30.), press.y + px(17.));
641        cx.simulate_mouse_move(item, None, Default::default());
642        cx.simulate_click(item, Default::default());
643        cx.run_until_parked();
644
645        assert_eq!(
646            clicked.get(),
647            1,
648            "the item's on_click must fire exactly once"
649        );
650    }
651
652    #[gpui::test]
653    fn shortcut_hint_is_painted_on_the_frame_the_menu_opens(cx: &mut TestAppContext) {
654        cx.update(|cx| {
655            crate::init(cx);
656            cx.bind_keys([KeyBinding::new("ctrl-c", CopyText, Some(CONTEXT))]);
657        });
658        let frames = Rc::new(Cell::new(0));
659        let (_, cx) = cx.add_window_view({
660            let frames = frames.clone();
661            move |_, _| UnfocusedRoot { frames }
662        });
663        cx.update(|window, cx| {
664            window.draw(cx).clear(cx);
665            assert!(window.focused(cx).is_none());
666        });
667        let frames_before_open = frames.get();
668
669        // Right-click inside the tab; the menu is built in a deferred callback
670        // and drawn on the frame that follows.
671        cx.simulate_mouse_down(
672            point(px(10.), px(10.)),
673            MouseButton::Right,
674            Default::default(),
675        );
676
677        assert_eq!(
678            frames.get(),
679            frames_before_open + 1,
680            "the press must be followed by exactly one frame for this to test the first one"
681        );
682        assert!(
683            cx.debug_bounds("kbd:ctrl-c").is_some(),
684            "the shortcut hint must be painted on the same frame as its item"
685        );
686    }
687}