ui: Update Checkbox design (#22794)

Nate Butler created

This PR shifts the design of checkboxes and introduces ways to style
checkboxes based on Elevation, or tint them with a custom color.

This may have some impacts on existing uses of checkboxes.

When creating a checkbox you now need to call `.fill` if you want the
checkbox to have a filled style.

Before:

![CleanShot 2025-01-07 at 15 54
57@2x](https://github.com/user-attachments/assets/44463383-018e-4e7d-ac60-f3e7e643661d)

![CleanShot 2025-01-07 at 15 56
17@2x](https://github.com/user-attachments/assets/c72af034-4987-418e-b91b-5f50337fb212)


After:

![CleanShot 2025-01-07 at 15 55
47@2x](https://github.com/user-attachments/assets/711dff92-9ec3-485a-89de-e28f0b709833)

![CleanShot 2025-01-07 at 15 56
02@2x](https://github.com/user-attachments/assets/63797be4-22b2-464d-b4d3-fefc0d95537a)


Release Notes:

- N/A

Change summary

crates/ui/src/components/toggle.rs | 282 ++++++++++++++++++++++++++++---
crates/ui/src/styles/elevation.rs  |  32 ++
crates/welcome/src/welcome.rs      | 148 +++++++++-------
3 files changed, 363 insertions(+), 99 deletions(-)

Detailed changes

crates/ui/src/components/toggle.rs 🔗

@@ -1,12 +1,13 @@
-#![allow(missing_docs)]
-
-use gpui::{div, prelude::*, ElementId, IntoElement, Styled, WindowContext};
+use gpui::{div, hsla, prelude::*, ElementId, Hsla, IntoElement, Styled, WindowContext};
 use std::sync::Arc;
 
-use crate::prelude::*;
 use crate::utils::is_light;
+use crate::{prelude::*, ElevationIndex};
 use crate::{Color, Icon, IconName, ToggleState};
 
+// TODO: Checkbox, CheckboxWithLabel, Switch, SwitchWithLabel all could be
+// restructured to use a ToggleLike, similar to Button/Buttonlike, Label/Labellike
+
 /// Creates a new checkbox
 pub fn checkbox(id: impl Into<ElementId>, toggle_state: ToggleState) -> Checkbox {
     Checkbox::new(id, toggle_state)
@@ -17,6 +18,19 @@ pub fn switch(id: impl Into<ElementId>, toggle_state: ToggleState) -> Switch {
     Switch::new(id, toggle_state)
 }
 
+#[derive(Debug, Default, Clone, PartialEq, Eq)]
+/// The visual style of a toggle
+pub enum ToggleStyle {
+    /// Toggle has a transparent background
+    #[default]
+    Ghost,
+    /// Toggle has a filled background based on the
+    /// elevation index of the parent container
+    ElevationBased(ElevationIndex),
+    /// A custom style using a color to tint the toggle
+    Custom(Hsla),
+}
+
 /// # Checkbox
 ///
 /// Checkboxes are used for multiple choices, not for mutually exclusive choices.
@@ -28,23 +42,30 @@ pub struct Checkbox {
     toggle_state: ToggleState,
     disabled: bool,
     on_click: Option<Box<dyn Fn(&ToggleState, &mut WindowContext) + 'static>>,
+    filled: bool,
+    style: ToggleStyle,
 }
 
 impl Checkbox {
+    /// Creates a new [`Checkbox`]
     pub fn new(id: impl Into<ElementId>, checked: ToggleState) -> Self {
         Self {
             id: id.into(),
             toggle_state: checked,
             disabled: false,
             on_click: None,
+            filled: false,
+            style: ToggleStyle::default(),
         }
     }
 
+    /// Sets the disabled state of the [`Checkbox`]
     pub fn disabled(mut self, disabled: bool) -> Self {
         self.disabled = disabled;
         self
     }
 
+    /// Binds a handler to the [`Checkbox`] that will be called when clicked
     pub fn on_click(
         mut self,
         handler: impl Fn(&ToggleState, &mut WindowContext) + 'static,
@@ -52,12 +73,55 @@ impl Checkbox {
         self.on_click = Some(Box::new(handler));
         self
     }
+
+    /// Sets the `fill` setting of the checkbox, indicating whether it should be filled or not
+    pub fn fill(mut self) -> Self {
+        self.filled = true;
+        self
+    }
+
+    /// Sets the style of the checkbox using the specified [`ToggleStyle`]
+    pub fn style(mut self, style: ToggleStyle) -> Self {
+        self.style = style;
+        self
+    }
+
+    /// Match the style of the checkbox to the current elevation using [`ToggleStyle::ElevationBased`]
+    pub fn elevation(mut self, elevation: ElevationIndex) -> Self {
+        self.style = ToggleStyle::ElevationBased(elevation);
+        self
+    }
+}
+
+impl Checkbox {
+    fn bg_color(&self, cx: &WindowContext) -> Hsla {
+        let style = self.style.clone();
+        match (style, self.filled) {
+            (ToggleStyle::Ghost, false) => cx.theme().colors().ghost_element_background,
+            (ToggleStyle::Ghost, true) => cx.theme().colors().element_background,
+            (ToggleStyle::ElevationBased(_), false) => gpui::transparent_black(),
+            (ToggleStyle::ElevationBased(elevation), true) => elevation.darker_bg(cx),
+            (ToggleStyle::Custom(_), false) => gpui::transparent_black(),
+            (ToggleStyle::Custom(color), true) => color.opacity(0.2),
+        }
+    }
+
+    fn border_color(&self, cx: &WindowContext) -> Hsla {
+        if self.disabled {
+            return cx.theme().colors().border_disabled;
+        }
+
+        match self.style.clone() {
+            ToggleStyle::Ghost => cx.theme().colors().border_variant,
+            ToggleStyle::ElevationBased(elevation) => elevation.on_elevation_bg(cx),
+            ToggleStyle::Custom(color) => color.opacity(0.3),
+        }
+    }
 }
 
 impl RenderOnce for Checkbox {
     fn render(self, cx: &mut WindowContext) -> impl IntoElement {
         let group_id = format!("checkbox_group_{:?}", self.id);
-
         let icon = match self.toggle_state {
             ToggleState::Selected => Some(Icon::new(IconName::Check).size(IconSize::Small).color(
                 if self.disabled {
@@ -78,23 +142,8 @@ impl RenderOnce for Checkbox {
             ToggleState::Unselected => None,
         };
 
-        let selected = self.toggle_state == ToggleState::Selected
-            || self.toggle_state == ToggleState::Indeterminate;
-
-        let (bg_color, border_color) = match (self.disabled, selected) {
-            (true, _) => (
-                cx.theme().colors().ghost_element_disabled,
-                cx.theme().colors().border_disabled,
-            ),
-            (false, true) => (
-                cx.theme().colors().element_selected,
-                cx.theme().colors().border,
-            ),
-            (false, false) => (
-                cx.theme().colors().element_background,
-                cx.theme().colors().border,
-            ),
-        };
+        let bg_color = self.bg_color(cx);
+        let border_color = self.border_color(cx);
 
         h_flex()
             .id(self.id)
@@ -137,9 +186,12 @@ pub struct CheckboxWithLabel {
     label: Label,
     checked: ToggleState,
     on_click: Arc<dyn Fn(&ToggleState, &mut WindowContext) + 'static>,
+    filled: bool,
+    style: ToggleStyle,
 }
 
 impl CheckboxWithLabel {
+    /// Creates a checkbox with an attached label
     pub fn new(
         id: impl Into<ElementId>,
         label: Label,
@@ -151,20 +203,45 @@ impl CheckboxWithLabel {
             label,
             checked,
             on_click: Arc::new(on_click),
+            filled: false,
+            style: ToggleStyle::default(),
         }
     }
+
+    /// Sets the style of the checkbox using the specified [`ToggleStyle`]
+    pub fn style(mut self, style: ToggleStyle) -> Self {
+        self.style = style;
+        self
+    }
+
+    /// Match the style of the checkbox to the current elevation using [`ToggleStyle::ElevationBased`]
+    pub fn elevation(mut self, elevation: ElevationIndex) -> Self {
+        self.style = ToggleStyle::ElevationBased(elevation);
+        self
+    }
+
+    /// Sets the `fill` setting of the checkbox, indicating whether it should be filled or not
+    pub fn fill(mut self) -> Self {
+        self.filled = true;
+        self
+    }
 }
 
 impl RenderOnce for CheckboxWithLabel {
     fn render(self, cx: &mut WindowContext) -> impl IntoElement {
         h_flex()
             .gap(DynamicSpacing::Base08.rems(cx))
-            .child(Checkbox::new(self.id.clone(), self.checked).on_click({
-                let on_click = self.on_click.clone();
-                move |checked, cx| {
-                    (on_click)(checked, cx);
-                }
-            }))
+            .child(
+                Checkbox::new(self.id.clone(), self.checked)
+                    .style(self.style)
+                    .when(self.filled, Checkbox::fill)
+                    .on_click({
+                        let on_click = self.on_click.clone();
+                        move |checked, cx| {
+                            (on_click)(checked, cx);
+                        }
+                    }),
+            )
             .child(
                 div()
                     .id(SharedString::from(format!("{}-label", self.id)))
@@ -188,6 +265,7 @@ pub struct Switch {
 }
 
 impl Switch {
+    /// Creates a new [`Switch`]
     pub fn new(id: impl Into<ElementId>, state: ToggleState) -> Self {
         Self {
             id: id.into(),
@@ -197,11 +275,13 @@ impl Switch {
         }
     }
 
+    /// Sets the disabled state of the [`Switch`]
     pub fn disabled(mut self, disabled: bool) -> Self {
         self.disabled = disabled;
         self
     }
 
+    /// Binds a handler to the [`Switch`] that will be called when clicked
     pub fn on_click(
         mut self,
         handler: impl Fn(&ToggleState, &mut WindowContext) + 'static,
@@ -282,8 +362,8 @@ impl RenderOnce for Switch {
     }
 }
 
-/// A [`Switch`] that has a [`Label`].
 #[derive(IntoElement)]
+/// A [`Switch`] that has a [`Label`].
 pub struct SwitchWithLabel {
     id: ElementId,
     label: Label,
@@ -292,6 +372,7 @@ pub struct SwitchWithLabel {
 }
 
 impl SwitchWithLabel {
+    /// Creates a switch with an attached label
     pub fn new(
         id: impl Into<ElementId>,
         label: Label,
@@ -352,6 +433,120 @@ impl ComponentPreview for Checkbox {
                     ),
                 ],
             ),
+            example_group_with_title(
+                "Default (Filled)",
+                vec![
+                    single_example(
+                        "Unselected",
+                        Checkbox::new("checkbox_unselected", ToggleState::Unselected).fill(),
+                    ),
+                    single_example(
+                        "Indeterminate",
+                        Checkbox::new("checkbox_indeterminate", ToggleState::Indeterminate).fill(),
+                    ),
+                    single_example(
+                        "Selected",
+                        Checkbox::new("checkbox_selected", ToggleState::Selected).fill(),
+                    ),
+                ],
+            ),
+            example_group_with_title(
+                "ElevationBased",
+                vec![
+                    single_example(
+                        "Unselected",
+                        Checkbox::new("checkbox_unfilled_unselected", ToggleState::Unselected)
+                            .style(ToggleStyle::ElevationBased(ElevationIndex::EditorSurface)),
+                    ),
+                    single_example(
+                        "Indeterminate",
+                        Checkbox::new(
+                            "checkbox_unfilled_indeterminate",
+                            ToggleState::Indeterminate,
+                        )
+                        .style(ToggleStyle::ElevationBased(ElevationIndex::EditorSurface)),
+                    ),
+                    single_example(
+                        "Selected",
+                        Checkbox::new("checkbox_unfilled_selected", ToggleState::Selected)
+                            .style(ToggleStyle::ElevationBased(ElevationIndex::EditorSurface)),
+                    ),
+                ],
+            ),
+            example_group_with_title(
+                "ElevationBased (Filled)",
+                vec![
+                    single_example(
+                        "Unselected",
+                        Checkbox::new("checkbox_filled_unselected", ToggleState::Unselected)
+                            .fill()
+                            .style(ToggleStyle::ElevationBased(ElevationIndex::EditorSurface)),
+                    ),
+                    single_example(
+                        "Indeterminate",
+                        Checkbox::new("checkbox_filled_indeterminate", ToggleState::Indeterminate)
+                            .fill()
+                            .style(ToggleStyle::ElevationBased(ElevationIndex::EditorSurface)),
+                    ),
+                    single_example(
+                        "Selected",
+                        Checkbox::new("checkbox_filled_selected", ToggleState::Selected)
+                            .fill()
+                            .style(ToggleStyle::ElevationBased(ElevationIndex::EditorSurface)),
+                    ),
+                ],
+            ),
+            example_group_with_title(
+                "Custom Color",
+                vec![
+                    single_example(
+                        "Unselected",
+                        Checkbox::new("checkbox_custom_unselected", ToggleState::Unselected)
+                            .style(ToggleStyle::Custom(hsla(142.0 / 360., 0.68, 0.45, 0.7))),
+                    ),
+                    single_example(
+                        "Indeterminate",
+                        Checkbox::new("checkbox_custom_indeterminate", ToggleState::Indeterminate)
+                            .style(ToggleStyle::Custom(hsla(142.0 / 360., 0.68, 0.45, 0.7))),
+                    ),
+                    single_example(
+                        "Selected",
+                        Checkbox::new("checkbox_custom_selected", ToggleState::Selected)
+                            .style(ToggleStyle::Custom(hsla(142.0 / 360., 0.68, 0.45, 0.7))),
+                    ),
+                ],
+            ),
+            example_group_with_title(
+                "Custom Color (Filled)",
+                vec![
+                    single_example(
+                        "Unselected",
+                        Checkbox::new("checkbox_custom_filled_unselected", ToggleState::Unselected)
+                            .fill()
+                            .style(ToggleStyle::Custom(hsla(142.0 / 360., 0.68, 0.45, 0.7))),
+                    ),
+                    single_example(
+                        "Indeterminate",
+                        Checkbox::new(
+                            "checkbox_custom_filled_indeterminate",
+                            ToggleState::Indeterminate,
+                        )
+                        .fill()
+                        .style(ToggleStyle::Custom(hsla(
+                            142.0 / 360.,
+                            0.68,
+                            0.45,
+                            0.7,
+                        ))),
+                    ),
+                    single_example(
+                        "Selected",
+                        Checkbox::new("checkbox_custom_filled_selected", ToggleState::Selected)
+                            .fill()
+                            .style(ToggleStyle::Custom(hsla(142.0 / 360., 0.68, 0.45, 0.7))),
+                    ),
+                ],
+            ),
             example_group_with_title(
                 "Disabled",
                 vec![
@@ -375,6 +570,35 @@ impl ComponentPreview for Checkbox {
                     ),
                 ],
             ),
+            example_group_with_title(
+                "Disabled (Filled)",
+                vec![
+                    single_example(
+                        "Unselected",
+                        Checkbox::new(
+                            "checkbox_disabled_filled_unselected",
+                            ToggleState::Unselected,
+                        )
+                        .fill()
+                        .disabled(true),
+                    ),
+                    single_example(
+                        "Indeterminate",
+                        Checkbox::new(
+                            "checkbox_disabled_filled_indeterminate",
+                            ToggleState::Indeterminate,
+                        )
+                        .fill()
+                        .disabled(true),
+                    ),
+                    single_example(
+                        "Selected",
+                        Checkbox::new("checkbox_disabled_filled_selected", ToggleState::Selected)
+                            .fill()
+                            .disabled(true),
+                    ),
+                ],
+            ),
         ]
     }
 }

crates/ui/src/styles/elevation.rs 🔗

@@ -22,12 +22,8 @@ pub enum ElevationIndex {
     EditorSurface,
     /// A surface that is elevated above the primary surface. but below washes, models, and dragged elements.
     ElevatedSurface,
-    /// A surface that is above all non-modal surfaces, and separates the app from focused intents, like dialogs, alerts, modals, etc.
-    Wash,
     /// A surface above the [ElevationIndex::Wash] that is used for dialogs, alerts, modals, etc.
     ModalSurface,
-    /// A surface above all other surfaces, reserved exclusively for dragged elements, like a dragged file, tab or other draggable element.
-    DraggedElement,
 }
 
 impl Display for ElevationIndex {
@@ -37,9 +33,7 @@ impl Display for ElevationIndex {
             ElevationIndex::Surface => write!(f, "Surface"),
             ElevationIndex::EditorSurface => write!(f, "Editor Surface"),
             ElevationIndex::ElevatedSurface => write!(f, "Elevated Surface"),
-            ElevationIndex::Wash => write!(f, "Wash"),
             ElevationIndex::ModalSurface => write!(f, "Modal Surface"),
-            ElevationIndex::DraggedElement => write!(f, "Dragged Element"),
         }
     }
 }
@@ -90,9 +84,31 @@ impl ElevationIndex {
             ElevationIndex::Surface => cx.theme().colors().surface_background,
             ElevationIndex::EditorSurface => cx.theme().colors().editor_background,
             ElevationIndex::ElevatedSurface => cx.theme().colors().elevated_surface_background,
-            ElevationIndex::Wash => gpui::transparent_black(),
             ElevationIndex::ModalSurface => cx.theme().colors().elevated_surface_background,
-            ElevationIndex::DraggedElement => gpui::transparent_black(),
+        }
+    }
+
+    /// Returns a color that is appropriate a filled element on this elevation
+    pub fn on_elevation_bg(&self, cx: &WindowContext) -> Hsla {
+        match self {
+            ElevationIndex::Background => cx.theme().colors().surface_background,
+            ElevationIndex::Surface => cx.theme().colors().background,
+            ElevationIndex::EditorSurface => cx.theme().colors().surface_background,
+            ElevationIndex::ElevatedSurface => cx.theme().colors().background,
+            ElevationIndex::ModalSurface => cx.theme().colors().background,
+        }
+    }
+
+    /// Attempts to return a darker background color than the current elevation index's background.
+    ///
+    /// If the current background color is already dark, it will return a lighter color instead.
+    pub fn darker_bg(&self, cx: &WindowContext) -> Hsla {
+        match self {
+            ElevationIndex::Background => cx.theme().colors().surface_background,
+            ElevationIndex::Surface => cx.theme().colors().editor_background,
+            ElevationIndex::EditorSurface => cx.theme().colors().surface_background,
+            ElevationIndex::ElevatedSurface => cx.theme().colors().editor_background,
+            ElevationIndex::ModalSurface => cx.theme().colors().editor_background,
         }
     }
 }

crates/welcome/src/welcome.rs 🔗

@@ -11,7 +11,7 @@ use gpui::{
 };
 use settings::{Settings, SettingsStore};
 use std::sync::Arc;
-use ui::{prelude::*, CheckboxWithLabel, Tooltip};
+use ui::{prelude::*, CheckboxWithLabel, ElevationIndex, Tooltip};
 use vim_mode_setting::VimModeSetting;
 use workspace::{
     dock::DockPosition,
@@ -269,72 +269,96 @@ impl Render for WelcomePage {
                             .child(
                                 h_flex()
                                     .justify_between()
-                                    .child(CheckboxWithLabel::new(
-                                        "enable-vim",
-                                        Label::new("Enable Vim Mode"),
-                                        if VimModeSetting::get_global(cx).0 {
-                                            ui::ToggleState::Selected
-                                        } else {
-                                            ui::ToggleState::Unselected
-                                        },
-                                        cx.listener(move |this, selection, cx| {
-                                            this.telemetry
-                                                .report_app_event("welcome page: toggle vim".to_string());
-                                            this.update_settings::<VimModeSetting>(
-                                                selection,
-                                                cx,
-                                                |setting, value| *setting = Some(value),
-                                            );
-                                        }),
-                                    ))
+                                    .child(
+                                        CheckboxWithLabel::new(
+                                            "enable-vim",
+                                            Label::new("Enable Vim Mode"),
+                                            if VimModeSetting::get_global(cx).0 {
+                                                ui::ToggleState::Selected
+                                            } else {
+                                                ui::ToggleState::Unselected
+                                            },
+                                            cx.listener(move |this, selection, cx| {
+                                                this.telemetry
+                                                    .report_app_event("welcome page: toggle vim".to_string());
+                                                this.update_settings::<VimModeSetting>(
+                                                    selection,
+                                                    cx,
+                                                    |setting, value| *setting = Some(value),
+                                                );
+                                            }),
+                                        )
+                                        .fill()
+                                        .elevation(ElevationIndex::ElevatedSurface),
+                                    )
                                     .child(
                                         IconButton::new("vim-mode", IconName::Info)
                                             .icon_size(IconSize::XSmall)
                                             .icon_color(Color::Muted)
-                                            .tooltip(|cx| Tooltip::text("You can also toggle Vim Mode via the command palette or Editor Controls menu.", cx)),
-                                    )
+                                            .tooltip(|cx| {
+                                                Tooltip::text(
+                                                    "You can also toggle Vim Mode via the command palette or Editor Controls menu.",
+                                                    cx,
+                                                )
+                                            }),
+                                    ),
                             )
-                            .child(CheckboxWithLabel::new(
-                                "enable-crash",
-                                Label::new("Send Crash Reports"),
-                                if TelemetrySettings::get_global(cx).diagnostics {
-                                    ui::ToggleState::Selected
-                                } else {
-                                    ui::ToggleState::Unselected
-                                },
-                                cx.listener(move |this, selection, cx| {
-                                    this.telemetry.report_app_event(
-                                        "welcome page: toggle diagnostic telemetry".to_string(),
-                                    );
-                                    this.update_settings::<TelemetrySettings>(selection, cx, {
-                                        move |settings, value| {
-                                            settings.diagnostics = Some(value);
-
-                                            telemetry::event!("Settings Changed", setting = "diagnostic telemetry", value);
-                                        }
-                                    });
-                                }),
-                            ))
-                            .child(CheckboxWithLabel::new(
-                                "enable-telemetry",
-                                Label::new("Send Telemetry"),
-                                if TelemetrySettings::get_global(cx).metrics {
-                                    ui::ToggleState::Selected
-                                } else {
-                                    ui::ToggleState::Unselected
-                                },
-                                cx.listener(move |this, selection, cx| {
-                                    this.telemetry.report_app_event(
-                                        "welcome page: toggle metric telemetry".to_string(),
-                                    );
-                                    this.update_settings::<TelemetrySettings>(selection, cx, {
-                                        move |settings, value| {
-                                            settings.metrics = Some(value);
-                                            telemetry::event!("Settings Changed", setting = "metric telemetry", value);
-                                        }
-                                    });
-                                }),
-                            )),
+                            .child(
+                                CheckboxWithLabel::new(
+                                    "enable-crash",
+                                    Label::new("Send Crash Reports"),
+                                    if TelemetrySettings::get_global(cx).diagnostics {
+                                        ui::ToggleState::Selected
+                                    } else {
+                                        ui::ToggleState::Unselected
+                                    },
+                                    cx.listener(move |this, selection, cx| {
+                                        this.telemetry.report_app_event(
+                                            "welcome page: toggle diagnostic telemetry".to_string(),
+                                        );
+                                        this.update_settings::<TelemetrySettings>(selection, cx, {
+                                            move |settings, value| {
+                                                settings.diagnostics = Some(value);
+                                                telemetry::event!(
+                                                    "Settings Changed",
+                                                    setting = "diagnostic telemetry",
+                                                    value
+                                                );
+                                            }
+                                        });
+                                    }),
+                                )
+                                .fill()
+                                .elevation(ElevationIndex::ElevatedSurface),
+                            )
+                            .child(
+                                CheckboxWithLabel::new(
+                                    "enable-telemetry",
+                                    Label::new("Send Telemetry"),
+                                    if TelemetrySettings::get_global(cx).metrics {
+                                        ui::ToggleState::Selected
+                                    } else {
+                                        ui::ToggleState::Unselected
+                                    },
+                                    cx.listener(move |this, selection, cx| {
+                                        this.telemetry.report_app_event(
+                                            "welcome page: toggle metric telemetry".to_string(),
+                                        );
+                                        this.update_settings::<TelemetrySettings>(selection, cx, {
+                                            move |settings, value| {
+                                                settings.metrics = Some(value);
+                                                telemetry::event!(
+                                                    "Settings Changed",
+                                                    setting = "metric telemetry",
+                                                    value
+                                                );
+                                            }
+                                        });
+                                    }),
+                                )
+                                .fill()
+                                .elevation(ElevationIndex::ElevatedSurface),
+                            ),
                     ),
             )
     }