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 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}