tessera_ui_basic_components/
switch.rs1use std::{
7 sync::Arc,
8 time::{Duration, Instant},
9};
10
11use derive_builder::Builder;
12use parking_lot::{RwLock, RwLockReadGuard, RwLockWriteGuard};
13use tessera_ui::{
14 Color, ComputedData, Constraint, CursorEventContent, DimensionValue, Dp, PressKeyEventType,
15 PxPosition,
16 accesskit::{Action, Role, Toggled},
17 tessera,
18 winit::window::CursorIcon,
19};
20
21use crate::{
22 animation,
23 pipelines::ShapeCommand,
24 shape_def::Shape,
25 surface::{SurfaceArgsBuilder, surface},
26};
27
28const ANIMATION_DURATION: Duration = Duration::from_millis(150);
29
30pub(crate) struct SwitchStateInner {
34 checked: bool,
35 progress: f32,
36 last_toggle_time: Option<Instant>,
37}
38
39impl Default for SwitchStateInner {
40 fn default() -> Self {
41 Self::new(false)
42 }
43}
44
45impl SwitchStateInner {
46 pub fn new(initial_state: bool) -> Self {
48 Self {
49 checked: initial_state,
50 progress: if initial_state { 1.0 } else { 0.0 },
51 last_toggle_time: None,
52 }
53 }
54
55 pub fn toggle(&mut self) {
57 self.checked = !self.checked;
58 self.last_toggle_time = Some(Instant::now());
59 }
60}
61
62#[derive(Clone)]
63pub struct SwitchState {
64 inner: Arc<RwLock<SwitchStateInner>>,
65}
66
67impl SwitchState {
68 pub fn new(initial_state: bool) -> Self {
69 Self {
70 inner: Arc::new(RwLock::new(SwitchStateInner::new(initial_state))),
71 }
72 }
73
74 pub(crate) fn read(&self) -> RwLockReadGuard<'_, SwitchStateInner> {
75 self.inner.read()
76 }
77
78 pub(crate) fn write(&self) -> RwLockWriteGuard<'_, SwitchStateInner> {
79 self.inner.write()
80 }
81
82 pub fn is_checked(&self) -> bool {
84 self.inner.read().checked
85 }
86
87 pub fn set_checked(&self, checked: bool) {
89 let mut inner = self.inner.write();
90 if inner.checked != checked {
91 inner.checked = checked;
92 inner.progress = if checked { 1.0 } else { 0.0 };
93 inner.last_toggle_time = None;
94 }
95 }
96
97 pub fn toggle(&self) {
99 self.inner.write().toggle();
100 }
101
102 pub fn animation_progress(&self) -> f32 {
104 self.inner.read().progress
105 }
106}
107
108impl Default for SwitchState {
109 fn default() -> Self {
110 Self::new(false)
111 }
112}
113
114#[derive(Builder, Clone)]
116#[builder(pattern = "owned")]
117pub struct SwitchArgs {
118 #[builder(default, setter(strip_option))]
119 pub on_toggle: Option<Arc<dyn Fn(bool) + Send + Sync>>,
120
121 #[builder(default = "Dp(52.0)")]
122 pub width: Dp,
123
124 #[builder(default = "Dp(32.0)")]
125 pub height: Dp,
126
127 #[builder(default = "Color::new(0.8, 0.8, 0.8, 1.0)")]
128 pub track_color: Color,
129
130 #[builder(default = "Color::new(0.6, 0.7, 0.9, 1.0)")]
131 pub track_checked_color: Color,
132
133 #[builder(default = "Color::WHITE")]
134 pub thumb_color: Color,
135
136 #[builder(default = "Dp(3.0)")]
137 pub thumb_padding: Dp,
138 #[builder(default, setter(strip_option, into))]
140 pub accessibility_label: Option<String>,
141 #[builder(default, setter(strip_option, into))]
143 pub accessibility_description: Option<String>,
144}
145
146impl Default for SwitchArgs {
147 fn default() -> Self {
148 SwitchArgsBuilder::default().build().unwrap()
149 }
150}
151
152fn update_progress_from_state(state: &SwitchState) {
153 let last_toggle_time = state.read().last_toggle_time;
154 if let Some(last_toggle_time) = last_toggle_time {
155 let elapsed = last_toggle_time.elapsed();
156 let fraction = (elapsed.as_secs_f32() / ANIMATION_DURATION.as_secs_f32()).min(1.0);
157 let checked = state.read().checked;
158 state.write().progress = if checked { fraction } else { 1.0 - fraction };
159 }
160}
161
162fn is_cursor_in_component(size: ComputedData, pos_option: Option<tessera_ui::PxPosition>) -> bool {
163 pos_option
164 .map(|pos| {
165 pos.x.0 >= 0 && pos.x.0 < size.width.0 && pos.y.0 >= 0 && pos.y.0 < size.height.0
166 })
167 .unwrap_or(false)
168}
169
170fn handle_input_events_switch(
171 state: &SwitchState,
172 on_toggle: &Option<Arc<dyn Fn(bool) + Send + Sync>>,
173 input: &mut tessera_ui::InputHandlerInput,
174) {
175 update_progress_from_state(state);
176
177 let size = input.computed_data;
178 let is_cursor_in = is_cursor_in_component(size, input.cursor_position_rel);
179
180 if is_cursor_in && on_toggle.is_some() {
181 input.requests.cursor_icon = CursorIcon::Pointer;
182 }
183
184 for e in input.cursor_events.iter() {
185 if matches!(
186 e.content,
187 CursorEventContent::Pressed(PressKeyEventType::Left)
188 ) && is_cursor_in
189 {
190 toggle_switch_state(state, on_toggle);
191 }
192 }
193}
194
195fn toggle_switch_state(
196 state: &SwitchState,
197 on_toggle: &Option<Arc<dyn Fn(bool) + Send + Sync>>,
198) -> bool {
199 let Some(on_toggle) = on_toggle else {
200 return false;
201 };
202
203 state.write().toggle();
204 let checked = state.read().checked;
205 on_toggle(checked);
206 true
207}
208
209fn apply_switch_accessibility(
210 input: &mut tessera_ui::InputHandlerInput<'_>,
211 state: &SwitchState,
212 on_toggle: &Option<Arc<dyn Fn(bool) + Send + Sync>>,
213 label: Option<&String>,
214 description: Option<&String>,
215) {
216 let checked = state.read().checked;
217 let mut builder = input.accessibility().role(Role::Switch);
218
219 if let Some(label) = label {
220 builder = builder.label(label.clone());
221 }
222 if let Some(description) = description {
223 builder = builder.description(description.clone());
224 }
225
226 builder = builder
227 .focusable()
228 .action(Action::Click)
229 .toggled(if checked {
230 Toggled::True
231 } else {
232 Toggled::False
233 });
234
235 builder.commit();
236
237 if on_toggle.is_some() {
238 let state = state.clone();
239 let on_toggle = on_toggle.clone();
240 input.set_accessibility_action_handler(move |action| {
241 if action == Action::Click {
242 toggle_switch_state(&state, &on_toggle);
243 }
244 });
245 }
246}
247
248fn interpolate_color(off: Color, on: Color, progress: f32) -> Color {
249 Color {
250 r: off.r + (on.r - off.r) * progress,
251 g: off.g + (on.g - off.g) * progress,
252 b: off.b + (on.b - off.b) * progress,
253 a: off.a + (on.a - off.a) * progress,
254 }
255}
256
257#[tessera]
289pub fn switch(args: impl Into<SwitchArgs>, state: SwitchState) {
290 let args: SwitchArgs = args.into();
291 let thumb_size = Dp(args.height.0 - (args.thumb_padding.0 * 2.0));
292
293 surface(
294 SurfaceArgsBuilder::default()
295 .width(DimensionValue::Fixed(thumb_size.to_px()))
296 .height(DimensionValue::Fixed(thumb_size.to_px()))
297 .style(args.thumb_color.into())
298 .shape(Shape::Ellipse)
299 .build()
300 .unwrap(),
301 None,
302 || {},
303 );
304
305 let on_toggle = args.on_toggle.clone();
306 let accessibility_on_toggle = on_toggle.clone();
307 let accessibility_label = args.accessibility_label.clone();
308 let accessibility_description = args.accessibility_description.clone();
309 let progress = state.read().progress;
310
311 let state_for_handler = state.clone();
312 input_handler(Box::new(move |mut input| {
313 handle_input_events_switch(&state_for_handler, &on_toggle, &mut input);
315 apply_switch_accessibility(
316 &mut input,
317 &state_for_handler,
318 &accessibility_on_toggle,
319 accessibility_label.as_ref(),
320 accessibility_description.as_ref(),
321 );
322 }));
323
324 measure(Box::new(move |input| {
325 let thumb_id = input.children_ids[0];
326 let thumb_constraint = Constraint::new(
327 DimensionValue::Wrap {
328 min: None,
329 max: None,
330 },
331 DimensionValue::Wrap {
332 min: None,
333 max: None,
334 },
335 );
336 let thumb_size = input.measure_child(thumb_id, &thumb_constraint)?;
337
338 let self_width_px = args.width.to_px();
339 let self_height_px = args.height.to_px();
340 let thumb_padding_px = args.thumb_padding.to_px();
341
342 let start_x = thumb_padding_px;
343 let end_x = self_width_px - thumb_size.width - thumb_padding_px;
344 let eased = animation::easing(progress);
345 let thumb_x = start_x.0 as f32 + (end_x.0 - start_x.0) as f32 * eased;
346
347 let thumb_y = (self_height_px - thumb_size.height) / 2;
348
349 input.place_child(
350 thumb_id,
351 PxPosition::new(tessera_ui::Px(thumb_x as i32), thumb_y),
352 );
353
354 let track_color = interpolate_color(args.track_color, args.track_checked_color, progress);
355 let track_command = ShapeCommand::Rect {
356 color: track_color,
357 corner_radii: glam::Vec4::splat((self_height_px.0 as f32) / 2.0).into(),
358 g2_k_value: 2.0, shadow: None,
360 };
361 input.metadata_mut().push_draw_command(track_command);
362
363 Ok(ComputedData {
364 width: self_width_px,
365 height: self_height_px,
366 })
367 }));
368}