Skip to main content

gpui_kit_material/components/
standard_button_group.rs

1//! M3 Expressive standard (non-connected) button group.
2//!
3//! Buttons or icon buttons sit apart by the size's gap (XS 18, S 12, M–XL 8)
4//! and interact: while one is pressed it widens by 15% of its own width and
5//! its adjacent neighbours give up that width between them, on the fast
6//! spatial spring, so the group's extent stays put. Releasing springs every
7//! item back to its natural width. Reduced motion jumps.
8//!
9//! Each item stays a complete [`MaterialButton`] (activation, focus, state
10//! layer, ripple and shape morph); the group only reads its press state and
11//! sets its width while it moves.
12use super::{ButtonSize, MaterialButton};
13use crate::{
14    motion::{self, MotionToken},
15    tokens::typography::rem,
16};
17use gpui::{prelude::*, *};
18use std::{cell::Cell, rc::Rc};
19
20/// The pressed item's growth, as a share of its own width.
21pub const EXPANDED_RATIO: f32 = 0.15;
22
23/// The gap between items of a standard group of `size` buttons.
24pub const fn standard_group_gap(size: ButtonSize) -> f32 {
25    match size {
26        ButtonSize::ExtraSmall => 18.,
27        ButtonSize::Small => 12.,
28        _ => 8.,
29    }
30}
31
32/// Width changes for every item while `pressed` is held, given each item's
33/// natural width: the pressed item gains `EXPANDED_RATIO` of its width and
34/// its neighbours share that loss. Unmeasured widths (0) change nothing.
35pub(crate) fn width_deltas(natural: &[f32], pressed: Option<usize>) -> Vec<f32> {
36    let mut deltas = vec![0.; natural.len()];
37    let Some(pressed) = pressed.filter(|&index| natural.get(index).is_some_and(|&w| w > 0.)) else {
38        return deltas;
39    };
40    let neighbours: Vec<usize> = [pressed.checked_sub(1), Some(pressed + 1)]
41        .into_iter()
42        .flatten()
43        .filter(|&index| index < natural.len())
44        .collect();
45    if neighbours.is_empty() {
46        return deltas;
47    }
48    let growth = natural[pressed] * EXPANDED_RATIO;
49    deltas[pressed] = growth;
50    for index in &neighbours {
51        deltas[*index] = -growth / neighbours.len() as f32;
52    }
53    deltas
54}
55
56/// Group density: each step below default takes 4dp off the buttons'
57/// height (Figma kit "Segmented button" Density 0, −1, −2).
58#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
59pub enum ButtonDensity {
60    #[default]
61    Default,
62    Comfortable,
63    Compact,
64}
65
66impl ButtonDensity {
67    pub const ALL: [Self; 3] = [Self::Default, Self::Comfortable, Self::Compact];
68    /// The height change in design px.
69    pub const fn offset(self) -> f32 {
70        match self {
71            Self::Default => 0.,
72            Self::Comfortable => -4.,
73            Self::Compact => -8.,
74        }
75    }
76}
77
78/// Per-item state that survives renders: ids, press probes and the
79/// natural main-axis extents measured while each item is at rest.
80#[derive(Default)]
81struct GroupState {
82    items: Vec<ItemState>,
83}
84
85struct ItemState {
86    id: ElementId,
87    pressed: Rc<Cell<bool>>,
88    natural: Rc<Cell<f32>>,
89}
90
91#[derive(gpui::IntoElement)]
92pub struct ButtonGroup {
93    id: ElementId,
94    label: SharedString,
95    size: ButtonSize,
96    vertical: bool,
97    density: ButtonDensity,
98    equal_widths: bool,
99    items: Vec<MaterialButton>,
100}
101
102impl ButtonGroup {
103    /// `label` names the group for assistive technology.
104    pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
105        Self {
106            id: id.into(),
107            label: label.into(),
108            size: ButtonSize::Small,
109            vertical: false,
110            density: ButtonDensity::Default,
111            equal_widths: false,
112            items: Vec::new(),
113        }
114    }
115    /// The size of the group's buttons, which sets the gap between them.
116    pub fn button_size(mut self, size: ButtonSize) -> Self {
117        self.size = size;
118        self
119    }
120    /// Stack the buttons vertically; a pressed button then grows in height.
121    pub fn vertical(mut self, vertical: bool) -> Self {
122        self.vertical = vertical;
123        self
124    }
125    /// Shorten every button by the density's step.
126    pub fn density(mut self, density: ButtonDensity) -> Self {
127        self.density = density;
128        self
129    }
130    /// Give every button the widest one's width.
131    pub fn equal_widths(mut self, equal: bool) -> Self {
132        self.equal_widths = equal;
133        self
134    }
135    /// Add a button or an icon button.
136    pub fn item(mut self, item: impl Into<MaterialButton>) -> Self {
137        self.items.push(item.into());
138        self
139    }
140    pub fn items<I: Into<MaterialButton>>(mut self, items: impl IntoIterator<Item = I>) -> Self {
141        self.items.extend(items.into_iter().map(Into::into));
142        self
143    }
144}
145
146impl RenderOnce for ButtonGroup {
147    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
148        let state = window.use_keyed_state((self.id.clone(), "button-group"), cx, |_, _| {
149            GroupState::default()
150        });
151        let count = self.items.len();
152        let (probes, naturals, ids) = state.update(cx, |state, _| {
153            while state.items.len() < count {
154                let index = state.items.len();
155                state.items.push(ItemState {
156                    id: ElementId::NamedChild(
157                        std::sync::Arc::new((self.id.clone(), "button-group-width").into()),
158                        index.to_string().into(),
159                    ),
160                    pressed: Rc::default(),
161                    natural: Rc::default(),
162                });
163            }
164            state.items.truncate(count);
165            let items = &state.items;
166            (
167                items
168                    .iter()
169                    .map(|item| item.pressed.clone())
170                    .collect::<Vec<_>>(),
171                items
172                    .iter()
173                    .map(|item| item.natural.clone())
174                    .collect::<Vec<_>>(),
175                items.iter().map(|item| item.id.clone()).collect::<Vec<_>>(),
176            )
177        });
178        let pressed = probes.iter().position(|probe| probe.get());
179        let natural: Vec<f32> = naturals.iter().map(|extent| extent.get()).collect();
180        let targets = width_deltas(&natural, pressed);
181        let vertical = self.vertical;
182        let equal = self.equal_widths;
183        // Horizontal equal widths share the widest measured width (which
184        // then holds while the group is equal); vertical ones stretch to
185        // the column.
186        let widest = natural.iter().copied().fold(0., f32::max);
187        let height = (self.size.height() + self.density.offset()).max(0.);
188        let dense = self.density != ButtonDensity::Default;
189        let mut row = div()
190            .id(self.id)
191            .role(Role::Group)
192            .aria_label(self.label)
193            .flex()
194            .map(|row| {
195                if vertical {
196                    row.flex_col()
197                } else {
198                    row.flex_row()
199                }
200            })
201            .flex_shrink_0()
202            .map(|row| match (vertical, equal) {
203                (true, true) => row.items_stretch(),
204                _ => row.items_center(),
205            })
206            .gap(rem(standard_group_gap(self.size)));
207        for (index, button) in self.items.into_iter().enumerate() {
208            let delta = motion::token_value(
209                ids[index].clone(),
210                targets[index],
211                MotionToken::FastSpatial,
212                window,
213                cx,
214            );
215            let moving = delta != 0.;
216            let extent = natural[index] + delta;
217            let button = button
218                .press_probe(probes[index].clone())
219                .when(dense, |button| button.h(rem(height)))
220                .when(equal && !vertical && widest > 0., |button| {
221                    button.min_w(px(widest))
222                })
223                .when(equal && vertical, |button| button.w_full())
224                .when(moving && natural[index] > 0., |button| {
225                    if vertical {
226                        button.h(px(extent.max(0.))).min_h(px(0.))
227                    } else {
228                        button.w(px(extent.max(0.))).min_w(px(0.))
229                    }
230                });
231            let measured = naturals[index].clone();
232            row = row.child(
233                div().relative().flex().flex_shrink_0().child(button).child(
234                    canvas(
235                        move |bounds, window, _| {
236                            // Only an item at rest shows its natural extent.
237                            let extent = f32::from(if vertical {
238                                bounds.size.height
239                            } else {
240                                bounds.size.width
241                            });
242                            if !moving && (measured.get() - extent).abs() > 0.01 {
243                                measured.set(extent);
244                                if equal && !vertical {
245                                    // The others take the new widest width next frame.
246                                    window.request_animation_frame();
247                                }
248                            }
249                        },
250                        |_, _, _, _| {},
251                    )
252                    .absolute()
253                    .size_full(),
254                ),
255            );
256        }
257        row
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::{ButtonGroup, EXPANDED_RATIO, standard_group_gap, width_deltas};
264    use crate::components::{ButtonSize, InteractionPreview, MaterialButton};
265    use crate::motion;
266    use gpui::{
267        Context, InteractiveElement as _, IntoElement, ParentElement as _, Render, Styled as _,
268        TestAppContext, VisualTestContext, Window, div, prelude::FluentBuilder as _, px,
269    };
270    use std::time::Duration;
271
272    const ITEMS: [&str; 3] = ["item-0", "item-1", "item-2"];
273
274    #[core::prelude::v1::test]
275    fn the_pressed_item_grows_and_its_neighbours_pay() {
276        assert_eq!(width_deltas(&[100., 100., 100.], None), [0., 0., 0.]);
277        let deltas = width_deltas(&[100., 80., 100., 60.], Some(1));
278        let growth = 80. * EXPANDED_RATIO;
279        assert_eq!(deltas, [-growth / 2., growth, -growth / 2., 0.]);
280        assert!(deltas.iter().sum::<f32>().abs() < 1e-4, "the extent stays");
281        let end = width_deltas(&[100., 100.], Some(1));
282        assert_eq!(end, [-100. * EXPANDED_RATIO, 100. * EXPANDED_RATIO]);
283        assert_eq!(width_deltas(&[0., 100.], Some(0)), [0., 0.], "unmeasured");
284        assert_eq!(
285            width_deltas(&[100.], Some(0)),
286            [0.],
287            "nothing to borrow from"
288        );
289        assert_eq!(standard_group_gap(ButtonSize::ExtraSmall), 18.);
290        assert_eq!(standard_group_gap(ButtonSize::Large), 8.);
291    }
292
293    struct Harness {
294        pressed: bool,
295    }
296    impl Render for Harness {
297        fn render(&mut self, window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
298            window.set_rem_size(px(14.));
299            div()
300                .flex()
301                .items_start()
302                .p(px(40.))
303                .child(
304                    ButtonGroup::new("group", "Group").items((0..3usize).map(|index| {
305                        MaterialButton::new(("item", index))
306                            .debug_selector(move || ITEMS[index].into())
307                            .child("Label")
308                            .when(index == 1 && self.pressed, |button| {
309                                button.preview_state(InteractionPreview::Pressed)
310                            })
311                    })),
312                )
313        }
314    }
315
316    fn draw(cx: &mut VisualTestContext) {
317        cx.update(|window, cx| {
318            window.refresh();
319            window.draw(cx).clear(cx);
320        });
321    }
322
323    #[gpui::test]
324    fn pressing_widens_an_item_and_release_restores_it(cx: &mut TestAppContext) {
325        cx.update(|cx| {
326            gpui_base::init(cx);
327            cx.set_reduce_motion(false);
328            motion::set_motion_scheme(motion::MotionScheme::Expressive);
329        });
330        let (view, cx) = cx.add_window_view(|_, _| Harness { pressed: false });
331        draw(cx);
332        draw(cx);
333        let widths = |cx: &mut VisualTestContext| {
334            ITEMS.map(|item| cx.debug_bounds(item).unwrap().size.width)
335        };
336        let rest = widths(cx);
337        let extent = |cx: &mut VisualTestContext| {
338            cx.debug_bounds("item-2").unwrap().right() - cx.debug_bounds("item-0").unwrap().left()
339        };
340        let rest_extent = extent(cx);
341        view.update(cx, |view, cx| {
342            view.pressed = true;
343            cx.notify();
344        });
345        for _ in 0..3 {
346            draw(cx);
347        }
348        cx.executor().advance_clock(Duration::from_secs(2));
349        draw(cx);
350        draw(cx);
351        let pressed = widths(cx);
352        assert!(
353            (pressed[1] - rest[1] * (1. + EXPANDED_RATIO)).abs() < px(0.5),
354            "{pressed:?} vs {rest:?}"
355        );
356        assert!(pressed[0] < rest[0] && pressed[2] < rest[2]);
357        assert!(
358            (extent(cx) - rest_extent).abs() < px(0.5),
359            "the group keeps its extent"
360        );
361        view.update(cx, |view, cx| {
362            view.pressed = false;
363            cx.notify();
364        });
365        for _ in 0..3 {
366            draw(cx);
367        }
368        cx.executor().advance_clock(Duration::from_secs(2));
369        draw(cx);
370        draw(cx);
371        assert_eq!(widths(cx), rest);
372    }
373}
374
375#[cfg(test)]
376mod layout_tests {
377    use super::{ButtonDensity, ButtonGroup};
378    use crate::components::MaterialButton;
379    use gpui::{
380        Context, InteractiveElement as _, IntoElement, ParentElement as _, Render, Styled as _,
381        TestAppContext, VisualTestContext, Window, div, px,
382    };
383
384    const ITEMS: [&str; 3] = ["item-0", "item-1", "item-2"];
385    const LABELS: [&str; 3] = ["S", "Medium", "Extra large"];
386
387    struct Harness {
388        vertical: bool,
389        density: ButtonDensity,
390        equal: bool,
391    }
392    impl Render for Harness {
393        fn render(&mut self, window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
394            window.set_rem_size(px(14.));
395            div().flex().items_start().p(px(40.)).child(
396                ButtonGroup::new("group", "Sizes")
397                    .vertical(self.vertical)
398                    .density(self.density)
399                    .equal_widths(self.equal)
400                    .items((0..3usize).map(|index| {
401                        MaterialButton::new(("item", index))
402                            .debug_selector(move || ITEMS[index].into())
403                            .child(LABELS[index])
404                    })),
405            )
406        }
407    }
408
409    fn draw(cx: &mut VisualTestContext) {
410        for _ in 0..3 {
411            cx.update(|window, cx| {
412                window.refresh();
413                window.draw(cx).clear(cx);
414            });
415        }
416    }
417
418    fn bounds(harness: Harness, cx: &mut TestAppContext) -> [gpui::Bounds<gpui::Pixels>; 3] {
419        cx.update(|cx| {
420            gpui_base::init(cx);
421            cx.set_reduce_motion(true);
422        });
423        let (_, cx) = cx.add_window_view(move |_, _| harness);
424        draw(cx);
425        ITEMS.map(|item| cx.debug_bounds(item).unwrap())
426    }
427
428    #[gpui::test]
429    fn a_vertical_compact_group_stacks_shorter_buttons(cx: &mut TestAppContext) {
430        let [first, second, _] = bounds(
431            Harness {
432                vertical: true,
433                density: ButtonDensity::Compact,
434                equal: false,
435            },
436            cx,
437        );
438        assert_eq!(first.size.height, px(40. - 8.));
439        assert_eq!(second.top(), first.bottom() + px(12.), "the S gap");
440    }
441
442    #[gpui::test]
443    fn equal_widths_share_the_widest_button(cx: &mut TestAppContext) {
444        let [a, b, c] = bounds(
445            Harness {
446                vertical: false,
447                density: ButtonDensity::Default,
448                equal: true,
449            },
450            cx,
451        );
452        assert!(c.size.width > px(80.));
453        assert_eq!(a.size.width, c.size.width);
454        assert_eq!(b.size.width, c.size.width);
455    }
456
457    #[gpui::test]
458    fn equal_vertical_buttons_stretch_to_the_column(cx: &mut TestAppContext) {
459        let [a, _, c] = bounds(
460            Harness {
461                vertical: true,
462                density: ButtonDensity::Comfortable,
463                equal: true,
464            },
465            cx,
466        );
467        assert_eq!(a.size.width, c.size.width);
468        assert_eq!(a.size.height, px(36.));
469    }
470}