tessera_ui_basic_components/
glass_switch.rs

1//! # Glass Switch Component Module
2//!
3//! This module provides a customizable, glassmorphic-style switch (toggle) UI component for the Tessera UI framework.
4//! The glass switch enables toggling a boolean state with smooth animated transitions and a frosted glass visual effect.
5//! It is suitable for modern user interfaces requiring visually appealing, interactive on/off controls, such as settings panels, forms, or dashboards.
6//! The component supports extensive customization, including size, color, border, and animation, and is designed for stateless usage with external state management.
7//! Typical usage involves integrating the switch into application UIs where a clear, elegant toggle is desired.
8//!
9//! See [`glass_switch`] for usage details and customization options.
10
11use std::{
12    sync::Arc,
13    time::{Duration, Instant},
14};
15
16use derive_builder::Builder;
17use parking_lot::RwLock;
18use tessera_ui::{
19    Color, ComputedData, Constraint, CursorEventContent, DimensionValue, Dp, PressKeyEventType,
20    PxPosition, tessera, winit::window::CursorIcon,
21};
22
23use crate::{
24    animation,
25    fluid_glass::{FluidGlassArgsBuilder, GlassBorder, fluid_glass},
26    shape_def::Shape,
27};
28
29const ANIMATION_DURATION: Duration = Duration::from_millis(150);
30
31/// State for the `glass_switch` component, handling animation.
32pub struct GlassSwitchState {
33    checked: bool,
34    progress: f32,
35    last_toggle_time: Option<Instant>,
36}
37
38impl Default for GlassSwitchState {
39    fn default() -> Self {
40        Self::new(false)
41    }
42}
43
44impl GlassSwitchState {
45    /// Creates a new `GlassSwitchState` with the given initial checked state.
46    pub fn new(initial_state: bool) -> Self {
47        Self {
48            checked: initial_state,
49            progress: if initial_state { 1.0 } else { 0.0 },
50            last_toggle_time: None,
51        }
52    }
53
54    /// Toggles the switch state.
55    pub fn toggle(&mut self) {
56        self.checked = !self.checked;
57        self.last_toggle_time = Some(Instant::now());
58    }
59
60    /// Returns whether the switch is currently checked.
61    pub fn is_checked(&self) -> bool {
62        self.checked
63    }
64}
65
66#[derive(Builder, Clone)]
67#[builder(pattern = "owned")]
68pub struct GlassSwitchArgs {
69    #[builder(default, setter(strip_option))]
70    pub on_toggle: Option<Arc<dyn Fn(bool) + Send + Sync>>,
71
72    #[builder(default = "Dp(52.0)")]
73    pub width: Dp,
74
75    #[builder(default = "Dp(32.0)")]
76    pub height: Dp,
77
78    /// Track color when switch is ON
79    #[builder(default = "Color::new(0.2, 0.7, 1.0, 0.5)")]
80    pub track_on_color: Color,
81    /// Track color when switch is OFF
82    #[builder(default = "Color::new(0.8, 0.8, 0.8, 0.5)")]
83    pub track_off_color: Color,
84
85    /// Thumb alpha when switch is ON (opacity when ON)
86    #[builder(default = "0.5")]
87    pub thumb_on_alpha: f32,
88    /// Thumb alpha when switch is OFF (opacity when OFF)
89    #[builder(default = "1.0")]
90    pub thumb_off_alpha: f32,
91
92    /// Border for the thumb
93    #[builder(default, setter(strip_option))]
94    pub thumb_border: Option<GlassBorder>,
95
96    /// Border for the track
97    #[builder(default, setter(strip_option))]
98    pub track_border: Option<GlassBorder>,
99
100    /// Padding around the thumb
101    #[builder(default = "Dp(3.0)")]
102    pub thumb_padding: Dp,
103}
104
105impl Default for GlassSwitchArgs {
106    fn default() -> Self {
107        GlassSwitchArgsBuilder::default().build().unwrap()
108    }
109}
110
111fn interpolate_color(off: Color, on: Color, progress: f32) -> Color {
112    Color {
113        r: off.r + (on.r - off.r) * progress,
114        g: off.g + (on.g - off.g) * progress,
115        b: off.b + (on.b - off.b) * progress,
116        a: off.a + (on.a - off.a) * progress,
117    }
118}
119
120fn update_progress_from_state(state: Arc<RwLock<GlassSwitchState>>) {
121    let last_toggle_time = state.read().last_toggle_time;
122    if let Some(last_toggle_time) = last_toggle_time {
123        let elapsed = last_toggle_time.elapsed();
124        let fraction = (elapsed.as_secs_f32() / ANIMATION_DURATION.as_secs_f32()).min(1.0);
125        let checked = state.read().checked;
126        state.write().progress = if checked { fraction } else { 1.0 - fraction };
127    }
128}
129
130/// Return true if the given cursor position is inside the component bounds.
131fn is_cursor_inside(size: ComputedData, cursor_pos: Option<PxPosition>) -> bool {
132    cursor_pos
133        .map(|pos| {
134            pos.x.0 >= 0 && pos.x.0 < size.width.0 && pos.y.0 >= 0 && pos.y.0 < size.height.0
135        })
136        .unwrap_or(false)
137}
138
139/// Return true if there is a left-press event in the input.
140fn was_pressed_left(input: &tessera_ui::InputHandlerInput) -> bool {
141    input.cursor_events.iter().any(|e| {
142        matches!(
143            e.content,
144            CursorEventContent::Pressed(PressKeyEventType::Left)
145        )
146    })
147}
148
149fn handle_input_events(
150    state: Arc<RwLock<GlassSwitchState>>,
151    on_toggle: Option<Arc<dyn Fn(bool) + Send + Sync>>,
152    input: &mut tessera_ui::InputHandlerInput,
153) {
154    let Some(on_toggle) = on_toggle else {
155        // No callback provided, do nothing (act disabled)
156        return;
157    };
158
159    // Update progress first
160    update_progress_from_state(state.clone());
161
162    // Cursor handling
163    let size = input.computed_data;
164    let is_cursor_in = is_cursor_inside(size, input.cursor_position_rel);
165
166    if is_cursor_in {
167        input.requests.cursor_icon = CursorIcon::Pointer;
168    }
169
170    // Handle press events: toggle state and call callback
171    let pressed = was_pressed_left(input);
172
173    if pressed && is_cursor_in {
174        // If internal state exists, toggle it and use the toggled value.
175        state.write().toggle();
176        on_toggle(state.read().checked);
177    }
178}
179#[tessera]
180/// A glass-like switch component for toggling a boolean state.
181///
182/// The `glass_switch` provides a visually appealing switch with a frosted glass effect.
183/// It animates smoothly between its "on" and "off" states and is fully customizable
184/// in terms of size, color, and border.
185///
186/// # Example
187///
188/// ```
189/// use std::sync::Arc;
190/// use tessera_ui_basic_components::glass_switch::{glass_switch, GlassSwitchArgs, GlassSwitchArgsBuilder, GlassSwitchState};
191/// use parking_lot::RwLock;
192///
193/// // In a real app, you would manage the state in shard state or elsewhere.
194/// let state = Arc::new(RwLock::new(GlassSwitchState::new(false)));
195///
196/// glass_switch(
197///     GlassSwitchArgsBuilder::default()
198///         .on_toggle(Arc::new(|new_state| {
199///             // Update your application state here
200///             println!("Switch toggled to: {}", new_state);
201///         }))
202///         .build()
203///         .unwrap(),
204///     state.clone()
205/// );
206///
207/// // Use the state to toggle the switch programmatically if needed.
208/// state.write().toggle(); // Toggle the switch state
209/// // or get the current on/off state
210/// assert_eq!(state.read().is_checked(), true); // true here after toggle
211/// ```
212///
213/// # Arguments
214///
215/// * `args` - An instance of `GlassSwitchArgs` which can be built using `GlassSwitchArgsBuilder`.
216///   - `checked`: A `bool` indicating the current state of the switch (`true` for on, `false` for off).
217///   - `on_toggle`: A callback `Arc<dyn Fn(bool) + Send + Sync>` that is called when the switch is clicked.
218///     It receives the new boolean state.
219///   - Other arguments for customization like `width`, `height`, `track_on_color`, `track_off_color`, etc.
220///     are also available.
221pub fn glass_switch(args: impl Into<GlassSwitchArgs>, state: Arc<RwLock<GlassSwitchState>>) {
222    let args: GlassSwitchArgs = args.into();
223    // Precompute pixel sizes to avoid repeated conversions
224    let width_px = args.width.to_px();
225    let height_px = args.height.to_px();
226    let thumb_dp = Dp(args.height.0 - (args.thumb_padding.0 * 2.0));
227    let thumb_px = thumb_dp.to_px();
228    let track_radius_dp = Dp(args.height.0 / 2.0);
229
230    // Track tint color interpolation based on progress
231    let progress = state.read().progress;
232    let track_color = interpolate_color(args.track_off_color, args.track_on_color, progress);
233
234    // Build and render track
235    let mut track_builder = FluidGlassArgsBuilder::default()
236        .width(DimensionValue::Fixed(width_px))
237        .height(DimensionValue::Fixed(height_px))
238        .tint_color(track_color)
239        .shape({
240            Shape::RoundedRectangle {
241                top_left: track_radius_dp,
242                top_right: track_radius_dp,
243                bottom_right: track_radius_dp,
244                bottom_left: track_radius_dp,
245                g2_k_value: 2.0, // Capsule shape
246            }
247        })
248        .blur_radius(8.0);
249    if let Some(border) = args.track_border {
250        track_builder = track_builder.border(border);
251    }
252    fluid_glass(track_builder.build().unwrap(), None, || {});
253
254    // Build and render thumb
255    let thumb_alpha =
256        args.thumb_off_alpha + (args.thumb_on_alpha - args.thumb_off_alpha) * progress;
257    let thumb_color = Color::new(1.0, 1.0, 1.0, thumb_alpha);
258    let mut thumb_builder = FluidGlassArgsBuilder::default()
259        .width(DimensionValue::Fixed(thumb_px))
260        .height(DimensionValue::Fixed(thumb_px))
261        .tint_color(thumb_color)
262        .refraction_height(1.0)
263        .shape(Shape::Ellipse);
264    if let Some(border) = args.thumb_border {
265        thumb_builder = thumb_builder.border(border);
266    }
267    fluid_glass(thumb_builder.build().unwrap(), None, || {});
268
269    let state_clone = state.clone();
270    let on_toggle = args.on_toggle.clone();
271    input_handler(Box::new(move |mut input| {
272        handle_input_events(state_clone.clone(), on_toggle.clone(), &mut input);
273    }));
274
275    // Measurement and placement
276    measure(Box::new(move |input| {
277        // Expect track then thumb as children
278        let track_id = input.children_ids[0];
279        let thumb_id = input.children_ids[1];
280
281        let track_constraint = Constraint::new(
282            DimensionValue::Fixed(width_px),
283            DimensionValue::Fixed(height_px),
284        );
285        let thumb_constraint = Constraint::new(
286            DimensionValue::Wrap {
287                min: None,
288                max: None,
289            },
290            DimensionValue::Wrap {
291                min: None,
292                max: None,
293            },
294        );
295
296        // Measure both children
297        let nodes_constraints = vec![(track_id, track_constraint), (thumb_id, thumb_constraint)];
298        let sizes_map = input.measure_children(nodes_constraints)?;
299
300        let _track_size = sizes_map.get(&track_id).unwrap();
301        let thumb_size = sizes_map.get(&thumb_id).unwrap();
302        let self_width_px = width_px;
303        let self_height_px = height_px;
304        let thumb_padding_px = args.thumb_padding.to_px();
305
306        // Use eased progress for placement
307        let eased_progress = animation::easing(state.read().progress);
308
309        input.place_child(
310            track_id,
311            PxPosition::new(tessera_ui::Px(0), tessera_ui::Px(0)),
312        );
313
314        let start_x = thumb_padding_px;
315        let end_x = self_width_px - thumb_size.width - thumb_padding_px;
316        let thumb_x = start_x.0 as f32 + (end_x.0 - start_x.0) as f32 * eased_progress;
317        let thumb_y = (self_height_px - thumb_size.height) / 2;
318
319        input.place_child(
320            thumb_id,
321            PxPosition::new(tessera_ui::Px(thumb_x as i32), thumb_y),
322        );
323
324        Ok(ComputedData {
325            width: self_width_px,
326            height: self_height_px,
327        })
328    }));
329}