1use crate::{
2 ActiveTheme, Disableable, FocusableExt, Side, Sizable, Size, StyleSized as _, StyledExt,
3 ThemeStyled as _, text::Text, tooltip::ComponentTooltip,
4};
5use gpui::{
6 App, Background, ElementId, Hsla, InteractiveElement, IntoElement, ParentElement as _,
7 RenderOnce, SharedString, StyleRefinement, Styled, Window, div, prelude::FluentBuilder as _,
8 px,
9};
10use gpui_base::{Switch as BaseSwitch, SwitchThumb, SwitchTrack, spring};
11use std::{rc::Rc, time::Duration};
12
13#[derive(IntoElement)]
15pub struct Switch {
16 id: ElementId,
17 style: StyleRefinement,
18 checked: bool,
19 disabled: bool,
20 label: Option<Text>,
21 accessibility_label: Option<SharedString>,
23 label_side: Side,
24 on_click: Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>,
25 size: Size,
26 color: Option<Hsla>,
27 tooltip: ComponentTooltip,
28 tab_stop: bool,
29 tab_index: isize,
30 focus_ring_enabled: bool,
31}
32
33impl Switch {
34 pub fn new(id: impl Into<ElementId>) -> Self {
36 let id: ElementId = id.into();
37 Self {
38 id: id.clone(),
39 style: StyleRefinement::default(),
40 checked: false,
41 disabled: false,
42 label: None,
43 accessibility_label: None,
44 on_click: None,
45 label_side: Side::Right,
46 size: Size::Medium,
47 color: None,
48 tooltip: ComponentTooltip::default(),
49 tab_stop: true,
50 tab_index: 0,
51 focus_ring_enabled: true,
52 }
53 }
54
55 pub fn checked(mut self, checked: bool) -> Self {
57 self.checked = checked;
58 self
59 }
60
61 pub fn label(mut self, label: impl Into<Text>) -> Self {
63 self.label = Some(label.into());
64 self
65 }
66
67 pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
74 self.accessibility_label = Some(label.into());
75 self
76 }
77
78 pub fn on_click<F>(self, handler: F) -> Self
80 where
81 F: Fn(&bool, &mut Window, &mut App) + 'static,
82 {
83 self.on_change(handler)
84 }
85
86 pub fn on_change<F>(mut self, handler: F) -> Self
93 where
94 F: Fn(&bool, &mut Window, &mut App) + 'static,
95 {
96 self.on_click = Some(Rc::new(handler));
97 self
98 }
99
100 pub fn color(mut self, color: impl Into<Hsla>) -> Self {
103 self.color = Some(color.into());
104 self
105 }
106
107 pub fn tooltip(mut self, tooltip: impl Into<SharedString>) -> Self {
109 self.tooltip.text = Some((tooltip.into(), None));
110 self
111 }
112
113 pub fn tooltip_show_delay(mut self, delay: Duration) -> Self {
117 self.tooltip.show_delay = Some(delay);
118 self
119 }
120
121 pub fn tab_stop(mut self, tab_stop: bool) -> Self {
124 self.tab_stop = tab_stop;
125 self
126 }
127
128 pub fn tab_index(mut self, tab_index: isize) -> Self {
130 self.tab_index = tab_index;
131 self
132 }
133}
134
135impl Styled for Switch {
136 fn style(&mut self) -> &mut gpui::StyleRefinement {
137 &mut self.style
138 }
139}
140
141impl Sizable for Switch {
142 fn with_size(mut self, size: impl Into<Size>) -> Self {
143 self.size = size.into();
144 self
145 }
146}
147
148impl Disableable for Switch {
149 fn disabled(mut self, disabled: bool) -> Self {
150 self.disabled = disabled;
151 self
152 }
153}
154
155impl FocusableExt for Switch {
156 fn focus_ring(mut self, enabled: bool) -> Self {
157 self.focus_ring_enabled = enabled;
158 self
159 }
160
161 fn is_focus_ring_enabled(&self) -> bool {
162 self.focus_ring_enabled
163 }
164}
165
166impl RenderOnce for Switch {
167 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
168 let checked = self.checked;
169 let on_click = self.on_click.clone();
170 let accessibility_label = self
171 .accessibility_label
172 .clone()
173 .or_else(|| self.label.as_ref().map(|label| label.get_text(cx)));
174 let focus_handle = window
175 .use_keyed_state(self.id.clone(), cx, |_, cx| cx.focus_handle())
176 .read(cx)
177 .clone();
178 let is_focused = focus_handle.is_focused(window);
179
180 let checked_bg = self
181 .color
182 .map(Background::from)
183 .unwrap_or(cx.theme().tokens.primary.into());
184 let unchecked_bg: Background = cx.theme().tokens.switch.into();
185 let disabled_bg = if checked { checked_bg } else { unchecked_bg }.opacity(0.5);
190 let toggle_bg: Background = cx.theme().tokens.switch_thumb.into();
191 let disabled_label_color = cx.theme().muted_foreground;
192
193 let (bg_width, bg_height) = match self.size {
194 Size::XSmall | Size::Small => (px(28.), px(16.)),
195 Size::Large => (px(44.), px(24.)),
196 _ => (px(36.), px(20.)),
197 };
198 let bar_width = match self.size {
199 Size::XSmall | Size::Small => px(12.),
200 Size::Large => px(20.),
201 _ => px(16.),
202 };
203 let inset = px(2.);
204 let radius = if cx.theme().radius >= px(4.) {
205 bg_height
206 } else {
207 cx.theme().radius
208 };
209
210 let thumb_x = spring(
216 (self.id.clone(), "thumb"),
217 if checked {
218 bg_width - bar_width - inset * 2
219 } else {
220 px(0.)
221 },
222 cx.theme().motion_tokens().spring_move,
223 window,
224 cx,
225 );
226
227 div().refine_style(&self.style).child(
228 BaseSwitch::new(self.id.clone())
229 .checked(checked)
230 .disabled(self.disabled)
231 .styles(|styles| {
232 styles.disabled(|style| {
233 style.text_color(disabled_label_color).cursor_not_allowed()
234 })
235 })
236 .when_some(accessibility_label, |this, label| {
237 this.accessibility_label(label)
238 })
239 .when_some(on_click, |this, on_click| {
240 this.on_change(move |next, _, window, cx| on_click(&next, window, cx))
241 })
242 .tab_stop(self.tab_stop)
243 .tab_index(self.tab_index)
244 .track_focus(&focus_handle)
245 .h_flex()
246 .gap_2()
247 .items_start()
248 .when(self.label_side.is_left(), |this| this.flex_row_reverse())
249 .child(
250 SwitchTrack::new((self.id.clone(), "track"))
252 .checked(checked)
253 .disabled(self.disabled)
254 .when(cfg!(test), |this| {
255 this.debug_selector(|| "switch-bar".into())
256 })
257 .w(bg_width)
258 .h(bg_height)
259 .flex_shrink_0()
260 .rounded(radius)
261 .flex()
262 .items_center()
263 .border_1()
270 .border_color(cx.theme().transparent)
271 .p(inset - px(1.))
272 .when(!checked, |this| this.bg(unchecked_bg))
273 .styles(|styles| {
274 styles
275 .checked(|style| style.bg(checked_bg))
276 .disabled(|style| style.bg(disabled_bg))
277 })
278 .when(is_focused && self.focus_ring_enabled, |this| {
281 this.focus_ring_style(window, cx)
282 })
283 .map(|this| self.tooltip.apply(this))
284 .child(
285 SwitchThumb::new(checked)
287 .rounded(radius)
288 .size(bar_width)
289 .left(thumb_x)
290 .bg(toggle_bg),
291 ),
292 )
293 .when_some(self.label, |this, label| {
294 this.child(
295 div()
296 .when(cfg!(test), |this| {
297 this.debug_selector(|| "switch-label".into())
298 })
299 .min_w_0()
300 .line_height(bg_height)
301 .child(label)
302 .map(|this| match self.size {
303 Size::Size(_) => this.input_text_size(Size::Medium),
304 size => this.input_text_size(size),
305 }),
306 )
307 }),
308 )
309 }
310}
311
312#[cfg(test)]
313mod tests {
314 use std::{cell::Cell, rc::Rc};
315
316 use gpui::{
317 Context, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, Render,
318 StatefulInteractiveElement as _, TestAppContext, VisualTestContext, point,
319 };
320
321 use super::*;
322
323 #[test]
324 fn an_explicit_accessibility_label_replaces_the_visible_one() {
325 let plain = Switch::new("wifi").label("Wi-Fi");
326 assert_eq!(plain.accessibility_label, None);
327 assert!(matches!(
328 &plain.label,
329 Some(Text::String(label)) if label.as_ref() == "Wi-Fi"
330 ));
331
332 let named = Switch::new("wifi")
333 .label("Wi-Fi")
334 .accessibility_label("Toggle Wi-Fi");
335 assert_eq!(
336 named.accessibility_label.as_deref(),
337 Some("Toggle Wi-Fi"),
338 "an explicit name must win over the visible label"
339 );
340 assert!(
341 matches!(
342 &named.label,
343 Some(Text::String(label)) if label.as_ref() == "Wi-Fi"
344 ),
345 "and must not change what is drawn"
346 );
347 }
348
349 struct SwitchHarness {
350 disabled: bool,
351 toggles: Rc<Cell<usize>>,
352 parent_clicks: Rc<Cell<usize>>,
353 }
354
355 impl Render for SwitchHarness {
356 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
357 let toggles = self.toggles.clone();
358 let parent_clicks = self.parent_clicks.clone();
359 div()
360 .id("switch-parent")
361 .tab_group()
362 .size(px(100.))
363 .on_click(move |_, _, _| parent_clicks.set(parent_clicks.get() + 1))
364 .child(Switch::new("switch").disabled(self.disabled).on_click(
365 move |checked, _, _| {
366 assert!(*checked);
367 toggles.set(toggles.get() + 1);
368 },
369 ))
370 }
371 }
372
373 fn harness(
374 cx: &mut TestAppContext,
375 disabled: bool,
376 ) -> (&mut VisualTestContext, Rc<Cell<usize>>, Rc<Cell<usize>>) {
377 cx.update(crate::init);
378 let toggles = Rc::new(Cell::new(0));
379 let parent_clicks = Rc::new(Cell::new(0));
380 let (_, cx) = cx.add_window_view({
381 let toggles = toggles.clone();
382 let parent_clicks = parent_clicks.clone();
383 move |_, _| SwitchHarness {
384 disabled,
385 toggles,
386 parent_clicks,
387 }
388 });
389 cx.update(|window, cx| window.draw(cx).clear(cx));
390 (cx, toggles, parent_clicks)
391 }
392
393 fn activate_key(cx: &mut VisualTestContext, key: &str) {
394 let keystroke = Keystroke::parse(key).unwrap();
395 cx.simulate_event(KeyDownEvent {
396 keystroke: keystroke.clone(),
397 is_held: false,
398 prefer_character_input: false,
399 });
400 cx.simulate_event(KeyUpEvent { keystroke });
401 }
402
403 #[gpui::test]
404 fn canonical_pointer_activation_fires_once_and_focuses(cx: &mut TestAppContext) {
405 let (cx, toggles, _) = harness(cx, false);
406 cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
407
408 assert_eq!(toggles.get(), 1);
409 cx.update(|window, cx| assert!(window.focused(cx).is_some()));
410 }
411
412 #[gpui::test]
413 fn canonical_switch_supports_tab_enter_and_space(cx: &mut TestAppContext) {
414 let (cx, toggles, _) = harness(cx, false);
415 cx.update(|window, cx| window.focus_next(cx));
416 cx.update(|window, cx| assert!(window.focused(cx).is_some()));
417
418 activate_key(cx, "enter");
419 activate_key(cx, "space");
420
421 assert_eq!(toggles.get(), 2);
422 }
423
424 #[gpui::test]
425 fn canonical_disabled_switch_is_inert_and_blocks_parent(cx: &mut TestAppContext) {
426 let (cx, toggles, parent_clicks) = harness(cx, true);
427 cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
428
429 assert_eq!(toggles.get(), 0);
430 assert_eq!(parent_clicks.get(), 0);
431 cx.update(|window, cx| assert!(window.focused(cx).is_none()));
432 }
433
434 struct FocusRingHarness {
435 disabled: bool,
436 focus_ring: bool,
437 }
438
439 impl Render for FocusRingHarness {
440 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
441 div().id("switch-parent").tab_group().size(px(100.)).child(
442 Switch::new("switch")
443 .label("Airplane mode")
444 .disabled(self.disabled)
445 .focus_ring(self.focus_ring),
446 )
447 }
448 }
449
450 fn focus_ring_harness(
451 cx: &mut TestAppContext,
452 disabled: bool,
453 focus_ring: bool,
454 ) -> &mut VisualTestContext {
455 cx.update(crate::init);
456 let (_, cx) = cx.add_window_view(move |_, _| FocusRingHarness {
457 disabled,
458 focus_ring,
459 });
460 cx.update(|window, cx| window.draw(cx).clear(cx));
461 cx
462 }
463
464 #[gpui::test]
465 fn focus_ring_hugs_the_track_when_the_switch_is_focused(cx: &mut TestAppContext) {
466 let cx = focus_ring_harness(cx, false, true);
467 assert!(
468 cx.debug_bounds("focus-ring").is_none(),
469 "an unfocused switch draws no ring"
470 );
471
472 cx.update(|window, cx| window.focus_next(cx));
473 cx.update(|window, cx| {
474 assert!(window.focused(cx).is_some());
475 window.draw(cx).clear(cx);
476 });
477
478 let ring = cx
479 .debug_bounds("focus-ring")
480 .expect("a focused switch must draw its focus ring");
481 let bar = cx.debug_bounds("switch-bar").unwrap();
482 let label = cx.debug_bounds("switch-label").unwrap();
483 assert!(ring.contains(&bar.origin), "the ring surrounds the track");
484 assert!(
485 ring.right() < label.origin.x,
486 "the ring hugs the track and leaves the label outside"
487 );
488 }
489
490 #[gpui::test]
491 fn focus_ring_can_be_turned_off(cx: &mut TestAppContext) {
492 let cx = focus_ring_harness(cx, false, false);
493 cx.update(|window, cx| window.focus_next(cx));
494 cx.update(|window, cx| {
495 assert!(window.focused(cx).is_some());
496 window.draw(cx).clear(cx);
497 });
498
499 assert!(
500 cx.debug_bounds("focus-ring").is_none(),
501 "`focus_ring(false)` must not draw a ring"
502 );
503 }
504
505 #[gpui::test]
506 fn disabled_switch_takes_no_focus_and_draws_no_ring(cx: &mut TestAppContext) {
507 let cx = focus_ring_harness(cx, true, true);
508 cx.update(|window, cx| window.focus_next(cx));
509 cx.update(|window, cx| {
510 assert!(window.focused(cx).is_none());
511 window.draw(cx).clear(cx);
512 });
513
514 assert!(cx.debug_bounds("focus-ring").is_none());
515 }
516
517 #[gpui::test]
518 fn long_labels_preserve_track_size_in_narrow_containers(cx: &mut TestAppContext) {
519 struct NarrowSwitch {
520 size: Size,
521 checked: bool,
522 disabled: bool,
523 }
524
525 impl Render for NarrowSwitch {
526 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
527 div()
528 .w(px(160.))
529 .debug_selector(|| "narrow-switch".into())
530 .child(
531 Switch::new("switch")
532 .with_size(self.size)
533 .checked(self.checked)
534 .disabled(self.disabled)
535 .label("Automatically transcribe downloaded episodes"),
536 )
537 }
538 }
539
540 cx.update(crate::init);
541 for (size, width, height) in [
542 (Size::Small, 28., 16.),
543 (Size::Medium, 36., 20.),
544 (Size::Large, 44., 24.),
545 ] {
546 for checked in [false, true] {
547 for disabled in [false, true] {
548 let (_, cx) = cx.add_window_view(move |_, _| NarrowSwitch {
549 size,
550 checked,
551 disabled,
552 });
553 cx.update(|window, cx| window.draw(cx).clear(cx));
554
555 let container = cx.debug_bounds("narrow-switch").unwrap();
556 let track = cx.debug_bounds("switch-bar").unwrap();
557 let label = cx.debug_bounds("switch-label").unwrap();
558 assert_eq!(track.size.width, px(width), "the track must not shrink");
559 assert_eq!(track.size.height, px(height));
560 assert!(label.origin.x >= track.right());
561 assert!(label.right() <= container.right());
562 assert!(label.size.height > track.size.height, "the label must wrap");
563 }
564 }
565 }
566 }
567
568 #[gpui::test]
569 fn label_prepaints_with_the_base_switch_content(cx: &mut TestAppContext) {
570 struct LabelHarness;
571
572 impl Render for LabelHarness {
573 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
574 div()
575 .debug_selector(|| "labeled-switch".into())
576 .child(Switch::new("switch").label("Airplane mode"))
577 }
578 }
579
580 cx.update(crate::init);
581 let (_, cx) = cx.add_window_view(|_, _| LabelHarness);
582 cx.update(|window, cx| window.draw(cx).clear(cx));
583
584 let bounds = cx
585 .debug_bounds("labeled-switch")
586 .expect("the complete labeled Switch must participate in prepaint");
587 assert!(bounds.size.width > px(36.));
588 let bar = cx
589 .debug_bounds("switch-bar")
590 .expect("the Switch bar must participate in prepaint");
591 let label = cx
592 .debug_bounds("switch-label")
593 .expect("the Switch label must participate in prepaint");
594 assert_eq!(bar.origin.y, label.origin.y);
595 }
596}