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 _};
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 {
303 self.accessibility_label = Some(label.into());
304 self
305 }
306
307 pub fn tab_index(mut self, tab_index: isize) -> Self {
309 self.tab_index = tab_index;
310 self
311 }
312
313 pub fn tab_stop(mut self, tab_stop: bool) -> Self {
315 self.tab_stop = tab_stop;
316 self
317 }
318
319 pub fn track_focus(mut self, focus_handle: &FocusHandle) -> Self {
321 self.provided_focus_handle = Some(focus_handle.clone());
322 self
323 }
324
325 fn focus_handle(&self, window: &mut Window, cx: &mut App) -> FocusHandle {
326 self.provided_focus_handle.clone().unwrap_or_else(|| {
327 window
328 .use_keyed_state(self.id.clone(), cx, |_, cx| cx.focus_handle())
329 .read(cx)
330 .clone()
331 })
332 }
333}
334
335impl Styled for Switch {
336 fn style(&mut self) -> &mut StyleRefinement {
337 &mut self.style
338 }
339}
340
341impl ParentElement for Switch {
342 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
343 self.children.extend(elements);
344 }
345}
346
347impl InteractiveElement for Switch {
348 fn interactivity(&mut self) -> &mut Interactivity {
349 self.base.interactivity()
350 }
351}
352
353impl StatefulInteractiveElement for Switch {}
354
355impl RenderOnce for Switch {
356 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
357 let focus_handle = self.focus_handle(window, cx);
358 let checked = self.checked;
359 let disabled = self.disabled;
360 let style = self.resolved_style();
361
362 self.base
363 .test_support()
364 .role(Role::Switch)
365 .aria_toggled(if checked {
366 Toggled::True
367 } else {
368 Toggled::False
369 })
370 .when_some(self.accessibility_label, |this, label| {
371 this.aria_label(label)
372 })
373 .when(!disabled, |this| {
374 this.track_focus(
375 &focus_handle
376 .tab_index(self.tab_index)
377 .tab_stop(self.tab_stop),
378 )
379 })
380 .when(disabled, |this| {
381 this.on_mouse_down(MouseButton::Left, |_, _, cx| {
382 cx.stop_propagation();
383 })
384 })
385 .when_some(
386 (!disabled).then_some(self.on_change).flatten(),
387 |this, on_change| {
388 this.on_click(move |event, window, cx| {
389 on_change(!checked, event, window, cx);
390 })
391 },
392 )
393 .children(self.children)
394 .refine_style(&style)
395 }
396}
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401 use std::{
402 cell::Cell,
403 rc::Rc,
404 sync::{Arc, Mutex},
405 };
406
407 use gpui::{
408 Context, Element as _, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, Render,
409 TestAppContext, VisualTestContext, accesskit, canvas, point, px,
410 };
411
412 #[test]
413 fn track_projects_checked_disabled_and_instance_style_priority() {
414 let normal_color = gpui::hsla(0.0, 0.0, 0.2, 1.0);
415 let checked_color = gpui::hsla(0.6, 0.7, 0.5, 1.0);
416 let disabled_color = gpui::hsla(0.6, 0.7, 0.5, 0.5);
417 let track = |checked, disabled| {
418 SwitchTrack::new(("track", usize::from(checked) * 2 + usize::from(disabled)))
419 .checked(checked)
420 .disabled(disabled)
421 .when(!checked, |this| this.bg(normal_color))
422 .styles(|styles| {
423 styles
424 .checked(|style| style.bg(checked_color))
425 .disabled(|style| style.when(checked, |style| style.bg(disabled_color)))
426 })
427 };
428
429 assert_eq!(
430 track(false, false).resolved_style().background,
431 Some(normal_color.into())
432 );
433 assert_eq!(
434 track(false, true).resolved_style().background,
435 Some(normal_color.into())
436 );
437 assert_eq!(
438 track(true, false).resolved_style().background,
439 Some(checked_color.into())
440 );
441 assert_eq!(
442 track(true, true).resolved_style().background,
443 Some(disabled_color.into())
444 );
445 assert_eq!(
448 track(true, true)
449 .bg(normal_color)
450 .resolved_style()
451 .background,
452 Some(disabled_color.into())
453 );
454 }
455
456 #[test]
457 fn track_identity_is_scoped_to_its_switch() {
458 fn track(switch_id: &'static str) -> SwitchTrack {
459 SwitchTrack::new((ElementId::from(switch_id), "track"))
460 }
461
462 let first = track("first-switch");
463 let second = track("second-switch");
464
465 assert_eq!(
466 gpui::Element::id(&first.base),
467 Some((ElementId::from("first-switch"), "track").into())
468 );
469 assert_eq!(
470 gpui::Element::id(&second.base),
471 Some((ElementId::from("second-switch"), "track").into())
472 );
473 assert_ne!(
474 gpui::Element::id(&first.base),
475 gpui::Element::id(&second.base)
476 );
477 }
478
479 #[test]
480 fn thumb_projects_checked_disabled_and_instance_style_priority() {
481 let checked_color = gpui::hsla(0.6, 0.7, 0.5, 1.0);
482 let disabled_color = gpui::hsla(0.1, 0.2, 0.3, 0.5);
483 let thumb = |checked, disabled| {
484 SwitchThumb::new(checked)
485 .disabled(disabled)
486 .styles(|styles| {
487 styles
488 .checked(|style| style.bg(checked_color))
489 .disabled(|style| style.bg(disabled_color))
490 })
491 };
492
493 assert_eq!(
494 thumb(true, false).resolved_style().background,
495 Some(checked_color.into())
496 );
497 assert_eq!(
498 thumb(true, true).resolved_style().background,
499 Some(disabled_color.into())
500 );
501 assert_eq!(
502 thumb(true, true)
503 .bg(checked_color)
504 .resolved_style()
505 .background,
506 Some(disabled_color.into())
507 );
508 }
509
510 struct SwitchHarness {
511 checked: bool,
512 disabled: bool,
513 toggles: Rc<Cell<usize>>,
514 keyboard_events: Rc<Cell<usize>>,
515 last_value: Rc<Cell<bool>>,
516 parent_clicks: Rc<Cell<usize>>,
517 }
518
519 impl Render for SwitchHarness {
520 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
521 let toggles = self.toggles.clone();
522 let keyboard_events = self.keyboard_events.clone();
523 let last_value = self.last_value.clone();
524 let parent_clicks = self.parent_clicks.clone();
525
526 div()
527 .id("switch-parent")
528 .tab_group()
529 .size(px(100.))
530 .on_click(move |_, _, _| parent_clicks.set(parent_clicks.get() + 1))
531 .child(
532 Switch::new("switch")
533 .checked(self.checked)
534 .disabled(self.disabled)
535 .size_full()
536 .on_change(move |value, event, _, _| {
537 toggles.set(toggles.get() + 1);
538 last_value.set(value);
539 if matches!(event, ClickEvent::Keyboard(_)) {
540 keyboard_events.set(keyboard_events.get() + 1);
541 }
542 }),
543 )
544 }
545 }
546
547 fn harness(
548 cx: &mut TestAppContext,
549 checked: bool,
550 disabled: bool,
551 ) -> (
552 &mut VisualTestContext,
553 Rc<Cell<usize>>,
554 Rc<Cell<usize>>,
555 Rc<Cell<bool>>,
556 Rc<Cell<usize>>,
557 ) {
558 let toggles = Rc::new(Cell::new(0));
559 let keyboard_events = Rc::new(Cell::new(0));
560 let last_value = Rc::new(Cell::new(checked));
561 let parent_clicks = Rc::new(Cell::new(0));
562 let (_, cx) = cx.add_window_view({
563 let toggles = toggles.clone();
564 let keyboard_events = keyboard_events.clone();
565 let last_value = last_value.clone();
566 let parent_clicks = parent_clicks.clone();
567 move |_, _| SwitchHarness {
568 checked,
569 disabled,
570 toggles,
571 keyboard_events,
572 last_value,
573 parent_clicks,
574 }
575 });
576 cx.update(|window, cx| window.draw(cx).clear(cx));
577 (cx, toggles, keyboard_events, last_value, parent_clicks)
578 }
579
580 fn activate_key(cx: &mut VisualTestContext, key: &str) {
581 let keystroke = Keystroke::parse(key).unwrap();
582 cx.simulate_event(KeyDownEvent {
583 keystroke: keystroke.clone(),
584 is_held: false,
585 prefer_character_input: false,
586 });
587 cx.simulate_event(KeyUpEvent { keystroke });
588 }
589
590 #[gpui::test]
591 fn pointer_reports_the_next_value_once(cx: &mut TestAppContext) {
592 let (cx, toggles, keyboard_events, last_value, _) = harness(cx, false, false);
593
594 cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
595
596 assert_eq!(toggles.get(), 1);
597 assert_eq!(keyboard_events.get(), 0);
598 assert!(last_value.get());
599 }
600
601 #[gpui::test]
602 fn enter_and_space_report_one_native_keyboard_activation_each(cx: &mut TestAppContext) {
603 let (cx, toggles, keyboard_events, last_value, _) = harness(cx, false, false);
604 cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
605 toggles.set(0);
606 cx.update(|window, cx| {
607 assert!(window.focused(cx).is_some());
608 window.draw(cx).clear(cx);
609 });
610
611 activate_key(cx, "enter");
612 activate_key(cx, "space");
613
614 assert_eq!(toggles.get(), 2);
615 assert_eq!(keyboard_events.get(), 2);
616 assert!(last_value.get());
617 }
618
619 #[gpui::test]
620 fn disabled_switch_is_inert_and_blocks_parent_activation(cx: &mut TestAppContext) {
621 let (cx, toggles, _, _, parent_clicks) = harness(cx, false, true);
622
623 cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
624 activate_key(cx, "enter");
625 activate_key(cx, "space");
626
627 assert_eq!(toggles.get(), 0);
628 assert_eq!(parent_clicks.get(), 0);
629 }
630
631 #[test]
632 fn application_owned_state_styles_are_available() {
633 let _ = Switch::new("states")
634 .styles(|styles| {
635 styles
636 .checked(|style| style.opacity(0.8))
637 .disabled(|style| style.opacity(0.5))
638 })
639 .hover(|style| style.opacity(0.9))
640 .active(|style| style.opacity(0.8))
641 .focus_visible(|style| style.opacity(0.7));
642 }
643
644 #[test]
645 fn semantic_root_styles_follow_switch_priority() {
646 let styled = |switch: Switch| {
647 switch.styles(|styles| {
648 styles
649 .checked(|style| style.opacity(0.8))
650 .disabled(|style| style.opacity(0.5))
651 })
652 };
653
654 assert_eq!(styled(Switch::new("normal")).resolved_style().opacity, None);
655 assert_eq!(
656 styled(Switch::new("checked").checked(true))
657 .resolved_style()
658 .opacity,
659 Some(0.8)
660 );
661 assert_eq!(
662 styled(Switch::new("checked-disabled").checked(true).disabled(true))
663 .resolved_style()
664 .opacity,
665 Some(0.5)
666 );
667 assert_eq!(
668 styled(
669 Switch::new("state-over-instance")
670 .checked(true)
671 .disabled(true)
672 .opacity(0.9),
673 )
674 .resolved_style()
675 .opacity,
676 Some(0.5)
677 );
678 }
679
680 #[gpui::test]
681 fn accessibility_exposes_switch_role_label_and_toggled_state(cx: &mut TestAppContext) {
682 type Captured = Arc<Mutex<Option<(accesskit::Node, accesskit::Node)>>>;
683
684 struct A11yProbe {
685 captured: Captured,
686 }
687
688 impl Render for A11yProbe {
689 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
690 let captured = self.captured.clone();
691 canvas(
692 move |_, window, cx| {
693 let mut info = |switch: Switch| {
694 let mut node = accesskit::Node::new(Role::Switch);
695 switch
696 .render(window, cx)
697 .into_element()
698 .write_a11y_info(&mut node);
699 node
700 };
701 let enabled = info(
702 Switch::new("enabled")
703 .checked(true)
704 .accessibility_label("Airplane mode")
705 .on_change(|_, _, _, _| {}),
706 );
707 let disabled = info(
708 Switch::new("disabled")
709 .checked(false)
710 .disabled(true)
711 .accessibility_label("Airplane mode")
712 .on_change(|_, _, _, _| {}),
713 );
714 *captured.lock().unwrap() = Some((enabled, disabled));
715 },
716 |_, _, _, _| {},
717 )
718 }
719 }
720
721 let captured: Captured = Arc::new(Mutex::new(None));
722 let result = captured.clone();
723 let (_, cx) = cx.add_window_view(move |_, _| A11yProbe { captured });
724 cx.update(|window, cx| window.draw(cx).clear(cx));
725 let (enabled, disabled) = result.lock().unwrap().take().unwrap();
726
727 assert_eq!(enabled.role(), Role::Switch);
728 assert_eq!(enabled.label(), Some("Airplane mode"));
729 assert_eq!(enabled.toggled(), Some(Toggled::True));
730 assert!(enabled.supports_action(accesskit::Action::Click));
731
732 assert_eq!(disabled.role(), Role::Switch);
733 assert_eq!(disabled.toggled(), Some(Toggled::False));
734 assert!(!disabled.supports_action(accesskit::Action::Click));
735 assert!(!disabled.is_disabled());
738 }
739}