Skip to main content

gpui_component/button/
toggle.rs

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