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    animation,
23    pipelines::ShapeCommand,
24    shape_def::Shape,
25    surface::{SurfaceArgsBuilder, surface},
26};
27
28const ANIMATION_DURATION: Duration = Duration::from_millis(150);
29
30/// Represents the state for the `switch` component, including checked status and animation progress.
31///
32/// This struct can be shared between multiple switches or managed externally to control the checked state and animation.
33pub(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    /// Creates a new `SwitchState` with the given initial checked state.
47    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    /// Toggles the checked state and updates the animation timestamp.
56    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    /// Returns whether the switch is currently checked.
83    pub fn is_checked(&self) -> bool {
84        self.inner.read().checked
85    }
86
87    /// Sets the checked state directly, resetting animation progress.
88    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    /// Toggles the switch and kicks off the animation timeline.
98    pub fn toggle(&self) {
99        self.inner.write().toggle();
100    }
101
102    /// Returns the current animation progress (0.0..1.0).
103    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/// Arguments for configuring the `switch` component.
115#[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    /// Optional accessibility label read by assistive technologies.
139    #[builder(default, setter(strip_option, into))]
140    pub accessibility_label: Option<String>,
141    /// Optional accessibility description.
142    #[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/// # switch
258///
259/// Renders an animated on/off toggle switch.
260///
261/// ## Usage
262///
263/// Use for settings or any other boolean state that the user can control.
264///
265/// ## Parameters
266///
267/// - `args` — configures the switch's appearance and `on_toggle` callback; see [`SwitchArgs`].
268/// - `state` — a clonable [`SwitchState`] to manage the checked/unchecked state.
269///
270/// ## Examples
271///
272/// ```
273/// use std::sync::Arc;
274/// use tessera_ui_basic_components::switch::{switch, SwitchArgsBuilder, SwitchState};
275///
276/// let switch_state = SwitchState::new(false);
277///
278/// switch(
279///     SwitchArgsBuilder::default()
280///         .on_toggle(Arc::new(|checked| {
281///             println!("Switch is now: {}", if checked { "ON" } else { "OFF" });
282///         }))
283///         .build()
284///         .unwrap(),
285///     switch_state,
286/// );
287/// ```
288#[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        // Delegate input handling to the extracted helper.
314        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, // Use G1 corners here specifically
359            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}