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