tessera_ui_basic_components/
switch.rs

1//! An interactive toggle switch component.
2//!
3//! ## Usage
4//!
5//! Use to control a boolean on/off state.
6use 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
34/// Represents the state for the `switch` component, including checked status and animation progress.
35///
36/// This struct can be shared between multiple switches or managed externally to control the checked state and animation.
37pub(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    /// Creates a new `SwitchState` with the given initial checked state.
51    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    /// Toggles the checked state and updates the animation timestamp.
60    pub fn toggle(&mut self) {
61        self.checked = !self.checked;
62        self.last_toggle_time = Some(Instant::now());
63    }
64}
65
66/// External state handle for the `switch` component.
67#[derive(Clone)]
68pub struct SwitchState {
69    inner: Arc<RwLock<SwitchStateInner>>,
70}
71
72impl SwitchState {
73    /// Creates a new state handle with the given initial value.
74    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    /// Returns whether the switch is currently checked.
89    pub fn is_checked(&self) -> bool {
90        self.inner.read().checked
91    }
92
93    /// Sets the checked state directly, resetting animation progress.
94    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    /// Toggles the switch and kicks off the animation timeline.
103    pub fn toggle(&self) {
104        self.inner.write().toggle();
105    }
106
107    /// Returns the current animation progress (0.0..1.0).
108    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/// Arguments for configuring the `switch` component.
120#[derive(Builder, Clone)]
121#[builder(pattern = "owned")]
122pub struct SwitchArgs {
123    /// Optional callback invoked when the switch toggles.
124    #[builder(default, setter(strip_option))]
125    pub on_toggle: Option<Arc<dyn Fn(bool) + Send + Sync>>,
126    /// Total width of the switch track.
127    #[builder(default = "Dp(52.0)")]
128    pub width: Dp,
129    /// Total height of the switch track (including padding).
130    #[builder(default = "Dp(32.0)")]
131    pub height: Dp,
132    /// Track color when the switch is off.
133    #[builder(default = "crate::material_color::global_material_scheme().surface_variant")]
134    pub track_color: Color,
135    /// Track color when the switch is on.
136    #[builder(default = "crate::material_color::global_material_scheme().primary")]
137    pub track_checked_color: Color,
138    /// Outline color for the track when the switch is off; fades out as the switch turns on.
139    #[builder(default = "crate::material_color::global_material_scheme().outline")]
140    pub track_outline_color: Color,
141    /// Border width for the track outline.
142    #[builder(default = "Dp(1.5)")]
143    pub track_outline_width: Dp,
144    /// Thumb color when the switch is off.
145    #[builder(default = "crate::material_color::global_material_scheme().on_surface_variant")]
146    pub thumb_color: Color,
147    /// Thumb color when the switch is on.
148    #[builder(default = "crate::material_color::global_material_scheme().on_primary")]
149    pub thumb_checked_color: Color,
150    /// Thumb outline color to mirror Material Design's stroked thumb when off.
151    #[builder(default = "crate::material_color::global_material_scheme().outline")]
152    pub thumb_border_color: Color,
153    /// Width of the thumb outline stroke.
154    #[builder(default = "Dp(1.5)")]
155    pub thumb_border_width: Dp,
156    /// Padding around the thumb inside the track.
157    #[builder(default = "Dp(4.0)")]
158    pub thumb_padding: Dp,
159    /// Optional accessibility label read by assistive technologies.
160    #[builder(default, setter(strip_option, into))]
161    pub accessibility_label: Option<String>,
162    /// Optional accessibility description.
163    #[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        // Delegate input handling to the extracted helper.
376        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], // Use G1 corners here specifically
424            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
436/// # switch
437///
438/// Convenience wrapper for `switch_with_child` that renders no thumb content.
439///
440/// ## Usage
441///
442/// Use when you want a standard on/off switch without a custom icon.
443///
444/// ## Parameters
445///
446/// - `args` — configures sizing, colors, and callbacks; see [`SwitchArgs`].
447/// - `state` — a clonable [`SwitchState`] that owns the checked state and animation.
448///
449/// ## Examples
450///
451/// ```
452/// use std::sync::Arc;
453/// use tessera_ui_basic_components::switch::{switch, SwitchArgsBuilder, SwitchState};
454///
455/// let switch_state = SwitchState::new(false);
456///
457/// switch(
458///     SwitchArgsBuilder::default()
459///         .on_toggle(Arc::new(|checked| {
460///             assert!(checked || !checked);
461///         }))
462///         .build()
463///         .unwrap(),
464///     switch_state,
465/// );
466/// ```
467pub fn switch(args: impl Into<SwitchArgs>, state: SwitchState) {
468    switch_inner(args.into(), state, None);
469}
470
471/// # switch_with_child
472///
473/// Animated Material 3 switch with required custom thumb content; ideal for boolean toggles that show an icon or label.
474///
475/// ## Usage
476///
477/// Use for settings or binary preferences; provide a `child` to draw custom content (e.g., an icon) inside the thumb.
478///
479/// ## Parameters
480///
481/// - `args` — configures sizing, colors, and callbacks; see [`SwitchArgs`].
482/// - `state` — a clonable [`SwitchState`] that owns the checked state and animation.
483/// - `child` — closure rendered at the thumb center.
484///
485/// ## Examples
486///
487/// ```
488/// use std::sync::Arc;
489/// use tessera_ui_basic_components::switch::{switch_with_child, SwitchArgsBuilder, SwitchState};
490/// use tessera_ui_basic_components::text::{text, TextArgsBuilder};
491///
492/// let switch_state = SwitchState::new(false);
493///
494/// switch_with_child(
495///     SwitchArgsBuilder::default()
496///         .on_toggle(Arc::new(|checked| {
497///             assert!(checked || !checked);
498///         }))
499///         .build()
500///         .unwrap(),
501///     switch_state,
502///     || {
503///         text(
504///             TextArgsBuilder::default()
505///                 .text("✓".to_string())
506///                 .build()
507///                 .unwrap(),
508///         );
509///     },
510/// );
511/// ```
512pub 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}