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