Skip to main content

gpui_kit_material/components/
split_button.rs

1//! M3 Expressive split button: a leading action segment and a trailing menu
2//! segment, 2dp apart, in the filled, tonal, outlined and elevated styles and
3//! the XS–XL button sizes.
4//!
5//! Each segment is a [`MaterialButton`] in its split-segment form: outer ends
6//! stay fully round while the inner corners widen on hover/focus and press
7//! (fast spatial spring). Opening the menu turns the trailing segment fully
8//! round and rotates its chevron half a turn. The caller owns `open` and
9//! receives requested changes; with [`SplitButton::menu`] the trailing
10//! segment opens that menu in Kit's popover overlay (below, end-aligned),
11//! whose outside clicks, Escape and item activation request closing.
12use super::{
13    ButtonShape, ButtonSize, ButtonVariant, InteractionPreview, MaterialButton, MaterialMenu,
14    MenuPopup,
15    button::{Form, Segment, segment_metrics},
16};
17use crate::{
18    icons::{Icon, IconName},
19    motion::{self, MotionToken},
20};
21use gpui::{prelude::*, *};
22use std::rc::Rc;
23
24/// One segment of a split button.
25#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
26pub enum SplitButtonPart {
27    Leading,
28    Trailing,
29}
30
31type ClickHandler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
32type ToggleHandler = Rc<dyn Fn(&bool, &mut Window, &mut App)>;
33
34#[derive(gpui::IntoElement)]
35pub struct SplitButton {
36    id: ElementId,
37    label: SharedString,
38    menu_label: SharedString,
39    icon: Option<Icon>,
40    variant: ButtonVariant,
41    size: ButtonSize,
42    open: bool,
43    disabled: bool,
44    preview: Option<(SplitButtonPart, InteractionPreview)>,
45    menu: Option<MaterialMenu>,
46    on_click: Option<ClickHandler>,
47    on_toggle: Option<ToggleHandler>,
48}
49
50impl SplitButton {
51    /// `label` is the action's visible label; `menu_label` names the menu
52    /// segment for assistive technology.
53    pub fn new(
54        id: impl Into<ElementId>,
55        label: impl Into<SharedString>,
56        menu_label: impl Into<SharedString>,
57    ) -> Self {
58        Self {
59            id: id.into(),
60            label: label.into(),
61            menu_label: menu_label.into(),
62            icon: None,
63            variant: ButtonVariant::Filled,
64            size: ButtonSize::Small,
65            open: false,
66            disabled: false,
67            preview: None,
68            menu: None,
69            on_click: None,
70            on_toggle: None,
71        }
72    }
73    /// The menu the trailing segment opens while `open`. Opening and
74    /// closing requests (the segment, outside clicks, Escape, an activated
75    /// item) arrive through `on_toggle`.
76    pub fn menu(mut self, menu: MaterialMenu) -> Self {
77        self.menu = Some(menu);
78        self
79    }
80    pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
81        self.icon = Some(icon.into());
82        self
83    }
84    /// Filled, tonal, outlined or elevated (text has no split form).
85    pub fn variant(mut self, variant: ButtonVariant) -> Self {
86        self.variant = variant;
87        self
88    }
89    pub fn button_size(mut self, size: ButtonSize) -> Self {
90        self.size = size;
91        self
92    }
93    /// Whether the caller's menu is open (the trailing segment is selected).
94    pub fn open(mut self, open: bool) -> Self {
95        self.open = open;
96        self
97    }
98    pub fn disabled(mut self, disabled: bool) -> Self {
99        self.disabled = disabled;
100        self
101    }
102    /// Render as a static specimen with `part` in one interaction state;
103    /// the other segment stays at rest.
104    pub fn preview_state(mut self, part: SplitButtonPart, state: InteractionPreview) -> Self {
105        self.preview = Some((part, state));
106        self
107    }
108    /// The leading action.
109    pub fn on_click(
110        mut self,
111        callback: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
112    ) -> Self {
113        self.on_click = Some(Rc::new(callback));
114        self
115    }
116    /// The requested menu state when the trailing segment is activated.
117    pub fn on_toggle(mut self, callback: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
118        self.on_toggle = Some(Rc::new(callback));
119        self
120    }
121
122    fn preview_of(&self, part: SplitButtonPart) -> Option<InteractionPreview> {
123        self.preview.map(|(previewed, state)| {
124            if previewed == part {
125                state
126            } else if state.is_disabled() {
127                InteractionPreview::Disabled
128            } else {
129                InteractionPreview::Enabled
130            }
131        })
132    }
133}
134
135/// The chevron's rotation in turns: half a turn while the menu is open.
136fn chevron_turns(open: bool) -> f32 {
137    if open { 0.5 } else { 0. }
138}
139
140impl RenderOnce for SplitButton {
141    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
142        let turns = motion::token_value(
143            (self.id.clone(), "split-chevron"),
144            chevron_turns(self.open),
145            MotionToken::FastSpatial,
146            window,
147            cx,
148        );
149        let metrics = segment_metrics(self.size);
150        let leading_preview = self.preview_of(SplitButtonPart::Leading);
151        let trailing_preview = self.preview_of(SplitButtonPart::Trailing);
152        let mut leading = MaterialButton::new((self.id.clone(), "split-leading"))
153            .variant(self.variant)
154            .button_size(self.size)
155            .shape(ButtonShape::Round)
156            .form(Form::Segment(Segment::Leading))
157            .disabled(self.disabled)
158            .when_some(self.icon, |button, icon| button.icon(icon))
159            .child(self.label);
160        if let Some(state) = leading_preview {
161            leading = leading.preview_state(state);
162        }
163        if let Some(callback) = self.on_click {
164            leading = leading.on_click(move |event, window, cx| callback(event, window, cx));
165        }
166        let chevron = Icon::from(IconName::ChevronDown)
167            .size(crate::tokens::typography::rem(metrics.trailing_icon))
168            .rotate(turns * std::f32::consts::TAU);
169        let open = self.open;
170        let mut trailing = MaterialButton::new((self.id.clone(), "split-trailing"))
171            .variant(self.variant)
172            .button_size(self.size)
173            .form(Form::Segment(Segment::Trailing))
174            .open(open)
175            .disabled(self.disabled)
176            .icon(chevron)
177            .accessibility_label(self.menu_label)
178            .aria_expanded(open);
179        if let Some(state) = trailing_preview {
180            trailing = trailing.preview_state(state);
181        }
182        let has_menu = self.menu.is_some() && !self.disabled;
183        if let Some(callback) = self.on_toggle.clone() {
184            // With a menu, `MenuPopup` toggles on the pointer press over the
185            // segment, so its click handles only keys (handling the pointer
186            // click too would re-toggle once the press re-rendered `open`).
187            trailing = trailing.on_click(move |event, window, cx| {
188                if !has_menu || matches!(event, ClickEvent::Keyboard(_)) {
189                    callback(&!open, window, cx)
190                }
191            });
192        }
193        let trailing = match self.menu {
194            Some(menu) if has_menu => {
195                let mut popup = MenuPopup::new((self.id.clone(), "split-menu"), trailing, menu)
196                    .open(open)
197                    .anchor(Anchor::TopRight);
198                if let Some(callback) = self.on_toggle {
199                    popup =
200                        popup.on_open_change(move |open, window, cx| callback(open, window, cx));
201                }
202                popup.into_any_element()
203            }
204            _ => trailing.into_any_element(),
205        };
206        div()
207            .id(self.id)
208            .role(Role::Group)
209            .flex()
210            .flex_row()
211            .flex_shrink_0()
212            .items_center()
213            .gap(crate::tokens::typography::rem(2.))
214            .child(leading)
215            .child(trailing)
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::{SplitButton, SplitButtonPart, chevron_turns};
222    use crate::components::{ButtonSize, InteractionPreview};
223    use gpui::{
224        Context, InteractiveElement as _, IntoElement, ParentElement as _, Render, Styled as _,
225        TestAppContext, VisualTestContext, Window, div, px,
226    };
227
228    struct Harness {
229        size: ButtonSize,
230        open: bool,
231    }
232    impl Render for Harness {
233        fn render(&mut self, window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
234            window.set_rem_size(px(14.));
235            div().flex().items_start().p(px(40.)).child(
236                div().flex().debug_selector(|| "split".into()).child(
237                    SplitButton::new("split", "Label", "More")
238                        .button_size(self.size)
239                        .open(self.open)
240                        .preview_state(SplitButtonPart::Trailing, InteractionPreview::Enabled),
241                ),
242            )
243        }
244    }
245
246    #[gpui::test]
247    fn segments_share_the_button_height_with_a_two_dp_gap(cx: &mut TestAppContext) {
248        cx.update(|cx| {
249            gpui_base::init(cx);
250            cx.set_reduce_motion(true);
251        });
252        for (size, height) in [
253            (ButtonSize::ExtraSmall, 32.),
254            (ButtonSize::Medium, 56.),
255            (ButtonSize::ExtraLarge, 136.),
256        ] {
257            let (_, cx) = cx.add_window_view(|_, _| Harness { size, open: true });
258            cx.update(|window, cx| {
259                window.refresh();
260                window.draw(cx).clear(cx);
261            });
262            let bounds = cx.debug_bounds("split").unwrap();
263            assert_eq!(bounds.size.height, px(height), "{size:?}");
264            let _: &mut VisualTestContext = cx;
265        }
266    }
267
268    #[core::prelude::v1::test]
269    fn the_chevron_turns_half_way_while_open() {
270        assert_eq!(chevron_turns(false), 0.);
271        assert_eq!(chevron_turns(true), 0.5);
272    }
273
274    #[core::prelude::v1::test]
275    fn a_previewed_segment_leaves_its_neighbor_at_rest() {
276        let split = SplitButton::new("s", "Label", "More")
277            .preview_state(SplitButtonPart::Leading, InteractionPreview::Pressed);
278        assert_eq!(
279            split.preview_of(SplitButtonPart::Leading),
280            Some(InteractionPreview::Pressed)
281        );
282        assert_eq!(
283            split.preview_of(SplitButtonPart::Trailing),
284            Some(InteractionPreview::Enabled)
285        );
286        let disabled = SplitButton::new("s", "Label", "More")
287            .preview_state(SplitButtonPart::Leading, InteractionPreview::Disabled);
288        assert_eq!(
289            disabled.preview_of(SplitButtonPart::Trailing),
290            Some(InteractionPreview::Disabled)
291        );
292    }
293}
294
295#[cfg(test)]
296mod menu_tests {
297    use super::SplitButton;
298    use crate::components::{MaterialMenu, MaterialMenuItem};
299    use gpui::{
300        Context, Entity, InteractiveElement as _, IntoElement, Modifiers, ParentElement as _,
301        Render, Styled as _, TestAppContext, VisualTestContext, Window, div, px,
302    };
303    use std::{cell::RefCell, rc::Rc};
304
305    struct Harness {
306        open: bool,
307        picked: Rc<RefCell<Vec<usize>>>,
308    }
309    impl Render for Harness {
310        fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
311            window.set_rem_size(px(14.));
312            let menu = MaterialMenu::new("menu").items((0..3).map(|ix| {
313                let picked = self.picked.clone();
314                MaterialMenuItem::new("Item").on_click(move |_, _, _| picked.borrow_mut().push(ix))
315            }));
316            div().flex().items_start().p(px(20.)).pl(px(400.)).child(
317                div().flex().debug_selector(|| "split".into()).child(
318                    SplitButton::new("split", "Save", "More")
319                        .menu(menu)
320                        .open(self.open)
321                        .on_toggle(cx.listener(|this, open: &bool, _, cx| {
322                            this.open = *open;
323                            cx.notify();
324                        })),
325                ),
326            )
327        }
328    }
329
330    fn settle(cx: &mut VisualTestContext) {
331        for _ in 0..3 {
332            cx.executor()
333                .advance_clock(std::time::Duration::from_millis(600));
334            cx.update(|window, cx| {
335                window.refresh();
336                window.draw(cx).clear(cx);
337            });
338        }
339    }
340
341    fn setup(cx: &mut TestAppContext) -> (Entity<Harness>, &mut VisualTestContext) {
342        cx.update(|cx| {
343            gpui_kit::init(cx);
344            crate::init(cx);
345            cx.set_reduce_motion(true);
346        });
347        cx.add_window_view(|_, _| Harness {
348            open: false,
349            picked: Rc::default(),
350        })
351    }
352
353    #[gpui::test]
354    fn the_trailing_segment_opens_the_menu_and_an_item_closes_it(cx: &mut TestAppContext) {
355        let (view, cx) = setup(cx);
356        settle(cx);
357        let split = cx.debug_bounds("split").unwrap();
358        // The chevron segment sits at the end.
359        let chevron = gpui::point(split.right() - px(20.), split.center().y);
360        cx.simulate_click(chevron, Modifiers::default());
361        settle(cx);
362        cx.update(|_, cx| assert!(view.read(cx).open, "a press opens the menu once"));
363        let surface = cx.debug_bounds("menu-popup-surface").unwrap();
364        assert!(surface.top() >= split.bottom(), "the menu opens below");
365        assert_eq!(
366            surface.right(),
367            split.right(),
368            "end-aligned with the button"
369        );
370        cx.simulate_keystrokes("down enter");
371        settle(cx);
372        cx.update(|_, cx| {
373            assert_eq!(*view.read(cx).picked.borrow(), vec![0]);
374            assert!(!view.read(cx).open, "activating an item closes the menu");
375        });
376    }
377
378    #[gpui::test]
379    fn the_leading_segment_does_not_open_the_menu(cx: &mut TestAppContext) {
380        let (view, cx) = setup(cx);
381        settle(cx);
382        let split = cx.debug_bounds("split").unwrap();
383        let leading = gpui::point(split.left() + px(16.), split.center().y);
384        cx.simulate_click(leading, Modifiers::default());
385        settle(cx);
386        cx.update(|_, cx| assert!(!view.read(cx).open));
387    }
388}