Skip to main content

rgpui_component/menu/
context_menu.rs

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