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