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}