tessera_ui_basic_components/
switch.rs

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