use std::rc::Rc;
use gpui_kit::{
App, ElementId, Hsla, IntoElement, ParentElement as _, Pixels, RenderOnce, SharedString,
StyleRefinement, Styled, Window,
base::{self, Disableable, Interpolate, StyledExt as _, transition},
point,
prelude::FluentBuilder as _,
px,
};
use crate::{ActiveTheme as _, Theme, ThemeTokens};
type ChangeHandler = Rc<dyn Fn(&bool, &mut Window, &mut App)>;
#[derive(IntoElement)]
pub struct Switch {
id: ElementId,
style: StyleRefinement,
checked: bool,
disabled: bool,
accessibility_label: Option<SharedString>,
on_change: Option<ChangeHandler>,
tab_index: isize,
tab_stop: bool,
}
impl Switch {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
style: StyleRefinement::default(),
checked: false,
disabled: false,
accessibility_label: None,
on_change: None,
tab_index: 0,
tab_stop: true,
}
}
pub fn checked(mut self, checked: bool) -> Self {
self.checked = checked;
self
}
pub fn is_checked(&self) -> bool {
self.checked
}
pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
self.accessibility_label = Some(label.into());
self
}
pub fn on_change(mut self, handler: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
pub fn tab_index(mut self, tab_index: isize) -> Self {
self.tab_index = tab_index;
self
}
pub fn tab_stop(mut self, tab_stop: bool) -> Self {
self.tab_stop = tab_stop;
self
}
}
impl Disableable for Switch {
fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
impl Styled for Switch {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct Progress {
checked: f32,
disabled: f32,
}
impl Progress {
fn of(checked: bool, disabled: bool) -> Self {
let weight = |on: bool| if on { 1. } else { 0. };
Self {
checked: weight(checked),
disabled: weight(disabled),
}
}
}
impl Interpolate for Progress {
fn interpolate(&self, target: &Self, progress: f32) -> Self {
let lerp = |from: f32, to: f32| from + (to - from) * progress;
Self {
checked: lerp(self.checked, target.checked),
disabled: lerp(self.disabled, target.disabled),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct Surface {
track: Hsla,
thumb_offset: Pixels,
}
impl Interpolate for Surface {
fn interpolate(&self, target: &Self, progress: f32) -> Self {
Self {
track: self.track.interpolate(&target.track, progress),
thumb_offset: self
.thumb_offset
.interpolate(&target.thumb_offset, progress),
}
}
}
impl Surface {
fn of(checked: bool, disabled: bool, theme: &ThemeTokens) -> Self {
let metrics = &theme.metrics;
let track = if checked {
theme.switch_track_on
} else {
theme.switch_track_off
};
Self {
track: if disabled {
track.opacity(theme.disabled_opacity)
} else {
track
},
thumb_offset: if checked {
metrics.switch_track_width
- metrics.switch_thumb_inset * 2.
- metrics.switch_thumb_size
} else {
px(0.)
},
}
}
fn at(progress: Progress, theme: &ThemeTokens) -> Self {
let checked = |disabled| {
Self::of(false, disabled, theme)
.interpolate(&Self::of(true, disabled, theme), progress.checked)
};
checked(false).interpolate(&checked(true), progress.disabled)
}
}
fn thumb_color(disabled: bool, theme: &ThemeTokens) -> Hsla {
if disabled {
theme
.base
.colors
.surface
.blend(theme.switch_thumb.opacity(theme.disabled_opacity))
} else {
theme.switch_thumb
}
}
struct Look {
thumb: Hsla,
width: Pixels,
height: Pixels,
thumb_size: Pixels,
inset: Pixels,
hit_height: Pixels,
ring: Hsla,
ring_spread: Pixels,
radius: Pixels,
}
impl RenderOnce for Switch {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let settings = Theme::global(cx);
let slide = settings.motion.slide_transition();
let pointer_cursors = settings.pointer_cursors;
let checked = self.checked;
let disabled = self.disabled;
let progress = transition(
ElementId::NamedChild(self.id.clone().into(), "progress".into()),
Progress::of(checked, disabled),
slide,
window,
cx,
);
let (look, surface) = {
let theme = cx.theme();
let metrics = &theme.metrics;
(
Look {
thumb: thumb_color(disabled, theme),
width: metrics.switch_track_width,
height: metrics.switch_track_height,
thumb_size: metrics.switch_thumb_size,
inset: metrics.switch_thumb_inset,
hit_height: if theme.touch {
metrics.control_md
} else {
metrics.switch_track_height
},
ring: theme.focus_ring(),
ring_spread: metrics.focus_ring,
radius: theme.radius_full(),
},
Surface::at(progress, theme),
)
};
let focus_handle = window
.use_keyed_state(
ElementId::NamedChild(self.id.clone().into(), "focus".into()),
cx,
|_, cx| cx.focus_handle(),
)
.read(cx)
.clone();
let focus_visible = focus_handle.is_focused(window) && window.last_input_was_keyboard();
let track_id = ElementId::NamedChild(self.id.clone().into(), "track".into());
let on_change = self.on_change;
base::Switch::new(self.id)
.track_focus(&focus_handle)
.checked(checked)
.disabled(disabled)
.tab_index(self.tab_index)
.tab_stop(self.tab_stop)
.when_some(self.accessibility_label, |this, label| {
this.accessibility_label(label)
})
.flex()
.flex_shrink_0()
.items_center()
.justify_center()
.w(look.width)
.h(look.hit_height)
.map(|this| {
if !disabled && pointer_cursors {
this.cursor_pointer()
} else {
this.cursor_default()
}
})
.refine_style(&self.style)
.child(
base::SwitchTrack::new(track_id)
.checked(checked)
.disabled(disabled)
.flex()
.items_center()
.w(look.width)
.h(look.height)
.p(look.inset)
.rounded(look.radius)
.bg(surface.track)
.when(focus_visible, |this| {
this.shadow(vec![gpui_kit::BoxShadow {
color: look.ring,
offset: point(px(0.), px(0.)),
blur_radius: px(0.),
spread_radius: look.ring_spread,
inset: false,
}])
})
.child(
base::SwitchThumb::new(checked)
.disabled(disabled)
.flex_shrink_0()
.size(look.thumb_size)
.ml(surface.thumb_offset)
.rounded(look.radius)
.bg(look.thumb),
),
)
.when_some(on_change.filter(|_| !disabled), |this, on_change| {
this.on_change(move |next, _, window, cx| on_change(&next, window, cx))
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::theme::{ThemeConfig, to_hex};
use gpui_kit::base::ThemeAppearance;
#[test]
fn the_thumb_travels_the_track_minus_the_insets() {
let theme = crate::theme::test_tokens(&ThemeConfig::dark(), ThemeAppearance::Dark);
let off = Surface::of(false, false, &theme);
let on = Surface::of(true, false, &theme);
assert_eq!(off.thumb_offset, px(0.));
assert_eq!(on.thumb_offset, px(12.), "32 - 2 * 2 - 16");
assert_eq!(to_hex(on.track), "#539af8", "the accent");
let light = crate::theme::test_tokens(&ThemeConfig::light(), ThemeAppearance::Light);
assert_eq!(
to_hex(Surface::of(true, false, &light).track),
"#339cff",
"the accent on light"
);
assert_eq!(off.track, theme.switch_track_off);
let mid = off.interpolate(&on, 0.5);
assert_eq!(mid.thumb_offset, px(6.));
}
#[test]
fn whole_progress_gives_the_look_of_that_state() {
let theme = crate::theme::test_tokens(&ThemeConfig::dark(), ThemeAppearance::Dark);
for checked in [false, true] {
for disabled in [false, true] {
assert_eq!(
Surface::at(Progress::of(checked, disabled), &theme),
Surface::of(checked, disabled, &theme),
"checked {checked}, disabled {disabled}"
);
}
}
let half = Surface::at(
Progress {
checked: 0.5,
disabled: 0.,
},
&theme,
);
assert_eq!(half.thumb_offset, px(6.));
}
#[test]
fn a_disabled_switch_fades_the_track_and_flattens_the_thumb() {
let theme = crate::theme::test_tokens(&ThemeConfig::dark(), ThemeAppearance::Dark);
let on = Surface::of(true, true, &theme);
assert_eq!(on.track.a, theme.disabled_opacity);
assert_eq!(to_hex(on.track.alpha(1.)), "#539af8");
assert_eq!(thumb_color(false, &theme).a, 1.);
let thumb = thumb_color(true, &theme);
assert_eq!(thumb.a, 1., "flat, so the track does not show through");
assert_eq!(to_hex(thumb), "#9c9c9c");
}
#[test]
fn builders_read_back() {
let switch = Switch::new("s").checked(true);
assert!(switch.is_checked());
assert!(!Switch::new("s").is_checked());
}
}