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