1use crate::{
2 ActiveTheme, Disableable, Side, Sizable, Size, StyledExt, text::Text, tooltip::ComponentTooltip,
3};
4use gpui::{
5 App, Background, ElementId, Hsla, InteractiveElement, IntoElement, ParentElement as _,
6 RenderOnce, SharedString, StyleRefinement, Styled, Window, div, prelude::FluentBuilder as _,
7 px,
8};
9use gpui_base::{Switch as BaseSwitch, SwitchThumb, SwitchTrack, spring};
10use std::rc::Rc;
11
12#[derive(IntoElement)]
14pub struct Switch {
15 id: ElementId,
16 style: StyleRefinement,
17 checked: bool,
18 disabled: bool,
19 label: Option<Text>,
20 accessibility_label: Option<SharedString>,
22 label_side: Side,
23 on_click: Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>,
24 size: Size,
25 color: Option<Hsla>,
26 tooltip: ComponentTooltip,
27}
28
29impl Switch {
30 pub fn new(id: impl Into<ElementId>) -> Self {
32 let id: ElementId = id.into();
33 Self {
34 id: id.clone(),
35 style: StyleRefinement::default(),
36 checked: false,
37 disabled: false,
38 label: None,
39 accessibility_label: None,
40 on_click: None,
41 label_side: Side::Right,
42 size: Size::Medium,
43 color: None,
44 tooltip: ComponentTooltip::default(),
45 }
46 }
47
48 pub fn checked(mut self, checked: bool) -> Self {
50 self.checked = checked;
51 self
52 }
53
54 pub fn label(mut self, label: impl Into<Text>) -> Self {
56 self.label = Some(label.into());
57 self
58 }
59
60 pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
67 self.accessibility_label = Some(label.into());
68 self
69 }
70
71 pub fn on_click<F>(self, handler: F) -> Self
73 where
74 F: Fn(&bool, &mut Window, &mut App) + 'static,
75 {
76 self.on_change(handler)
77 }
78
79 pub fn on_change<F>(mut self, handler: F) -> Self
86 where
87 F: Fn(&bool, &mut Window, &mut App) + 'static,
88 {
89 self.on_click = Some(Rc::new(handler));
90 self
91 }
92
93 pub fn color(mut self, color: impl Into<Hsla>) -> Self {
96 self.color = Some(color.into());
97 self
98 }
99
100 pub fn tooltip(mut self, tooltip: impl Into<SharedString>) -> Self {
102 self.tooltip.text = Some((tooltip.into(), None));
103 self
104 }
105}
106
107impl Styled for Switch {
108 fn style(&mut self) -> &mut gpui::StyleRefinement {
109 &mut self.style
110 }
111}
112
113impl Sizable for Switch {
114 fn with_size(mut self, size: impl Into<Size>) -> Self {
115 self.size = size.into();
116 self
117 }
118}
119
120impl Disableable for Switch {
121 fn disabled(mut self, disabled: bool) -> Self {
122 self.disabled = disabled;
123 self
124 }
125}
126
127impl RenderOnce for Switch {
128 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
129 let checked = self.checked;
130 let on_click = self.on_click.clone();
131 let accessibility_label = self
132 .accessibility_label
133 .clone()
134 .or_else(|| self.label.as_ref().map(|label| label.get_text(cx)));
135
136 let checked_bg = self
137 .color
138 .map(Background::from)
139 .unwrap_or(cx.theme().tokens.primary.into());
140 let unchecked_bg: Background = cx.theme().tokens.switch.into();
141 let disabled_bg = if checked { checked_bg } else { unchecked_bg }.opacity(0.5);
146 let toggle_bg: Background = cx.theme().tokens.switch_thumb.into();
147 let disabled_label_color = cx.theme().muted_foreground;
148
149 let (bg_width, bg_height) = match self.size {
150 Size::XSmall | Size::Small => (px(28.), px(16.)),
151 _ => (px(36.), px(20.)),
152 };
153 let bar_width = match self.size {
154 Size::XSmall | Size::Small => px(12.),
155 _ => px(16.),
156 };
157 let inset = px(2.);
158 let radius = if cx.theme().radius >= px(4.) {
159 bg_height
160 } else {
161 cx.theme().radius
162 };
163
164 let thumb_x = spring(
170 (self.id.clone(), "thumb"),
171 if checked {
172 bg_width - bar_width - inset * 2
173 } else {
174 px(0.)
175 },
176 cx.theme().motion_tokens().spring_move,
177 window,
178 cx,
179 );
180
181 div().refine_style(&self.style).child(
182 BaseSwitch::new(self.id.clone())
183 .checked(checked)
184 .disabled(self.disabled)
185 .styles(|styles| {
186 styles.disabled(|style| {
187 style.text_color(disabled_label_color).cursor_not_allowed()
188 })
189 })
190 .when_some(accessibility_label, |this, label| {
191 this.accessibility_label(label)
192 })
193 .when_some(on_click, |this, on_click| {
194 this.on_change(move |next, _, window, cx| on_click(&next, window, cx))
195 })
196 .h_flex()
197 .gap_2()
198 .items_start()
199 .when(self.label_side.is_left(), |this| this.flex_row_reverse())
200 .child(
201 SwitchTrack::new((self.id.clone(), "track"))
203 .checked(checked)
204 .disabled(self.disabled)
205 .when(cfg!(test), |this| {
206 this.debug_selector(|| "switch-bar".into())
207 })
208 .w(bg_width)
209 .h(bg_height)
210 .rounded(radius)
211 .flex()
212 .items_center()
213 .border(inset)
214 .border_color(cx.theme().transparent)
215 .when(!checked, |this| this.bg(unchecked_bg))
216 .styles(|styles| {
217 styles
218 .checked(|style| style.bg(checked_bg))
219 .disabled(|style| style.bg(disabled_bg))
220 })
221 .map(|this| self.tooltip.apply(this))
222 .child(
223 SwitchThumb::new(checked)
225 .rounded(radius)
226 .size(bar_width)
227 .left(thumb_x)
228 .bg(toggle_bg),
229 ),
230 )
231 .when_some(self.label, |this, label| {
232 this.child(
233 div()
234 .when(cfg!(test), |this| {
235 this.debug_selector(|| "switch-label".into())
236 })
237 .line_height(bg_height)
238 .child(label)
239 .map(|this| match self.size {
240 Size::XSmall | Size::Small => this.text_sm(),
241 _ => this.text_base(),
242 }),
243 )
244 }),
245 )
246 }
247}
248
249#[cfg(test)]
250mod tests {
251 use std::{cell::Cell, rc::Rc};
252
253 use gpui::{
254 Context, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, Render,
255 StatefulInteractiveElement as _, TestAppContext, VisualTestContext, point,
256 };
257
258 use super::*;
259
260 #[test]
261 fn an_explicit_accessibility_label_replaces_the_visible_one() {
262 let plain = Switch::new("wifi").label("Wi-Fi");
263 assert_eq!(plain.accessibility_label, None);
264 assert!(matches!(
265 &plain.label,
266 Some(Text::String(label)) if label.as_ref() == "Wi-Fi"
267 ));
268
269 let named = Switch::new("wifi")
270 .label("Wi-Fi")
271 .accessibility_label("Toggle Wi-Fi");
272 assert_eq!(
273 named.accessibility_label.as_deref(),
274 Some("Toggle Wi-Fi"),
275 "an explicit name must win over the visible label"
276 );
277 assert!(
278 matches!(
279 &named.label,
280 Some(Text::String(label)) if label.as_ref() == "Wi-Fi"
281 ),
282 "and must not change what is drawn"
283 );
284 }
285
286 struct SwitchHarness {
287 disabled: bool,
288 toggles: Rc<Cell<usize>>,
289 parent_clicks: Rc<Cell<usize>>,
290 }
291
292 impl Render for SwitchHarness {
293 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
294 let toggles = self.toggles.clone();
295 let parent_clicks = self.parent_clicks.clone();
296 div()
297 .id("switch-parent")
298 .tab_group()
299 .size(px(100.))
300 .on_click(move |_, _, _| parent_clicks.set(parent_clicks.get() + 1))
301 .child(Switch::new("switch").disabled(self.disabled).on_click(
302 move |checked, _, _| {
303 assert!(*checked);
304 toggles.set(toggles.get() + 1);
305 },
306 ))
307 }
308 }
309
310 fn harness(
311 cx: &mut TestAppContext,
312 disabled: bool,
313 ) -> (&mut VisualTestContext, Rc<Cell<usize>>, Rc<Cell<usize>>) {
314 cx.update(crate::init);
315 let toggles = Rc::new(Cell::new(0));
316 let parent_clicks = Rc::new(Cell::new(0));
317 let (_, cx) = cx.add_window_view({
318 let toggles = toggles.clone();
319 let parent_clicks = parent_clicks.clone();
320 move |_, _| SwitchHarness {
321 disabled,
322 toggles,
323 parent_clicks,
324 }
325 });
326 cx.update(|window, cx| window.draw(cx).clear(cx));
327 (cx, toggles, parent_clicks)
328 }
329
330 fn activate_key(cx: &mut VisualTestContext, key: &str) {
331 let keystroke = Keystroke::parse(key).unwrap();
332 cx.simulate_event(KeyDownEvent {
333 keystroke: keystroke.clone(),
334 is_held: false,
335 prefer_character_input: false,
336 });
337 cx.simulate_event(KeyUpEvent { keystroke });
338 }
339
340 #[gpui::test]
341 fn canonical_pointer_activation_fires_once_and_focuses(cx: &mut TestAppContext) {
342 let (cx, toggles, _) = harness(cx, false);
343 cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
344
345 assert_eq!(toggles.get(), 1);
346 cx.update(|window, cx| assert!(window.focused(cx).is_some()));
347 }
348
349 #[gpui::test]
350 fn canonical_switch_supports_tab_enter_and_space(cx: &mut TestAppContext) {
351 let (cx, toggles, _) = harness(cx, false);
352 cx.update(|window, cx| window.focus_next(cx));
353 cx.update(|window, cx| assert!(window.focused(cx).is_some()));
354
355 activate_key(cx, "enter");
356 activate_key(cx, "space");
357
358 assert_eq!(toggles.get(), 2);
359 }
360
361 #[gpui::test]
362 fn canonical_disabled_switch_is_inert_and_blocks_parent(cx: &mut TestAppContext) {
363 let (cx, toggles, parent_clicks) = harness(cx, true);
364 cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
365
366 assert_eq!(toggles.get(), 0);
367 assert_eq!(parent_clicks.get(), 0);
368 cx.update(|window, cx| assert!(window.focused(cx).is_none()));
369 }
370
371 #[gpui::test]
372 fn label_prepaints_with_the_base_switch_content(cx: &mut TestAppContext) {
373 struct LabelHarness;
374
375 impl Render for LabelHarness {
376 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
377 div()
378 .debug_selector(|| "labeled-switch".into())
379 .child(Switch::new("switch").label("Airplane mode"))
380 }
381 }
382
383 cx.update(crate::init);
384 let (_, cx) = cx.add_window_view(|_, _| LabelHarness);
385 cx.update(|window, cx| window.draw(cx).clear(cx));
386
387 let bounds = cx
388 .debug_bounds("labeled-switch")
389 .expect("the complete labeled Switch must participate in prepaint");
390 assert!(bounds.size.width > px(36.));
391 let bar = cx
392 .debug_bounds("switch-bar")
393 .expect("the Switch bar must participate in prepaint");
394 let label = cx
395 .debug_bounds("switch-label")
396 .expect("the Switch label must participate in prepaint");
397 assert_eq!(bar.origin.y, label.origin.y);
398 }
399}