Skip to main content

gpui_component/menu/
dropdown_menu.rs

1use std::rc::Rc;
2
3use gpui::{
4    Anchor, App, Context, DismissEvent, ElementId, Entity, Focusable, InteractiveElement,
5    IntoElement, RenderOnce, SharedString, StyleRefinement, Styled, Window, prelude::FluentBuilder,
6};
7
8use crate::{Selectable, button::Button, menu::PopupMenu, popover::Popover};
9
10/// A dropdown menu trait for buttons and other interactive elements
11pub trait DropdownMenu: Styled + Selectable + InteractiveElement + IntoElement + 'static {
12    /// Create a dropdown menu with the given items, anchored to the TopLeft corner
13    fn dropdown_menu(
14        self,
15        f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
16    ) -> DropdownMenuPopover<Self> {
17        self.dropdown_menu_with_anchor(Anchor::TopLeft, f)
18    }
19
20    /// Create a dropdown menu with the given items, anchored to the given corner
21    fn dropdown_menu_with_anchor(
22        mut self,
23        anchor: impl Into<Anchor>,
24        f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
25    ) -> DropdownMenuPopover<Self> {
26        let style = self.style().clone();
27        let id = self.interactivity().element_id.clone();
28
29        DropdownMenuPopover::new(id.unwrap_or(0.into()), anchor, self, f).trigger_style(style)
30    }
31}
32
33impl DropdownMenu for Button {}
34
35#[derive(IntoElement)]
36pub struct DropdownMenuPopover<T: Selectable + IntoElement + 'static> {
37    id: ElementId,
38    style: StyleRefinement,
39    anchor: Anchor,
40    trigger: T,
41    builder: Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu>,
42    on_open_change: Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>,
43}
44
45impl<T> DropdownMenuPopover<T>
46where
47    T: Selectable + IntoElement + 'static,
48{
49    fn new(
50        id: ElementId,
51        anchor: impl Into<Anchor>,
52        trigger: T,
53        builder: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
54    ) -> Self {
55        Self {
56            id: SharedString::from(format!("dropdown-menu:{:?}", id)).into(),
57            style: StyleRefinement::default(),
58            anchor: anchor.into(),
59            trigger,
60            builder: Rc::new(builder),
61            on_open_change: None,
62        }
63    }
64
65    /// Set the anchor corner for the dropdown menu popover.
66    pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
67        self.anchor = anchor.into();
68        self
69    }
70
71    /// Set the style refinement for the dropdown menu trigger.
72    fn trigger_style(mut self, style: StyleRefinement) -> Self {
73        self.style = style;
74        self
75    }
76
77    /// Add a callback to be called when the menu opens or closes.
78    ///
79    /// The `&bool` parameter is the **new open state**.
80    pub fn on_open_change(
81        mut self,
82        callback: impl Fn(&bool, &mut Window, &mut App) + 'static,
83    ) -> Self {
84        self.on_open_change = Some(Rc::new(callback));
85        self
86    }
87}
88
89#[derive(Default)]
90struct DropdownMenuState {
91    menu: Option<Entity<PopupMenu>>,
92}
93
94impl<T> RenderOnce for DropdownMenuPopover<T>
95where
96    T: Selectable + IntoElement + 'static,
97{
98    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
99        let builder = self.builder.clone();
100        let menu_state =
101            window.use_keyed_state(self.id.clone(), cx, |_, _| DropdownMenuState::default());
102
103        Popover::new(SharedString::from(format!("popover:{}", self.id)))
104            .appearance(false)
105            .overlay_closable(false)
106            .trigger(self.trigger)
107            .trigger_style(self.style)
108            .anchor(self.anchor)
109            .when_some(self.on_open_change, |this, callback| {
110                this.on_open_change(move |open, window, cx| callback(open, window, cx))
111            })
112            .content(move |_, window, cx| {
113                // Here is special logic to only create the PopupMenu once and reuse it.
114                // Because this `content` will called in every time render, so we need to store the menu
115                // in state to avoid recreating at every render.
116                //
117                // And we also need to rebuild the menu when it is dismissed, to rebuild menu items
118                // dynamically for support `dropdown_menu` method, so we listen for DismissEvent below.
119                let menu = match menu_state.read(cx).menu.clone() {
120                    Some(menu) => menu,
121                    None => {
122                        let builder = builder.clone();
123                        let menu = PopupMenu::build(window, cx, move |menu, window, cx| {
124                            builder(menu, window, cx)
125                        });
126                        menu_state.update(cx, |state, _| {
127                            state.menu = Some(menu.clone());
128                        });
129                        menu.focus_handle(cx).focus(window, cx);
130
131                        // Listen for dismiss events from the PopupMenu to close the popover.
132                        let popover_state = cx.entity();
133                        window
134                            .subscribe(&menu, cx, {
135                                let menu_state = menu_state.clone();
136                                move |_, _: &DismissEvent, window, cx| {
137                                    popover_state.update(cx, |state, cx| {
138                                        state.dismiss(window, cx);
139                                    });
140                                    menu_state.update(cx, |state, _| {
141                                        state.menu = None;
142                                    });
143                                }
144                            })
145                            .detach();
146
147                        menu.clone()
148                    }
149                };
150
151                menu.clone()
152            })
153    }
154}