toggle.rs

  1use gpui::{
  2    div, hsla, prelude::*, AnyView, CursorStyle, ElementId, Hsla, IntoElement, Styled, Window,
  3};
  4use std::sync::Arc;
  5
  6use crate::utils::is_light;
  7use crate::{prelude::*, ElevationIndex, KeyBinding};
  8use crate::{Color, Icon, IconName, ToggleState};
  9
 10// TODO: Checkbox, CheckboxWithLabel, and Switch could all be
 11// restructured to use a ToggleLike, similar to Button/Buttonlike, Label/Labellike
 12
 13/// Creates a new checkbox.
 14pub fn checkbox(id: impl Into<ElementId>, toggle_state: ToggleState) -> Checkbox {
 15    Checkbox::new(id, toggle_state)
 16}
 17
 18/// Creates a new switch.
 19pub fn switch(id: impl Into<ElementId>, toggle_state: ToggleState) -> Switch {
 20    Switch::new(id, toggle_state)
 21}
 22
 23/// The visual style of a toggle.
 24#[derive(Debug, Default, Clone, PartialEq, Eq)]
 25pub enum ToggleStyle {
 26    /// Toggle has a transparent background
 27    #[default]
 28    Ghost,
 29    /// Toggle has a filled background based on the
 30    /// elevation index of the parent container
 31    ElevationBased(ElevationIndex),
 32    /// A custom style using a color to tint the toggle
 33    Custom(Hsla),
 34}
 35
 36/// # Checkbox
 37///
 38/// Checkboxes are used for multiple choices, not for mutually exclusive choices.
 39/// Each checkbox works independently from other checkboxes in the list,
 40/// therefore checking an additional box does not affect any other selections.
 41#[derive(IntoElement)]
 42pub struct Checkbox {
 43    id: ElementId,
 44    toggle_state: ToggleState,
 45    disabled: bool,
 46    on_click: Option<Box<dyn Fn(&ToggleState, &mut Window, &mut App) + 'static>>,
 47    filled: bool,
 48    style: ToggleStyle,
 49    tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyView>>,
 50    label: Option<SharedString>,
 51}
 52
 53impl Checkbox {
 54    /// Creates a new [`Checkbox`].
 55    pub fn new(id: impl Into<ElementId>, checked: ToggleState) -> Self {
 56        Self {
 57            id: id.into(),
 58            toggle_state: checked,
 59            disabled: false,
 60            on_click: None,
 61            filled: false,
 62            style: ToggleStyle::default(),
 63            tooltip: None,
 64            label: None,
 65        }
 66    }
 67
 68    /// Sets the disabled state of the [`Checkbox`].
 69    pub fn disabled(mut self, disabled: bool) -> Self {
 70        self.disabled = disabled;
 71        self
 72    }
 73
 74    /// Binds a handler to the [`Checkbox`] that will be called when clicked.
 75    pub fn on_click(
 76        mut self,
 77        handler: impl Fn(&ToggleState, &mut Window, &mut App) + 'static,
 78    ) -> Self {
 79        self.on_click = Some(Box::new(handler));
 80        self
 81    }
 82
 83    /// Sets the `fill` setting of the checkbox, indicating whether it should be filled.
 84    pub fn fill(mut self) -> Self {
 85        self.filled = true;
 86        self
 87    }
 88
 89    /// Sets the style of the checkbox using the specified [`ToggleStyle`].
 90    pub fn style(mut self, style: ToggleStyle) -> Self {
 91        self.style = style;
 92        self
 93    }
 94
 95    /// Match the style of the checkbox to the current elevation using [`ToggleStyle::ElevationBased`].
 96    pub fn elevation(mut self, elevation: ElevationIndex) -> Self {
 97        self.style = ToggleStyle::ElevationBased(elevation);
 98        self
 99    }
100
101    /// Sets the tooltip for the checkbox.
102    pub fn tooltip(mut self, tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static) -> Self {
103        self.tooltip = Some(Box::new(tooltip));
104        self
105    }
106
107    /// Set the label for the checkbox.
108    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
109        self.label = Some(label.into());
110        self
111    }
112}
113
114impl Checkbox {
115    fn bg_color(&self, cx: &App) -> Hsla {
116        let style = self.style.clone();
117        match (style, self.filled) {
118            (ToggleStyle::Ghost, false) => cx.theme().colors().ghost_element_background,
119            (ToggleStyle::Ghost, true) => cx.theme().colors().element_background,
120            (ToggleStyle::ElevationBased(_), false) => gpui::transparent_black(),
121            (ToggleStyle::ElevationBased(elevation), true) => elevation.darker_bg(cx),
122            (ToggleStyle::Custom(_), false) => gpui::transparent_black(),
123            (ToggleStyle::Custom(color), true) => color.opacity(0.2),
124        }
125    }
126
127    fn border_color(&self, cx: &App) -> Hsla {
128        if self.disabled {
129            return cx.theme().colors().border_variant;
130        }
131
132        match self.style.clone() {
133            ToggleStyle::Ghost => cx.theme().colors().border,
134            ToggleStyle::ElevationBased(elevation) => elevation.on_elevation_bg(cx),
135            ToggleStyle::Custom(color) => color.opacity(0.3),
136        }
137    }
138}
139
140impl RenderOnce for Checkbox {
141    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
142        let group_id = format!("checkbox_group_{:?}", self.id);
143        let icon = match self.toggle_state {
144            ToggleState::Selected => Some(Icon::new(IconName::Check).size(IconSize::Small).color(
145                if self.disabled {
146                    Color::Disabled
147                } else {
148                    Color::Selected
149                },
150            )),
151            ToggleState::Indeterminate => Some(
152                Icon::new(IconName::Dash)
153                    .size(IconSize::Small)
154                    .color(if self.disabled {
155                        Color::Disabled
156                    } else {
157                        Color::Selected
158                    }),
159            ),
160            ToggleState::Unselected => None,
161        };
162
163        let bg_color = self.bg_color(cx);
164        let border_color = self.border_color(cx);
165
166        let checkbox = h_flex()
167            .justify_center()
168            .size(DynamicSpacing::Base20.rems(cx))
169            .group(group_id.clone())
170            .child(
171                div()
172                    .flex()
173                    .flex_none()
174                    .justify_center()
175                    .items_center()
176                    .m(DynamicSpacing::Base04.px(cx))
177                    .size(DynamicSpacing::Base16.rems(cx))
178                    .rounded_sm()
179                    .bg(bg_color)
180                    .border_1()
181                    .border_color(border_color)
182                    .when(self.disabled, |this| {
183                        this.cursor(CursorStyle::OperationNotAllowed)
184                    })
185                    .when(self.disabled, |this| {
186                        this.bg(cx.theme().colors().element_disabled.opacity(0.6))
187                    })
188                    .when(!self.disabled, |this| {
189                        this.group_hover(group_id.clone(), |el| {
190                            el.bg(cx.theme().colors().element_hover)
191                        })
192                    })
193                    .children(icon),
194            );
195
196        h_flex()
197            .id(self.id)
198            .gap(DynamicSpacing::Base06.rems(cx))
199            .child(checkbox)
200            .when_some(
201                self.on_click.filter(|_| !self.disabled),
202                |this, on_click| {
203                    this.on_click(move |_, window, cx| {
204                        on_click(&self.toggle_state.inverse(), window, cx)
205                    })
206                },
207            )
208            // TODO: Allow label size to be different from default.
209            // TODO: Allow label color to be different from muted.
210            .when_some(self.label, |this, label| {
211                this.child(Label::new(label).color(Color::Muted))
212            })
213            .when_some(self.tooltip, |this, tooltip| {
214                this.tooltip(move |window, cx| tooltip(window, cx))
215            })
216    }
217}
218
219/// A [`Checkbox`] that has a [`Label`].
220#[derive(IntoElement)]
221pub struct CheckboxWithLabel {
222    id: ElementId,
223    label: Label,
224    checked: ToggleState,
225    on_click: Arc<dyn Fn(&ToggleState, &mut Window, &mut App) + 'static>,
226    filled: bool,
227    style: ToggleStyle,
228}
229
230// TODO: Remove `CheckboxWithLabel` now that `label` is a method of `Checkbox`.
231impl CheckboxWithLabel {
232    /// Creates a checkbox with an attached label.
233    pub fn new(
234        id: impl Into<ElementId>,
235        label: Label,
236        checked: ToggleState,
237        on_click: impl Fn(&ToggleState, &mut Window, &mut App) + 'static,
238    ) -> Self {
239        Self {
240            id: id.into(),
241            label,
242            checked,
243            on_click: Arc::new(on_click),
244            filled: false,
245            style: ToggleStyle::default(),
246        }
247    }
248
249    /// Sets the style of the checkbox using the specified [`ToggleStyle`].
250    pub fn style(mut self, style: ToggleStyle) -> Self {
251        self.style = style;
252        self
253    }
254
255    /// Match the style of the checkbox to the current elevation using [`ToggleStyle::ElevationBased`].
256    pub fn elevation(mut self, elevation: ElevationIndex) -> Self {
257        self.style = ToggleStyle::ElevationBased(elevation);
258        self
259    }
260
261    /// Sets the `fill` setting of the checkbox, indicating whether it should be filled.
262    pub fn fill(mut self) -> Self {
263        self.filled = true;
264        self
265    }
266}
267
268impl RenderOnce for CheckboxWithLabel {
269    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
270        h_flex()
271            .gap(DynamicSpacing::Base08.rems(cx))
272            .child(
273                Checkbox::new(self.id.clone(), self.checked)
274                    .style(self.style)
275                    .when(self.filled, Checkbox::fill)
276                    .on_click({
277                        let on_click = self.on_click.clone();
278                        move |checked, window, cx| {
279                            (on_click)(checked, window, cx);
280                        }
281                    }),
282            )
283            .child(
284                div()
285                    .id(SharedString::from(format!("{}-label", self.id)))
286                    .on_click(move |_event, window, cx| {
287                        (self.on_click)(&self.checked.inverse(), window, cx);
288                    })
289                    .child(self.label),
290            )
291    }
292}
293
294/// # Switch
295///
296/// Switches are used to represent opposite states, such as enabled or disabled.
297#[derive(IntoElement)]
298pub struct Switch {
299    id: ElementId,
300    toggle_state: ToggleState,
301    disabled: bool,
302    on_click: Option<Box<dyn Fn(&ToggleState, &mut Window, &mut App) + 'static>>,
303    label: Option<SharedString>,
304    key_binding: Option<KeyBinding>,
305}
306
307impl Switch {
308    /// Creates a new [`Switch`].
309    pub fn new(id: impl Into<ElementId>, state: ToggleState) -> Self {
310        Self {
311            id: id.into(),
312            toggle_state: state,
313            disabled: false,
314            on_click: None,
315            label: None,
316            key_binding: None,
317        }
318    }
319
320    /// Sets the disabled state of the [`Switch`].
321    pub fn disabled(mut self, disabled: bool) -> Self {
322        self.disabled = disabled;
323        self
324    }
325
326    /// Binds a handler to the [`Switch`] that will be called when clicked.
327    pub fn on_click(
328        mut self,
329        handler: impl Fn(&ToggleState, &mut Window, &mut App) + 'static,
330    ) -> Self {
331        self.on_click = Some(Box::new(handler));
332        self
333    }
334
335    /// Sets the label of the [`Switch`].
336    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
337        self.label = Some(label.into());
338        self
339    }
340
341    /// Display the keybinding that triggers the switch action.
342    pub fn key_binding(mut self, key_binding: impl Into<Option<KeyBinding>>) -> Self {
343        self.key_binding = key_binding.into();
344        self
345    }
346}
347
348impl RenderOnce for Switch {
349    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
350        let is_on = self.toggle_state == ToggleState::Selected;
351        let adjust_ratio = if is_light(cx) { 1.5 } else { 1.0 };
352        let base_color = cx.theme().colors().text;
353
354        let bg_color = if is_on {
355            cx.theme()
356                .colors()
357                .element_background
358                .blend(base_color.opacity(0.08))
359        } else {
360            cx.theme().colors().element_background
361        };
362        let thumb_color = base_color.opacity(0.8);
363        let thumb_hover_color = base_color;
364        let border_color = cx.theme().colors().border_variant;
365        // Lighter themes need higher contrast borders
366        let border_hover_color = if is_on {
367            border_color.blend(base_color.opacity(0.16 * adjust_ratio))
368        } else {
369            border_color.blend(base_color.opacity(0.05 * adjust_ratio))
370        };
371        let thumb_opacity = match (is_on, self.disabled) {
372            (_, true) => 0.2,
373            (true, false) => 1.0,
374            (false, false) => 0.5,
375        };
376
377        let group_id = format!("switch_group_{:?}", self.id);
378
379        let switch = h_flex()
380            .w(DynamicSpacing::Base32.rems(cx))
381            .h(DynamicSpacing::Base20.rems(cx))
382            .group(group_id.clone())
383            .child(
384                h_flex()
385                    .when(is_on, |on| on.justify_end())
386                    .when(!is_on, |off| off.justify_start())
387                    .items_center()
388                    .size_full()
389                    .rounded_full()
390                    .px(DynamicSpacing::Base02.px(cx))
391                    .bg(bg_color)
392                    .border_1()
393                    .border_color(border_color)
394                    .when(!self.disabled, |this| {
395                        this.group_hover(group_id.clone(), |el| el.border_color(border_hover_color))
396                    })
397                    .child(
398                        div()
399                            .size(DynamicSpacing::Base12.rems(cx))
400                            .rounded_full()
401                            .bg(thumb_color)
402                            .when(!self.disabled, |this| {
403                                this.group_hover(group_id.clone(), |el| el.bg(thumb_hover_color))
404                            })
405                            .opacity(thumb_opacity),
406                    ),
407            );
408
409        h_flex()
410            .id(self.id)
411            .gap(DynamicSpacing::Base06.rems(cx))
412            .child(switch)
413            .when_some(
414                self.on_click.filter(|_| !self.disabled),
415                |this, on_click| {
416                    this.on_click(move |_, window, cx| {
417                        on_click(&self.toggle_state.inverse(), window, cx)
418                    })
419                },
420            )
421            .when_some(self.label, |this, label| {
422                this.child(Label::new(label).size(LabelSize::Small))
423            })
424            .children(self.key_binding)
425    }
426}
427
428impl ComponentPreview for Checkbox {
429    fn description() -> impl Into<Option<&'static str>> {
430        "A checkbox lets people choose between a pair of opposing states, like enabled and disabled, using a different appearance to indicate each state."
431    }
432
433    fn examples(_window: &mut Window, _: &mut App) -> Vec<ComponentExampleGroup<Self>> {
434        vec![
435            example_group_with_title(
436                "Default",
437                vec![
438                    single_example(
439                        "Unselected",
440                        Checkbox::new("checkbox_unselected", ToggleState::Unselected),
441                    ),
442                    single_example(
443                        "Indeterminate",
444                        Checkbox::new("checkbox_indeterminate", ToggleState::Indeterminate),
445                    ),
446                    single_example(
447                        "Selected",
448                        Checkbox::new("checkbox_selected", ToggleState::Selected),
449                    ),
450                ],
451            ),
452            example_group_with_title(
453                "Default (Filled)",
454                vec![
455                    single_example(
456                        "Unselected",
457                        Checkbox::new("checkbox_unselected", ToggleState::Unselected).fill(),
458                    ),
459                    single_example(
460                        "Indeterminate",
461                        Checkbox::new("checkbox_indeterminate", ToggleState::Indeterminate).fill(),
462                    ),
463                    single_example(
464                        "Selected",
465                        Checkbox::new("checkbox_selected", ToggleState::Selected).fill(),
466                    ),
467                ],
468            ),
469            example_group_with_title(
470                "ElevationBased",
471                vec![
472                    single_example(
473                        "Unselected",
474                        Checkbox::new("checkbox_unfilled_unselected", ToggleState::Unselected)
475                            .style(ToggleStyle::ElevationBased(ElevationIndex::EditorSurface)),
476                    ),
477                    single_example(
478                        "Indeterminate",
479                        Checkbox::new(
480                            "checkbox_unfilled_indeterminate",
481                            ToggleState::Indeterminate,
482                        )
483                        .style(ToggleStyle::ElevationBased(ElevationIndex::EditorSurface)),
484                    ),
485                    single_example(
486                        "Selected",
487                        Checkbox::new("checkbox_unfilled_selected", ToggleState::Selected)
488                            .style(ToggleStyle::ElevationBased(ElevationIndex::EditorSurface)),
489                    ),
490                ],
491            ),
492            example_group_with_title(
493                "ElevationBased (Filled)",
494                vec![
495                    single_example(
496                        "Unselected",
497                        Checkbox::new("checkbox_filled_unselected", ToggleState::Unselected)
498                            .fill()
499                            .style(ToggleStyle::ElevationBased(ElevationIndex::EditorSurface)),
500                    ),
501                    single_example(
502                        "Indeterminate",
503                        Checkbox::new("checkbox_filled_indeterminate", ToggleState::Indeterminate)
504                            .fill()
505                            .style(ToggleStyle::ElevationBased(ElevationIndex::EditorSurface)),
506                    ),
507                    single_example(
508                        "Selected",
509                        Checkbox::new("checkbox_filled_selected", ToggleState::Selected)
510                            .fill()
511                            .style(ToggleStyle::ElevationBased(ElevationIndex::EditorSurface)),
512                    ),
513                ],
514            ),
515            example_group_with_title(
516                "Custom Color",
517                vec![
518                    single_example(
519                        "Unselected",
520                        Checkbox::new("checkbox_custom_unselected", ToggleState::Unselected)
521                            .style(ToggleStyle::Custom(hsla(142.0 / 360., 0.68, 0.45, 0.7))),
522                    ),
523                    single_example(
524                        "Indeterminate",
525                        Checkbox::new("checkbox_custom_indeterminate", ToggleState::Indeterminate)
526                            .style(ToggleStyle::Custom(hsla(142.0 / 360., 0.68, 0.45, 0.7))),
527                    ),
528                    single_example(
529                        "Selected",
530                        Checkbox::new("checkbox_custom_selected", ToggleState::Selected)
531                            .style(ToggleStyle::Custom(hsla(142.0 / 360., 0.68, 0.45, 0.7))),
532                    ),
533                ],
534            ),
535            example_group_with_title(
536                "Custom Color (Filled)",
537                vec![
538                    single_example(
539                        "Unselected",
540                        Checkbox::new("checkbox_custom_filled_unselected", ToggleState::Unselected)
541                            .fill()
542                            .style(ToggleStyle::Custom(hsla(142.0 / 360., 0.68, 0.45, 0.7))),
543                    ),
544                    single_example(
545                        "Indeterminate",
546                        Checkbox::new(
547                            "checkbox_custom_filled_indeterminate",
548                            ToggleState::Indeterminate,
549                        )
550                        .fill()
551                        .style(ToggleStyle::Custom(hsla(
552                            142.0 / 360.,
553                            0.68,
554                            0.45,
555                            0.7,
556                        ))),
557                    ),
558                    single_example(
559                        "Selected",
560                        Checkbox::new("checkbox_custom_filled_selected", ToggleState::Selected)
561                            .fill()
562                            .style(ToggleStyle::Custom(hsla(142.0 / 360., 0.68, 0.45, 0.7))),
563                    ),
564                ],
565            ),
566            example_group_with_title(
567                "Disabled",
568                vec![
569                    single_example(
570                        "Unselected",
571                        Checkbox::new("checkbox_disabled_unselected", ToggleState::Unselected)
572                            .disabled(true),
573                    ),
574                    single_example(
575                        "Indeterminate",
576                        Checkbox::new(
577                            "checkbox_disabled_indeterminate",
578                            ToggleState::Indeterminate,
579                        )
580                        .disabled(true),
581                    ),
582                    single_example(
583                        "Selected",
584                        Checkbox::new("checkbox_disabled_selected", ToggleState::Selected)
585                            .disabled(true),
586                    ),
587                ],
588            ),
589            example_group_with_title(
590                "Disabled (Filled)",
591                vec![
592                    single_example(
593                        "Unselected",
594                        Checkbox::new(
595                            "checkbox_disabled_filled_unselected",
596                            ToggleState::Unselected,
597                        )
598                        .fill()
599                        .disabled(true),
600                    ),
601                    single_example(
602                        "Indeterminate",
603                        Checkbox::new(
604                            "checkbox_disabled_filled_indeterminate",
605                            ToggleState::Indeterminate,
606                        )
607                        .fill()
608                        .disabled(true),
609                    ),
610                    single_example(
611                        "Selected",
612                        Checkbox::new("checkbox_disabled_filled_selected", ToggleState::Selected)
613                            .fill()
614                            .disabled(true),
615                    ),
616                ],
617            ),
618        ]
619    }
620}
621
622impl ComponentPreview for Switch {
623    fn description() -> impl Into<Option<&'static str>> {
624        "A switch toggles between two mutually exclusive states, typically used for enabling or disabling a setting."
625    }
626
627    fn examples(_window: &mut Window, _cx: &mut App) -> Vec<ComponentExampleGroup<Self>> {
628        vec![
629            example_group_with_title(
630                "Default",
631                vec![
632                    single_example(
633                        "Off",
634                        Switch::new("switch_off", ToggleState::Unselected).on_click(|_, _, _cx| {}),
635                    ),
636                    single_example(
637                        "On",
638                        Switch::new("switch_on", ToggleState::Selected).on_click(|_, _, _cx| {}),
639                    ),
640                ],
641            ),
642            example_group_with_title(
643                "Disabled",
644                vec![
645                    single_example(
646                        "Off",
647                        Switch::new("switch_disabled_off", ToggleState::Unselected).disabled(true),
648                    ),
649                    single_example(
650                        "On",
651                        Switch::new("switch_disabled_on", ToggleState::Selected).disabled(true),
652                    ),
653                ],
654            ),
655            example_group_with_title(
656                "Label Permutations",
657                vec![
658                    single_example(
659                        "Label",
660                        Switch::new("switch_with_label", ToggleState::Selected)
661                            .label("Always save on quit"),
662                    ),
663                    single_example(
664                        "Keybinding",
665                        Switch::new("switch_with_label", ToggleState::Selected)
666                            .key_binding(theme_preview_keybinding("cmd-shift-e")),
667                    ),
668                ],
669            ),
670        ]
671    }
672}
673
674impl ComponentPreview for CheckboxWithLabel {
675    fn description() -> impl Into<Option<&'static str>> {
676        "A checkbox with an associated label, allowing users to select an option while providing a descriptive text."
677    }
678
679    fn examples(_window: &mut Window, _: &mut App) -> Vec<ComponentExampleGroup<Self>> {
680        vec![example_group(vec![
681            single_example(
682                "Unselected",
683                CheckboxWithLabel::new(
684                    "checkbox_with_label_unselected",
685                    Label::new("Always save on quit"),
686                    ToggleState::Unselected,
687                    |_, _, _| {},
688                ),
689            ),
690            single_example(
691                "Indeterminate",
692                CheckboxWithLabel::new(
693                    "checkbox_with_label_indeterminate",
694                    Label::new("Always save on quit"),
695                    ToggleState::Indeterminate,
696                    |_, _, _| {},
697                ),
698            ),
699            single_example(
700                "Selected",
701                CheckboxWithLabel::new(
702                    "checkbox_with_label_selected",
703                    Label::new("Always save on quit"),
704                    ToggleState::Selected,
705                    |_, _, _| {},
706                ),
707            ),
708        ])]
709    }
710}