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 alignment::Alignment,
23 animation,
24 boxed::{BoxedArgsBuilder, boxed},
25 material_color,
26 pipelines::shape::command::ShapeCommand,
27 shape_def::Shape,
28 surface::{SurfaceArgsBuilder, SurfaceStyle, surface},
29};
30
31const ANIMATION_DURATION: Duration = Duration::from_millis(150);
32const THUMB_OFF_SCALE: f32 = 0.72;
33
34pub(crate) struct SwitchStateInner {
38 checked: bool,
39 progress: f32,
40 last_toggle_time: Option<Instant>,
41}
42
43impl Default for SwitchStateInner {
44 fn default() -> Self {
45 Self::new(false)
46 }
47}
48
49impl SwitchStateInner {
50 pub fn new(initial_state: bool) -> Self {
52 Self {
53 checked: initial_state,
54 progress: if initial_state { 1.0 } else { 0.0 },
55 last_toggle_time: None,
56 }
57 }
58
59 pub fn toggle(&mut self) {
61 self.checked = !self.checked;
62 self.last_toggle_time = Some(Instant::now());
63 }
64}
65
66#[derive(Clone)]
68pub struct SwitchState {
69 inner: Arc<RwLock<SwitchStateInner>>,
70}
71
72impl SwitchState {
73 pub fn new(initial_state: bool) -> Self {
75 Self {
76 inner: Arc::new(RwLock::new(SwitchStateInner::new(initial_state))),
77 }
78 }
79
80 pub(crate) fn read(&self) -> RwLockReadGuard<'_, SwitchStateInner> {
81 self.inner.read()
82 }
83
84 pub(crate) fn write(&self) -> RwLockWriteGuard<'_, SwitchStateInner> {
85 self.inner.write()
86 }
87
88 pub fn is_checked(&self) -> bool {
90 self.inner.read().checked
91 }
92
93 pub fn set_checked(&self, checked: bool) {
95 if self.inner.read().checked != checked {
96 self.inner.write().checked = checked;
97 self.inner.write().progress = if checked { 1.0 } else { 0.0 };
98 self.inner.write().last_toggle_time = None;
99 }
100 }
101
102 pub fn toggle(&self) {
104 self.inner.write().toggle();
105 }
106
107 pub fn animation_progress(&self) -> f32 {
109 self.inner.read().progress
110 }
111}
112
113impl Default for SwitchState {
114 fn default() -> Self {
115 Self::new(false)
116 }
117}
118
119#[derive(Builder, Clone)]
121#[builder(pattern = "owned")]
122pub struct SwitchArgs {
123 #[builder(default, setter(strip_option))]
125 pub on_toggle: Option<Arc<dyn Fn(bool) + Send + Sync>>,
126 #[builder(default = "Dp(52.0)")]
128 pub width: Dp,
129 #[builder(default = "Dp(32.0)")]
131 pub height: Dp,
132 #[builder(default = "crate::material_color::global_material_scheme().surface_variant")]
134 pub track_color: Color,
135 #[builder(default = "crate::material_color::global_material_scheme().primary")]
137 pub track_checked_color: Color,
138 #[builder(default = "crate::material_color::global_material_scheme().outline")]
140 pub track_outline_color: Color,
141 #[builder(default = "Dp(1.5)")]
143 pub track_outline_width: Dp,
144 #[builder(default = "crate::material_color::global_material_scheme().on_surface_variant")]
146 pub thumb_color: Color,
147 #[builder(default = "crate::material_color::global_material_scheme().on_primary")]
149 pub thumb_checked_color: Color,
150 #[builder(default = "crate::material_color::global_material_scheme().outline")]
152 pub thumb_border_color: Color,
153 #[builder(default = "Dp(1.5)")]
155 pub thumb_border_width: Dp,
156 #[builder(default = "Dp(4.0)")]
158 pub thumb_padding: Dp,
159 #[builder(default, setter(strip_option, into))]
161 pub accessibility_label: Option<String>,
162 #[builder(default, setter(strip_option, into))]
164 pub accessibility_description: Option<String>,
165}
166
167impl Default for SwitchArgs {
168 fn default() -> Self {
169 SwitchArgsBuilder::default()
170 .build()
171 .expect("builder construction failed")
172 }
173}
174
175fn update_progress_from_state(state: &SwitchState) {
176 let last_toggle_time = state.read().last_toggle_time;
177 if let Some(last_toggle_time) = last_toggle_time {
178 let elapsed = last_toggle_time.elapsed();
179 let fraction = (elapsed.as_secs_f32() / ANIMATION_DURATION.as_secs_f32()).min(1.0);
180 let checked = state.read().checked;
181 let target = if checked { 1.0 } else { 0.0 };
182 let progress = if checked { fraction } else { 1.0 - fraction };
183
184 state.write().progress = progress;
185
186 if (progress - target).abs() < f32::EPSILON || fraction >= 1.0 {
187 state.write().progress = target;
188 state.write().last_toggle_time = None;
189 }
190 }
191}
192
193fn is_cursor_in_component(size: ComputedData, pos_option: Option<tessera_ui::PxPosition>) -> bool {
194 pos_option
195 .map(|pos| {
196 pos.x.0 >= 0 && pos.x.0 < size.width.0 && pos.y.0 >= 0 && pos.y.0 < size.height.0
197 })
198 .unwrap_or(false)
199}
200
201fn handle_input_events_switch(
202 state: &SwitchState,
203 on_toggle: &Option<Arc<dyn Fn(bool) + Send + Sync>>,
204 input: &mut tessera_ui::InputHandlerInput,
205) {
206 update_progress_from_state(state);
207
208 let size = input.computed_data;
209 let is_cursor_in = is_cursor_in_component(size, input.cursor_position_rel);
210
211 if is_cursor_in && on_toggle.is_some() {
212 input.requests.cursor_icon = CursorIcon::Pointer;
213 }
214
215 for e in input.cursor_events.iter() {
216 if matches!(
217 e.content,
218 CursorEventContent::Pressed(PressKeyEventType::Left)
219 ) && is_cursor_in
220 {
221 toggle_switch_state(state, on_toggle);
222 }
223 }
224}
225
226fn toggle_switch_state(
227 state: &SwitchState,
228 on_toggle: &Option<Arc<dyn Fn(bool) + Send + Sync>>,
229) -> bool {
230 let Some(on_toggle) = on_toggle else {
231 return false;
232 };
233
234 state.write().toggle();
235 let checked = state.read().checked;
236 on_toggle(checked);
237 true
238}
239
240fn apply_switch_accessibility(
241 input: &mut tessera_ui::InputHandlerInput<'_>,
242 state: &SwitchState,
243 on_toggle: &Option<Arc<dyn Fn(bool) + Send + Sync>>,
244 label: Option<&String>,
245 description: Option<&String>,
246) {
247 let checked = state.read().checked;
248 let mut builder = input.accessibility().role(Role::Switch);
249
250 if let Some(label) = label {
251 builder = builder.label(label.clone());
252 }
253 if let Some(description) = description {
254 builder = builder.description(description.clone());
255 }
256
257 builder = builder.toggled(if checked {
258 Toggled::True
259 } else {
260 Toggled::False
261 });
262
263 if on_toggle.is_some() {
264 builder = builder.focusable().action(Action::Click);
265 } else {
266 builder = builder.disabled();
267 }
268
269 builder.commit();
270
271 if on_toggle.is_some() {
272 let state = state.clone();
273 let on_toggle = on_toggle.clone();
274 input.set_accessibility_action_handler(move |action| {
275 if action == Action::Click {
276 toggle_switch_state(&state, &on_toggle);
277 }
278 });
279 }
280}
281
282fn interpolate_color(off: Color, on: Color, progress: f32) -> Color {
283 Color {
284 r: off.r + (on.r - off.r) * progress,
285 g: off.g + (on.g - off.g) * progress,
286 b: off.b + (on.b - off.b) * progress,
287 a: off.a + (on.a - off.a) * progress,
288 }
289}
290
291#[tessera]
292fn switch_inner(
293 args: SwitchArgs,
294 state: SwitchState,
295 child: Option<Box<dyn FnOnce() + Send + Sync>>,
296) {
297 update_progress_from_state(&state);
298
299 let thumb_size = Dp(args.height.0 - (args.thumb_padding.0 * 2.0));
300 let progress = state.read().progress;
301 let eased_progress = animation::easing(progress);
302 let thumb_scale = THUMB_OFF_SCALE + (1.0 - THUMB_OFF_SCALE) * eased_progress;
303 let scheme = material_color::global_material_scheme();
304 let interactive = args.on_toggle.is_some();
305
306 let mut track_color = interpolate_color(args.track_color, args.track_checked_color, progress);
307 let mut track_outline_color =
308 interpolate_color(args.track_outline_color, args.track_checked_color, progress);
309 let mut thumb_color = interpolate_color(args.thumb_color, args.thumb_checked_color, progress);
310 let mut thumb_border_color =
311 interpolate_color(args.thumb_border_color, args.thumb_checked_color, progress);
312
313 if !interactive {
314 track_color = material_color::blend_over(track_color, scheme.on_surface, 0.12);
315 track_outline_color =
316 material_color::blend_over(track_outline_color, scheme.on_surface, 0.12);
317 thumb_color = material_color::blend_over(thumb_color, scheme.on_surface, 0.38);
318 thumb_border_color =
319 material_color::blend_over(thumb_border_color, scheme.on_surface, 0.12);
320 }
321
322 let thumb_style = SurfaceStyle::FilledOutlined {
323 fill_color: thumb_color,
324 border_color: thumb_border_color,
325 border_width: args.thumb_border_width,
326 };
327 let base_thumb_px = thumb_size.to_px();
328 let thumb_size_px = tessera_ui::Px((base_thumb_px.0 as f32 * thumb_scale).round() as i32);
329
330 surface(
331 SurfaceArgsBuilder::default()
332 .width(DimensionValue::Fixed(thumb_size_px))
333 .height(DimensionValue::Fixed(thumb_size_px))
334 .style(thumb_style)
335 .shape(Shape::Ellipse)
336 .build()
337 .expect("builder construction failed"),
338 None,
339 {
340 move || {
341 if let Some(child) = child {
342 boxed(
343 BoxedArgsBuilder::default()
344 .width(DimensionValue::Fixed(thumb_size_px))
345 .height(DimensionValue::Fixed(thumb_size_px))
346 .alignment(Alignment::Center)
347 .build()
348 .expect("builder construction failed"),
349 |scope| {
350 scope.child(move || {
351 child();
352 });
353 },
354 );
355 }
356 }
357 },
358 );
359
360 let on_toggle = args.on_toggle.clone();
361 let accessibility_on_toggle = on_toggle.clone();
362 let accessibility_label = args.accessibility_label.clone();
363 let accessibility_description = args.accessibility_description.clone();
364 let progress_for_measure = progress;
365 let track_outline_width = args.track_outline_width;
366 let thumb_padding = args.thumb_padding;
367 let base_thumb_px_for_measure = base_thumb_px;
368 let track_color_for_measure = track_color;
369 let track_outline_color_for_measure = track_outline_color;
370 let width = args.width;
371 let height = args.height;
372
373 let state_for_handler = state.clone();
374 input_handler(Box::new(move |mut input| {
375 handle_input_events_switch(&state_for_handler, &on_toggle, &mut input);
377 apply_switch_accessibility(
378 &mut input,
379 &state_for_handler,
380 &accessibility_on_toggle,
381 accessibility_label.as_ref(),
382 accessibility_description.as_ref(),
383 );
384 }));
385
386 measure(Box::new(move |input| {
387 let thumb_id = input.children_ids[0];
388 let thumb_constraint = Constraint::new(
389 DimensionValue::Wrap {
390 min: None,
391 max: None,
392 },
393 DimensionValue::Wrap {
394 min: None,
395 max: None,
396 },
397 );
398 let thumb_size = input.measure_child(thumb_id, &thumb_constraint)?;
399
400 let self_width_px = width.to_px();
401 let self_height_px = height.to_px();
402 let thumb_padding_px = thumb_padding.to_px();
403
404 let start_center_x = thumb_padding_px.0 as f32 + base_thumb_px_for_measure.0 as f32 / 2.0;
405 let end_center_x = self_width_px.0 as f32
406 - thumb_padding_px.0 as f32
407 - base_thumb_px_for_measure.0 as f32 / 2.0;
408 let eased = animation::easing(progress_for_measure);
409 let thumb_center_x = start_center_x + (end_center_x - start_center_x) * eased;
410 let thumb_x = thumb_center_x - thumb_size.width.0 as f32 / 2.0;
411
412 let thumb_y = (self_height_px - thumb_size.height) / 2;
413
414 input.place_child(
415 thumb_id,
416 PxPosition::new(tessera_ui::Px(thumb_x as i32), thumb_y),
417 );
418
419 let track_command = ShapeCommand::FilledOutlinedRect {
420 color: track_color_for_measure,
421 border_color: track_outline_color_for_measure,
422 corner_radii: glam::Vec4::splat((self_height_px.0 as f32) / 2.0).into(),
423 corner_g2: [2.0; 4], shadow: None,
425 border_width: track_outline_width.to_pixels_f32(),
426 };
427 input.metadata_mut().push_draw_command(track_command);
428
429 Ok(ComputedData {
430 width: self_width_px,
431 height: self_height_px,
432 })
433 }));
434}
435
436pub fn switch(args: impl Into<SwitchArgs>, state: SwitchState) {
468 switch_inner(args.into(), state, None);
469}
470
471pub fn switch_with_child(
513 args: impl Into<SwitchArgs>,
514 state: SwitchState,
515 child: impl FnOnce() + Send + Sync + 'static,
516) {
517 switch_inner(args.into(), state, Some(Box::new(child)));
518}