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, Focusable,
5    GlobalElementId, Hitbox, HitboxBehavior, InspectorElementId, InteractiveElement, IntoElement,
6    MouseButton, MouseDownEvent, ParentElement, Pixels, Point, StyleRefinement, Styled,
7    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    _subscription: Option<Subscription>,
122}
123
124pub struct ContextMenuState {
125    element: Option<AnyElement>,
126    shared_state: Rc<RefCell<ContextMenuSharedState>>,
127}
128
129impl Default for ContextMenuState {
130    fn default() -> Self {
131        Self {
132            element: None,
133            shared_state: Rc::new(RefCell::new(ContextMenuSharedState {
134                menu_view: None,
135                open: false,
136                position: Default::default(),
137                _subscription: None,
138            })),
139        }
140    }
141}
142
143impl<E: ParentElement + Styled + IntoElement + 'static> Element for ContextMenu<E> {
144    type RequestLayoutState = ContextMenuState;
145    type PrepaintState = Hitbox;
146
147    fn id(&self) -> Option<ElementId> {
148        Some(self.id.clone())
149    }
150
151    fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
152        None
153    }
154
155    fn request_layout(
156        &mut self,
157        id: Option<&gpui::GlobalElementId>,
158        _: Option<&gpui::InspectorElementId>,
159        window: &mut Window,
160        cx: &mut App,
161    ) -> (gpui::LayoutId, Self::RequestLayoutState) {
162        let anchor = self.anchor;
163
164        self.with_element_state(
165            id.unwrap(),
166            window,
167            cx,
168            |this, state: &mut ContextMenuState, window, cx| {
169                let (position, open) = {
170                    let shared_state = state.shared_state.borrow();
171                    (shared_state.position, shared_state.open)
172                };
173                let menu_view = state.shared_state.borrow().menu_view.clone();
174                let mut menu_element = None;
175                if open {
176                    let has_menu_item = menu_view
177                        .as_ref()
178                        .map(|menu| !menu.read(cx).is_empty())
179                        .unwrap_or(false);
180
181                    if has_menu_item {
182                        menu_element = Some(
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(position)
194                                                .snap_to_window_with_margin(px(8.))
195                                                .anchor(anchor)
196                                                .when_some(menu_view, |this, menu| {
197                                                    // Focus the menu, so that can be handle the action.
198                                                    if !menu
199                                                        .focus_handle(cx)
200                                                        .contains_focused(window, cx)
201                                                    {
202                                                        menu.focus_handle(cx).focus(window, cx);
203                                                    }
204
205                                                    this.child(menu.clone())
206                                                }),
207                                        ),
208                                ),
209                            )
210                            .with_priority(gpui_base::POPUP_PRIORITY)
211                            .into_any(),
212                        );
213                    }
214                }
215
216                let mut element = this
217                    .element
218                    .take()
219                    .expect("Element should exists.")
220                    .children(menu_element)
221                    .into_any_element();
222
223                let layout_id = element.request_layout(window, cx);
224
225                (
226                    layout_id,
227                    ContextMenuState {
228                        element: Some(element),
229                        ..Default::default()
230                    },
231                )
232            },
233        )
234    }
235
236    fn prepaint(
237        &mut self,
238        _: Option<&gpui::GlobalElementId>,
239        _: Option<&InspectorElementId>,
240        bounds: gpui::Bounds<gpui::Pixels>,
241        request_layout: &mut Self::RequestLayoutState,
242        window: &mut Window,
243        cx: &mut App,
244    ) -> Self::PrepaintState {
245        if let Some(element) = &mut request_layout.element {
246            element.prepaint(window, cx);
247        }
248        window.insert_hitbox(bounds, HitboxBehavior::Normal)
249    }
250
251    fn paint(
252        &mut self,
253        id: Option<&gpui::GlobalElementId>,
254        _: Option<&InspectorElementId>,
255        _: gpui::Bounds<gpui::Pixels>,
256        request_layout: &mut Self::RequestLayoutState,
257        hitbox: &mut Self::PrepaintState,
258        window: &mut Window,
259        cx: &mut App,
260    ) {
261        if let Some(element) = &mut request_layout.element {
262            element.paint(window, cx);
263        }
264
265        // Take the builder before setting up element state to avoid borrow issues
266        let builder = self.menu.clone();
267
268        self.with_element_state(
269            id.unwrap(),
270            window,
271            cx,
272            |_view, state: &mut ContextMenuState, window, _| {
273                let shared_state = state.shared_state.clone();
274
275                let hitbox = hitbox.clone();
276                // When right mouse click, to build content menu, and show it at the mouse position.
277                window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| {
278                    if phase.bubble()
279                        && event.button == MouseButton::Right
280                        && hitbox.is_hovered(window)
281                    {
282                        // Capture the focused element to restore focus to on dismiss.
283                        // If focus is still on the previous menu, keep its captured focus.
284                        let previous_focus_handle = window.focused(cx).and_then(|focused| {
285                            let shared_state = shared_state.borrow();
286                            match shared_state.menu_view.as_ref() {
287                                Some(menu) if menu.read(cx).focus_handle == focused => {
288                                    menu.read(cx).previous_focus_handle.clone()
289                                }
290                                _ => Some(focused),
291                            }
292                        });
293
294                        {
295                            let mut shared_state = shared_state.borrow_mut();
296                            // Clear any existing menu view to allow immediate replacement
297                            // Set the new position and open the menu
298                            shared_state.menu_view = None;
299                            shared_state._subscription = None;
300                            shared_state.position = event.position;
301                            shared_state.open = true;
302                        }
303
304                        // Use defer to build the menu in the next frame, avoiding race conditions
305                        window.defer(cx, {
306                            let shared_state = shared_state.clone();
307                            let builder = builder.clone();
308                            move |window, cx| {
309                                let menu = PopupMenu::build(window, cx, move |menu, window, cx| {
310                                    let Some(build) = &builder else {
311                                        return menu;
312                                    };
313                                    build(menu, window, cx)
314                                });
315                                menu.update(cx, |menu, cx| {
316                                    menu.set_previous_focus(previous_focus_handle, cx);
317                                });
318
319                                // Set up the subscription for dismiss handling
320                                let _subscription = window.subscribe(&menu, cx, {
321                                    let shared_state = shared_state.clone();
322                                    move |_, _: &DismissEvent, window, _cx| {
323                                        shared_state.borrow_mut().open = false;
324                                        window.refresh();
325                                    }
326                                });
327
328                                // Update the shared state with the built menu and subscription
329                                {
330                                    let mut state = shared_state.borrow_mut();
331                                    state.menu_view = Some(menu.clone());
332                                    state._subscription = Some(_subscription);
333                                    window.refresh();
334                                }
335                            }
336                        });
337                    }
338                });
339            },
340        );
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347    use crate::theme::Theme;
348    use gpui::{
349        Context, FocusHandle, IntoElement, Render, TestAppContext, VisualTestContext, actions,
350        point, px,
351    };
352    use std::cell::Cell;
353
354    actions!(context_menu_test, [RemoveTab]);
355
356    /// The regression shape: the action handler lives on the trigger's
357    /// ancestor (like an action bar), which is NOT on the focus path while
358    /// focus is in the content area.
359    struct TestRoot {
360        content_focus: FocusHandle,
361        received: Rc<Cell<bool>>,
362    }
363
364    impl Render for TestRoot {
365        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
366            let received = self.received.clone();
367            div()
368                .size_full()
369                .child(
370                    div()
371                        .id("content")
372                        .h(px(40.))
373                        .track_focus(&self.content_focus),
374                )
375                .child(
376                    div()
377                        .id("action-bar")
378                        .h(px(60.))
379                        .on_action(move |_: &RemoveTab, _, _| received.set(true))
380                        .child(
381                            div()
382                                .id("tab")
383                                .size_full()
384                                .context_menu(|menu, _, _| menu.menu("Close", Box::new(RemoveTab))),
385                        ),
386                )
387        }
388    }
389
390    #[gpui::test]
391    fn action_bubbles_from_trigger_and_focus_restores_on_dismiss(cx: &mut TestAppContext) {
392        cx.update(|cx| {
393            cx.set_global(Theme::default());
394            super::super::popup_menu::init(cx);
395        });
396
397        let received = Rc::new(Cell::new(false));
398        let (root, cx) = cx.add_window_view({
399            let received = received.clone();
400            move |window, cx| {
401                let content_focus = cx.focus_handle();
402                content_focus.focus(window, cx);
403                TestRoot {
404                    content_focus,
405                    received,
406                }
407            }
408        });
409        let content_focus = root.read_with(cx, |root, _| root.content_focus.clone());
410        let cx: &mut VisualTestContext = cx;
411        cx.run_until_parked();
412        cx.update(|window, cx| {
413            _ = window.draw(cx);
414        });
415
416        // Right-click inside the tab to open the context menu.
417        cx.simulate_event(MouseDownEvent {
418            button: MouseButton::Right,
419            position: point(px(50.), px(70.)),
420            modifiers: Default::default(),
421            click_count: 1,
422            first_mouse: false,
423        });
424        // The menu entity is built in a deferred callback, then rendered
425        // (which also focuses it) on the next draw.
426        cx.run_until_parked();
427        cx.update(|window, cx| {
428            _ = window.draw(cx);
429        });
430
431        // Select "Close" and confirm. Keyboard confirm and mouse click share
432        // the same `confirm` path in `PopupMenu`.
433        cx.simulate_keystrokes("down enter");
434        cx.run_until_parked();
435
436        // The action must reach the handler on the trigger's ancestor chain,
437        // even though the action bar was never on the focus path.
438        assert!(received.get());
439        // And dismiss must restore focus to where it was before the menu
440        // opened, keeping the dangling-focus fix (#2614).
441        cx.update(|window, cx| {
442            assert_eq!(window.focused(cx).as_ref(), Some(&content_focus));
443        });
444    }
445}