Skip to main content

glassy_ui/
switch.rs

1use std::rc::Rc;
2use std::time::Duration;
3
4use crate::motion::{Ease, Motion, MotionStyle, StyledSlot, Transition};
5use crate::theme::ActiveTheme;
6use gpui::{
7    div, prelude::*, px, App, ClickEvent, FocusHandle, FontWeight, IntoElement, KeyDownEvent,
8    RenderOnce, SharedString, StyleRefinement, Styled, Window,
9};
10
11use crate::compat::{AccessibilityExt, Role, Toggled};
12
13use crate::button::ButtonVariant;
14use crate::chrome::{box_shadow, button_chrome, focus_ring};
15
16type SwitchClickHandler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
17type SwitchChangeHandler = Rc<dyn Fn(bool, &mut Window, &mut App) + 'static>;
18
19struct SwitchState {
20    focus_handle: FocusHandle,
21    on: bool,
22    last_prop: bool,
23    rendered: bool,
24}
25
26/// 36×20 pill matching Paper `Glassy UI` → Switches.
27///
28/// Without a listener the thumb keeps its own state. With [`Switch::on_change`]
29/// or [`Switch::on_click`], [`Switch::on`] is the source of truth each render.
30#[derive(IntoElement)]
31pub struct Switch {
32    id: SharedString,
33    on: bool,
34    disabled: bool,
35    label: Option<SharedString>,
36    style: StyleRefinement,
37    on_click: Option<SwitchClickHandler>,
38    on_change: Option<SwitchChangeHandler>,
39}
40
41impl Switch {
42    pub fn new(id: impl Into<SharedString>) -> Self {
43        Self {
44            id: id.into(),
45            on: false,
46            disabled: false,
47            label: None,
48            style: StyleRefinement::default(),
49            on_click: None,
50            on_change: None,
51        }
52    }
53
54    pub fn on(mut self, on: bool) -> Self {
55        self.on = on;
56        self
57    }
58
59    pub fn disabled(mut self, disabled: bool) -> Self {
60        self.disabled = disabled;
61        self
62    }
63
64    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
65        self.label = Some(label.into());
66        self
67    }
68
69    pub fn on_click(
70        mut self,
71        listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
72    ) -> Self {
73        self.on_click = Some(Rc::new(listener));
74        self
75    }
76
77    /// New on/off after a pointer or keyboard activation.
78    pub fn on_change(mut self, listener: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
79        self.on_change = Some(Rc::new(listener));
80        self
81    }
82}
83
84impl Styled for Switch {
85    fn style(&mut self) -> &mut StyleRefinement {
86        &mut self.style
87    }
88}
89
90impl RenderOnce for Switch {
91    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
92        let motion_id = self.id.clone();
93        let initial = self.on;
94        let state = window.use_keyed_state(self.id.clone(), cx, move |_, cx| SwitchState {
95            focus_handle: cx.focus_handle(),
96            on: initial,
97            last_prop: initial,
98            rendered: false,
99        });
100        let controlled = self.on_change.is_some() || self.on_click.is_some();
101        if controlled {
102            if state.read(cx).on != self.on {
103                state.update(cx, |switch, _| switch.on = self.on);
104            }
105        } else if state.read(cx).last_prop != self.on {
106            state.update(cx, |switch, _| {
107                switch.on = self.on;
108                switch.last_prop = self.on;
109            });
110        }
111
112        let on = if controlled {
113            self.on
114        } else {
115            state.read(cx).on
116        };
117        let animate_thumb = state.read(cx).rendered;
118        if !animate_thumb {
119            state.update(cx, |switch, _| switch.rendered = true);
120        }
121        let theme = cx.theme();
122        let variant = if self.disabled {
123            ButtonVariant::Ghost
124        } else if on {
125            ButtonVariant::Primary
126        } else {
127            ButtonVariant::Outline
128        };
129        let chrome = button_chrome(theme, variant);
130        let thumb = if self.disabled {
131            theme.muted_fg()
132        } else if on || theme.is_dark() {
133            theme.on_solid
134        } else {
135            button_chrome(theme, ButtonVariant::Primary).bg
136        };
137
138        let mut shadows = vec![box_shadow(0., 1., chrome.inset, 0., 0.)];
139        if chrome.shadow_blur > 0.0 {
140            shadows.push(box_shadow(
141                0.,
142                chrome.shadow_y,
143                chrome.shadow,
144                chrome.shadow_blur,
145                0.,
146            ));
147        }
148
149        let interactive = !self.disabled;
150        let focus_handle = state.read(cx).focus_handle.clone().tab_stop(interactive);
151        let focused = focus_handle.is_focused(window);
152        if focused {
153            shadows.push(focus_ring(theme));
154        }
155
156        let track = div()
157            .flex()
158            .items_center()
159            .when(on, |el| el.justify_end())
160            .when(!on, |el| el.justify_start())
161            .w(px(36.))
162            .h(px(20.))
163            .flex_shrink_0()
164            .p(px(1.))
165            .rounded(px(10.))
166            .border_1()
167            .border_color(chrome.border)
168            .bg(chrome.bg)
169            .shadow(shadows)
170            .child(
171                Motion::new()
172                    .id(format!("{motion_id}-thumb-{on}"))
173                    .initial(MotionStyle::new().x(px(if animate_thumb {
174                        if on {
175                            -16.
176                        } else {
177                            16.
178                        }
179                    } else {
180                        0.
181                    })))
182                    .animate(MotionStyle::new().x(px(0.)))
183                    .transition(Transition::tween(Duration::from_millis(180)).ease(Ease::EaseOut))
184                    .child(
185                        div()
186                            .size(px(16.))
187                            .flex_shrink_0()
188                            .rounded(px(8.))
189                            .bg(thumb)
190                            .shadow(vec![
191                                box_shadow(0., 1., theme.on_solid.opacity(0.22), 0., 0.),
192                                box_shadow(0., 2., chrome.shadow, 6., 0.),
193                            ]),
194                    ),
195            );
196
197        let label_color = if self.disabled {
198            theme.muted_fg()
199        } else {
200            theme.ink
201        };
202        let aria_label = self.label.clone();
203        let debug_selector = format!("{}-{}", self.id, if on { "on" } else { "off" });
204
205        let el = div()
206            .id(self.id)
207            .debug_selector(move || debug_selector.clone())
208            .role(Role::Switch)
209            .aria_toggled(if on { Toggled::True } else { Toggled::False })
210            .when_some(aria_label, |el, label| el.aria_label(label))
211            .track_focus(&focus_handle)
212            .tab_stop(interactive)
213            .flex()
214            .items_center()
215            .gap(px(8.))
216            .refine_style(&self.style)
217            .when(interactive, |el| el.cursor_pointer())
218            .when(!interactive, |el| el.cursor_default())
219            .child(track)
220            .when_some(self.label, |el, label| {
221                el.child(
222                    div()
223                        .font_family(theme.font_family)
224                        .font_weight(FontWeight::NORMAL)
225                        .text_size(px(14.))
226                        .line_height(px(20.))
227                        .text_color(label_color)
228                        .child(label),
229                )
230            });
231
232        if interactive {
233            let on_click = self.on_click;
234            let on_change = self.on_change;
235            let activate = Rc::new(
236                move |event: &ClickEvent, window: &mut Window, cx: &mut App| {
237                    let next = !state.read(cx).on;
238                    if !controlled {
239                        state.update(cx, |switch, cx| {
240                            switch.on = next;
241                            cx.notify();
242                        });
243                    }
244                    if let Some(on_change) = &on_change {
245                        on_change(next, window, cx);
246                    }
247                    if let Some(on_click) = &on_click {
248                        on_click(event, window, cx);
249                    }
250                },
251            );
252            let keyboard = activate.clone();
253            let click_focus = focus_handle.clone();
254            el.on_key_down(move |event: &KeyDownEvent, window, cx| {
255                if event.keystroke.modifiers.modified() {
256                    return;
257                }
258                if matches!(event.keystroke.key.as_str(), "enter" | "space") {
259                    keyboard(&ClickEvent::default(), window, cx);
260                    cx.stop_propagation();
261                }
262            })
263            .on_click(move |event, window, cx| {
264                click_focus.focus(window);
265                activate(event, window, cx);
266            })
267        } else {
268            el
269        }
270    }
271}