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}