Skip to main content

gpui_component/button/
dropdown_button.rs

1use gpui::Corners;
2use gpui::{
3    Anchor, App, Context, Edges, ElementId, InteractiveElement as _, IntoElement, ParentElement,
4    RenderOnce, SharedString, StyleRefinement, Styled, Window, div, prelude::FluentBuilder,
5};
6use rust_i18n::t;
7
8use crate::{
9    Disableable, Selectable, Sizable, Size, StyledExt as _,
10    menu::{DropdownMenu, PopupMenu},
11};
12
13use super::{Button, ButtonVariant, ButtonVariants};
14
15/// Group name shared by both halves, so hovering one can style the other.
16const HALVES_GROUP: &str = "dropdown-button";
17
18/// A split button: an action button with an attached menu trigger.
19///
20/// The two halves stay visually joined. A `ghost` split is transparent at
21/// rest; hovering either half surfaces the whole control with the hovered half
22/// emphasized, and it stays surfaced while the menu is open, so the pair reads
23/// as one control rather than two buttons.
24///
25#[derive(IntoElement)]
26pub struct DropdownButton {
27    id: ElementId,
28    style: StyleRefinement,
29    button: Option<Button>,
30    menu:
31        Option<Box<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static>>,
32    selected: bool,
33    disabled: bool,
34    menu_accessibility_label: Option<SharedString>,
35    // The button props, applied to both halves. Unset means the inner
36    // [`Button`] keeps whatever it was given.
37    outline: bool,
38    variant: Option<ButtonVariant>,
39    size: Option<Size>,
40    anchor: Anchor,
41}
42
43impl DropdownButton {
44    /// Create a new DropdownButton.
45    pub fn new(id: impl Into<ElementId>) -> Self {
46        Self {
47            id: id.into(),
48            style: StyleRefinement::default(),
49            button: None,
50            menu: None,
51            selected: false,
52            disabled: false,
53            menu_accessibility_label: None,
54            outline: false,
55            variant: None,
56            size: None,
57            anchor: Anchor::TopRight,
58        }
59    }
60
61    fn effective_variant(&self) -> ButtonVariant {
62        self.variant
63            .or_else(|| self.button.as_ref().map(Button::variant))
64            .unwrap_or_default()
65    }
66
67    fn effective_size(&self) -> Size {
68        self.size
69            .or_else(|| self.button.as_ref().map(Button::button_size))
70            .unwrap_or_default()
71    }
72
73    /// Set the left button of the dropdown button.
74    ///
75    /// The button keeps its own label, icon, tooltip and click handler. A
76    /// variant or size set on the [`DropdownButton`] applies to both halves and
77    /// overrides the one set here. When either outer value is unset, this
78    /// button's value becomes the shared value for both halves.
79    pub fn button(mut self, button: Button) -> Self {
80        self.button = Some(button);
81        self
82    }
83
84    /// Set the dropdown menu of the button.
85    pub fn dropdown_menu(
86        mut self,
87        menu: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
88    ) -> Self {
89        self.menu = Some(Box::new(menu));
90        self
91    }
92
93    /// Set the dropdown menu of the button with anchor corner.
94    pub fn dropdown_menu_with_anchor(
95        mut self,
96        anchor: impl Into<Anchor>,
97        menu: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
98    ) -> Self {
99        self.menu = Some(Box::new(menu));
100        self.anchor = anchor.into();
101        self
102    }
103
104    /// Set the name a screen reader announces for the menu trigger. Defaults
105    /// to the localized "More options".
106    ///
107    /// The trigger shows only a caret, so it has no visible label to read.
108    /// The inner [`Button`] names the action half.
109    pub fn menu_accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
110        self.menu_accessibility_label = Some(label.into());
111        self
112    }
113
114    /// Set the button to outline style.
115    ///
116    /// See also: [`Button::outline`]
117    pub fn outline(mut self) -> Self {
118        self.outline = true;
119        self
120    }
121}
122
123impl Disableable for DropdownButton {
124    fn disabled(mut self, disabled: bool) -> Self {
125        self.disabled = disabled;
126        self
127    }
128}
129
130impl Styled for DropdownButton {
131    fn style(&mut self) -> &mut gpui::StyleRefinement {
132        &mut self.style
133    }
134}
135
136impl Sizable for DropdownButton {
137    fn with_size(mut self, size: impl Into<Size>) -> Self {
138        self.size = Some(size.into());
139        self
140    }
141}
142
143impl ButtonVariants for DropdownButton {
144    fn with_variant(mut self, variant: ButtonVariant) -> Self {
145        self.variant = Some(variant);
146        self
147    }
148}
149
150impl Selectable for DropdownButton {
151    fn selected(mut self, selected: bool) -> Self {
152        self.selected = selected;
153        self
154    }
155
156    fn is_selected(&self) -> bool {
157        self.selected
158    }
159}
160
161impl RenderOnce for DropdownButton {
162    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
163        debug_assert!(
164            self.button.is_some() || self.menu.is_some(),
165            "a DropdownButton needs a `button`, a `dropdown_menu`, or both"
166        );
167
168        let variant = self.effective_variant();
169        let size = self.effective_size();
170        let selected = self.selected || self.button.as_ref().is_some_and(Selectable::is_selected);
171        // Only a ghost split has no surface at rest, so only it needs hovering
172        // one half to reveal the other, and the action half to stay revealed
173        // while the menu holds the trigger pressed.
174        let is_ghost = variant.is_ghost();
175        let menu_open = window.use_keyed_state(self.id.clone(), cx, |_, _| false);
176        let is_menu_open = *menu_open.read(cx);
177
178        div()
179            .id(self.id)
180            .when(is_ghost, |this| this.group(HALVES_GROUP))
181            .h_flex()
182            .refine_style(&self.style)
183            .when_some(self.button, |this, button| {
184                let disabled = self.disabled || button.is_disabled();
185                this.child(
186                    button
187                        .border_corners(Corners {
188                            top_left: true,
189                            top_right: false,
190                            bottom_left: true,
191                            bottom_right: false,
192                        })
193                        .border_edges(Edges::all(true))
194                        .selected(selected)
195                        .disabled(disabled)
196                        .when(self.outline, |this| this.outline())
197                        .with_size(size)
198                        .with_variant(variant)
199                        .when(is_ghost, |this| {
200                            this.hover_group(HALVES_GROUP)
201                                .hover_group_held(is_menu_open)
202                        }),
203                )
204            })
205            .when_some(self.menu, |this, menu| {
206                this.child(
207                    Button::new("popup")
208                        .dropdown_caret(true)
209                        .accessibility_label(
210                            self.menu_accessibility_label
211                                .unwrap_or_else(|| t!("DropdownButton.more_options").into()),
212                        )
213                        .border_corners(Corners {
214                            top_left: false,
215                            top_right: true,
216                            bottom_left: false,
217                            bottom_right: true,
218                        })
219                        .border_edges(Edges {
220                            left: false,
221                            top: true,
222                            right: true,
223                            bottom: true,
224                        })
225                        .selected(selected)
226                        .disabled(self.disabled)
227                        .when(self.outline, |this| this.outline())
228                        .with_size(size)
229                        .with_variant(variant)
230                        .when(is_ghost, |this| this.hover_group(HALVES_GROUP))
231                        .dropdown_menu_with_anchor(self.anchor, menu)
232                        .on_open_change(move |open, _, cx| {
233                            menu_open.update(cx, |state, cx| {
234                                *state = *open;
235                                cx.notify();
236                            })
237                        }),
238                )
239            })
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    #[gpui::test]
248    fn test_dropdown_button_builder(_cx: &mut gpui::TestAppContext) {
249        let button = Button::new("inner").label("Action");
250        let dropdown = DropdownButton::new("complex-dropdown")
251            .button(button)
252            .primary()
253            .outline()
254            .large()
255            .disabled(false)
256            .selected(false)
257            .dropdown_menu_with_anchor(Anchor::BottomLeft, |menu, _, _| menu);
258
259        assert!(dropdown.button.is_some());
260        assert_eq!(dropdown.variant, Some(ButtonVariant::Primary));
261        assert!(dropdown.outline);
262        assert_eq!(dropdown.size, Some(Size::Large));
263        assert!(!dropdown.disabled);
264        assert!(!dropdown.selected);
265        assert!(dropdown.menu.is_some());
266        assert_eq!(dropdown.anchor, Anchor::BottomLeft);
267    }
268
269    /// An unset variant or size leaves the inner button's own to survive, so a
270    /// caller can style the halves from either level.
271    #[gpui::test]
272    fn inner_button_keeps_its_own_variant_and_size(_cx: &mut gpui::TestAppContext) {
273        let dropdown = DropdownButton::new("dropdown")
274            .button(Button::new("inner").label("Action").danger().small())
275            .dropdown_menu(|menu, _, _| menu);
276
277        assert_eq!(dropdown.variant, None);
278        assert_eq!(dropdown.size, None);
279    }
280
281    #[gpui::test]
282    fn inner_ghost_becomes_the_split_variant(_cx: &mut gpui::TestAppContext) {
283        let dropdown = DropdownButton::new("dropdown")
284            .button(Button::new("inner").label("Action").ghost())
285            .dropdown_menu(|menu, _, _| menu);
286
287        assert_eq!(dropdown.effective_variant(), ButtonVariant::Ghost);
288    }
289
290    #[gpui::test]
291    fn inner_size_becomes_the_split_size(_cx: &mut gpui::TestAppContext) {
292        let dropdown = DropdownButton::new("dropdown")
293            .button(Button::new("inner").label("Action").small())
294            .dropdown_menu(|menu, _, _| menu);
295
296        assert_eq!(dropdown.effective_size(), Size::Small);
297    }
298}