1use super::InteractionPreview;
4use super::button::ButtonFocusRing;
5use crate::{
6 color::ColorRole,
7 icons::IconName,
8 motion::{self, MotionToken},
9 theme::color,
10 tokens::{state, typography::rem},
11};
12use gpui::{prelude::*, *};
13use std::rc::Rc;
14
15#[derive(gpui::IntoElement)]
20pub struct MaterialSwitch {
21 id: ElementId,
22 label: SharedString,
23 checked: bool,
24 disabled: bool,
25 error: bool,
26 icons: SwitchIcons,
27 preview: Option<InteractionPreview>,
28 on_change: Option<SwitchChange>,
29}
30type SwitchChange = Rc<dyn Fn(&bool, &mut Window, &mut App)>;
31
32#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
34pub enum SwitchIcons {
35 #[default]
37 None,
38 Selected,
40 Both,
43}
44
45impl SwitchIcons {
46 fn shows(self, checked: bool) -> bool {
47 match self {
48 Self::None => false,
49 Self::Selected => checked,
50 Self::Both => true,
51 }
52 }
53}
54
55impl MaterialSwitch {
56 pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
57 Self {
58 id: id.into(),
59 label: label.into(),
60 checked: false,
61 disabled: false,
62 error: false,
63 icons: SwitchIcons::None,
64 preview: None,
65 on_change: None,
66 }
67 }
68 pub fn preview_state(mut self, state: InteractionPreview) -> Self {
74 self.preview = Some(state);
75 self
76 }
77 pub fn checked(mut self, checked: bool) -> Self {
78 self.checked = checked;
79 self
80 }
81 pub fn disabled(mut self, disabled: bool) -> Self {
82 self.disabled = disabled;
83 self
84 }
85 pub fn error(mut self, error: bool) -> Self {
89 self.error = error;
90 self
91 }
92 pub fn icons(mut self, icons: SwitchIcons) -> Self {
94 self.icons = icons;
95 self
96 }
97 pub fn on_change(mut self, f: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
98 self.on_change = Some(Rc::new(f));
99 self
100 }
101}
102const TRACK_WIDTH: f32 = 52.;
106const TRACK_HEIGHT: f32 = 32.;
107const TRACK_OUTLINE: f32 = 2.;
108const HANDLE_UNSELECTED: f32 = 16.;
111const HANDLE_SELECTED: f32 = 24.;
112const HANDLE_PRESSED: f32 = 28.;
113const STATE_LAYER: f32 = 40.;
114const ICON: f32 = 16.;
115const CENTER_UNSELECTED: f32 = 16.;
118const CENTER_SELECTED: f32 = 36.;
119
120fn handle_size(checked: bool, icon: bool, pressed: bool) -> f32 {
122 if pressed {
123 HANDLE_PRESSED
124 } else if checked || icon {
125 HANDLE_SELECTED
126 } else {
127 HANDLE_UNSELECTED
128 }
129}
130
131fn handle_left(t: f32, size: f32) -> f32 {
136 let t = if t.is_finite() { t } else { 0. };
137 let size = size.clamp(0., HANDLE_PRESSED);
138 let center = CENTER_UNSELECTED + (CENTER_SELECTED - CENTER_UNSELECTED) * t;
139 (center - size / 2.)
140 .max(TRACK_OUTLINE)
141 .min(TRACK_WIDTH - TRACK_OUTLINE - size)
142}
143
144#[derive(Clone, Copy, Debug, PartialEq)]
146struct SwitchPaint {
147 track: Rgba,
148 outline: Rgba,
149 handle: Rgba,
150 handle_active: Rgba,
152 icon: Rgba,
153 layer: Rgba,
155}
156
157fn switch_paint(checked: bool, disabled: bool, error: bool) -> SwitchPaint {
169 let role = color;
170 let container = |r| role(r).opacity(state::DISABLED_CONTAINER);
171 if error && !disabled {
172 return if checked {
173 SwitchPaint {
174 track: role(ColorRole::ERROR),
175 outline: role(ColorRole::ERROR),
176 handle: role(ColorRole::ON_ERROR),
177 handle_active: role(ColorRole::ERROR_CONTAINER),
178 icon: role(ColorRole::ON_ERROR_CONTAINER),
179 layer: role(ColorRole::ERROR),
180 }
181 } else {
182 SwitchPaint {
183 track: role(ColorRole::SURFACE_CONTAINER_HIGHEST),
184 outline: role(ColorRole::ERROR),
185 handle: role(ColorRole::ERROR),
186 handle_active: role(ColorRole::ON_ERROR_CONTAINER),
187 icon: role(ColorRole::SURFACE_CONTAINER_HIGHEST),
188 layer: role(ColorRole::ERROR),
189 }
190 };
191 }
192 match (checked, disabled) {
193 (true, false) => SwitchPaint {
194 track: role(ColorRole::PRIMARY),
195 outline: role(ColorRole::PRIMARY),
196 handle: role(ColorRole::ON_PRIMARY),
197 handle_active: role(ColorRole::PRIMARY_CONTAINER),
198 icon: role(ColorRole::ON_PRIMARY_CONTAINER),
199 layer: role(ColorRole::PRIMARY),
200 },
201 (false, false) => SwitchPaint {
202 track: role(ColorRole::SURFACE_CONTAINER_HIGHEST),
203 outline: role(ColorRole::OUTLINE),
204 handle: role(ColorRole::OUTLINE),
205 handle_active: role(ColorRole::ON_SURFACE_VARIANT),
206 icon: role(ColorRole::SURFACE_CONTAINER_HIGHEST),
207 layer: role(ColorRole::ON_SURFACE),
208 },
209 (true, true) => SwitchPaint {
210 track: container(ColorRole::ON_SURFACE),
211 outline: Rgba {
212 a: 0.,
213 ..role(ColorRole::ON_SURFACE)
214 },
215 handle: role(ColorRole::SURFACE),
216 handle_active: role(ColorRole::SURFACE),
217 icon: state::disabled(role(ColorRole::ON_SURFACE)),
218 layer: role(ColorRole::ON_SURFACE),
219 },
220 (false, true) => {
221 let handle = state::disabled(role(ColorRole::ON_SURFACE));
222 SwitchPaint {
223 track: container(ColorRole::SURFACE_CONTAINER_HIGHEST),
224 outline: container(ColorRole::ON_SURFACE),
225 handle,
226 handle_active: handle,
227 icon: state::disabled(role(ColorRole::SURFACE_CONTAINER_HIGHEST)),
228 layer: role(ColorRole::ON_SURFACE),
229 }
230 }
231 }
232}
233
234const SWITCH_GROUP: &str = "material-switch";
236
237impl RenderOnce for MaterialSwitch {
238 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
239 let focus = window
240 .use_keyed_state((self.id.clone(), "focus"), cx, |_, cx| cx.focus_handle())
241 .read(cx)
242 .clone();
243 let press_state = window.use_keyed_state((self.id.clone(), "pressed"), cx, |_, _| false);
244 let preview = self.preview;
245 let disabled = self.disabled || preview.is_some_and(|p| p.is_disabled());
246 let enabled = !disabled;
247 let live = enabled && preview.is_none();
248 let forced = |state| enabled && preview == Some(state);
249 let keyboard_focus = forced(InteractionPreview::Focused)
250 || (live && focus.is_focused(window) && window.last_input_was_keyboard());
251 if !live && *press_state.read(cx) {
255 press_state.update(cx, |pressed, _| *pressed = false);
256 }
257 let pressed = forced(InteractionPreview::Pressed) || (live && *press_state.read(cx));
258 let icon = self.icons.shows(self.checked);
259 let t = motion::selection_value(
262 (self.id.clone(), "thumb"),
263 if self.checked { 1. } else { 0. },
264 window,
265 cx,
266 );
267 let target = handle_size(self.checked, icon, pressed);
268 let size = if preview.is_some() {
269 target
270 } else {
271 motion::token_value(
272 (self.id.clone(), "handle-size"),
273 target,
274 MotionToken::FastSpatial,
275 window,
276 cx,
277 )
278 }
279 .clamp(0., HANDLE_PRESSED);
280 let left = handle_left(t, size);
281 let paint = switch_paint(self.checked, disabled, self.error);
282 let clear = Rgba {
283 a: 0.,
284 ..paint.layer
285 };
286 let (hover, press, focused) = (
287 state::hovered(clear, paint.layer),
288 state::pressed(clear, paint.layer),
289 state::layer(clear, paint.layer, state::FOCUS),
290 );
291 let active_handle = keyboard_focus || pressed || forced(InteractionPreview::Hovered);
292 let center = left + size / 2.;
295 let layer_origin = (
296 center - STATE_LAYER / 2. - TRACK_OUTLINE,
297 TRACK_HEIGHT / 2. - STATE_LAYER / 2. - TRACK_OUTLINE,
298 );
299 let glyph = if self.checked {
300 IconName::Check
301 } else {
302 IconName::Close
303 };
304 let action = self.on_change;
305 let release = press_state.clone();
306 let release_out = press_state.clone();
307 let switch = gpui_base::Switch::new(self.id)
308 .track_focus(&focus)
309 .accessibility_label(self.label)
310 .checked(self.checked)
311 .disabled(disabled)
312 .tab_stop(preview.is_none())
313 .on_change(move |value, _, window, cx| {
314 if let Some(f) = &action {
315 f(&value, window, cx);
316 }
317 })
318 .when(live, |switch| {
319 switch
320 .on_mouse_down(MouseButton::Left, move |_, window, cx| {
321 press_state.update(cx, |pressed, _| *pressed = true);
322 window.refresh();
323 })
324 .on_mouse_up(MouseButton::Left, move |_, window, cx| {
325 release.update(cx, |pressed, _| *pressed = false);
326 window.refresh();
327 })
328 .on_mouse_up_out(MouseButton::Left, move |_, window, cx| {
329 release_out.update(cx, |pressed, _| *pressed = false);
330 window.refresh();
331 })
332 })
333 .group(SWITCH_GROUP)
334 .relative()
335 .w(rem(TRACK_WIDTH))
336 .h(rem(TRACK_HEIGHT))
337 .rounded_full()
338 .border(rem(TRACK_OUTLINE))
339 .border_color(paint.outline)
340 .bg(paint.track)
341 .child(
342 div()
343 .id("state-layer")
344 .absolute()
345 .left(rem(layer_origin.0))
346 .top(rem(layer_origin.1))
347 .size(rem(STATE_LAYER))
348 .flex()
349 .items_center()
350 .justify_center()
351 .rounded_full()
352 .when(keyboard_focus, |layer| layer.bg(focused))
353 .when(forced(InteractionPreview::Hovered), |layer| layer.bg(hover))
354 .when(pressed, |layer| layer.bg(press))
355 .when(live && !pressed && !keyboard_focus, |layer| {
356 layer.group_hover(SWITCH_GROUP, move |style| style.bg(hover))
357 })
358 .child(
359 div()
360 .id("handle")
361 .when(cfg!(test), |handle| {
362 handle.debug_selector(|| "switch-handle".into())
363 })
364 .flex()
365 .flex_shrink_0()
366 .items_center()
367 .justify_center()
368 .size(rem(size))
369 .rounded_full()
370 .bg(if active_handle {
371 paint.handle_active
372 } else {
373 paint.handle
374 })
375 .when(live, |handle| {
376 let active = paint.handle_active;
377 handle
380 .cursor_pointer()
381 .group_hover(SWITCH_GROUP, move |style| style.bg(active))
382 })
383 .when(icon, |handle| {
384 handle.child(glyph.icon().size(rem(ICON)).color(paint.icon))
385 }),
386 ),
387 );
388 ButtonFocusRing::new(switch, focus, enabled, rem(TRACK_HEIGHT / 2.))
389 .forced(forced(InteractionPreview::Focused))
390 }
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396 use core::prelude::v1::test;
397
398 #[test]
399 fn the_handle_stays_inside_the_outline_through_overshoot() {
400 for t in [-0.3, -0.05, 0., 0.25, 0.5, 0.75, 1., 1.05, 1.3] {
401 for size in [14., 16., 24., 28., 29.] {
402 let left = handle_left(t, size);
403 let size = f32::min(size, HANDLE_PRESSED);
404 assert!(left >= TRACK_OUTLINE, "{t}: inside the leading outline");
405 assert!(
406 left + size <= TRACK_WIDTH - TRACK_OUTLINE,
407 "{t}: inside the trailing outline"
408 );
409 }
410 }
411 assert_eq!(handle_left(0., 16.), 8.);
412 assert_eq!(handle_left(1., 24.), 24.);
413 assert_eq!(handle_left(0., 24.), 4., "an unselected icon handle");
414 }
415
416 #[test]
417 fn spring_overshoot_moves_the_handle_past_rest() {
418 assert!(handle_left(1.05, 24.) > handle_left(1., 24.) || handle_left(1., 24.) == 24.);
419 assert!(handle_left(-0.05, 16.) < handle_left(0., 16.));
420 }
421
422 #[test]
423 fn handle_sizes_follow_the_m3_spec() {
424 assert_eq!(handle_size(false, false, false), 16.);
425 assert_eq!(handle_size(false, true, false), 24.);
426 assert_eq!(handle_size(true, false, false), 24.);
427 assert_eq!(handle_size(true, true, true), 28.);
428 assert_eq!(handle_size(false, false, true), 28.);
429 assert!(!SwitchIcons::Selected.shows(false));
430 assert!(SwitchIcons::Selected.shows(true));
431 assert!(SwitchIcons::Both.shows(false));
432 }
433
434 #[test]
435 fn switch_uses_m3_roles_in_every_state() {
436 let c = color;
437 let on = switch_paint(true, false, false);
438 assert_eq!(on.track, c(ColorRole::PRIMARY));
439 assert_eq!(on.handle, c(ColorRole::ON_PRIMARY));
440 assert_eq!(on.handle_active, c(ColorRole::PRIMARY_CONTAINER));
441 assert_eq!(on.icon, c(ColorRole::ON_PRIMARY_CONTAINER));
442 let off = switch_paint(false, false, false);
443 assert_eq!(off.track, c(ColorRole::SURFACE_CONTAINER_HIGHEST));
444 assert_eq!(off.outline, c(ColorRole::OUTLINE));
445 assert_eq!(off.handle, c(ColorRole::OUTLINE));
446 assert_eq!(off.handle_active, c(ColorRole::ON_SURFACE_VARIANT));
447 assert_eq!(off.icon, c(ColorRole::SURFACE_CONTAINER_HIGHEST));
448 let on_surface = c(ColorRole::ON_SURFACE);
449 let disabled_on = switch_paint(true, true, false);
450 assert_eq!(
451 disabled_on.track,
452 on_surface.opacity(state::DISABLED_CONTAINER)
453 );
454 assert_eq!(disabled_on.handle, c(ColorRole::SURFACE));
455 assert_eq!(disabled_on.icon, state::disabled(on_surface));
456 let disabled_off = switch_paint(false, true, false);
457 assert_eq!(disabled_off.handle, state::disabled(on_surface));
458 assert_eq!(
459 disabled_off.outline,
460 on_surface.opacity(state::DISABLED_CONTAINER)
461 );
462 assert_eq!(
463 disabled_off.track,
464 c(ColorRole::SURFACE_CONTAINER_HIGHEST).opacity(state::DISABLED_CONTAINER)
465 );
466 assert_eq!(STATE_LAYER, 40.);
467 assert_eq!(ICON, 16.);
468 }
469
470 #[test]
471 fn an_error_switch_uses_the_error_roles_unless_disabled() {
472 let c = color;
473 let on = switch_paint(true, false, true);
474 assert_eq!(on.track, c(ColorRole::ERROR));
475 assert_eq!(on.handle, c(ColorRole::ON_ERROR));
476 assert_eq!(on.handle_active, c(ColorRole::ERROR_CONTAINER));
477 assert_eq!(on.icon, c(ColorRole::ON_ERROR_CONTAINER));
478 assert_eq!(on.layer, c(ColorRole::ERROR));
479 let off = switch_paint(false, false, true);
480 assert_eq!(off.track, c(ColorRole::SURFACE_CONTAINER_HIGHEST));
481 assert_eq!(off.outline, c(ColorRole::ERROR));
482 assert_eq!(off.handle, c(ColorRole::ERROR));
483 assert_eq!(off.handle_active, c(ColorRole::ON_ERROR_CONTAINER));
484 assert_eq!(
485 switch_paint(true, true, true),
486 switch_paint(true, true, false)
487 );
488 assert_eq!(
489 switch_paint(false, true, true),
490 switch_paint(false, true, false)
491 );
492 }
493
494 struct PressHarness(SwitchIcons, bool, bool);
495 impl Render for PressHarness {
496 fn render(&mut self, window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
497 window.set_rem_size(px(14.));
498 div().p(px(20.)).child(
499 MaterialSwitch::new("press-switch", "Wi-Fi")
500 .checked(self.1)
501 .disabled(self.2)
502 .icons(self.0),
503 )
504 }
505 }
506
507 #[gpui::test]
508 fn a_press_ended_while_disabled_does_not_stick(cx: &mut TestAppContext) {
509 cx.update(|cx| {
510 gpui_base::init(cx);
511 cx.set_reduce_motion(true);
512 });
513 let (view, cx) = cx.add_window_view(|_, _| PressHarness(SwitchIcons::None, true, false));
514 draw(cx);
515 let rest = cx.debug_bounds("switch-handle").unwrap();
516 cx.simulate_mouse_move(rest.center(), None, Modifiers::default());
517 cx.simulate_mouse_down(rest.center(), MouseButton::Left, Modifiers::default());
518 draw(cx);
519 let set_disabled = |disabled, cx: &mut VisualTestContext| {
520 view.update(cx, |view, cx| {
521 view.2 = disabled;
522 cx.notify();
523 });
524 draw(cx);
525 };
526 set_disabled(true, cx);
527 cx.simulate_mouse_up(rest.center(), MouseButton::Left, Modifiers::default());
528 draw(cx);
529 set_disabled(false, cx);
530 assert_eq!(
531 cx.debug_bounds("switch-handle").unwrap().size,
532 size(px(24.), px(24.)),
533 "the handle is back at rest"
534 );
535 }
536
537 #[gpui::test]
538 fn pressing_grows_the_handle_to_28dp_around_its_center(cx: &mut TestAppContext) {
539 cx.update(|cx| {
540 gpui_base::init(cx);
541 cx.set_reduce_motion(true);
542 });
543 let (_, cx) = cx.add_window_view(|_, _| PressHarness(SwitchIcons::None, true, false));
544 draw(cx);
545 let rest = cx.debug_bounds("switch-handle").unwrap();
546 assert_eq!(rest.size, size(px(24.), px(24.)));
547 cx.simulate_mouse_move(rest.center(), None, Modifiers::default());
548 cx.simulate_mouse_down(rest.center(), MouseButton::Left, Modifiers::default());
549 draw(cx);
550 let pressed = cx.debug_bounds("switch-handle").unwrap();
551 assert_eq!(pressed.size, size(px(28.), px(28.)));
552 assert_eq!(pressed.center(), rest.center());
553 cx.simulate_mouse_up(rest.center(), MouseButton::Left, Modifiers::default());
554 draw(cx);
555 assert_eq!(
556 cx.debug_bounds("switch-handle").unwrap().size,
557 size(px(24.), px(24.)),
558 "releasing returns the handle to rest"
559 );
560 }
561
562 #[gpui::test]
563 fn an_icon_grows_the_unselected_handle_to_24dp(cx: &mut TestAppContext) {
564 cx.update(|cx| {
565 gpui_base::init(cx);
566 cx.set_reduce_motion(true);
567 });
568 let (_, cx) = cx.add_window_view(|_, _| PressHarness(SwitchIcons::Both, false, false));
569 draw(cx);
570 assert_eq!(
571 cx.debug_bounds("switch-handle").unwrap().size,
572 size(px(24.), px(24.))
573 );
574 let (_, cx) = cx.add_window_view(|_, _| PressHarness(SwitchIcons::Selected, false, false));
575 draw(cx);
576 assert_eq!(
577 cx.debug_bounds("switch-handle").unwrap().size,
578 size(px(16.), px(16.))
579 );
580 }
581
582 fn draw(cx: &mut VisualTestContext) {
583 cx.update(|w, cx| {
584 w.refresh();
585 w.draw(cx).clear(cx);
586 });
587 }
588}