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