ui: Update Checkbox design (#22794)

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
This commit is contained in:
Nate Butler 2025-01-07 16:11:39 -05:00 committed by GitHub
parent 7a66c764b4
commit c4b470685d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 363 additions and 99 deletions

View file

@ -1,12 +1,13 @@
#![allow(missing_docs)] use gpui::{div, hsla, prelude::*, ElementId, Hsla, IntoElement, Styled, WindowContext};
use gpui::{div, prelude::*, ElementId, IntoElement, Styled, WindowContext};
use std::sync::Arc; use std::sync::Arc;
use crate::prelude::*;
use crate::utils::is_light; use crate::utils::is_light;
use crate::{prelude::*, ElevationIndex};
use crate::{Color, Icon, IconName, ToggleState}; 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 /// Creates a new checkbox
pub fn checkbox(id: impl Into<ElementId>, toggle_state: ToggleState) -> Checkbox { pub fn checkbox(id: impl Into<ElementId>, toggle_state: ToggleState) -> Checkbox {
Checkbox::new(id, toggle_state) 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) 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 /// # Checkbox
/// ///
/// Checkboxes are used for multiple choices, not for mutually exclusive choices. /// Checkboxes are used for multiple choices, not for mutually exclusive choices.
@ -28,23 +42,30 @@ pub struct Checkbox {
toggle_state: ToggleState, toggle_state: ToggleState,
disabled: bool, disabled: bool,
on_click: Option<Box<dyn Fn(&ToggleState, &mut WindowContext) + 'static>>, on_click: Option<Box<dyn Fn(&ToggleState, &mut WindowContext) + 'static>>,
filled: bool,
style: ToggleStyle,
} }
impl Checkbox { impl Checkbox {
/// Creates a new [`Checkbox`]
pub fn new(id: impl Into<ElementId>, checked: ToggleState) -> Self { pub fn new(id: impl Into<ElementId>, checked: ToggleState) -> Self {
Self { Self {
id: id.into(), id: id.into(),
toggle_state: checked, toggle_state: checked,
disabled: false, disabled: false,
on_click: None, on_click: None,
filled: false,
style: ToggleStyle::default(),
} }
} }
/// Sets the disabled state of the [`Checkbox`]
pub fn disabled(mut self, disabled: bool) -> Self { pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled; self.disabled = disabled;
self self
} }
/// Binds a handler to the [`Checkbox`] that will be called when clicked
pub fn on_click( pub fn on_click(
mut self, mut self,
handler: impl Fn(&ToggleState, &mut WindowContext) + 'static, handler: impl Fn(&ToggleState, &mut WindowContext) + 'static,
@ -52,12 +73,55 @@ impl Checkbox {
self.on_click = Some(Box::new(handler)); self.on_click = Some(Box::new(handler));
self 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 { impl RenderOnce for Checkbox {
fn render(self, cx: &mut WindowContext) -> impl IntoElement { fn render(self, cx: &mut WindowContext) -> impl IntoElement {
let group_id = format!("checkbox_group_{:?}", self.id); let group_id = format!("checkbox_group_{:?}", self.id);
let icon = match self.toggle_state { let icon = match self.toggle_state {
ToggleState::Selected => Some(Icon::new(IconName::Check).size(IconSize::Small).color( ToggleState::Selected => Some(Icon::new(IconName::Check).size(IconSize::Small).color(
if self.disabled { if self.disabled {
@ -78,23 +142,8 @@ impl RenderOnce for Checkbox {
ToggleState::Unselected => None, ToggleState::Unselected => None,
}; };
let selected = self.toggle_state == ToggleState::Selected let bg_color = self.bg_color(cx);
|| self.toggle_state == ToggleState::Indeterminate; let border_color = self.border_color(cx);
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,
),
};
h_flex() h_flex()
.id(self.id) .id(self.id)
@ -137,9 +186,12 @@ pub struct CheckboxWithLabel {
label: Label, label: Label,
checked: ToggleState, checked: ToggleState,
on_click: Arc<dyn Fn(&ToggleState, &mut WindowContext) + 'static>, on_click: Arc<dyn Fn(&ToggleState, &mut WindowContext) + 'static>,
filled: bool,
style: ToggleStyle,
} }
impl CheckboxWithLabel { impl CheckboxWithLabel {
/// Creates a checkbox with an attached label
pub fn new( pub fn new(
id: impl Into<ElementId>, id: impl Into<ElementId>,
label: Label, label: Label,
@ -151,20 +203,45 @@ impl CheckboxWithLabel {
label, label,
checked, checked,
on_click: Arc::new(on_click), 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 { impl RenderOnce for CheckboxWithLabel {
fn render(self, cx: &mut WindowContext) -> impl IntoElement { fn render(self, cx: &mut WindowContext) -> impl IntoElement {
h_flex() h_flex()
.gap(DynamicSpacing::Base08.rems(cx)) .gap(DynamicSpacing::Base08.rems(cx))
.child(Checkbox::new(self.id.clone(), self.checked).on_click({ .child(
let on_click = self.on_click.clone(); Checkbox::new(self.id.clone(), self.checked)
move |checked, cx| { .style(self.style)
(on_click)(checked, cx); .when(self.filled, Checkbox::fill)
} .on_click({
})) let on_click = self.on_click.clone();
move |checked, cx| {
(on_click)(checked, cx);
}
}),
)
.child( .child(
div() div()
.id(SharedString::from(format!("{}-label", self.id))) .id(SharedString::from(format!("{}-label", self.id)))
@ -188,6 +265,7 @@ pub struct Switch {
} }
impl Switch { impl Switch {
/// Creates a new [`Switch`]
pub fn new(id: impl Into<ElementId>, state: ToggleState) -> Self { pub fn new(id: impl Into<ElementId>, state: ToggleState) -> Self {
Self { Self {
id: id.into(), id: id.into(),
@ -197,11 +275,13 @@ impl Switch {
} }
} }
/// Sets the disabled state of the [`Switch`]
pub fn disabled(mut self, disabled: bool) -> Self { pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled; self.disabled = disabled;
self self
} }
/// Binds a handler to the [`Switch`] that will be called when clicked
pub fn on_click( pub fn on_click(
mut self, mut self,
handler: impl Fn(&ToggleState, &mut WindowContext) + 'static, handler: impl Fn(&ToggleState, &mut WindowContext) + 'static,
@ -282,8 +362,8 @@ impl RenderOnce for Switch {
} }
} }
/// A [`Switch`] that has a [`Label`].
#[derive(IntoElement)] #[derive(IntoElement)]
/// A [`Switch`] that has a [`Label`].
pub struct SwitchWithLabel { pub struct SwitchWithLabel {
id: ElementId, id: ElementId,
label: Label, label: Label,
@ -292,6 +372,7 @@ pub struct SwitchWithLabel {
} }
impl SwitchWithLabel { impl SwitchWithLabel {
/// Creates a switch with an attached label
pub fn new( pub fn new(
id: impl Into<ElementId>, id: impl Into<ElementId>,
label: Label, 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( example_group_with_title(
"Disabled", "Disabled",
vec![ 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),
),
],
),
] ]
} }
} }

View file

@ -22,12 +22,8 @@ pub enum ElevationIndex {
EditorSurface, EditorSurface,
/// A surface that is elevated above the primary surface. but below washes, models, and dragged elements. /// A surface that is elevated above the primary surface. but below washes, models, and dragged elements.
ElevatedSurface, 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. /// A surface above the [ElevationIndex::Wash] that is used for dialogs, alerts, modals, etc.
ModalSurface, 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 { impl Display for ElevationIndex {
@ -37,9 +33,7 @@ impl Display for ElevationIndex {
ElevationIndex::Surface => write!(f, "Surface"), ElevationIndex::Surface => write!(f, "Surface"),
ElevationIndex::EditorSurface => write!(f, "Editor Surface"), ElevationIndex::EditorSurface => write!(f, "Editor Surface"),
ElevationIndex::ElevatedSurface => write!(f, "Elevated Surface"), ElevationIndex::ElevatedSurface => write!(f, "Elevated Surface"),
ElevationIndex::Wash => write!(f, "Wash"),
ElevationIndex::ModalSurface => write!(f, "Modal Surface"), 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::Surface => cx.theme().colors().surface_background,
ElevationIndex::EditorSurface => cx.theme().colors().editor_background, ElevationIndex::EditorSurface => cx.theme().colors().editor_background,
ElevationIndex::ElevatedSurface => cx.theme().colors().elevated_surface_background, ElevationIndex::ElevatedSurface => cx.theme().colors().elevated_surface_background,
ElevationIndex::Wash => gpui::transparent_black(),
ElevationIndex::ModalSurface => cx.theme().colors().elevated_surface_background, 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,
} }
} }
} }

View file

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