gpui_component/menu/
context_menu.rs

1use std::{cell::RefCell, rc::Rc};
2
3use gpui::{
4    anchored, deferred, div, prelude::FluentBuilder, px, AnyElement, App, Context, Corner,
5    DismissEvent, Element, ElementId, Entity, Focusable, GlobalElementId, InspectorElementId,
6    InteractiveElement, IntoElement, MouseButton, MouseDownEvent, ParentElement, Pixels, Point,
7    StyleRefinement, Styled, Subscription, Window,
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<Box<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(Box::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()
172                                    .position(position)
173                                    .snap_to_window_with_margin(px(8.))
174                                    .anchor(anchor)
175                                    .when_some(menu_view, |this, menu| {
176                                        // Focus the menu, so that can be handle the action.
177                                        if !menu.focus_handle(cx).contains_focused(window, cx) {
178                                            menu.focus_handle(cx).focus(window);
179                                        }
180
181                                        this.child(div().occlude().child(menu.clone()))
182                                    }),
183                            )
184                            .with_priority(1)
185                            .into_any(),
186                        );
187                    }
188                }
189
190                let mut element = this
191                    .element
192                    .take()
193                    .expect("Element should exists.")
194                    .children(menu_element)
195                    .into_any_element();
196
197                let layout_id = element.request_layout(window, cx);
198
199                (
200                    layout_id,
201                    ContextMenuState {
202                        element: Some(element),
203                        ..Default::default()
204                    },
205                )
206            },
207        )
208    }
209
210    fn prepaint(
211        &mut self,
212        _: Option<&gpui::GlobalElementId>,
213        _: Option<&InspectorElementId>,
214        _: gpui::Bounds<gpui::Pixels>,
215        request_layout: &mut Self::RequestLayoutState,
216        window: &mut Window,
217        cx: &mut App,
218    ) -> Self::PrepaintState {
219        if let Some(element) = &mut request_layout.element {
220            element.prepaint(window, cx);
221        }
222    }
223
224    fn paint(
225        &mut self,
226        id: Option<&gpui::GlobalElementId>,
227        _: Option<&InspectorElementId>,
228        bounds: gpui::Bounds<gpui::Pixels>,
229        request_layout: &mut Self::RequestLayoutState,
230        _: &mut Self::PrepaintState,
231        window: &mut Window,
232        cx: &mut App,
233    ) {
234        if let Some(element) = &mut request_layout.element {
235            element.paint(window, cx);
236        }
237
238        let Some(builder) = self.menu.take() else {
239            return;
240        };
241
242        self.with_element_state(
243            id.unwrap(),
244            window,
245            cx,
246            |_view, state: &mut ContextMenuState, window, _| {
247                let shared_state = state.shared_state.clone();
248
249                // When right mouse click, to build content menu, and show it at the mouse position.
250                window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| {
251                    if phase.bubble()
252                        && event.button == MouseButton::Right
253                        && bounds.contains(&event.position)
254                    {
255                        {
256                            let mut shared_state = shared_state.borrow_mut();
257                            shared_state.position = event.position;
258                            shared_state.open = true;
259                        }
260
261                        let menu = PopupMenu::build(window, cx, |menu, window, cx| {
262                            (builder)(menu, window, cx)
263                        })
264                        .into_element();
265
266                        let _subscription = window.subscribe(&menu, cx, {
267                            let shared_state = shared_state.clone();
268                            move |_, _: &DismissEvent, window, _| {
269                                shared_state.borrow_mut().open = false;
270                                window.refresh();
271                            }
272                        });
273
274                        shared_state.borrow_mut().menu_view = Some(menu.clone());
275                        shared_state.borrow_mut()._subscription = Some(_subscription);
276                        window.refresh();
277                    }
278                });
279            },
280        );
281    }
282}