Skip to main content

gpui_component/menu/
context_menu.rs

1use std::{cell::RefCell, rc::Rc};
2
3use gpui::{
4    AnyElement, App, Context, Corner, DismissEvent, Element, ElementId, Entity, Focusable,
5    GlobalElementId, InspectorElementId, InteractiveElement, IntoElement, MouseButton,
6    MouseDownEvent, ParentElement, Pixels, Point, StyleRefinement, Styled, Subscription, Window,
7    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: 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    fn context_menu(
19        self,
20        f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
21    ) -> ContextMenu<Self> {
22        ContextMenu::new("context-menu", self).menu(f)
23    }
24}
25
26impl<E: ParentElement + Styled> ContextMenuExt for E {}
27
28/// A context menu that can be shown on right-click.
29pub struct ContextMenu<E: ParentElement + Styled + Sized> {
30    id: ElementId,
31    element: Option<E>,
32    menu: Option<Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu>>,
33    // This is not in use, just for style refinement forwarding.
34    _ignore_style: StyleRefinement,
35    anchor: Corner,
36}
37
38impl<E: ParentElement + Styled> ContextMenu<E> {
39    /// Create a new context menu with the given ID.
40    pub fn new(id: impl Into<ElementId>, element: E) -> Self {
41        Self {
42            id: id.into(),
43            element: Some(element),
44            menu: None,
45            anchor: Corner::TopLeft,
46            _ignore_style: StyleRefinement::default(),
47        }
48    }
49
50    /// Build the context menu using the given builder function.
51    #[must_use]
52    fn menu<F>(mut self, builder: F) -> Self
53    where
54        F: Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
55    {
56        self.menu = Some(Rc::new(builder));
57        self
58    }
59
60    fn with_element_state<R>(
61        &mut self,
62        id: &GlobalElementId,
63        window: &mut Window,
64        cx: &mut App,
65        f: impl FnOnce(&mut Self, &mut ContextMenuState, &mut Window, &mut App) -> R,
66    ) -> R {
67        window.with_optional_element_state::<ContextMenuState, _>(
68            Some(id),
69            |element_state, window| {
70                let mut element_state = element_state.unwrap().unwrap_or_default();
71                let result = f(self, &mut element_state, window, cx);
72                (result, Some(element_state))
73            },
74        )
75    }
76}
77
78impl<E: ParentElement + Styled> ParentElement for ContextMenu<E> {
79    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
80        if let Some(element) = &mut self.element {
81            element.extend(elements);
82        }
83    }
84}
85
86impl<E: ParentElement + Styled> Styled for ContextMenu<E> {
87    fn style(&mut self) -> &mut StyleRefinement {
88        if let Some(element) = &mut self.element {
89            element.style()
90        } else {
91            &mut self._ignore_style
92        }
93    }
94}
95
96impl<E: ParentElement + Styled + IntoElement + 'static> IntoElement for ContextMenu<E> {
97    type Element = Self;
98
99    fn into_element(self) -> Self::Element {
100        self
101    }
102}
103
104struct ContextMenuSharedState {
105    menu_view: Option<Entity<PopupMenu>>,
106    open: bool,
107    position: Point<Pixels>,
108    _subscription: Option<Subscription>,
109}
110
111pub struct ContextMenuState {
112    element: Option<AnyElement>,
113    shared_state: Rc<RefCell<ContextMenuSharedState>>,
114}
115
116impl Default for ContextMenuState {
117    fn default() -> Self {
118        Self {
119            element: None,
120            shared_state: Rc::new(RefCell::new(ContextMenuSharedState {
121                menu_view: None,
122                open: false,
123                position: Default::default(),
124                _subscription: None,
125            })),
126        }
127    }
128}
129
130impl<E: ParentElement + Styled + IntoElement + 'static> Element for ContextMenu<E> {
131    type RequestLayoutState = ContextMenuState;
132    type PrepaintState = ();
133
134    fn id(&self) -> Option<ElementId> {
135        Some(self.id.clone())
136    }
137
138    fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
139        None
140    }
141
142    fn request_layout(
143        &mut self,
144        id: Option<&gpui::GlobalElementId>,
145        _: Option<&gpui::InspectorElementId>,
146        window: &mut Window,
147        cx: &mut App,
148    ) -> (gpui::LayoutId, Self::RequestLayoutState) {
149        let anchor = self.anchor;
150
151        self.with_element_state(
152            id.unwrap(),
153            window,
154            cx,
155            |this, state: &mut ContextMenuState, window, cx| {
156                let (position, open) = {
157                    let shared_state = state.shared_state.borrow();
158                    (shared_state.position, shared_state.open)
159                };
160                let menu_view = state.shared_state.borrow().menu_view.clone();
161                let mut menu_element = None;
162                if open {
163                    let has_menu_item = menu_view
164                        .as_ref()
165                        .map(|menu| !menu.read(cx).is_empty())
166                        .unwrap_or(false);
167
168                    if has_menu_item {
169                        menu_element = Some(
170                            deferred(
171                                anchored().child(
172                                    div()
173                                        .w(window.bounds().size.width)
174                                        .h(window.bounds().size.height)
175                                        .occlude()
176                                        .child(
177                                            anchored()
178                                                .position(position)
179                                                .snap_to_window_with_margin(px(8.))
180                                                .anchor(anchor)
181                                                .when_some(menu_view, |this, menu| {
182                                                    // Focus the menu, so that can be handle the action.
183                                                    if !menu
184                                                        .focus_handle(cx)
185                                                        .contains_focused(window, cx)
186                                                    {
187                                                        menu.focus_handle(cx).focus(window);
188                                                    }
189
190                                                    this.child(menu.clone())
191                                                }),
192                                        ),
193                                ),
194                            )
195                            .with_priority(1)
196                            .into_any(),
197                        );
198                    }
199                }
200
201                let mut element = this
202                    .element
203                    .take()
204                    .expect("Element should exists.")
205                    .children(menu_element)
206                    .into_any_element();
207
208                let layout_id = element.request_layout(window, cx);
209
210                (
211                    layout_id,
212                    ContextMenuState {
213                        element: Some(element),
214                        ..Default::default()
215                    },
216                )
217            },
218        )
219    }
220
221    fn prepaint(
222        &mut self,
223        _: Option<&gpui::GlobalElementId>,
224        _: Option<&InspectorElementId>,
225        _: gpui::Bounds<gpui::Pixels>,
226        request_layout: &mut Self::RequestLayoutState,
227        window: &mut Window,
228        cx: &mut App,
229    ) -> Self::PrepaintState {
230        if let Some(element) = &mut request_layout.element {
231            element.prepaint(window, cx);
232        }
233    }
234
235    fn paint(
236        &mut self,
237        id: Option<&gpui::GlobalElementId>,
238        _: Option<&InspectorElementId>,
239        bounds: gpui::Bounds<gpui::Pixels>,
240        request_layout: &mut Self::RequestLayoutState,
241        _: &mut Self::PrepaintState,
242        window: &mut Window,
243        cx: &mut App,
244    ) {
245        if let Some(element) = &mut request_layout.element {
246            element.paint(window, cx);
247        }
248
249        // Take the builder before setting up element state to avoid borrow issues
250        let builder = self.menu.clone();
251
252        self.with_element_state(
253            id.unwrap(),
254            window,
255            cx,
256            |_view, state: &mut ContextMenuState, window, _| {
257                let shared_state = state.shared_state.clone();
258
259                // When right mouse click, to build content menu, and show it at the mouse position.
260                window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| {
261                    if phase.bubble()
262                        && event.button == MouseButton::Right
263                        && bounds.contains(&event.position)
264                    {
265                        {
266                            let mut shared_state = shared_state.borrow_mut();
267                            // Clear any existing menu view to allow immediate replacement
268                            // Set the new position and open the menu
269                            shared_state.menu_view = None;
270                            shared_state._subscription = None;
271                            shared_state.position = event.position;
272                            shared_state.open = true;
273                        }
274
275                        // Use defer to build the menu in the next frame, avoiding race conditions
276                        window.defer(cx, {
277                            let shared_state = shared_state.clone();
278                            let builder = builder.clone();
279                            move |window, cx| {
280                                let menu = PopupMenu::build(window, cx, move |menu, window, cx| {
281                                    let Some(build) = &builder else {
282                                        return menu;
283                                    };
284                                    build(menu, window, cx)
285                                });
286
287                                // Set up the subscription for dismiss handling
288                                let _subscription = window.subscribe(&menu, cx, {
289                                    let shared_state = shared_state.clone();
290                                    move |_, _: &DismissEvent, window, _cx| {
291                                        shared_state.borrow_mut().open = false;
292                                        window.refresh();
293                                    }
294                                });
295
296                                // Update the shared state with the built menu and subscription
297                                {
298                                    let mut state = shared_state.borrow_mut();
299                                    state.menu_view = Some(menu.clone());
300                                    state._subscription = Some(_subscription);
301                                    window.refresh();
302                                }
303                            }
304                        });
305                    }
306                });
307            },
308        );
309    }
310}