Skip to main content

gpui_component/button/
toggle.rs

1use std::{cell::Cell, rc::Rc};
2
3use gpui::{
4    AnyElement, App, Axis, Corners, Edges, ElementId, InteractiveElement, IntoElement,
5    ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, StyleRefinement, Styled,
6    Window, prelude::FluentBuilder as _, relative,
7};
8use gpui_base::{Toggle as BaseToggle, ToggleGroup as BaseToggleGroup};
9use smallvec::{SmallVec, smallvec};
10
11use crate::{ActiveTheme, Disableable, Icon, Sizable, Size, StyledExt, tooltip::ComponentTooltip};
12
13#[derive(Default, Copy, Debug, Clone, PartialEq, Eq, Hash)]
14pub enum ToggleVariant {
15    #[default]
16    Ghost,
17    Outline,
18}
19
20pub trait ToggleVariants: Sized {
21    /// Set the variant of the toggle.
22    fn with_variant(self, variant: ToggleVariant) -> Self;
23    /// Set the variant to ghost.
24    fn ghost(self) -> Self {
25        self.with_variant(ToggleVariant::Ghost)
26    }
27    /// Set the variant to outline.
28    fn outline(self) -> Self {
29        self.with_variant(ToggleVariant::Outline)
30    }
31}
32
33#[derive(IntoElement)]
34pub struct Toggle {
35    id: ElementId,
36    style: StyleRefinement,
37    checked: bool,
38    size: Size,
39    variant: ToggleVariant,
40    disabled: bool,
41    border_corners: Corners<bool>,
42    border_edges: Edges<bool>,
43    children: SmallVec<[AnyElement; 1]>,
44    on_click: Option<Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>>,
45    tooltip: ComponentTooltip,
46}
47
48impl Toggle {
49    /// Create a new Toggle element.
50    pub fn new(id: impl Into<ElementId>) -> Self {
51        Self {
52            id: id.into(),
53            style: StyleRefinement::default(),
54            checked: false,
55            size: Size::default(),
56            variant: ToggleVariant::default(),
57            disabled: false,
58            border_corners: Corners {
59                top_left: true,
60                top_right: true,
61                bottom_left: true,
62                bottom_right: true,
63            },
64            border_edges: Edges::all(true),
65            children: smallvec![],
66            on_click: None,
67            tooltip: ComponentTooltip::default(),
68        }
69    }
70
71    /// Set tooltip text for the toggle.
72    pub fn tooltip(mut self, tooltip: impl Into<SharedString>) -> Self {
73        self.tooltip.text = Some((tooltip.into(), None));
74        self
75    }
76
77    /// Add a label to the toggle.
78    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
79        let label: SharedString = label.into();
80        self.children.push(label.into_any_element());
81        self
82    }
83
84    /// Add icon to the toggle.
85    pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
86        let icon: Icon = icon.into();
87        self.children.push(icon.into());
88        self
89    }
90
91    /// Set the checked state of the toggle, default: false
92    pub fn checked(mut self, checked: bool) -> Self {
93        self.checked = checked;
94        self
95    }
96
97    /// Set the callback to be called when the toggle is clicked.
98    ///
99    /// The `&bool` parameter represents the new checked state of the toggle.
100    pub fn on_click(mut self, handler: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
101        self.on_click = Some(Box::new(handler));
102        self
103    }
104
105    pub(crate) fn border_corners(mut self, corners: impl Into<Corners<bool>>) -> Self {
106        self.border_corners = corners.into();
107        self
108    }
109
110    pub(crate) fn border_edges(mut self, edges: impl Into<Edges<bool>>) -> Self {
111        self.border_edges = edges.into();
112        self
113    }
114}
115
116impl ToggleVariants for Toggle {
117    fn with_variant(mut self, variant: ToggleVariant) -> Self {
118        self.variant = variant;
119        self
120    }
121}
122
123impl ParentElement for Toggle {
124    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
125        self.children.extend(elements);
126    }
127}
128
129impl Disableable for Toggle {
130    fn disabled(mut self, disabled: bool) -> Self {
131        self.disabled = disabled;
132        self
133    }
134}
135
136impl Sizable for Toggle {
137    fn with_size(mut self, size: impl Into<Size>) -> Self {
138        self.size = size.into();
139        self
140    }
141}
142
143impl Styled for Toggle {
144    fn style(&mut self) -> &mut StyleRefinement {
145        &mut self.style
146    }
147}
148
149impl RenderOnce for Toggle {
150    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
151        let checked = self.checked;
152        let disabled = self.disabled;
153        let hoverable = !disabled && !checked;
154        let rounding = cx.theme().radius;
155        let pressed_background = cx.theme().tokens.accent;
156        let pressed_foreground = cx.theme().accent_foreground;
157        let instance_style = self.style.clone();
158
159        BaseToggle::new(self.id)
160            .pressed(checked)
161            .disabled(disabled)
162            .when_some(
163                self.tooltip.text.as_ref().map(|(text, _)| text.clone()),
164                |this, label| this.accessibility_label(label),
165            )
166            .when_some(self.on_click, |this, on_click| {
167                this.on_change(move |next, _, window, cx| on_click(&next, window, cx))
168            })
169            .flex()
170            .flex_row()
171            .line_height(relative(1.25))
172            .items_center()
173            .justify_center()
174            .map(|this| match self.size {
175                Size::XSmall => this.min_w_5().h_5().px_0p5().text_xs(),
176                Size::Small => this.min_w_6().h_6().px_1().text_sm(),
177                Size::Large => this.min_w_9().h_9().px_3().text_lg(),
178                _ => this.min_w_8().h_8().px_2(),
179            })
180            .when(self.border_corners.top_left, |this| {
181                this.rounded_tl(rounding)
182            })
183            .when(self.border_corners.top_right, |this| {
184                this.rounded_tr(rounding)
185            })
186            .when(self.border_corners.bottom_left, |this| {
187                this.rounded_bl(rounding)
188            })
189            .when(self.border_corners.bottom_right, |this| {
190                this.rounded_br(rounding)
191            })
192            .when(self.variant == ToggleVariant::Outline, |this| {
193                this.when(self.border_edges.left, |this| this.border_l_1())
194                    .when(self.border_edges.right, |this| this.border_r_1())
195                    .when(self.border_edges.top, |this| this.border_t_1())
196                    .when(self.border_edges.bottom, |this| this.border_b_1())
197                    .border_color(cx.theme().border)
198                    .bg(cx.theme().tokens.background)
199            })
200            .when(hoverable, |this| {
201                this.hover(|this| {
202                    this.bg(cx.theme().tokens.accent)
203                        .text_color(cx.theme().accent_foreground)
204                })
205            })
206            .styles(|styles| {
207                styles.pressed(|style| {
208                    style
209                        .bg(pressed_background)
210                        .text_color(pressed_foreground)
211                        .refine_style(&instance_style)
212                })
213            })
214            .refine_style(&self.style)
215            .children(self.children)
216            .map(|this| self.tooltip.apply(this))
217    }
218}
219
220/// A group of toggles.
221#[derive(IntoElement)]
222pub struct ToggleGroup {
223    id: ElementId,
224    style: StyleRefinement,
225    size: Size,
226    variant: ToggleVariant,
227    disabled: bool,
228    segmented: bool,
229    items: Vec<Toggle>,
230    on_click: Option<Rc<dyn Fn(&Vec<bool>, &mut Window, &mut App) + 'static>>,
231}
232
233impl ToggleGroup {
234    /// Create a new ToggleGroup element.
235    pub fn new(id: impl Into<ElementId>) -> Self {
236        Self {
237            id: id.into(),
238            style: StyleRefinement::default(),
239            size: Size::default(),
240            variant: ToggleVariant::default(),
241            disabled: false,
242            segmented: false,
243            items: Vec::new(),
244            on_click: None,
245        }
246    }
247
248    /// Add a child [`Toggle`] to the group.
249    pub fn child(mut self, toggle: impl Into<Toggle>) -> Self {
250        self.items.push(toggle.into());
251        self
252    }
253
254    /// Add multiple [`Toggle`]s to the group.
255    pub fn children(mut self, children: impl IntoIterator<Item = impl Into<Toggle>>) -> Self {
256        self.items.extend(children.into_iter().map(Into::into));
257        self
258    }
259
260    /// Set the callback to be called when the toggle group changes.
261    ///
262    /// The `&Vec<bool>` parameter represents the new check state of each [`Toggle`] in the group.
263    pub fn on_click(
264        mut self,
265        on_click: impl Fn(&Vec<bool>, &mut Window, &mut App) + 'static,
266    ) -> Self {
267        self.on_click = Some(Rc::new(on_click));
268        self
269    }
270
271    /// Render the group as a connected segmented control.
272    ///
273    /// This keeps the existing multi-toggle behavior, but removes the default
274    /// gap and joins adjacent item borders into a single segmented outline.
275    pub fn segmented(mut self) -> Self {
276        self.segmented = true;
277        self
278    }
279}
280
281impl Sizable for ToggleGroup {
282    fn with_size(mut self, size: impl Into<Size>) -> Self {
283        self.size = size.into();
284        self
285    }
286}
287
288impl ToggleVariants for ToggleGroup {
289    fn with_variant(mut self, variant: ToggleVariant) -> Self {
290        self.variant = variant;
291        self
292    }
293}
294
295impl Disableable for ToggleGroup {
296    fn disabled(mut self, disabled: bool) -> Self {
297        self.disabled = disabled;
298        self
299    }
300}
301
302impl Styled for ToggleGroup {
303    fn style(&mut self) -> &mut StyleRefinement {
304        &mut self.style
305    }
306}
307
308impl RenderOnce for ToggleGroup {
309    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
310        let disabled = self.disabled;
311        let items_len = self.items.len();
312        let checks = self
313            .items
314            .iter()
315            .map(|item| item.checked)
316            .collect::<Vec<bool>>();
317        let clicked_index = Rc::new(Cell::new(None));
318
319        BaseToggleGroup::new(self.id)
320            .axis(Axis::Horizontal)
321            .child(
322                crate::h_flex()
323                    .items_center()
324                    .when(!self.segmented, |this| this.gap_2())
325                    .refine_style(&self.style)
326                    .children(self.items.into_iter().enumerate().map({
327                        {
328                            let clicked_index = clicked_index.clone();
329                            move |(ix, item)| {
330                                let item = if !self.segmented || items_len == 1 {
331                                    item
332                                } else if ix == 0 {
333                                    item.border_corners(Corners {
334                                        top_left: true,
335                                        top_right: false,
336                                        bottom_left: true,
337                                        bottom_right: false,
338                                    })
339                                    .border_edges(Edges {
340                                        left: true,
341                                        top: true,
342                                        right: true,
343                                        bottom: true,
344                                    })
345                                } else if ix == items_len - 1 {
346                                    item.border_corners(Corners {
347                                        top_left: false,
348                                        top_right: true,
349                                        bottom_left: false,
350                                        bottom_right: true,
351                                    })
352                                    .border_edges(Edges {
353                                        left: false,
354                                        top: true,
355                                        right: true,
356                                        bottom: true,
357                                    })
358                                } else {
359                                    item.border_corners(Corners {
360                                        top_left: false,
361                                        top_right: false,
362                                        bottom_left: false,
363                                        bottom_right: false,
364                                    })
365                                    .border_edges(Edges {
366                                        left: false,
367                                        top: true,
368                                        right: true,
369                                        bottom: true,
370                                    })
371                                };
372
373                                let effective_disabled = disabled || item.disabled;
374                                let clicked_index = clicked_index.clone();
375                                item.disabled(effective_disabled)
376                                    .with_size(self.size)
377                                    .with_variant(self.variant)
378                                    .on_click(move |_, _, cx| {
379                                        clicked_index.set(Some(ix));
380                                        cx.propagate();
381                                    })
382                            }
383                        }
384                    })),
385            )
386            .when_some(
387                (!disabled).then_some(self.on_click).flatten(),
388                |this, on_click| {
389                    this.on_click(move |_, window, cx| {
390                        let Some(ix) = clicked_index.take() else {
391                            return;
392                        };
393                        let mut next = checks.clone();
394                        next[ix] = !next[ix];
395                        on_click(&next, window, cx);
396                    })
397                },
398            )
399    }
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405    use crate::{IconName, h_flex};
406    use gpui::{
407        Context, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, Render,
408        StatefulInteractiveElement, TestAppContext, VisualTestContext, point, px,
409    };
410    use std::cell::{Cell, RefCell};
411
412    struct ToggleHarness {
413        disabled: bool,
414        changes: Rc<RefCell<Vec<bool>>>,
415        parent_clicks: Rc<Cell<usize>>,
416    }
417
418    impl Render for ToggleHarness {
419        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
420            let changes = self.changes.clone();
421            let parent_clicks = self.parent_clicks.clone();
422            h_flex()
423                .id("toggle-parent")
424                .tab_group()
425                .size(px(100.))
426                .on_click(move |_, _, _| parent_clicks.set(parent_clicks.get() + 1))
427                .child(
428                    Toggle::new("toggle")
429                        .label("Bold")
430                        .disabled(self.disabled)
431                        .size_full()
432                        .on_click(move |next, _, _| changes.borrow_mut().push(*next)),
433                )
434        }
435    }
436
437    fn harness(
438        cx: &mut TestAppContext,
439        disabled: bool,
440    ) -> (
441        &mut VisualTestContext,
442        Rc<RefCell<Vec<bool>>>,
443        Rc<Cell<usize>>,
444    ) {
445        cx.update(crate::init);
446        let changes = Rc::new(RefCell::new(Vec::new()));
447        let parent_clicks = Rc::new(Cell::new(0));
448        let (_, cx) = cx.add_window_view({
449            let changes = changes.clone();
450            let parent_clicks = parent_clicks.clone();
451            move |_, _| ToggleHarness {
452                disabled,
453                changes,
454                parent_clicks,
455            }
456        });
457        cx.update(|window, cx| window.draw(cx).clear(cx));
458        (cx, changes, parent_clicks)
459    }
460
461    fn activate_key(cx: &mut VisualTestContext, key: &str) {
462        let keystroke = Keystroke::parse(key).unwrap();
463        cx.simulate_event(KeyDownEvent {
464            keystroke: keystroke.clone(),
465            is_held: false,
466            prefer_character_input: false,
467        });
468        cx.simulate_event(KeyUpEvent { keystroke });
469    }
470
471    #[gpui::test]
472    fn canonical_pointer_activation_fires_once_and_focuses(cx: &mut TestAppContext) {
473        let (cx, changes, _) = harness(cx, false);
474        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
475        assert_eq!(changes.borrow().as_slice(), &[true]);
476        cx.update(|window, cx| assert!(window.focused(cx).is_some()));
477    }
478
479    #[gpui::test]
480    fn canonical_toggle_supports_tab_enter_and_space(cx: &mut TestAppContext) {
481        let (cx, changes, _) = harness(cx, false);
482        cx.update(|window, cx| window.focus_next(cx));
483        cx.update(|window, cx| assert!(window.focused(cx).is_some()));
484        activate_key(cx, "enter");
485        activate_key(cx, "space");
486        assert_eq!(changes.borrow().as_slice(), &[true, true]);
487    }
488
489    #[gpui::test]
490    fn canonical_disabled_toggle_is_inert_and_blocks_parent(cx: &mut TestAppContext) {
491        let (cx, changes, parent_clicks) = harness(cx, true);
492        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
493        assert!(changes.borrow().is_empty());
494        assert_eq!(parent_clicks.get(), 0);
495    }
496
497    struct ToggleGroupHarness {
498        install_group_callback: bool,
499        child_clicks: Rc<Cell<usize>>,
500        group_changes: Rc<RefCell<Vec<Vec<bool>>>>,
501    }
502
503    impl Render for ToggleGroupHarness {
504        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
505            let child_clicks = self.child_clicks.clone();
506            let mut group = ToggleGroup::new("toggle-group")
507                .w(px(120.))
508                .child(
509                    Toggle::new("one")
510                        .label("One")
511                        .checked(true)
512                        .on_click(move |_, _, _| child_clicks.set(child_clicks.get() + 1)),
513                )
514                .child(Toggle::new("two").label("Two"));
515            if self.install_group_callback {
516                let changes = self.group_changes.clone();
517                group = group.on_click(move |next, _, _| changes.borrow_mut().push(next.clone()));
518            }
519            group
520        }
521    }
522
523    fn toggle_group_harness(
524        cx: &mut TestAppContext,
525        install_group_callback: bool,
526    ) -> (
527        &mut VisualTestContext,
528        Rc<Cell<usize>>,
529        Rc<RefCell<Vec<Vec<bool>>>>,
530    ) {
531        cx.update(crate::init);
532        let child_clicks = Rc::new(Cell::new(0));
533        let group_changes = Rc::new(RefCell::new(Vec::new()));
534        let (_, cx) = cx.add_window_view({
535            let child_clicks = child_clicks.clone();
536            let group_changes = group_changes.clone();
537            move |_, _| ToggleGroupHarness {
538                install_group_callback,
539                child_clicks,
540                group_changes,
541            }
542        });
543        cx.update(|window, cx| window.draw(cx).clear(cx));
544        (cx, child_clicks, group_changes)
545    }
546
547    #[gpui::test]
548    fn legacy_toggle_group_overrides_child_callback_even_without_group_callback(
549        cx: &mut TestAppContext,
550    ) {
551        let (cx, child_clicks, changes) = toggle_group_harness(cx, false);
552        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
553        assert_eq!(child_clicks.get(), 0);
554        assert!(changes.borrow().is_empty());
555    }
556
557    #[gpui::test]
558    fn legacy_toggle_group_pointer_flips_only_the_clicked_rendered_value(cx: &mut TestAppContext) {
559        let (cx, child_clicks, changes) = toggle_group_harness(cx, true);
560        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
561        assert_eq!(child_clicks.get(), 0);
562        assert_eq!(changes.borrow().as_slice(), &[vec![false, false]]);
563    }
564
565    #[gpui::test]
566    fn legacy_toggle_group_keyboard_click_does_not_reach_the_group_callback(
567        cx: &mut TestAppContext,
568    ) {
569        let (cx, child_clicks, changes) = toggle_group_harness(cx, true);
570        cx.update(|window, cx| window.focus_next(cx));
571        activate_key(cx, "enter");
572        assert_eq!(child_clicks.get(), 0);
573        assert!(changes.borrow().is_empty());
574    }
575
576    #[test]
577    fn instance_style_remains_the_final_visual_override() {
578        let toggle = Toggle::new("styled").checked(true).opacity(0.37);
579        assert_eq!(toggle.style.opacity, Some(0.37));
580    }
581
582    #[gpui::test]
583    fn test_toggle_builder(_cx: &mut gpui::TestAppContext) {
584        let toggle = Toggle::new("complex-toggle")
585            .label("Enable Feature")
586            .icon(IconName::Check)
587            .checked(true)
588            .outline()
589            .large()
590            .disabled(false)
591            .on_click(|_, _, _| {});
592
593        assert_eq!(toggle.children.len(), 2); // label + icon
594        assert!(toggle.checked);
595        assert_eq!(toggle.variant, ToggleVariant::Outline);
596        assert_eq!(toggle.size, Size::Large);
597        assert!(!toggle.disabled);
598        assert!(toggle.on_click.is_some());
599    }
600
601    #[gpui::test]
602    fn test_toggle_group_builder(_cx: &mut gpui::TestAppContext) {
603        let group = ToggleGroup::new("complex-group")
604            .child(Toggle::new("toggle1").label("Option 1"))
605            .child(Toggle::new("toggle2").label("Option 2").checked(true))
606            .child(Toggle::new("toggle3").label("Option 3"))
607            .outline()
608            .large()
609            .segmented()
610            .disabled(false)
611            .on_click(|_, _, _| {});
612
613        assert_eq!(group.items.len(), 3);
614        assert_eq!(group.variant, ToggleVariant::Outline);
615        assert_eq!(group.size, Size::Large);
616        assert!(group.segmented);
617        assert!(!group.disabled);
618        assert!(group.on_click.is_some());
619    }
620}