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 =
586 cx.update(|_, cx| Keystroke::parse_with_os(key, cx.platform_info().os).unwrap());
587 cx.simulate_event(KeyDownEvent {
588 keystroke: keystroke.clone(),
589 is_held: false,
590 prefer_character_input: false,
591 });
592 cx.simulate_event(KeyUpEvent { keystroke });
593 }
594
595 #[gpui::test]
596 fn pointer_reports_the_next_value_once(cx: &mut TestAppContext) {
597 let (cx, toggles, keyboard_events, last_value, _) = harness(cx, false, false);
598
599 cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
600
601 assert_eq!(toggles.get(), 1);
602 assert_eq!(keyboard_events.get(), 0);
603 assert!(last_value.get());
604 }
605
606 #[gpui::test]
607 fn enter_and_space_report_one_native_keyboard_activation_each(cx: &mut TestAppContext) {
608 let (cx, toggles, keyboard_events, last_value, _) = harness(cx, false, false);
609 cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
610 toggles.set(0);
611 cx.update(|window, cx| {
612 assert!(window.focused(cx).is_some());
613 window.draw(cx).clear(cx);
614 });
615
616 activate_key(cx, "enter");
617 activate_key(cx, "space");
618
619 assert_eq!(toggles.get(), 2);
620 assert_eq!(keyboard_events.get(), 2);
621 assert!(last_value.get());
622 }
623
624 #[gpui::test]
625 fn disabled_switch_is_inert_and_blocks_parent_activation(cx: &mut TestAppContext) {
626 let (cx, toggles, _, _, parent_clicks) = harness(cx, false, true);
627
628 cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
629 activate_key(cx, "enter");
630 activate_key(cx, "space");
631
632 assert_eq!(toggles.get(), 0);
633 assert_eq!(parent_clicks.get(), 0);
634 }
635
636 #[test]
637 fn application_owned_state_styles_are_available() {
638 let _ = Switch::new("states")
639 .styles(|styles| {
640 styles
641 .checked(|style| style.opacity(0.8))
642 .disabled(|style| style.opacity(0.5))
643 })
644 .hover(|style| style.opacity(0.9))
645 .active(|style| style.opacity(0.8))
646 .focus_visible(|style| style.opacity(0.7));
647 }
648
649 #[test]
650 fn semantic_root_styles_follow_switch_priority() {
651 let styled = |switch: Switch| {
652 switch.styles(|styles| {
653 styles
654 .checked(|style| style.opacity(0.8))
655 .disabled(|style| style.opacity(0.5))
656 })
657 };
658
659 assert_eq!(styled(Switch::new("normal")).resolved_style().opacity, None);
660 assert_eq!(
661 styled(Switch::new("checked").checked(true))
662 .resolved_style()
663 .opacity,
664 Some(0.8)
665 );
666 assert_eq!(
667 styled(Switch::new("checked-disabled").checked(true).disabled(true))
668 .resolved_style()
669 .opacity,
670 Some(0.5)
671 );
672 assert_eq!(
673 styled(
674 Switch::new("state-over-instance")
675 .checked(true)
676 .disabled(true)
677 .opacity(0.9),
678 )
679 .resolved_style()
680 .opacity,
681 Some(0.5)
682 );
683 }
684
685 #[gpui::test]
686 fn accessibility_exposes_switch_role_label_and_toggled_state(cx: &mut TestAppContext) {
687 type Captured = Arc<Mutex<Option<(accesskit::Node, accesskit::Node)>>>;
688
689 struct A11yProbe {
690 captured: Captured,
691 }
692
693 impl Render for A11yProbe {
694 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
695 let captured = self.captured.clone();
696 canvas(
697 move |_, window, cx| {
698 let mut info = |switch: Switch| {
699 let mut node = accesskit::Node::new(Role::Switch);
700 switch
701 .render(window, cx)
702 .into_element()
703 .write_a11y_info(&mut node);
704 node
705 };
706 let enabled = info(
707 Switch::new("enabled")
708 .checked(true)
709 .accessibility_label("Airplane mode")
710 .on_change(|_, _, _, _| {}),
711 );
712 let disabled = info(
713 Switch::new("disabled")
714 .checked(false)
715 .disabled(true)
716 .accessibility_label("Airplane mode")
717 .on_change(|_, _, _, _| {}),
718 );
719 *captured.lock().unwrap() = Some((enabled, disabled));
720 },
721 |_, _, _, _| {},
722 )
723 }
724 }
725
726 let captured: Captured = Arc::new(Mutex::new(None));
727 let result = captured.clone();
728 let (_, cx) = cx.add_window_view(move |_, _| A11yProbe { captured });
729 cx.update(|window, cx| window.draw(cx).clear(cx));
730 let (enabled, disabled) = result.lock().unwrap().take().unwrap();
731
732 assert_eq!(enabled.role(), Role::Switch);
733 assert_eq!(enabled.label(), Some("Airplane mode"));
734 assert_eq!(enabled.toggled(), Some(Toggled::True));
735 assert!(enabled.supports_action(accesskit::Action::Click));
736
737 assert_eq!(disabled.role(), Role::Switch);
738 assert_eq!(disabled.toggled(), Some(Toggled::False));
739 assert!(!disabled.supports_action(accesskit::Action::Click));
740 assert!(!enabled.is_disabled());
741 assert!(disabled.is_disabled());
742 }
743}