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