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 = Keystroke::parse(key).unwrap();
471        cx.simulate_event(KeyDownEvent {
472            keystroke: keystroke.clone(),
473            is_held: false,
474            prefer_character_input: false,
475        });
476        cx.simulate_event(KeyUpEvent { keystroke });
477    }
478
479    #[gpui::test]
480    fn canonical_pointer_activation_fires_once_and_focuses(cx: &mut TestAppContext) {
481        let (cx, changes, _) = harness(cx, false);
482        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
483        assert_eq!(changes.borrow().as_slice(), &[true]);
484        cx.update(|window, cx| assert!(window.focused(cx).is_some()));
485    }
486
487    #[gpui::test]
488    fn canonical_toggle_supports_tab_enter_and_space(cx: &mut TestAppContext) {
489        let (cx, changes, _) = harness(cx, false);
490        cx.update(|window, cx| window.focus_next(cx));
491        cx.update(|window, cx| assert!(window.focused(cx).is_some()));
492        activate_key(cx, "enter");
493        activate_key(cx, "space");
494        assert_eq!(changes.borrow().as_slice(), &[true, true]);
495    }
496
497    #[gpui::test]
498    fn canonical_disabled_toggle_is_inert_and_blocks_parent(cx: &mut TestAppContext) {
499        let (cx, changes, parent_clicks) = harness(cx, true);
500        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
501        assert!(changes.borrow().is_empty());
502        assert_eq!(parent_clicks.get(), 0);
503    }
504
505    struct ToggleGroupHarness {
506        install_group_callback: bool,
507        child_clicks: Rc<Cell<usize>>,
508        group_changes: Rc<RefCell<Vec<Vec<bool>>>>,
509    }
510
511    impl Render for ToggleGroupHarness {
512        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
513            let child_clicks = self.child_clicks.clone();
514            let mut group = ToggleGroup::new("toggle-group")
515                .w(px(120.))
516                .child(
517                    Toggle::new("one")
518                        .label("One")
519                        .checked(true)
520                        .on_click(move |_, _, _| child_clicks.set(child_clicks.get() + 1)),
521                )
522                .child(Toggle::new("two").label("Two"));
523            if self.install_group_callback {
524                let changes = self.group_changes.clone();
525                group = group.on_click(move |next, _, _| changes.borrow_mut().push(next.clone()));
526            }
527            group
528        }
529    }
530
531    fn toggle_group_harness(
532        cx: &mut TestAppContext,
533        install_group_callback: bool,
534    ) -> (
535        &mut VisualTestContext,
536        Rc<Cell<usize>>,
537        Rc<RefCell<Vec<Vec<bool>>>>,
538    ) {
539        cx.update(crate::init);
540        let child_clicks = Rc::new(Cell::new(0));
541        let group_changes = Rc::new(RefCell::new(Vec::new()));
542        let (_, cx) = cx.add_window_view({
543            let child_clicks = child_clicks.clone();
544            let group_changes = group_changes.clone();
545            move |_, _| ToggleGroupHarness {
546                install_group_callback,
547                child_clicks,
548                group_changes,
549            }
550        });
551        cx.update(|window, cx| window.draw(cx).clear(cx));
552        (cx, child_clicks, group_changes)
553    }
554
555    #[gpui::test]
556    fn legacy_toggle_group_overrides_child_callback_even_without_group_callback(
557        cx: &mut TestAppContext,
558    ) {
559        let (cx, child_clicks, changes) = toggle_group_harness(cx, false);
560        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
561        assert_eq!(child_clicks.get(), 0);
562        assert!(changes.borrow().is_empty());
563    }
564
565    #[gpui::test]
566    fn legacy_toggle_group_pointer_flips_only_the_clicked_rendered_value(cx: &mut TestAppContext) {
567        let (cx, child_clicks, changes) = toggle_group_harness(cx, true);
568        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
569        assert_eq!(child_clicks.get(), 0);
570        assert_eq!(changes.borrow().as_slice(), &[vec![false, false]]);
571    }
572
573    #[gpui::test]
574    fn legacy_toggle_group_keyboard_click_does_not_reach_the_group_callback(
575        cx: &mut TestAppContext,
576    ) {
577        let (cx, child_clicks, changes) = toggle_group_harness(cx, true);
578        cx.update(|window, cx| window.focus_next(cx));
579        activate_key(cx, "enter");
580        assert_eq!(child_clicks.get(), 0);
581        assert!(changes.borrow().is_empty());
582    }
583
584    #[test]
585    fn instance_style_remains_the_final_visual_override() {
586        let toggle = Toggle::new("styled").checked(true).opacity(0.37);
587        assert_eq!(toggle.style.opacity, Some(0.37));
588    }
589
590    #[gpui::test]
591    fn test_toggle_builder(_cx: &mut gpui::TestAppContext) {
592        let toggle = Toggle::new("complex-toggle")
593            .label("Enable Feature")
594            .icon(IconName::Check)
595            .checked(true)
596            .outline()
597            .large()
598            .disabled(false)
599            .on_click(|_, _, _| {});
600
601        assert_eq!(toggle.children.len(), 2); // label + icon
602        assert!(toggle.checked);
603        assert_eq!(toggle.variant, ToggleVariant::Outline);
604        assert_eq!(toggle.size, Size::Large);
605        assert!(!toggle.disabled);
606        assert!(toggle.on_click.is_some());
607    }
608
609    #[gpui::test]
610    fn test_toggle_group_builder(_cx: &mut gpui::TestAppContext) {
611        let group = ToggleGroup::new("complex-group")
612            .child(Toggle::new("toggle1").label("Option 1"))
613            .child(Toggle::new("toggle2").label("Option 2").checked(true))
614            .child(Toggle::new("toggle3").label("Option 3"))
615            .outline()
616            .large()
617            .segmented()
618            .disabled(false)
619            .on_click(|_, _, _| {});
620
621        assert_eq!(group.items.len(), 3);
622        assert_eq!(group.variant, ToggleVariant::Outline);
623        assert_eq!(group.size, Size::Large);
624        assert!(group.segmented);
625        assert!(!group.disabled);
626        assert!(group.on_click.is_some());
627    }
628}