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