1use std::rc::Rc;
2
3use gpui::{
4 AnyElement, App, ClickEvent, Div, ElementId, FocusHandle, InteractiveElement, Interactivity,
5 IntoElement, MouseButton, ParentElement, Refineable as _, RenderOnce, Role, SharedString,
6 Stateful, StatefulInteractiveElement, StyleRefinement, Styled, Toggled, Window, div,
7 prelude::FluentBuilder as _,
8};
9use smallvec::SmallVec;
10
11use crate::{StateStyle, StyledExt as _, TestSupportExt as _, a11y_disabled::A11yDisabledExt as _};
12
13type ChangeHandler = Rc<dyn Fn(bool, &ClickEvent, &mut Window, &mut App)>;
14
15#[derive(IntoElement)]
22pub struct Switch {
23 id: ElementId,
24 base: Stateful<Div>,
25 style: StyleRefinement,
26 semantic_styles: SwitchStyles,
27 checked: bool,
28 disabled: bool,
29 children: SmallVec<[AnyElement; 2]>,
30 on_change: Option<ChangeHandler>,
31 accessibility_label: Option<SharedString>,
32 tab_index: isize,
33 tab_stop: bool,
34 provided_focus_handle: Option<FocusHandle>,
35}
36
37#[derive(Default)]
39pub struct SwitchStyles {
40 checked: StyleRefinement,
41 disabled: StyleRefinement,
42}
43
44impl SwitchStyles {
45 pub fn checked(mut self, build: impl FnOnce(StateStyle) -> StateStyle) -> Self {
46 self.checked
47 .refine(&build(StateStyle::default()).into_refinement());
48 self
49 }
50
51 pub fn disabled(mut self, build: impl FnOnce(StateStyle) -> StateStyle) -> Self {
52 self.disabled
53 .refine(&build(StateStyle::default()).into_refinement());
54 self
55 }
56}
57
58#[derive(IntoElement)]
60pub struct SwitchTrack {
61 base: Stateful<Div>,
62 style: StyleRefinement,
63 semantic_styles: SwitchTrackStyles,
64 checked: bool,
65 disabled: bool,
66 children: SmallVec<[AnyElement; 1]>,
67}
68
69#[derive(Default)]
71pub struct SwitchTrackStyles {
72 checked: StyleRefinement,
73 disabled: StyleRefinement,
74}
75
76impl SwitchTrackStyles {
77 pub fn checked(mut self, build: impl FnOnce(StateStyle) -> StateStyle) -> Self {
78 self.checked
79 .refine(&build(StateStyle::default()).into_refinement());
80 self
81 }
82
83 pub fn disabled(mut self, build: impl FnOnce(StateStyle) -> StateStyle) -> Self {
84 self.disabled
85 .refine(&build(StateStyle::default()).into_refinement());
86 self
87 }
88}
89
90impl SwitchTrack {
91 pub fn new(id: impl Into<ElementId>) -> Self {
92 Self {
93 base: div().id(id),
94 style: StyleRefinement::default(),
95 semantic_styles: SwitchTrackStyles::default(),
96 checked: false,
97 disabled: false,
98 children: SmallVec::new(),
99 }
100 }
101
102 pub fn checked(mut self, checked: bool) -> Self {
103 self.checked = checked;
104 self
105 }
106
107 pub fn disabled(mut self, disabled: bool) -> Self {
108 self.disabled = disabled;
109 self
110 }
111
112 pub fn styles(mut self, build: impl FnOnce(SwitchTrackStyles) -> SwitchTrackStyles) -> Self {
113 self.semantic_styles = build(self.semantic_styles);
114 self
115 }
116
117 fn resolved_style(&self) -> StyleRefinement {
118 crate::state_style::resolve_style(
119 &self.style,
120 [
121 self.checked.then_some(&self.semantic_styles.checked),
122 self.disabled.then_some(&self.semantic_styles.disabled),
123 ]
124 .into_iter()
125 .flatten(),
126 )
127 }
128}
129
130impl Styled for SwitchTrack {
131 fn style(&mut self) -> &mut StyleRefinement {
132 &mut self.style
133 }
134}
135
136impl ParentElement for SwitchTrack {
137 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
138 self.children.extend(elements);
139 }
140}
141
142impl InteractiveElement for SwitchTrack {
143 fn interactivity(&mut self) -> &mut Interactivity {
144 self.base.interactivity()
145 }
146}
147
148impl StatefulInteractiveElement for SwitchTrack {}
149
150impl RenderOnce for SwitchTrack {
151 fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
152 let style = self.resolved_style();
153 self.base.children(self.children).refine_style(&style)
154 }
155}
156
157#[derive(IntoElement)]
159pub struct SwitchThumb {
160 base: Div,
161 style: StyleRefinement,
162 semantic_styles: SwitchThumbStyles,
163 checked: bool,
164 disabled: bool,
165 children: SmallVec<[AnyElement; 1]>,
166}
167
168#[derive(Default)]
170pub struct SwitchThumbStyles {
171 checked: StyleRefinement,
172 disabled: StyleRefinement,
173}
174
175impl SwitchThumbStyles {
176 pub fn checked(mut self, build: impl FnOnce(StateStyle) -> StateStyle) -> Self {
177 self.checked
178 .refine(&build(StateStyle::default()).into_refinement());
179 self
180 }
181
182 pub fn disabled(mut self, build: impl FnOnce(StateStyle) -> StateStyle) -> Self {
183 self.disabled
184 .refine(&build(StateStyle::default()).into_refinement());
185 self
186 }
187}
188
189impl SwitchThumb {
190 pub fn new(checked: bool) -> Self {
191 Self {
192 base: div(),
193 style: StyleRefinement::default(),
194 semantic_styles: SwitchThumbStyles::default(),
195 checked,
196 disabled: false,
197 children: SmallVec::new(),
198 }
199 }
200
201 pub fn disabled(mut self, disabled: bool) -> Self {
202 self.disabled = disabled;
203 self
204 }
205
206 pub fn styles(mut self, build: impl FnOnce(SwitchThumbStyles) -> SwitchThumbStyles) -> Self {
207 self.semantic_styles = build(self.semantic_styles);
208 self
209 }
210
211 fn resolved_style(&self) -> StyleRefinement {
212 crate::state_style::resolve_style(
213 &self.style,
214 [
215 self.checked.then_some(&self.semantic_styles.checked),
216 self.disabled.then_some(&self.semantic_styles.disabled),
217 ]
218 .into_iter()
219 .flatten(),
220 )
221 }
222}
223
224impl Styled for SwitchThumb {
225 fn style(&mut self) -> &mut StyleRefinement {
226 &mut self.style
227 }
228}
229
230impl ParentElement for SwitchThumb {
231 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
232 self.children.extend(elements);
233 }
234}
235
236impl RenderOnce for SwitchThumb {
237 fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
238 let style = self.resolved_style();
239 self.base.children(self.children).refine_style(&style)
240 }
241}
242
243impl Switch {
244 pub fn new(id: impl Into<ElementId>) -> Self {
245 let id = id.into();
246 Self {
247 base: div().id(id.clone()),
248 id,
249 style: StyleRefinement::default(),
250 semantic_styles: SwitchStyles::default(),
251 checked: false,
252 disabled: false,
253 children: SmallVec::new(),
254 on_change: None,
255 accessibility_label: None,
256 tab_index: 0,
257 tab_stop: true,
258 provided_focus_handle: None,
259 }
260 }
261
262 pub fn checked(mut self, checked: bool) -> Self {
264 self.checked = checked;
265 self
266 }
267
268 pub fn disabled(mut self, disabled: bool) -> Self {
270 self.disabled = disabled;
271 self
272 }
273
274 pub fn styles(mut self, build: impl FnOnce(SwitchStyles) -> SwitchStyles) -> Self {
276 self.semantic_styles = build(self.semantic_styles);
277 self
278 }
279
280 fn resolved_style(&self) -> StyleRefinement {
281 crate::state_style::resolve_style(
282 &self.style,
283 [
284 self.checked.then_some(&self.semantic_styles.checked),
285 self.disabled.then_some(&self.semantic_styles.disabled),
286 ]
287 .into_iter()
288 .flatten(),
289 )
290 }
291
292 pub fn on_change(
294 mut self,
295 handler: impl Fn(bool, &ClickEvent, &mut Window, &mut App) + 'static,
296 ) -> Self {
297 self.on_change = Some(Rc::new(handler));
298 self
299 }
300
301 pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
306 self.accessibility_label = Some(label.into());
307 self
308 }
309
310 pub fn tab_index(mut self, tab_index: isize) -> Self {
312 self.tab_index = tab_index;
313 self
314 }
315
316 pub fn tab_stop(mut self, tab_stop: bool) -> Self {
318 self.tab_stop = tab_stop;
319 self
320 }
321
322 pub fn track_focus(mut self, focus_handle: &FocusHandle) -> Self {
324 self.provided_focus_handle = Some(focus_handle.clone());
325 self
326 }
327
328 fn focus_handle(&self, window: &mut Window, cx: &mut App) -> FocusHandle {
329 self.provided_focus_handle.clone().unwrap_or_else(|| {
330 window
331 .use_keyed_state(self.id.clone(), cx, |_, cx| cx.focus_handle())
332 .read(cx)
333 .clone()
334 })
335 }
336}
337
338impl Styled for Switch {
339 fn style(&mut self) -> &mut StyleRefinement {
340 &mut self.style
341 }
342}
343
344impl ParentElement for Switch {
345 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
346 self.children.extend(elements);
347 }
348}
349
350impl InteractiveElement for Switch {
351 fn interactivity(&mut self) -> &mut Interactivity {
352 self.base.interactivity()
353 }
354}
355
356impl StatefulInteractiveElement for Switch {}
357
358impl RenderOnce for Switch {
359 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
360 let focus_handle = self.focus_handle(window, cx);
361 let checked = self.checked;
362 let disabled = self.disabled;
363 let style = self.resolved_style();
364
365 self.base
366 .test_support()
367 .role(Role::Switch)
368 .aria_toggled(if checked {
369 Toggled::True
370 } else {
371 Toggled::False
372 })
373 .when_some(self.accessibility_label, |this, label| {
374 this.aria_label(label)
375 })
376 .when(!disabled, |this| {
377 this.track_focus(
378 &focus_handle
379 .tab_index(self.tab_index)
380 .tab_stop(self.tab_stop),
381 )
382 })
383 .when(disabled, |this| {
384 this.on_mouse_down(MouseButton::Left, |_, _, cx| {
385 cx.stop_propagation();
386 })
387 })
388 .when_some(
389 (!disabled).then_some(self.on_change).flatten(),
390 |this, on_change| {
391 this.on_click(move |event, window, cx| {
392 on_change(!checked, event, window, cx);
393 })
394 },
395 )
396 .children(self.children)
397 .refine_style(&style)
398 .a11y_disabled(disabled)
399 }
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405 use std::{
406 cell::Cell,
407 rc::Rc,
408 sync::{Arc, Mutex},
409 };
410
411 use gpui::{
412 Context, Element as _, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, Render,
413 TestAppContext, VisualTestContext, accesskit, canvas, point, px,
414 };
415
416 #[test]
417 fn track_projects_checked_disabled_and_instance_style_priority() {
418 let normal_color = gpui::hsla(0.0, 0.0, 0.2, 1.0);
419 let checked_color = gpui::hsla(0.6, 0.7, 0.5, 1.0);
420 let disabled_color = gpui::hsla(0.6, 0.7, 0.5, 0.5);
421 let track = |checked, disabled| {
422 SwitchTrack::new(("track", usize::from(checked) * 2 + usize::from(disabled)))
423 .checked(checked)
424 .disabled(disabled)
425 .when(!checked, |this| this.bg(normal_color))
426 .styles(|styles| {
427 styles
428 .checked(|style| style.bg(checked_color))
429 .disabled(|style| style.when(checked, |style| style.bg(disabled_color)))
430 })
431 };
432
433 assert_eq!(
434 track(false, false).resolved_style().background,
435 Some(normal_color.into())
436 );
437 assert_eq!(
438 track(false, true).resolved_style().background,
439 Some(normal_color.into())
440 );
441 assert_eq!(
442 track(true, false).resolved_style().background,
443 Some(checked_color.into())
444 );
445 assert_eq!(
446 track(true, true).resolved_style().background,
447 Some(disabled_color.into())
448 );
449 assert_eq!(
452 track(true, true)
453 .bg(normal_color)
454 .resolved_style()
455 .background,
456 Some(disabled_color.into())
457 );
458 }
459
460 #[test]
461 fn track_identity_is_scoped_to_its_switch() {
462 fn track(switch_id: &'static str) -> SwitchTrack {
463 SwitchTrack::new((ElementId::from(switch_id), "track"))
464 }
465
466 let first = track("first-switch");
467 let second = track("second-switch");
468
469 assert_eq!(
470 gpui::Element::id(&first.base),
471 Some((ElementId::from("first-switch"), "track").into())
472 );
473 assert_eq!(
474 gpui::Element::id(&second.base),
475 Some((ElementId::from("second-switch"), "track").into())
476 );
477 assert_ne!(
478 gpui::Element::id(&first.base),
479 gpui::Element::id(&second.base)
480 );
481 }
482
483 #[test]
484 fn thumb_projects_checked_disabled_and_instance_style_priority() {
485 let checked_color = gpui::hsla(0.6, 0.7, 0.5, 1.0);
486 let disabled_color = gpui::hsla(0.1, 0.2, 0.3, 0.5);
487 let thumb = |checked, disabled| {
488 SwitchThumb::new(checked)
489 .disabled(disabled)
490 .styles(|styles| {
491 styles
492 .checked(|style| style.bg(checked_color))
493 .disabled(|style| style.bg(disabled_color))
494 })
495 };
496
497 assert_eq!(
498 thumb(true, false).resolved_style().background,
499 Some(checked_color.into())
500 );
501 assert_eq!(
502 thumb(true, true).resolved_style().background,
503 Some(disabled_color.into())
504 );
505 assert_eq!(
506 thumb(true, true)
507 .bg(checked_color)
508 .resolved_style()
509 .background,
510 Some(disabled_color.into())
511 );
512 }
513
514 struct SwitchHarness {
515 checked: bool,
516 disabled: bool,
517 toggles: Rc<Cell<usize>>,
518 keyboard_events: Rc<Cell<usize>>,
519 last_value: Rc<Cell<bool>>,
520 parent_clicks: Rc<Cell<usize>>,
521 }
522
523 impl Render for SwitchHarness {
524 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
525 let toggles = self.toggles.clone();
526 let keyboard_events = self.keyboard_events.clone();
527 let last_value = self.last_value.clone();
528 let parent_clicks = self.parent_clicks.clone();
529
530 div()
531 .id("switch-parent")
532 .tab_group()
533 .size(px(100.))
534 .on_click(move |_, _, _| parent_clicks.set(parent_clicks.get() + 1))
535 .child(
536 Switch::new("switch")
537 .checked(self.checked)
538 .disabled(self.disabled)
539 .size_full()
540 .on_change(move |value, event, _, _| {
541 toggles.set(toggles.get() + 1);
542 last_value.set(value);
543 if matches!(event, ClickEvent::Keyboard(_)) {
544 keyboard_events.set(keyboard_events.get() + 1);
545 }
546 }),
547 )
548 }
549 }
550
551 fn harness(
552 cx: &mut TestAppContext,
553 checked: bool,
554 disabled: bool,
555 ) -> (
556 &mut VisualTestContext,
557 Rc<Cell<usize>>,
558 Rc<Cell<usize>>,
559 Rc<Cell<bool>>,
560 Rc<Cell<usize>>,
561 ) {
562 let toggles = Rc::new(Cell::new(0));
563 let keyboard_events = Rc::new(Cell::new(0));
564 let last_value = Rc::new(Cell::new(checked));
565 let parent_clicks = Rc::new(Cell::new(0));
566 let (_, cx) = cx.add_window_view({
567 let toggles = toggles.clone();
568 let keyboard_events = keyboard_events.clone();
569 let last_value = last_value.clone();
570 let parent_clicks = parent_clicks.clone();
571 move |_, _| SwitchHarness {
572 checked,
573 disabled,
574 toggles,
575 keyboard_events,
576 last_value,
577 parent_clicks,
578 }
579 });
580 cx.update(|window, cx| window.draw(cx).clear(cx));
581 (cx, toggles, keyboard_events, last_value, parent_clicks)
582 }
583
584 fn activate_key(cx: &mut VisualTestContext, key: &str) {
585 let keystroke = Keystroke::parse(key).unwrap();
586 cx.simulate_event(KeyDownEvent {
587 keystroke: keystroke.clone(),
588 is_held: false,
589 prefer_character_input: false,
590 });
591 cx.simulate_event(KeyUpEvent { keystroke });
592 }
593
594 #[gpui::test]
595 fn pointer_reports_the_next_value_once(cx: &mut TestAppContext) {
596 let (cx, toggles, keyboard_events, last_value, _) = harness(cx, false, false);
597
598 cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
599
600 assert_eq!(toggles.get(), 1);
601 assert_eq!(keyboard_events.get(), 0);
602 assert!(last_value.get());
603 }
604
605 #[gpui::test]
606 fn enter_and_space_report_one_native_keyboard_activation_each(cx: &mut TestAppContext) {
607 let (cx, toggles, keyboard_events, last_value, _) = harness(cx, false, false);
608 cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
609 toggles.set(0);
610 cx.update(|window, cx| {
611 assert!(window.focused(cx).is_some());
612 window.draw(cx).clear(cx);
613 });
614
615 activate_key(cx, "enter");
616 activate_key(cx, "space");
617
618 assert_eq!(toggles.get(), 2);
619 assert_eq!(keyboard_events.get(), 2);
620 assert!(last_value.get());
621 }
622
623 #[gpui::test]
624 fn disabled_switch_is_inert_and_blocks_parent_activation(cx: &mut TestAppContext) {
625 let (cx, toggles, _, _, parent_clicks) = harness(cx, false, true);
626
627 cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
628 activate_key(cx, "enter");
629 activate_key(cx, "space");
630
631 assert_eq!(toggles.get(), 0);
632 assert_eq!(parent_clicks.get(), 0);
633 }
634
635 #[test]
636 fn application_owned_state_styles_are_available() {
637 let _ = Switch::new("states")
638 .styles(|styles| {
639 styles
640 .checked(|style| style.opacity(0.8))
641 .disabled(|style| style.opacity(0.5))
642 })
643 .hover(|style| style.opacity(0.9))
644 .active(|style| style.opacity(0.8))
645 .focus_visible(|style| style.opacity(0.7));
646 }
647
648 #[test]
649 fn semantic_root_styles_follow_switch_priority() {
650 let styled = |switch: Switch| {
651 switch.styles(|styles| {
652 styles
653 .checked(|style| style.opacity(0.8))
654 .disabled(|style| style.opacity(0.5))
655 })
656 };
657
658 assert_eq!(styled(Switch::new("normal")).resolved_style().opacity, None);
659 assert_eq!(
660 styled(Switch::new("checked").checked(true))
661 .resolved_style()
662 .opacity,
663 Some(0.8)
664 );
665 assert_eq!(
666 styled(Switch::new("checked-disabled").checked(true).disabled(true))
667 .resolved_style()
668 .opacity,
669 Some(0.5)
670 );
671 assert_eq!(
672 styled(
673 Switch::new("state-over-instance")
674 .checked(true)
675 .disabled(true)
676 .opacity(0.9),
677 )
678 .resolved_style()
679 .opacity,
680 Some(0.5)
681 );
682 }
683
684 #[gpui::test]
685 fn accessibility_exposes_switch_role_label_and_toggled_state(cx: &mut TestAppContext) {
686 type Captured = Arc<Mutex<Option<(accesskit::Node, accesskit::Node)>>>;
687
688 struct A11yProbe {
689 captured: Captured,
690 }
691
692 impl Render for A11yProbe {
693 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
694 let captured = self.captured.clone();
695 canvas(
696 move |_, window, cx| {
697 let mut info = |switch: Switch| {
698 let mut node = accesskit::Node::new(Role::Switch);
699 switch
700 .render(window, cx)
701 .into_element()
702 .write_a11y_info(&mut node);
703 node
704 };
705 let enabled = info(
706 Switch::new("enabled")
707 .checked(true)
708 .accessibility_label("Airplane mode")
709 .on_change(|_, _, _, _| {}),
710 );
711 let disabled = info(
712 Switch::new("disabled")
713 .checked(false)
714 .disabled(true)
715 .accessibility_label("Airplane mode")
716 .on_change(|_, _, _, _| {}),
717 );
718 *captured.lock().unwrap() = Some((enabled, disabled));
719 },
720 |_, _, _, _| {},
721 )
722 }
723 }
724
725 let captured: Captured = Arc::new(Mutex::new(None));
726 let result = captured.clone();
727 let (_, cx) = cx.add_window_view(move |_, _| A11yProbe { captured });
728 cx.update(|window, cx| window.draw(cx).clear(cx));
729 let (enabled, disabled) = result.lock().unwrap().take().unwrap();
730
731 assert_eq!(enabled.role(), Role::Switch);
732 assert_eq!(enabled.label(), Some("Airplane mode"));
733 assert_eq!(enabled.toggled(), Some(Toggled::True));
734 assert!(enabled.supports_action(accesskit::Action::Click));
735
736 assert_eq!(disabled.role(), Role::Switch);
737 assert_eq!(disabled.toggled(), Some(Toggled::False));
738 assert!(!disabled.supports_action(accesskit::Action::Click));
739 assert!(!enabled.is_disabled());
740 assert!(disabled.is_disabled());
741 }
742}