Skip to main content

gpui_cn/menu/
dropdown.rs

1use std::rc::Rc;
2
3use gpui_kit::{
4    AnyElement, App, ElementId, Entity, FocusHandle, InteractiveElement as _, IntoElement, Length,
5    MouseButton, ParentElement as _, RenderOnce, StyleRefinement, Styled, Window,
6    base::{
7        Align, ElementExt as _, GlobalState, Selectable, StyledExt as _, TestSupportExt as _,
8        actions::{Confirm, SelectDown},
9    },
10    div,
11    prelude::FluentBuilder as _,
12};
13
14use super::{MenuAnchor, MenuEntry, MenuState, TRIGGER_CONTEXT, panel::MenuPanels};
15
16/// Builds the trigger, marked open while the menu is.
17type TriggerBuilder = Box<dyn FnOnce(bool) -> AnyElement>;
18pub(crate) type EntriesBuilder = Rc<dyn Fn(&mut Window, &mut App) -> Vec<MenuEntry>>;
19/// Opens the menu, from the keyboard when the flag is set.
20type Opener = Rc<dyn Fn(bool, &mut Window, &mut App)>;
21
22/// A menu of commands that opens under a trigger, as shadcn's
23/// `DropdownMenu`.
24///
25/// A press on the trigger opens the menu; so do Enter, Space, and Down
26/// while the trigger has focus, which also put the keyboard on the first
27/// row. The rows are built as the menu opens, so their enabled state is
28/// current. The menu opens under the trigger, a gap away, lined up with
29/// its trailing edge unless [`align`](Self::align) says otherwise, and
30/// over it when there is no room below.
31///
32/// ```
33/// use gpui_cn::{Button, DropdownMenu, MenuItem, MenuState};
34/// use gpui_kit::{Entity, IntoElement};
35///
36/// fn actions(state: &Entity<MenuState>) -> impl IntoElement {
37///     DropdownMenu::new("actions", state)
38///         .trigger(Button::new("actions-trigger").label("Actions"))
39///         .items(|_, _| {
40///             vec![
41///                 MenuItem::new("rename", "Rename").into(),
42///                 MenuItem::new("delete", "Delete").destructive().into(),
43///             ]
44///         })
45/// }
46/// ```
47///
48/// The id keys the rows and the menu's motion, so it must be stable
49/// across frames.
50#[derive(IntoElement)]
51pub struct DropdownMenu {
52    id: ElementId,
53    state: Entity<MenuState>,
54    trigger: Option<TriggerBuilder>,
55    items: Option<EntriesBuilder>,
56    align: Align,
57    action_context: Option<FocusHandle>,
58    menu_width: Option<Length>,
59    style: StyleRefinement,
60}
61
62impl DropdownMenu {
63    /// A dropdown menu on `state`, with a stable id.
64    pub fn new(id: impl Into<ElementId>, state: &Entity<MenuState>) -> Self {
65        Self {
66            id: id.into(),
67            state: state.clone(),
68            trigger: None,
69            items: None,
70            align: Align::End,
71            action_context: None,
72            menu_width: None,
73            style: StyleRefinement::default(),
74        }
75    }
76
77    /// The element that opens the menu, such as a [`Button`](crate::Button)
78    /// without a click handler of its own. It is marked open while the
79    /// menu shows.
80    pub fn trigger(mut self, trigger: impl Selectable + IntoElement + 'static) -> Self {
81        self.trigger = Some(Box::new(move |open| trigger.open(open).into_any_element()));
82        self
83    }
84
85    /// Builds the rows each time the menu opens.
86    pub fn items(
87        mut self,
88        items: impl Fn(&mut Window, &mut App) -> Vec<MenuEntry> + 'static,
89    ) -> Self {
90        self.items = Some(Rc::new(items));
91        self
92    }
93
94    /// Which edge of the trigger the menu lines up with. The default is
95    /// `End`, as a select's menu opens.
96    pub fn align(mut self, align: Align) -> Self {
97        self.align = align;
98        self
99    }
100
101    /// The element the items' actions go to, and whose key bindings their
102    /// shortcuts show. Without one they go to the element focused when
103    /// the menu opens, as its key binding would, or up from this element
104    /// when nothing is focused.
105    pub fn action_context(mut self, focus_handle: &FocusHandle) -> Self {
106        self.action_context = Some(focus_handle.clone());
107        self
108    }
109
110    /// A fixed menu width. Without one the menu is as wide as its rows,
111    /// between the theme's menu minimum and maximum.
112    pub fn menu_width(mut self, width: impl Into<Length>) -> Self {
113        self.menu_width = Some(width.into());
114        self
115    }
116}
117
118impl Styled for DropdownMenu {
119    fn style(&mut self) -> &mut StyleRefinement {
120        &mut self.style
121    }
122}
123
124impl RenderOnce for DropdownMenu {
125    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
126        let state = self.state;
127        let (open, trigger_bounds) = {
128            let state = state.read(cx);
129            (state.is_open(), state.trigger_bounds().clone())
130        };
131        let open_menu: Opener = {
132            let state = state.clone();
133            let items = self.items;
134            let align = self.align;
135            let context = self.action_context;
136            let trigger_bounds = trigger_bounds.clone();
137            Rc::new(move |keyboard, window, cx| {
138                let entries = items
139                    .as_ref()
140                    .map(|items| items(window, cx))
141                    .unwrap_or_default();
142                let anchor = MenuAnchor::Trigger(trigger_bounds.get(), align);
143                state.update(cx, |state, cx| {
144                    match &context {
145                        Some(context) => state.open_for(entries, anchor, context, window, cx),
146                        None => state.open(entries, anchor, window, cx),
147                    }
148                    if keyboard {
149                        state.highlight_edge(false, cx);
150                    }
151                });
152            })
153        };
154        div()
155            .id(self.id.clone())
156            .test_support()
157            .key_context(TRIGGER_CONTEXT)
158            .flex_shrink_0()
159            .refine_style(&self.style)
160            .relative()
161            .on_prepaint(move |bounds, _, _| trigger_bounds.set(bounds))
162            .on_mouse_down(MouseButton::Left, {
163                let state = state.clone();
164                let open_menu = open_menu.clone();
165                move |_, window, cx| {
166                    window.prevent_default();
167                    cx.stop_propagation();
168                    GlobalState::suppress_text_selection(cx);
169                    // A press on the trigger while the menu is open reaches
170                    // the menu's outside press first, which closes it; that
171                    // press must not open it again.
172                    if state.read(cx).is_open() != open {
173                        return;
174                    }
175                    if open {
176                        state.update(cx, |state, cx| state.close(window, cx));
177                    } else {
178                        open_menu(false, window, cx);
179                    }
180                }
181            })
182            .when(!open, |this| {
183                let confirm = open_menu.clone();
184                this.on_action(move |_: &Confirm, window, cx| confirm(true, window, cx))
185                    .on_action(move |_: &SelectDown, window, cx| open_menu(true, window, cx))
186            })
187            .children(self.trigger.map(|trigger| trigger(open)))
188            .child(MenuPanels::new(self.id, &state).width(self.menu_width))
189    }
190}