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