Skip to main content

liora_components/
switch.rs

1use crate::gpui_compat::element_id;
2use crate::motion::{MotionDuration, MotionEasing, motion_animation, slide_snap};
3use gpui::{
4    AnimationExt, App, Context, FocusHandle, Focusable, Hsla, KeyBinding, MouseButton, Render,
5    Rgba, Window, prelude::*, px,
6};
7
8fn rgba(r: u8, g: u8, b: u8, a: f32) -> Hsla {
9    Rgba {
10        r: r as f32 / 255.0,
11        g: g as f32 / 255.0,
12        b: b as f32 / 255.0,
13        a,
14    }
15    .into()
16}
17
18gpui::actions!(switch, [SwitchToggle]);
19
20pub struct Switch {
21    checked: bool,
22    thumb_from_checked: bool,
23    disabled: bool,
24    focus_handle: FocusHandle,
25    on_change: Option<Box<dyn Fn(bool, &mut Window, &mut App) + 'static>>,
26}
27
28impl Switch {
29    pub fn new(checked: bool, cx: &mut Context<Self>) -> Self {
30        Self {
31            checked,
32            thumb_from_checked: checked,
33            disabled: false,
34            focus_handle: cx.focus_handle(),
35            on_change: None,
36        }
37    }
38
39    pub fn disabled(mut self, d: bool) -> Self {
40        self.disabled = d;
41        self
42    }
43    pub fn on_change(mut self, cb: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
44        self.on_change = Some(Box::new(cb));
45        self
46    }
47
48    pub fn set_on_change(&mut self, cb: impl Fn(bool, &mut Window, &mut App) + 'static) {
49        self.on_change = Some(Box::new(cb));
50    }
51
52    pub fn checked(&self) -> bool {
53        self.checked
54    }
55
56    pub fn register_key_bindings(cx: &mut App) {
57        cx.bind_keys([
58            KeyBinding::new("space", SwitchToggle, None),
59            KeyBinding::new("enter", SwitchToggle, None),
60        ]);
61    }
62
63    fn toggle(&mut self, _: &SwitchToggle, window: &mut Window, cx: &mut Context<Self>) {
64        if !self.disabled {
65            self.thumb_from_checked = self.checked;
66            self.checked = !self.checked;
67            cx.notify();
68            if let Some(ref cb) = self.on_change {
69                cb(self.checked, window, cx);
70            }
71        }
72    }
73}
74
75impl Focusable for Switch {
76    fn focus_handle(&self, _cx: &App) -> FocusHandle {
77        self.focus_handle.clone()
78    }
79}
80
81impl Render for Switch {
82    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
83        let theme = &cx.global::<liora_core::Config>().theme;
84        let focused = self.focus_handle.is_focused(_window);
85        let w = 40.0;
86        let h = 22.0;
87        let thumb_sz = 16.0;
88        let thumb_start = 3.0;
89        let thumb_end = w - thumb_sz - 3.0;
90        let checked = self.checked;
91        let from_checked = self.thumb_from_checked;
92        let from_left = if from_checked { thumb_end } else { thumb_start };
93        let to_left = if checked { thumb_end } else { thumb_start };
94        let thumb_motion_id = format!(
95            "liora-switch-thumb-motion:{:?}:{from_checked}:{checked}",
96            self.focus_handle
97        );
98
99        let thumb_color = if self.disabled {
100            theme.neutral.text_disabled
101        } else {
102            rgba(255, 255, 255, 1.0)
103        };
104        let track_color = if self.disabled {
105            theme.neutral.hover
106        } else if self.checked {
107            theme.primary.base
108        } else {
109            theme.neutral.border
110        };
111
112        // Focus ring color
113        let focus_color = if self.checked {
114            theme.primary.base.opacity(0.5)
115        } else {
116            theme.neutral.border.opacity(0.5)
117        };
118
119        let mut track = gpui::div()
120            .relative()
121            .flex_none()
122            .w(px(w))
123            .h(px(h))
124            .rounded(px(h / 2.0))
125            .bg(track_color)
126            .child(
127                gpui::div()
128                    .absolute()
129                    .top(px((h - thumb_sz) / 2.0))
130                    .w(px(thumb_sz))
131                    .h(px(thumb_sz))
132                    .rounded(px(thumb_sz / 2.0))
133                    .bg(thumb_color)
134                    .with_animation(
135                        element_id(thumb_motion_id),
136                        motion_animation(MotionDuration::Normal, MotionEasing::Linear),
137                        move |thumb, delta| {
138                            let left = if (to_left - from_left).abs() < f32::EPSILON {
139                                to_left
140                            } else {
141                                slide_snap(from_left, to_left, delta)
142                            };
143
144                            thumb.left(px(left))
145                        },
146                    ),
147            );
148
149        if !self.disabled {
150            track = track.on_mouse_up(
151                MouseButton::Left,
152                cx.listener(|this, _, window, cx| {
153                    this.toggle(&SwitchToggle, window, cx);
154                }),
155            );
156        }
157
158        let mut el = gpui::div().p(px(2.0)).child(track);
159
160        if focused && !self.disabled {
161            el = el
162                .rounded(px((h + 4.0) / 2.0))
163                .border_2()
164                .border_color(focus_color);
165        } else {
166            el = el.border_2().border_color(rgba(0, 0, 0, 0.0));
167        }
168
169        if !self.disabled {
170            el = el.cursor_pointer().track_focus(&self.focus_handle);
171            el = el.on_mouse_down(
172                MouseButton::Left,
173                cx.listener(|this, _, window, cx| {
174                    window.focus(&this.focus_handle);
175                }),
176            );
177        } else {
178            el = el.cursor_not_allowed();
179        }
180
181        el.on_action(cx.listener(Self::toggle))
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    #[test]
188    fn switch_thumb_uses_elastic_motion() {
189        let source = include_str!("switch.rs")
190            .split("#[cfg(test)]")
191            .next()
192            .unwrap();
193
194        assert!(source.contains("with_animation("));
195        assert!(source.contains("MotionEasing::Linear"));
196        assert!(source.contains("slide_snap(from_left, to_left, delta)"));
197        assert!(source.contains("thumb_from_checked"));
198    }
199}