tessera_ui_basic_components/
switch.rs

1//! # Switch Component Module
2//!
3//! This module provides a customizable toggle switch UI component for boolean state management in the Tessera UI framework.
4//! The `switch` component is commonly used for toggling settings or preferences in user interfaces, offering a modern,
5//! animated on/off control. It supports both controlled (external state via [`SwitchState`]) and uncontrolled usage
6//! (via `checked` and `on_toggle` parameters), and allows for appearance customization such as track and thumb colors, size, and padding.
7//!
8//! ## Typical Usage
9//! - Settings panels, feature toggles, or any scenario requiring a boolean on/off control.
10//! - Can be integrated into forms or interactive UIs where immediate feedback and smooth animation are desired.
11//!
12//! ## Key Features
13//! - Stateless component model: state is managed externally or via parameters, following Tessera's architecture.
14//! - Animation support for smooth transitions between checked and unchecked states.
15//! - Highly customizable appearance and behavior via [`SwitchArgs`].
16//! - Designed for ergonomic integration with the Tessera component tree and event system.
17//!
18//! See [`SwitchArgs`], [`SwitchState`], and [`switch()`] for details and usage examples.
19use std::{
20    sync::Arc,
21    time::{Duration, Instant},
22};
23
24use derive_builder::Builder;
25use parking_lot::RwLock;
26use tessera_ui::{
27    Color, ComputedData, Constraint, CursorEventContent, DimensionValue, Dp, PressKeyEventType,
28    PxPosition, tessera, winit::window::CursorIcon,
29};
30
31use crate::{
32    animation,
33    pipelines::ShapeCommand,
34    shape_def::Shape,
35    surface::{SurfaceArgsBuilder, surface},
36};
37
38const ANIMATION_DURATION: Duration = Duration::from_millis(150);
39
40///
41/// Represents the state for the `switch` component, including checked status and animation progress.
42///
43/// This struct can be shared between multiple switches or managed externally to control the checked state and animation.
44///
45/// # Fields
46///
47/// - `checked`: Indicates whether the switch is currently on (`true`) or off (`false`).
48///
49/// # Example
50/// ```
51/// use tessera_ui_basic_components::switch::{SwitchState, SwitchArgs, switch};
52/// use std::sync::Arc;
53/// use parking_lot::RwLock;
54///
55/// let state = Arc::new(RwLock::new(SwitchState::new(false)));
56///
57/// switch(SwitchArgs {
58///     on_toggle: Some(Arc::new(move |checked| {
59///         println!("Switch toggled: {}", checked);
60///     })),
61///     ..Default::default()
62/// }, state.clone());
63/// ```
64pub struct SwitchState {
65    checked: bool,
66    progress: f32,
67    last_toggle_time: Option<Instant>,
68}
69
70impl Default for SwitchState {
71    fn default() -> Self {
72        Self::new(false)
73    }
74}
75
76impl SwitchState {
77    /// Creates a new `SwitchState` with the given initial checked state.
78    ///
79    /// # Arguments
80    ///
81    /// * `initial_state` - Whether the switch should start as checked (`true`) or unchecked (`false`).
82    pub fn new(initial_state: bool) -> Self {
83        Self {
84            checked: initial_state,
85            progress: if initial_state { 1.0 } else { 0.0 },
86            last_toggle_time: None,
87        }
88    }
89
90    /// Toggles the checked state and updates the animation timestamp.
91    pub fn toggle(&mut self) {
92        self.checked = !self.checked;
93        self.last_toggle_time = Some(Instant::now());
94    }
95
96    /// Returns whether the switch is currently checked.
97    pub fn is_checked(&self) -> bool {
98        self.checked
99    }
100}
101
102/// Arguments for configuring the `switch` component.
103///
104/// This struct allows customization of the switch's state, appearance, and behavior.
105///
106/// # Fields
107/// - `state`: Optional external state for the switch. If provided, the switch will use and update this state.
108/// - `checked`: Initial checked state if `state` is not provided.
109/// - `on_toggle`: Callback invoked when the switch is toggled, receiving the new checked state.
110/// - `width`: Width of the switch track.
111/// - `height`: Height of the switch track.
112/// - `track_color`: Color of the track when unchecked.
113/// - `track_checked_color`: Color of the track when checked.
114/// - `thumb_color`: Color of the thumb (handle).
115/// - `thumb_padding`: Padding between the thumb and the track edge.
116///
117/// # Example
118///
119/// ```
120/// use tessera_ui_basic_components::switch::{SwitchArgs, switch, SwitchState};
121/// use std::sync::Arc;
122/// use parking_lot::RwLock;
123///
124/// let state = Arc::new(RwLock::new(SwitchState::new(false)));
125///
126/// switch(SwitchArgs {
127///     on_toggle: Some(Arc::new(|checked| {
128///         println!("Switch toggled: {}", checked);
129///     })),
130///     ..Default::default()
131/// }, state.clone());
132/// ```
133#[derive(Builder, Clone)]
134#[builder(pattern = "owned")]
135pub struct SwitchArgs {
136    #[builder(default, setter(strip_option))]
137    pub on_toggle: Option<Arc<dyn Fn(bool) + Send + Sync>>,
138
139    #[builder(default = "Dp(52.0)")]
140    pub width: Dp,
141
142    #[builder(default = "Dp(32.0)")]
143    pub height: Dp,
144
145    #[builder(default = "Color::new(0.8, 0.8, 0.8, 1.0)")]
146    pub track_color: Color,
147
148    #[builder(default = "Color::new(0.6, 0.7, 0.9, 1.0)")]
149    pub track_checked_color: Color,
150
151    #[builder(default = "Color::WHITE")]
152    pub thumb_color: Color,
153
154    #[builder(default = "Dp(3.0)")]
155    pub thumb_padding: Dp,
156}
157
158impl Default for SwitchArgs {
159    fn default() -> Self {
160        SwitchArgsBuilder::default().build().unwrap()
161    }
162}
163
164fn update_progress_from_state(state: &Arc<RwLock<SwitchState>>) {
165    let last_toggle_time = state.read().last_toggle_time;
166    if let Some(last_toggle_time) = last_toggle_time {
167        let elapsed = last_toggle_time.elapsed();
168        let fraction = (elapsed.as_secs_f32() / ANIMATION_DURATION.as_secs_f32()).min(1.0);
169        let checked = state.read().checked;
170        state.write().progress = if checked { fraction } else { 1.0 - fraction };
171    }
172}
173
174fn is_cursor_in_component(size: ComputedData, pos_option: Option<tessera_ui::PxPosition>) -> bool {
175    pos_option
176        .map(|pos| {
177            pos.x.0 >= 0 && pos.x.0 < size.width.0 && pos.y.0 >= 0 && pos.y.0 < size.height.0
178        })
179        .unwrap_or(false)
180}
181
182fn handle_input_events_switch(
183    state: &Arc<RwLock<SwitchState>>,
184    on_toggle: &Option<Arc<dyn Fn(bool) + Send + Sync>>,
185    input: &mut tessera_ui::InputHandlerInput,
186) {
187    let Some(on_toggle) = on_toggle else {
188        return; // No-op if no on_toggle is provided (disabled state)
189    };
190
191    update_progress_from_state(state);
192
193    let size = input.computed_data;
194    let is_cursor_in = is_cursor_in_component(size, input.cursor_position_rel);
195
196    if is_cursor_in {
197        input.requests.cursor_icon = CursorIcon::Pointer;
198    }
199
200    for e in input.cursor_events.iter() {
201        if let CursorEventContent::Pressed(PressKeyEventType::Left) = &e.content
202            && is_cursor_in
203        {
204            state.write().toggle();
205            let new_state = state.read().checked;
206            on_toggle(new_state);
207        }
208    }
209}
210
211fn interpolate_color(off: Color, on: Color, progress: f32) -> Color {
212    Color {
213        r: off.r + (on.r - off.r) * progress,
214        g: off.g + (on.g - off.g) * progress,
215        b: off.b + (on.b - off.b) * progress,
216        a: off.a + (on.a - off.a) * progress,
217    }
218}
219
220/// A UI component that displays a toggle switch for boolean state.
221///
222/// The `switch` component provides a customizable on/off control, commonly used for toggling settings.
223/// It can be controlled via external state (`SwitchState`) or by using the `checked` and `on_toggle` parameters.
224///
225/// # Arguments
226/// * `args` - Parameters for configuring the switch, see [`SwitchArgs`].
227///
228/// # Example
229///
230/// ```
231/// use tessera_ui::Dp;
232/// use tessera_ui_basic_components::switch::{SwitchArgs, switch, SwitchState};
233/// use std::sync::Arc;
234/// use parking_lot::RwLock;
235///
236/// let state = Arc::new(RwLock::new(SwitchState::new(false)));
237///
238/// switch(SwitchArgs {
239///     on_toggle: Some(Arc::new(|checked| {
240///         println!("Switch toggled: {}", checked);
241///     })),
242///     width: Dp(60.0),
243///     height: Dp(36.0),
244///     ..Default::default()
245/// }, state.clone());
246/// ```
247#[tessera]
248pub fn switch(args: impl Into<SwitchArgs>, state: Arc<RwLock<SwitchState>>) {
249    let args: SwitchArgs = args.into();
250    let thumb_size = Dp(args.height.0 - (args.thumb_padding.0 * 2.0));
251
252    surface(
253        SurfaceArgsBuilder::default()
254            .width(DimensionValue::Fixed(thumb_size.to_px()))
255            .height(DimensionValue::Fixed(thumb_size.to_px()))
256            .style(args.thumb_color.into())
257            .shape(Shape::Ellipse)
258            .build()
259            .unwrap(),
260        None,
261        || {},
262    );
263
264    let on_toggle = args.on_toggle.clone();
265    let progress = state.read().progress;
266
267    input_handler(Box::new(move |mut input| {
268        // Delegate input handling to the extracted helper.
269        handle_input_events_switch(&state, &on_toggle, &mut input);
270    }));
271
272    measure(Box::new(move |input| {
273        let thumb_id = input.children_ids[0];
274        let thumb_constraint = Constraint::new(
275            DimensionValue::Wrap {
276                min: None,
277                max: None,
278            },
279            DimensionValue::Wrap {
280                min: None,
281                max: None,
282            },
283        );
284        let thumb_size = input.measure_child(thumb_id, &thumb_constraint)?;
285
286        let self_width_px = args.width.to_px();
287        let self_height_px = args.height.to_px();
288        let thumb_padding_px = args.thumb_padding.to_px();
289
290        let start_x = thumb_padding_px;
291        let end_x = self_width_px - thumb_size.width - thumb_padding_px;
292        let eased = animation::easing(progress);
293        let thumb_x = start_x.0 as f32 + (end_x.0 - start_x.0) as f32 * eased;
294
295        let thumb_y = (self_height_px - thumb_size.height) / 2;
296
297        input.place_child(
298            thumb_id,
299            PxPosition::new(tessera_ui::Px(thumb_x as i32), thumb_y),
300        );
301
302        let track_color = interpolate_color(args.track_color, args.track_checked_color, progress);
303        let track_command = ShapeCommand::Rect {
304            color: track_color,
305            corner_radii: glam::Vec4::splat((self_height_px.0 as f32) / 2.0).into(),
306            g2_k_value: 2.0, // Use G1 corners here specifically
307            shadow: None,
308        };
309        input.metadata_mut().push_draw_command(track_command);
310
311        Ok(ComputedData {
312            width: self_width_px,
313            height: self_height_px,
314        })
315    }));
316}