Skip to main content

gpui_component/menu/
dropdown_menu.rs

1use std::rc::Rc;
2
3use gpui::{
4    Anchor, AnyElement, App, Context, DismissEvent, Element, ElementId, Entity, FocusHandle,
5    Focusable, GlobalElementId, InspectorElementId, InteractiveElement, IntoElement, LayoutId,
6    RenderOnce, SharedString, Styled, Window, prelude::FluentBuilder,
7};
8
9use crate::{Selectable, button::Button, menu::PopupMenu, popover::Popover};
10
11/// A dropdown menu trait for buttons and other interactive elements
12pub trait DropdownMenu: Styled + Selectable + InteractiveElement + IntoElement + 'static {
13    /// Create a dropdown menu with the given items, anchored to the TopLeft corner
14    fn dropdown_menu(
15        self,
16        f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
17    ) -> DropdownMenuPopover<Self> {
18        self.dropdown_menu_with_anchor(Anchor::TopLeft, f)
19    }
20
21    /// Create a dropdown menu with the given items, anchored to the given corner
22    fn dropdown_menu_with_anchor(
23        mut self,
24        anchor: impl Into<Anchor>,
25        f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
26    ) -> DropdownMenuPopover<Self> {
27        let id = self.interactivity().element_id.clone();
28
29        DropdownMenuPopover::new(id.unwrap_or(0.into()), anchor, self, f)
30    }
31}
32
33impl DropdownMenu for Button {}
34
35#[derive(IntoElement)]
36pub struct DropdownMenuPopover<T: Selectable + IntoElement + 'static> {
37    id: ElementId,
38    anchor: Anchor,
39    trigger: T,
40    builder: Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu>,
41    on_open_change: Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>,
42}
43
44impl<T> DropdownMenuPopover<T>
45where
46    T: Selectable + IntoElement + 'static,
47{
48    fn new(
49        id: ElementId,
50        anchor: impl Into<Anchor>,
51        trigger: T,
52        builder: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
53    ) -> Self {
54        Self {
55            id: SharedString::from(format!("dropdown-menu:{:?}", id)).into(),
56            anchor: anchor.into(),
57            trigger,
58            builder: Rc::new(builder),
59            on_open_change: None,
60        }
61    }
62
63    /// Set the anchor corner for the dropdown menu popover.
64    pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
65        self.anchor = anchor.into();
66        self
67    }
68
69    /// Add a callback to be called when the menu opens or closes.
70    ///
71    /// The `&bool` parameter is the **new open state**.
72    pub fn on_open_change(
73        mut self,
74        callback: impl Fn(&bool, &mut Window, &mut App) + 'static,
75    ) -> Self {
76        self.on_open_change = Some(Rc::new(callback));
77        self
78    }
79}
80
81#[derive(Default)]
82struct DropdownMenuState {
83    menu: Option<Entity<PopupMenu>>,
84}
85
86impl<T> RenderOnce for DropdownMenuPopover<T>
87where
88    T: Selectable + IntoElement + 'static,
89{
90    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
91        TriggerFocus::new(self.id.clone(), move |trigger_focus, window, cx| {
92            self.render_popover(trigger_focus, window, cx)
93                .into_any_element()
94        })
95    }
96}
97
98impl<T> DropdownMenuPopover<T>
99where
100    T: Selectable + IntoElement + 'static,
101{
102    fn render_popover(
103        self,
104        trigger_focus: FocusHandle,
105        window: &mut Window,
106        cx: &mut App,
107    ) -> Popover {
108        let builder = self.builder.clone();
109        let menu_state =
110            window.use_keyed_state(self.id.clone(), cx, |_, _| DropdownMenuState::default());
111
112        Popover::new(SharedString::from(format!("popover:{}", self.id)))
113            .appearance(false)
114            .overlay_closable(false)
115            .trigger(self.trigger)
116            .anchor(self.anchor)
117            .when_some(self.on_open_change, |this, callback| {
118                this.on_open_change(move |open, window, cx| callback(open, window, cx))
119            })
120            .content(move |_, window, cx| {
121                // Here is special logic to only create the PopupMenu once and reuse it.
122                // Because this `content` will called in every time render, so we need to store the menu
123                // in state to avoid recreating at every render.
124                //
125                // And we also need to rebuild the menu when it is dismissed, to rebuild menu items
126                // dynamically for support `dropdown_menu` method, so we listen for DismissEvent below.
127                let menu = match menu_state.read(cx).menu.clone() {
128                    Some(menu) => menu,
129                    None => {
130                        let builder = builder.clone();
131                        let menu = PopupMenu::build(window, cx, move |menu, window, cx| {
132                            builder(menu, window, cx)
133                        });
134                        menu.update(cx, |menu, cx| {
135                            menu.set_trigger_focus(Some(trigger_focus.clone()), cx)
136                        });
137                        menu_state.update(cx, |state, _| {
138                            state.menu = Some(menu.clone());
139                        });
140                        menu.focus_handle(cx).focus(window, cx);
141
142                        // Listen for dismiss events from the PopupMenu to close the popover.
143                        //
144                        // Hold Weak handles here, not strong clones: the listener
145                        // lives as long as `menu`, which `menu_state` owns, so a
146                        // strong capture would close the cycle
147                        // `menu_state -> menu -> listener -> menu_state` and leak
148                        // the `PopupMenu` and `DropdownMenuState` when the trigger
149                        // stops being rendered while the menu is open.
150                        let popover_state = cx.entity().downgrade();
151                        window
152                            .subscribe(&menu, cx, {
153                                let menu_state = menu_state.downgrade();
154                                move |_, _: &DismissEvent, window, cx| {
155                                    if let Some(popover_state) = popover_state.upgrade() {
156                                        popover_state.update(cx, |state, cx| {
157                                            state.dismiss(window, cx);
158                                        });
159                                    }
160                                    _ = menu_state.update(cx, |state, _| {
161                                        state.menu = None;
162                                    });
163                                }
164                            })
165                            .detach();
166
167                        menu.clone()
168                    }
169                };
170
171                menu.clone()
172            })
173    }
174}
175
176type TriggerFocusBuild = Box<dyn FnOnce(FocusHandle, &mut Window, &mut App) -> AnyElement>;
177
178/// Registers a focus handle on the trigger's dispatch node without ever
179/// focusing it, so the menu opened from the trigger can resolve its shortcut
180/// hints against the trigger's key contexts on the frame it opens. GPUI looks
181/// a handle up in the previously rendered frame; the trigger was in it when
182/// the menu was not yet.
183struct TriggerFocus {
184    id: ElementId,
185    build: Option<TriggerFocusBuild>,
186}
187
188#[derive(Default)]
189struct TriggerFocusState {
190    focus_handle: Option<FocusHandle>,
191}
192
193struct TriggerFocusFrame {
194    focus_handle: FocusHandle,
195    child: AnyElement,
196}
197
198impl TriggerFocus {
199    fn new(
200        id: ElementId,
201        build: impl FnOnce(FocusHandle, &mut Window, &mut App) -> AnyElement + 'static,
202    ) -> Self {
203        Self {
204            id,
205            build: Some(Box::new(build)),
206        }
207    }
208}
209
210impl IntoElement for TriggerFocus {
211    type Element = Self;
212
213    fn into_element(self) -> Self::Element {
214        self
215    }
216}
217
218impl Element for TriggerFocus {
219    type RequestLayoutState = TriggerFocusFrame;
220    type PrepaintState = ();
221
222    fn id(&self) -> Option<ElementId> {
223        Some(self.id.clone())
224    }
225
226    fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
227        None
228    }
229
230    fn request_layout(
231        &mut self,
232        id: Option<&GlobalElementId>,
233        _: Option<&InspectorElementId>,
234        window: &mut Window,
235        cx: &mut App,
236    ) -> (LayoutId, Self::RequestLayoutState) {
237        let focus_handle =
238            window.with_optional_element_state::<TriggerFocusState, _>(id, |state, _| {
239                let mut state = state.flatten().unwrap_or_default();
240                let focus_handle = state
241                    .focus_handle
242                    .get_or_insert_with(|| cx.focus_handle())
243                    .clone();
244                (focus_handle, Some(state))
245            });
246        let build = self.build.take().expect("TriggerFocus is laid out once");
247        let mut child = build(focus_handle.clone(), window, cx);
248        let layout_id = child.request_layout(window, cx);
249
250        (
251            layout_id,
252            TriggerFocusFrame {
253                focus_handle,
254                child,
255            },
256        )
257    }
258
259    fn prepaint(
260        &mut self,
261        _: Option<&GlobalElementId>,
262        _: Option<&InspectorElementId>,
263        _: gpui::Bounds<gpui::Pixels>,
264        frame: &mut Self::RequestLayoutState,
265        window: &mut Window,
266        cx: &mut App,
267    ) {
268        window.set_focus_handle(&frame.focus_handle, cx);
269        frame.child.prepaint(window, cx);
270    }
271
272    fn paint(
273        &mut self,
274        _: Option<&GlobalElementId>,
275        _: Option<&InspectorElementId>,
276        _: gpui::Bounds<gpui::Pixels>,
277        frame: &mut Self::RequestLayoutState,
278        _: &mut Self::PrepaintState,
279        window: &mut Window,
280        cx: &mut App,
281    ) {
282        frame.child.paint(window, cx);
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use gpui::{
290        KeyBinding, MouseButton, ParentElement as _, Render, TestAppContext, WeakEntity, actions,
291        div, point, px,
292    };
293    use std::cell::{Cell, RefCell};
294
295    actions!(dropdown_menu_test, [CopyText]);
296
297    const CONTEXT: &str = "dropdown_menu_test";
298
299    /// The story shape: the key binding lives in the key context of the
300    /// trigger's ancestor, the menu names no `action_context`, and other
301    /// content outside that context paints after the trigger.
302    struct TestRoot {
303        frames: Rc<Cell<usize>>,
304    }
305
306    impl Render for TestRoot {
307        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
308            self.frames.set(self.frames.get() + 1);
309            div()
310                .size_full()
311                .child(
312                    div()
313                        .key_context(CONTEXT)
314                        .on_action(|_: &CopyText, _, _| {})
315                        .child(
316                            Button::new("trigger")
317                                .label("Edit")
318                                .w(px(100.))
319                                .h(px(30.))
320                                .dropdown_menu(|menu, _, _| menu.menu("Copy", Box::new(CopyText))),
321                        ),
322                )
323                .child(div().child("Status"))
324        }
325    }
326
327    /// Records the `PopupMenu` the dropdown builds, so a test can observe
328    /// its release through a weak handle.
329    struct MenuProbeRoot {
330        menu: Rc<RefCell<Option<WeakEntity<PopupMenu>>>>,
331    }
332
333    impl Render for MenuProbeRoot {
334        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
335            let slot = self.menu.clone();
336            div().size_full().child(
337                Button::new("trigger")
338                    .label("Edit")
339                    .w(px(100.))
340                    .h(px(30.))
341                    .dropdown_menu(move |menu, _, cx| {
342                        *slot.borrow_mut() = Some(cx.weak_entity());
343                        menu.menu("Copy", Box::new(CopyText))
344                    }),
345            )
346        }
347    }
348
349    /// Opening the menu and closing the window without dismissing it must
350    /// release the `PopupMenu`: the dismiss subscription lives as long as the
351    /// menu, which `DropdownMenuState` owns, so strong captures in it formed
352    /// a cycle that no element-state collection could break. The state still
353    /// holds the menu while it is open, so the menu's release also proves the
354    /// `DropdownMenuState` was released.
355    ///
356    /// The popover must go too: it holds the deferred-popover registration
357    /// while open, and a leaked one keeps every later right-click menu
358    /// stepping aside as if a popup were still showing.
359    #[gpui::test]
360    fn open_without_dismiss_releases_the_menu(cx: &mut TestAppContext) {
361        cx.update(|cx| crate::init(cx));
362        let menu = Rc::new(RefCell::new(None::<WeakEntity<PopupMenu>>));
363
364        {
365            let (_, cx) = cx.add_window_view(|_, _| MenuProbeRoot { menu: menu.clone() });
366            cx.update(|window, cx| window.draw(cx).clear(cx));
367
368            // Click the trigger; the menu opens and is left open.
369            cx.simulate_mouse_down(
370                point(px(10.), px(10.)),
371                MouseButton::Left,
372                Default::default(),
373            );
374            cx.run_until_parked();
375            assert!(
376                menu.borrow()
377                    .as_ref()
378                    .and_then(|menu| menu.upgrade())
379                    .is_some(),
380                "the menu must be open before the window closes"
381            );
382
383            // Close the window without dismissing the menu.
384            cx.update(|window, _| window.remove_window());
385            cx.run_until_parked();
386        }
387
388        assert!(
389            menu.borrow()
390                .as_ref()
391                .and_then(|menu| menu.upgrade())
392                .is_none(),
393            "the PopupMenu must be released with the window"
394        );
395        cx.update(|cx| {
396            assert!(
397                !gpui_base::GlobalState::is_in_deferred_context(cx),
398                "the popover's deferred registration must be released with the window"
399            )
400        });
401    }
402
403    #[gpui::test]
404    fn shortcut_hint_is_painted_on_the_frame_the_menu_opens(cx: &mut TestAppContext) {
405        cx.update(|cx| {
406            crate::init(cx);
407            cx.bind_keys([KeyBinding::new("ctrl-c", CopyText, Some(CONTEXT))]);
408        });
409        let frames = Rc::new(Cell::new(0));
410        let (_, cx) = cx.add_window_view({
411            let frames = frames.clone();
412            move |_, _| TestRoot { frames }
413        });
414        // The popup host captures its trigger bounds on the first frame.
415        cx.update(|window, cx| window.draw(cx).clear(cx));
416        let frames_before_open = frames.get();
417
418        cx.simulate_mouse_down(
419            point(px(10.), px(10.)),
420            MouseButton::Left,
421            Default::default(),
422        );
423
424        assert_eq!(
425            frames.get(),
426            frames_before_open + 1,
427            "the press must be followed by exactly one frame for this to test the first one"
428        );
429        assert!(
430            cx.debug_bounds("kbd:ctrl-c").is_some(),
431            "the shortcut hint must be painted on the same frame as its item"
432        );
433    }
434}