Skip to main content

gpui_cn/
switch.rs

1use std::rc::Rc;
2
3use gpui_kit::{
4    App, ElementId, Hsla, IntoElement, ParentElement as _, Pixels, RenderOnce, SharedString,
5    StyleRefinement, Styled, Window,
6    base::{self, Disableable, Interpolate, StyledExt as _, transition},
7    point,
8    prelude::FluentBuilder as _,
9    px,
10};
11
12use crate::{ActiveTheme as _, Theme, ThemeTokens};
13
14type ChangeHandler = Rc<dyn Fn(&bool, &mut Window, &mut App)>;
15
16/// A shadcn-style switch on `gpui_base::Switch`: a pill track with a
17/// round thumb that slides to the right when checked.
18///
19/// Base owns focus, Tab order, Enter and Space activation, the disabled
20/// contract, and the switch role with its toggled state. This type owns
21/// the look: the track colors, the thumb, the slide, the focus ring, and
22/// the disabled strength.
23///
24/// The checked value is the application's. Activation reports the next
25/// value through [`on_change`](Self::on_change), and the application
26/// renders it back through [`checked`](Self::checked).
27///
28/// ```
29/// use gpui_cn::Switch;
30///
31/// let _ = Switch::new("wifi")
32///     .checked(true)
33///     .accessibility_label("Wi-Fi")
34///     .on_change(|checked, _, _| println!("{checked}"));
35/// ```
36///
37/// The id keys focus and motion, so it must be stable across frames and
38/// unique under the nearest stateful ancestor.
39#[derive(IntoElement)]
40pub struct Switch {
41    id: ElementId,
42    style: StyleRefinement,
43    checked: bool,
44    disabled: bool,
45    accessibility_label: Option<SharedString>,
46    on_change: Option<ChangeHandler>,
47    tab_index: isize,
48    tab_stop: bool,
49}
50
51impl Switch {
52    /// A switch with a stable id, off.
53    pub fn new(id: impl Into<ElementId>) -> Self {
54        Self {
55            id: id.into(),
56            style: StyleRefinement::default(),
57            checked: false,
58            disabled: false,
59            accessibility_label: None,
60            on_change: None,
61            tab_index: 0,
62            tab_stop: true,
63        }
64    }
65
66    /// Whether the switch is on.
67    pub fn checked(mut self, checked: bool) -> Self {
68        self.checked = checked;
69        self
70    }
71
72    /// Whether the switch is on.
73    pub fn is_checked(&self) -> bool {
74        self.checked
75    }
76
77    /// The name a screen reader announces. A switch has no visible text of
78    /// its own, so every switch should have one.
79    pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
80        self.accessibility_label = Some(label.into());
81        self
82    }
83
84    /// Called with the next value when the switch is activated by pointer,
85    /// Enter, or Space. The value comes by reference so `cx.listener` fits.
86    pub fn on_change(mut self, handler: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
87        self.on_change = Some(Rc::new(handler));
88        self
89    }
90
91    /// The focus traversal index. The default is `0`.
92    pub fn tab_index(mut self, tab_index: isize) -> Self {
93        self.tab_index = tab_index;
94        self
95    }
96
97    /// Whether Tab reaches the switch. The default is `true`.
98    pub fn tab_stop(mut self, tab_stop: bool) -> Self {
99        self.tab_stop = tab_stop;
100        self
101    }
102}
103
104impl Disableable for Switch {
105    fn disabled(mut self, disabled: bool) -> Self {
106        self.disabled = disabled;
107        self
108    }
109}
110
111impl Styled for Switch {
112    fn style(&mut self) -> &mut StyleRefinement {
113        &mut self.style
114    }
115}
116
117/// How far the switch is into checked and disabled, from 0 to 1. The
118/// progress animates and the track is mixed from the current theme each
119/// frame, so a theme change repaints at once instead of fading from the
120/// old theme's colors.
121#[derive(Clone, Copy, Debug, PartialEq)]
122struct Progress {
123    checked: f32,
124    disabled: f32,
125}
126
127impl Progress {
128    fn of(checked: bool, disabled: bool) -> Self {
129        let weight = |on: bool| if on { 1. } else { 0. };
130        Self {
131            checked: weight(checked),
132            disabled: weight(disabled),
133        }
134    }
135}
136
137impl Interpolate for Progress {
138    fn interpolate(&self, target: &Self, progress: f32) -> Self {
139        let lerp = |from: f32, to: f32| from + (to - from) * progress;
140        Self {
141            checked: lerp(self.checked, target.checked),
142            disabled: lerp(self.disabled, target.disabled),
143        }
144    }
145}
146
147/// The look that follows the progress: the track color and where the
148/// thumb sits, measured from the track's padding edge.
149#[derive(Clone, Copy, Debug, PartialEq)]
150struct Surface {
151    track: Hsla,
152    thumb_offset: Pixels,
153}
154
155impl Interpolate for Surface {
156    fn interpolate(&self, target: &Self, progress: f32) -> Self {
157        Self {
158            track: self.track.interpolate(&target.track, progress),
159            thumb_offset: self
160                .thumb_offset
161                .interpolate(&target.thumb_offset, progress),
162        }
163    }
164}
165
166impl Surface {
167    /// The track and thumb position for `checked`. A disabled track keeps
168    /// its color at the disabled strength, over whatever is behind it.
169    fn of(checked: bool, disabled: bool, theme: &ThemeTokens) -> Self {
170        let metrics = &theme.metrics;
171        let track = if checked {
172            theme.switch_track_on
173        } else {
174            theme.switch_track_off
175        };
176        Self {
177            track: if disabled {
178                track.opacity(theme.disabled_opacity)
179            } else {
180                track
181            },
182            thumb_offset: if checked {
183                metrics.switch_track_width
184                    - metrics.switch_thumb_inset * 2.
185                    - metrics.switch_thumb_size
186            } else {
187                px(0.)
188            },
189        }
190    }
191
192    /// The look at `progress`, mixed from the looks of the current theme.
193    /// Whole progress gives exactly the look of that state.
194    fn at(progress: Progress, theme: &ThemeTokens) -> Self {
195        let checked = |disabled| {
196            Self::of(false, disabled, theme)
197                .interpolate(&Self::of(true, disabled, theme), progress.checked)
198        };
199        checked(false).interpolate(&checked(true), progress.disabled)
200    }
201}
202
203/// The thumb color. GPUI fades each quad on its own, so a disabled thumb
204/// cannot fade as a group with its track: it would show the track through
205/// itself. The reference composites the whole control over its card, so
206/// the disabled thumb is painted as the thumb at the disabled strength
207/// over the card surface (#a7a7a7 on dark).
208fn thumb_color(disabled: bool, theme: &ThemeTokens) -> Hsla {
209    if disabled {
210        // `blend` is the sRGB source-over the GPU applies, so the flat
211        // thumb matches what a faded one would have shown over the card.
212        theme
213            .base
214            .colors
215            .surface
216            .blend(theme.switch_thumb.opacity(theme.disabled_opacity))
217    } else {
218        theme.switch_thumb
219    }
220}
221
222/// Everything the render needs from the theme, read in one borrow.
223struct Look {
224    thumb: Hsla,
225    width: Pixels,
226    height: Pixels,
227    thumb_size: Pixels,
228    inset: Pixels,
229    hit_height: Pixels,
230    ring: Hsla,
231    ring_spread: Pixels,
232    radius: Pixels,
233}
234
235impl RenderOnce for Switch {
236    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
237        let settings = Theme::global(cx);
238        let slide = settings.motion.slide_transition();
239        let pointer_cursors = settings.pointer_cursors;
240        let checked = self.checked;
241        let disabled = self.disabled;
242        let progress = transition(
243            ElementId::NamedChild(self.id.clone().into(), "progress".into()),
244            Progress::of(checked, disabled),
245            slide,
246            window,
247            cx,
248        );
249        let (look, surface) = {
250            let theme = cx.theme();
251            let metrics = &theme.metrics;
252            (
253                Look {
254                    thumb: thumb_color(disabled, theme),
255                    width: metrics.switch_track_width,
256                    height: metrics.switch_track_height,
257                    thumb_size: metrics.switch_thumb_size,
258                    inset: metrics.switch_thumb_inset,
259                    // On touch the track is the platform's own size and
260                    // the switch takes the row's hit height around it.
261                    hit_height: if theme.touch {
262                        metrics.control_md
263                    } else {
264                        metrics.switch_track_height
265                    },
266                    ring: theme.focus_ring(),
267                    ring_spread: metrics.focus_ring,
268                    radius: theme.radius_full(),
269                },
270                Surface::at(progress, theme),
271            )
272        };
273
274        // The switch owns its focus handle and hands it to base, so it can
275        // tell whether the ring shows.
276        let focus_handle = window
277            .use_keyed_state(
278                ElementId::NamedChild(self.id.clone().into(), "focus".into()),
279                cx,
280                |_, cx| cx.focus_handle(),
281            )
282            .read(cx)
283            .clone();
284        let focus_visible = focus_handle.is_focused(window) && window.last_input_was_keyboard();
285
286        let track_id = ElementId::NamedChild(self.id.clone().into(), "track".into());
287        let on_change = self.on_change;
288        base::Switch::new(self.id)
289            .track_focus(&focus_handle)
290            .checked(checked)
291            .disabled(disabled)
292            .tab_index(self.tab_index)
293            .tab_stop(self.tab_stop)
294            .when_some(self.accessibility_label, |this, label| {
295                this.accessibility_label(label)
296            })
297            .flex()
298            .flex_shrink_0()
299            .items_center()
300            .justify_center()
301            .w(look.width)
302            .h(look.hit_height)
303            .map(|this| {
304                if !disabled && pointer_cursors {
305                    this.cursor_pointer()
306                } else {
307                    this.cursor_default()
308                }
309            })
310            .refine_style(&self.style)
311            .child(
312                base::SwitchTrack::new(track_id)
313                    .checked(checked)
314                    .disabled(disabled)
315                    .flex()
316                    .items_center()
317                    .w(look.width)
318                    .h(look.height)
319                    .p(look.inset)
320                    .rounded(look.radius)
321                    .bg(surface.track)
322                    .when(focus_visible, |this| {
323                        this.shadow(vec![gpui_kit::BoxShadow {
324                            color: look.ring,
325                            offset: point(px(0.), px(0.)),
326                            blur_radius: px(0.),
327                            spread_radius: look.ring_spread,
328                            inset: false,
329                        }])
330                    })
331                    .child(
332                        base::SwitchThumb::new(checked)
333                            .disabled(disabled)
334                            .flex_shrink_0()
335                            .size(look.thumb_size)
336                            .ml(surface.thumb_offset)
337                            .rounded(look.radius)
338                            .bg(look.thumb),
339                    ),
340            )
341            .when_some(on_change.filter(|_| !disabled), |this, on_change| {
342                this.on_change(move |next, _, window, cx| on_change(&next, window, cx))
343            })
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350    use crate::theme::{ThemeConfig, to_hex};
351    use gpui_kit::base::ThemeAppearance;
352
353    #[test]
354    fn the_thumb_travels_the_track_minus_the_insets() {
355        let theme = crate::theme::test_tokens(&ThemeConfig::dark(), ThemeAppearance::Dark);
356        let off = Surface::of(false, false, &theme);
357        let on = Surface::of(true, false, &theme);
358        assert_eq!(off.thumb_offset, px(0.));
359        assert_eq!(on.thumb_offset, px(12.), "32 - 2 * 2 - 16");
360        assert_eq!(to_hex(on.track), "#539af8", "the accent");
361        let light = crate::theme::test_tokens(&ThemeConfig::light(), ThemeAppearance::Light);
362        assert_eq!(
363            to_hex(Surface::of(true, false, &light).track),
364            "#339cff",
365            "the accent on light"
366        );
367        assert_eq!(off.track, theme.switch_track_off);
368        let mid = off.interpolate(&on, 0.5);
369        assert_eq!(mid.thumb_offset, px(6.));
370    }
371
372    #[test]
373    fn whole_progress_gives_the_look_of_that_state() {
374        let theme = crate::theme::test_tokens(&ThemeConfig::dark(), ThemeAppearance::Dark);
375        for checked in [false, true] {
376            for disabled in [false, true] {
377                assert_eq!(
378                    Surface::at(Progress::of(checked, disabled), &theme),
379                    Surface::of(checked, disabled, &theme),
380                    "checked {checked}, disabled {disabled}"
381                );
382            }
383        }
384        let half = Surface::at(
385            Progress {
386                checked: 0.5,
387                disabled: 0.,
388            },
389            &theme,
390        );
391        assert_eq!(half.thumb_offset, px(6.));
392    }
393
394    #[test]
395    fn a_disabled_switch_fades_the_track_and_flattens_the_thumb() {
396        // The reference's disabled toggle on dark: the accent and white at
397        // the disabled strength over the #232323 card.
398        let theme = crate::theme::test_tokens(&ThemeConfig::dark(), ThemeAppearance::Dark);
399        let on = Surface::of(true, true, &theme);
400        assert_eq!(on.track.a, theme.disabled_opacity);
401        assert_eq!(to_hex(on.track.alpha(1.)), "#539af8");
402        assert_eq!(thumb_color(false, &theme).a, 1.);
403        let thumb = thumb_color(true, &theme);
404        assert_eq!(thumb.a, 1., "flat, so the track does not show through");
405        assert_eq!(to_hex(thumb), "#9c9c9c");
406    }
407
408    #[test]
409    fn builders_read_back() {
410        let switch = Switch::new("s").checked(true);
411        assert!(switch.is_checked());
412        assert!(!Switch::new("s").is_checked());
413    }
414}