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