1use std::rc::Rc;
2
3use gpui_kit::{
4 App, ElementId, Hsla, IntoElement, ParentElement as _, Pixels, RenderOnce, SharedString,
5 StyleRefinement, Styled, Window,
6 base::{self, Disableable, Interpolate, StyledExt as _, transition},
7 point,
8 prelude::FluentBuilder as _,
9 px,
10};
11
12use crate::{ActiveTheme as _, Theme, ThemeTokens};
13
14type ChangeHandler = Rc<dyn Fn(&bool, &mut Window, &mut App)>;
15
16#[derive(IntoElement)]
40pub struct Switch {
41 id: ElementId,
42 style: StyleRefinement,
43 checked: bool,
44 disabled: bool,
45 accessibility_label: Option<SharedString>,
46 on_change: Option<ChangeHandler>,
47 tab_index: isize,
48 tab_stop: bool,
49}
50
51impl Switch {
52 pub fn new(id: impl Into<ElementId>) -> Self {
54 Self {
55 id: id.into(),
56 style: StyleRefinement::default(),
57 checked: false,
58 disabled: false,
59 accessibility_label: None,
60 on_change: None,
61 tab_index: 0,
62 tab_stop: true,
63 }
64 }
65
66 pub fn checked(mut self, checked: bool) -> Self {
68 self.checked = checked;
69 self
70 }
71
72 pub fn is_checked(&self) -> bool {
74 self.checked
75 }
76
77 pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
80 self.accessibility_label = Some(label.into());
81 self
82 }
83
84 pub fn on_change(mut self, handler: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
87 self.on_change = Some(Rc::new(handler));
88 self
89 }
90
91 pub fn tab_index(mut self, tab_index: isize) -> Self {
93 self.tab_index = tab_index;
94 self
95 }
96
97 pub fn tab_stop(mut self, tab_stop: bool) -> Self {
99 self.tab_stop = tab_stop;
100 self
101 }
102}
103
104impl Disableable for Switch {
105 fn disabled(mut self, disabled: bool) -> Self {
106 self.disabled = disabled;
107 self
108 }
109}
110
111impl Styled for Switch {
112 fn style(&mut self) -> &mut StyleRefinement {
113 &mut self.style
114 }
115}
116
117#[derive(Clone, Copy, Debug, PartialEq)]
122struct Progress {
123 checked: f32,
124 disabled: f32,
125}
126
127impl Progress {
128 fn of(checked: bool, disabled: bool) -> Self {
129 let weight = |on: bool| if on { 1. } else { 0. };
130 Self {
131 checked: weight(checked),
132 disabled: weight(disabled),
133 }
134 }
135}
136
137impl Interpolate for Progress {
138 fn interpolate(&self, target: &Self, progress: f32) -> Self {
139 let lerp = |from: f32, to: f32| from + (to - from) * progress;
140 Self {
141 checked: lerp(self.checked, target.checked),
142 disabled: lerp(self.disabled, target.disabled),
143 }
144 }
145}
146
147#[derive(Clone, Copy, Debug, PartialEq)]
150struct Surface {
151 track: Hsla,
152 thumb_offset: Pixels,
153}
154
155impl Interpolate for Surface {
156 fn interpolate(&self, target: &Self, progress: f32) -> Self {
157 Self {
158 track: self.track.interpolate(&target.track, progress),
159 thumb_offset: self
160 .thumb_offset
161 .interpolate(&target.thumb_offset, progress),
162 }
163 }
164}
165
166impl Surface {
167 fn of(checked: bool, disabled: bool, theme: &ThemeTokens) -> Self {
170 let metrics = &theme.metrics;
171 let track = if checked {
172 theme.switch_track_on
173 } else {
174 theme.switch_track_off
175 };
176 Self {
177 track: if disabled {
178 track.opacity(theme.disabled_opacity)
179 } else {
180 track
181 },
182 thumb_offset: if checked {
183 metrics.switch_track_width
184 - metrics.switch_thumb_inset * 2.
185 - metrics.switch_thumb_size
186 } else {
187 px(0.)
188 },
189 }
190 }
191
192 fn at(progress: Progress, theme: &ThemeTokens) -> Self {
195 let checked = |disabled| {
196 Self::of(false, disabled, theme)
197 .interpolate(&Self::of(true, disabled, theme), progress.checked)
198 };
199 checked(false).interpolate(&checked(true), progress.disabled)
200 }
201}
202
203fn thumb_color(disabled: bool, theme: &ThemeTokens) -> Hsla {
209 if disabled {
210 theme
213 .base
214 .colors
215 .surface
216 .blend(theme.switch_thumb.opacity(theme.disabled_opacity))
217 } else {
218 theme.switch_thumb
219 }
220}
221
222struct Look {
224 thumb: Hsla,
225 width: Pixels,
226 height: Pixels,
227 thumb_size: Pixels,
228 inset: Pixels,
229 hit_height: Pixels,
230 ring: Hsla,
231 ring_spread: Pixels,
232 radius: Pixels,
233}
234
235impl RenderOnce for Switch {
236 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
237 let settings = Theme::global(cx);
238 let slide = settings.motion.slide_transition();
239 let pointer_cursors = settings.pointer_cursors;
240 let checked = self.checked;
241 let disabled = self.disabled;
242 let progress = transition(
243 ElementId::NamedChild(self.id.clone().into(), "progress".into()),
244 Progress::of(checked, disabled),
245 slide,
246 window,
247 cx,
248 );
249 let (look, surface) = {
250 let theme = cx.theme();
251 let metrics = &theme.metrics;
252 (
253 Look {
254 thumb: thumb_color(disabled, theme),
255 width: metrics.switch_track_width,
256 height: metrics.switch_track_height,
257 thumb_size: metrics.switch_thumb_size,
258 inset: metrics.switch_thumb_inset,
259 hit_height: if theme.touch {
262 metrics.control_md
263 } else {
264 metrics.switch_track_height
265 },
266 ring: theme.focus_ring(),
267 ring_spread: metrics.focus_ring,
268 radius: theme.radius_full(),
269 },
270 Surface::at(progress, theme),
271 )
272 };
273
274 let focus_handle = window
277 .use_keyed_state(
278 ElementId::NamedChild(self.id.clone().into(), "focus".into()),
279 cx,
280 |_, cx| cx.focus_handle(),
281 )
282 .read(cx)
283 .clone();
284 let focus_visible = focus_handle.is_focused(window) && window.last_input_was_keyboard();
285
286 let track_id = ElementId::NamedChild(self.id.clone().into(), "track".into());
287 let on_change = self.on_change;
288 base::Switch::new(self.id)
289 .track_focus(&focus_handle)
290 .checked(checked)
291 .disabled(disabled)
292 .tab_index(self.tab_index)
293 .tab_stop(self.tab_stop)
294 .when_some(self.accessibility_label, |this, label| {
295 this.accessibility_label(label)
296 })
297 .flex()
298 .flex_shrink_0()
299 .items_center()
300 .justify_center()
301 .w(look.width)
302 .h(look.hit_height)
303 .map(|this| {
304 if !disabled && pointer_cursors {
305 this.cursor_pointer()
306 } else {
307 this.cursor_default()
308 }
309 })
310 .refine_style(&self.style)
311 .child(
312 base::SwitchTrack::new(track_id)
313 .checked(checked)
314 .disabled(disabled)
315 .flex()
316 .items_center()
317 .w(look.width)
318 .h(look.height)
319 .p(look.inset)
320 .rounded(look.radius)
321 .bg(surface.track)
322 .when(focus_visible, |this| {
323 this.shadow(vec![gpui_kit::BoxShadow {
324 color: look.ring,
325 offset: point(px(0.), px(0.)),
326 blur_radius: px(0.),
327 spread_radius: look.ring_spread,
328 inset: false,
329 }])
330 })
331 .child(
332 base::SwitchThumb::new(checked)
333 .disabled(disabled)
334 .flex_shrink_0()
335 .size(look.thumb_size)
336 .ml(surface.thumb_offset)
337 .rounded(look.radius)
338 .bg(look.thumb),
339 ),
340 )
341 .when_some(on_change.filter(|_| !disabled), |this, on_change| {
342 this.on_change(move |next, _, window, cx| on_change(&next, window, cx))
343 })
344 }
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350 use crate::theme::{ThemeConfig, to_hex};
351 use gpui_kit::base::ThemeAppearance;
352
353 #[test]
354 fn the_thumb_travels_the_track_minus_the_insets() {
355 let theme = crate::theme::test_tokens(&ThemeConfig::dark(), ThemeAppearance::Dark);
356 let off = Surface::of(false, false, &theme);
357 let on = Surface::of(true, false, &theme);
358 assert_eq!(off.thumb_offset, px(0.));
359 assert_eq!(on.thumb_offset, px(12.), "32 - 2 * 2 - 16");
360 assert_eq!(to_hex(on.track), "#539af8", "the accent");
361 let light = crate::theme::test_tokens(&ThemeConfig::light(), ThemeAppearance::Light);
362 assert_eq!(
363 to_hex(Surface::of(true, false, &light).track),
364 "#339cff",
365 "the accent on light"
366 );
367 assert_eq!(off.track, theme.switch_track_off);
368 let mid = off.interpolate(&on, 0.5);
369 assert_eq!(mid.thumb_offset, px(6.));
370 }
371
372 #[test]
373 fn whole_progress_gives_the_look_of_that_state() {
374 let theme = crate::theme::test_tokens(&ThemeConfig::dark(), ThemeAppearance::Dark);
375 for checked in [false, true] {
376 for disabled in [false, true] {
377 assert_eq!(
378 Surface::at(Progress::of(checked, disabled), &theme),
379 Surface::of(checked, disabled, &theme),
380 "checked {checked}, disabled {disabled}"
381 );
382 }
383 }
384 let half = Surface::at(
385 Progress {
386 checked: 0.5,
387 disabled: 0.,
388 },
389 &theme,
390 );
391 assert_eq!(half.thumb_offset, px(6.));
392 }
393
394 #[test]
395 fn a_disabled_switch_fades_the_track_and_flattens_the_thumb() {
396 let theme = crate::theme::test_tokens(&ThemeConfig::dark(), ThemeAppearance::Dark);
399 let on = Surface::of(true, true, &theme);
400 assert_eq!(on.track.a, theme.disabled_opacity);
401 assert_eq!(to_hex(on.track.alpha(1.)), "#539af8");
402 assert_eq!(thumb_color(false, &theme).a, 1.);
403 let thumb = thumb_color(true, &theme);
404 assert_eq!(thumb.a, 1., "flat, so the track does not show through");
405 assert_eq!(to_hex(thumb), "#9c9c9c");
406 }
407
408 #[test]
409 fn builders_read_back() {
410 let switch = Switch::new("s").checked(true);
411 assert!(switch.is_checked());
412 assert!(!Switch::new("s").is_checked());
413 }
414}