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, 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),
),
],
),
]
}
}

View file

@ -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,
}
}
}