Skip to main content

gpui_component/button/
button_group.rs

1use gpui::Corners;
2use gpui::InteractiveElement;
3use gpui::ParentElement;
4use gpui::{App, Axis, Edges, ElementId, IntoElement, Window};
5use gpui::{
6    RenderOnce, StatefulInteractiveElement as _, StyleRefinement, Styled, div,
7    prelude::FluentBuilder as _,
8};
9use std::{cell::Cell, rc::Rc};
10
11use crate::{
12    Disableable, Sizable, Size, StyledExt,
13    button::{Button, ButtonVariant, ButtonVariants},
14};
15
16/// A ButtonGroup element, to wrap multiple buttons in a group.
17#[derive(IntoElement)]
18pub struct ButtonGroup {
19    id: ElementId,
20    style: StyleRefinement,
21    children: Vec<Button>,
22    pub(super) multiple: bool,
23    pub(super) disabled: bool,
24    pub(super) layout: Axis,
25
26    // The button props
27    pub(super) compact: bool,
28    pub(super) outline: bool,
29    pub(super) variant: Option<ButtonVariant>,
30    pub(super) size: Option<Size>,
31
32    on_click: Option<Box<dyn Fn(&Vec<usize>, &mut Window, &mut App) + 'static>>,
33}
34
35impl Disableable for ButtonGroup {
36    fn disabled(mut self, disabled: bool) -> Self {
37        self.disabled = disabled;
38        self
39    }
40}
41
42impl ButtonGroup {
43    /// Creates a new ButtonGroup.
44    pub fn new(id: impl Into<ElementId>) -> Self {
45        Self {
46            id: id.into(),
47            style: StyleRefinement::default(),
48            children: Vec::new(),
49            variant: None,
50            size: None,
51            compact: false,
52            outline: false,
53            multiple: false,
54            disabled: false,
55            layout: Axis::Horizontal,
56            on_click: None,
57        }
58    }
59
60    /// Adds a button as a child to the ButtonGroup.
61    pub fn child(mut self, child: Button) -> Self {
62        self.children.push(child.disabled(self.disabled));
63        self
64    }
65
66    /// Adds multiple buttons as children to the ButtonGroup.
67    pub fn children(mut self, children: impl IntoIterator<Item = Button>) -> Self {
68        self.children.extend(children);
69        self
70    }
71
72    /// With the multiple selection mode, default is false (single selection).
73    pub fn multiple(mut self, multiple: bool) -> Self {
74        self.multiple = multiple;
75        self
76    }
77
78    /// Set the layout of the button group. Default is `Axis::Horizontal`.
79    pub fn layout(mut self, layout: Axis) -> Self {
80        self.layout = layout;
81        self
82    }
83
84    /// With the compact mode for the ButtonGroup.
85    ///
86    /// See also: [`Button::compact()`]
87    pub fn compact(mut self) -> Self {
88        self.compact = true;
89        self
90    }
91
92    /// With the outline mode for the ButtonGroup.
93    ///
94    /// See also: [`Button::outline()`]
95    pub fn outline(mut self) -> Self {
96        self.outline = true;
97        self
98    }
99
100    /// Sets the on_click handler for the ButtonGroup.
101    ///
102    /// The handler first argument is a vector of the selected button indices.
103    ///
104    /// The `&Vec<usize>` is the indices of the clicked (selected in `multiple` mode) buttons.
105    /// For example: `[0, 2, 3]` is means the first, third and fourth buttons are clicked.
106    ///
107    /// ```ignore
108    /// ButtonGroup::new("size-button")
109    ///    .child(Button::new("large").label("Large").selected(self.size == Size::Large))
110    ///    .child(Button::new("medium").label("Medium").selected(self.size == Size::Medium))
111    ///    .child(Button::new("small").label("Small").selected(self.size == Size::Small))
112    ///    .on_click(cx.listener(|view, clicks: &Vec<usize>, _, cx| {
113    ///        if clicks.contains(&0) {
114    ///            view.size = Size::Large;
115    ///        } else if clicks.contains(&1) {
116    ///            view.size = Size::Medium;
117    ///        } else if clicks.contains(&2) {
118    ///            view.size = Size::Small;
119    ///        }
120    ///        cx.notify();
121    ///    }))
122    /// ```
123    pub fn on_click(
124        mut self,
125        handler: impl Fn(&Vec<usize>, &mut Window, &mut App) + 'static,
126    ) -> Self {
127        self.on_click = Some(Box::new(handler));
128        self
129    }
130}
131
132impl Sizable for ButtonGroup {
133    fn with_size(mut self, size: impl Into<Size>) -> Self {
134        self.size = Some(size.into());
135        self
136    }
137}
138
139impl Styled for ButtonGroup {
140    fn style(&mut self) -> &mut gpui::StyleRefinement {
141        &mut self.style
142    }
143}
144
145impl ButtonVariants for ButtonGroup {
146    fn with_variant(mut self, variant: ButtonVariant) -> Self {
147        self.variant = Some(variant);
148        self
149    }
150}
151
152impl RenderOnce for ButtonGroup {
153    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
154        let children_len = self.children.len();
155        let mut selected_ixs: Vec<usize> = Vec::new();
156        let state = Rc::new(Cell::new(None));
157
158        for (ix, child) in self.children.iter().enumerate() {
159            if child.selected {
160                selected_ixs.push(ix);
161            }
162        }
163
164        let vertical = self.layout == Axis::Vertical;
165
166        div()
167            .id(self.id)
168            .flex()
169            .when(vertical, |this| this.flex_col().justify_center())
170            .when(!vertical, |this| this.items_center())
171            .refine_style(&self.style)
172            .children(
173                self.children
174                    .into_iter()
175                    .enumerate()
176                    .map(|(child_index, child)| {
177                        let state = Rc::clone(&state);
178                        // The group as a whole is a toggle control, so every
179                        // child advertises its pressed state.
180                        let selected = child.selected;
181                        let child = child.toggled(selected);
182                        let child = if children_len == 1 {
183                            child
184                        } else if child_index == 0 {
185                            // First
186                            child
187                                .border_corners(Corners {
188                                    top_left: true,
189                                    top_right: vertical,
190                                    bottom_left: !vertical,
191                                    bottom_right: false,
192                                })
193                                .border_edges(Edges {
194                                    left: true,
195                                    top: true,
196                                    right: true,
197                                    bottom: true,
198                                })
199                        } else if child_index == children_len - 1 {
200                            // Last
201                            child
202                                .border_edges(Edges {
203                                    left: vertical,
204                                    top: !vertical,
205                                    right: true,
206                                    bottom: true,
207                                })
208                                .border_corners(Corners {
209                                    top_left: false,
210                                    top_right: !vertical,
211                                    bottom_left: vertical,
212                                    bottom_right: true,
213                                })
214                        } else {
215                            // Middle
216                            child
217                                .border_corners(Corners {
218                                    top_left: false,
219                                    top_right: false,
220                                    bottom_left: false,
221                                    bottom_right: false,
222                                })
223                                .border_edges(Edges {
224                                    left: vertical,
225                                    top: !vertical,
226                                    right: true,
227                                    bottom: true,
228                                })
229                        }
230                        .when_some(self.size, |this, size| this.with_size(size))
231                        .when_some(self.variant, |this, variant| this.with_variant(variant))
232                        .when(self.compact, |this| this.compact())
233                        .when(self.outline, |this| this.outline())
234                        .when(self.on_click.is_some(), |this| {
235                            this.on_click(move |_, _, _| {
236                                state.set(Some(child_index));
237                            })
238                        });
239
240                        child
241                    }),
242            )
243            .when_some(
244                self.on_click.filter(|_| !self.disabled),
245                move |this, on_click| {
246                    this.on_click(move |_, window, cx| {
247                        let mut selected_ixs = selected_ixs.clone();
248                        if let Some(ix) = state.get() {
249                            if self.multiple {
250                                if let Some(pos) = selected_ixs.iter().position(|&i| i == ix) {
251                                    selected_ixs.remove(pos);
252                                } else {
253                                    selected_ixs.push(ix);
254                                }
255                            } else {
256                                selected_ixs.clear();
257                                selected_ixs.push(ix);
258                            }
259                        }
260
261                        on_click(&selected_ixs, window, cx);
262                    })
263                },
264            )
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271    use crate::Selectable as _;
272    use gpui::{
273        Axis, Context, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, Render, TestAppContext,
274        VisualTestContext, point, px,
275    };
276    use std::{cell::Cell, rc::Rc};
277
278    struct GroupHarness {
279        multiple: bool,
280        install_group_callback: bool,
281        child_clicks: Rc<Cell<usize>>,
282        group_changes: Rc<std::cell::RefCell<Vec<Vec<usize>>>>,
283    }
284
285    impl Render for GroupHarness {
286        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
287            let child_clicks = self.child_clicks.clone();
288            let mut group = ButtonGroup::new("group")
289                .multiple(self.multiple)
290                .size(px(120.))
291                .child(
292                    Button::new("one")
293                        .label("One")
294                        .on_click(move |_, _, _| child_clicks.set(child_clicks.get() + 1)),
295                )
296                .child(Button::new("two").label("Two").selected(true));
297            if self.install_group_callback {
298                let changes = self.group_changes.clone();
299                group = group.on_click(move |next, _, _| changes.borrow_mut().push(next.clone()));
300            }
301            group
302        }
303    }
304
305    fn group_harness(
306        cx: &mut TestAppContext,
307        multiple: bool,
308        install_group_callback: bool,
309    ) -> (
310        &mut VisualTestContext,
311        Rc<Cell<usize>>,
312        Rc<std::cell::RefCell<Vec<Vec<usize>>>>,
313    ) {
314        cx.update(crate::init);
315        let child_clicks = Rc::new(Cell::new(0));
316        let changes = Rc::new(std::cell::RefCell::new(Vec::new()));
317        let (_, cx) = cx.add_window_view({
318            let child_clicks = child_clicks.clone();
319            let group_changes = changes.clone();
320            move |_, _| GroupHarness {
321                multiple,
322                install_group_callback,
323                child_clicks,
324                group_changes,
325            }
326        });
327        cx.update(|window, cx| window.draw(cx).clear(cx));
328        (cx, child_clicks, changes)
329    }
330
331    fn activate_key(cx: &mut VisualTestContext, key: &str) {
332        let keystroke = Keystroke::parse(key).unwrap();
333        cx.simulate_event(KeyDownEvent {
334            keystroke: keystroke.clone(),
335            is_held: false,
336            prefer_character_input: false,
337        });
338        cx.simulate_event(KeyUpEvent { keystroke });
339    }
340
341    fn click_first(cx: &mut VisualTestContext) {
342        cx.simulate_click(point(px(10.), px(60.)), Modifiers::default());
343    }
344
345    #[gpui::test]
346    fn legacy_group_callback_overrides_the_child_callback(cx: &mut TestAppContext) {
347        let (cx, child_clicks, changes) = group_harness(cx, false, true);
348        click_first(cx);
349        assert_eq!(child_clicks.get(), 0);
350        assert_eq!(changes.borrow().as_slice(), &[vec![0]]);
351    }
352
353    #[gpui::test]
354    fn legacy_child_callback_survives_without_a_group_callback(cx: &mut TestAppContext) {
355        let (cx, child_clicks, changes) = group_harness(cx, false, false);
356        click_first(cx);
357        assert_eq!(child_clicks.get(), 1);
358        assert!(changes.borrow().is_empty());
359    }
360
361    #[gpui::test]
362    fn legacy_single_and_multiple_results_use_the_rendered_selection(cx: &mut TestAppContext) {
363        let (cx, _, single) = group_harness(cx, false, true);
364        click_first(cx);
365        assert_eq!(single.borrow().as_slice(), &[vec![0]]);
366
367        let (cx, _, multiple) = group_harness(cx, true, true);
368        click_first(cx);
369        assert_eq!(multiple.borrow().as_slice(), &[vec![1, 0]]);
370    }
371
372    #[gpui::test]
373    fn legacy_keyboard_click_does_not_reach_the_group_callback(cx: &mut TestAppContext) {
374        let (cx, _, changes) = group_harness(cx, false, true);
375        cx.update(|window, cx| window.focus_next(cx));
376        activate_key(cx, "enter");
377        assert!(changes.borrow().is_empty());
378    }
379
380    #[gpui::test]
381    fn legacy_disabled_state_depends_on_builder_order(cx: &mut TestAppContext) {
382        struct DisabledOrderHarness(Rc<Cell<usize>>);
383
384        impl Render for DisabledOrderHarness {
385            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
386                let first = self.0.clone();
387                let second = self.0.clone();
388                crate::v_flex()
389                    .child(
390                        ButtonGroup::new("disabled-before-child")
391                            .size(px(120.))
392                            .disabled(true)
393                            .child(
394                                Button::new("first")
395                                    .label("First")
396                                    .on_click(move |_, _, _| first.set(first.get() + 1)),
397                            ),
398                    )
399                    .child(
400                        ButtonGroup::new("disabled-after-child")
401                            .size(px(120.))
402                            .child(
403                                Button::new("second")
404                                    .label("Second")
405                                    .on_click(move |_, _, _| second.set(second.get() + 1)),
406                            )
407                            .disabled(true),
408                    )
409            }
410        }
411
412        cx.update(crate::init);
413        let clicks = Rc::new(Cell::new(0));
414        let (_, cx) = cx.add_window_view({
415            let clicks = clicks.clone();
416            move |_, _| DisabledOrderHarness(clicks)
417        });
418        cx.update(|window, cx| window.draw(cx).clear(cx));
419        cx.simulate_click(point(px(10.), px(60.)), Modifiers::default());
420        assert_eq!(clicks.get(), 0);
421        cx.simulate_click(point(px(10.), px(180.)), Modifiers::default());
422        assert_eq!(clicks.get(), 1);
423    }
424
425    #[gpui::test]
426    fn test_button_group_builder(_cx: &mut gpui::TestAppContext) {
427        let group = ButtonGroup::new("complex-group")
428            .child(Button::new("btn1").label("One"))
429            .child(Button::new("btn2").label("Two"))
430            .child(Button::new("btn3").label("Three"))
431            .primary()
432            .large()
433            .outline()
434            .compact()
435            .multiple(true)
436            .layout(Axis::Vertical)
437            .disabled(false)
438            .on_click(|_, _, _| {});
439
440        assert_eq!(group.children.len(), 3);
441        assert_eq!(group.variant, Some(ButtonVariant::Primary));
442        assert_eq!(group.size, Some(Size::Large));
443        assert!(group.outline);
444        assert!(group.compact);
445        assert!(group.multiple);
446        assert_eq!(group.layout, Axis::Vertical);
447        assert!(!group.disabled);
448        assert!(group.on_click.is_some());
449    }
450}