tessera_ui_basic_components/
glass_switch.rs

1//! A switch (toggle) component with a glassmorphic visual style.
2//!
3//! ## Usage
4//!
5//! Use in settings, forms, or toolbars to control a boolean state.
6use std::{
7    sync::Arc,
8    time::{Duration, Instant},
9};
10
11use derive_builder::Builder;
12use parking_lot::{RwLock, RwLockReadGuard, RwLockWriteGuard};
13use tessera_ui::{
14    Color, ComputedData, Constraint, CursorEventContent, DimensionValue, Dp, PressKeyEventType,
15    PxPosition,
16    accesskit::{Action, Role, Toggled},
17    tessera,
18    winit::window::CursorIcon,
19};
20
21use crate::{
22    animation,
23    fluid_glass::{FluidGlassArgsBuilder, GlassBorder, fluid_glass},
24    shape_def::Shape,
25};
26
27const ANIMATION_DURATION: Duration = Duration::from_millis(150);
28
29/// State for the `glass_switch` component, handling animation.
30pub(crate) struct GlassSwitchStateInner {
31    checked: bool,
32    progress: f32,
33    last_toggle_time: Option<Instant>,
34}
35
36impl Default for GlassSwitchStateInner {
37    fn default() -> Self {
38        Self::new(false)
39    }
40}
41
42impl GlassSwitchStateInner {
43    /// Creates a new `GlassSwitchState` with the given initial checked state.
44    pub fn new(initial_state: bool) -> Self {
45        Self {
46            checked: initial_state,
47            progress: if initial_state { 1.0 } else { 0.0 },
48            last_toggle_time: None,
49        }
50    }
51
52    /// Toggles the switch state.
53    pub fn toggle(&mut self) {
54        self.checked = !self.checked;
55        self.last_toggle_time = Some(Instant::now());
56    }
57}
58
59#[derive(Clone)]
60pub struct GlassSwitchState {
61    inner: Arc<RwLock<GlassSwitchStateInner>>,
62}
63
64impl GlassSwitchState {
65    pub fn new(initial_state: bool) -> Self {
66        Self {
67            inner: Arc::new(RwLock::new(GlassSwitchStateInner::new(initial_state))),
68        }
69    }
70
71    pub(crate) fn read(&self) -> RwLockReadGuard<'_, GlassSwitchStateInner> {
72        self.inner.read()
73    }
74
75    pub(crate) fn write(&self) -> RwLockWriteGuard<'_, GlassSwitchStateInner> {
76        self.inner.write()
77    }
78
79    /// Returns whether the switch is currently checked.
80    pub fn is_checked(&self) -> bool {
81        self.inner.read().checked
82    }
83
84    /// Sets the checked state directly, resetting animation progress.
85    pub fn set_checked(&self, checked: bool) {
86        let mut inner = self.inner.write();
87        if inner.checked != checked {
88            inner.checked = checked;
89            inner.progress = if checked { 1.0 } else { 0.0 };
90            inner.last_toggle_time = None;
91        }
92    }
93
94    /// Toggles the switch and starts the animation timeline.
95    pub fn toggle(&self) {
96        self.inner.write().toggle();
97    }
98
99    /// Returns the current animation progress (0.0..1.0).
100    pub fn animation_progress(&self) -> f32 {
101        self.inner.read().progress
102    }
103}
104
105impl Default for GlassSwitchState {
106    fn default() -> Self {
107        Self::new(false)
108    }
109}
110
111#[derive(Builder, Clone)]
112#[builder(pattern = "owned")]
113pub struct GlassSwitchArgs {
114    #[builder(default, setter(strip_option))]
115    pub on_toggle: Option<Arc<dyn Fn(bool) + Send + Sync>>,
116
117    #[builder(default = "Dp(52.0)")]
118    pub width: Dp,
119
120    #[builder(default = "Dp(32.0)")]
121    pub height: Dp,
122
123    /// Track color when switch is ON
124    #[builder(default = "Color::new(0.2, 0.7, 1.0, 0.5)")]
125    pub track_on_color: Color,
126    /// Track color when switch is OFF
127    #[builder(default = "Color::new(0.8, 0.8, 0.8, 0.5)")]
128    pub track_off_color: Color,
129
130    /// Thumb alpha when switch is ON (opacity when ON)
131    #[builder(default = "0.5")]
132    pub thumb_on_alpha: f32,
133    /// Thumb alpha when switch is OFF (opacity when OFF)
134    #[builder(default = "1.0")]
135    pub thumb_off_alpha: f32,
136
137    /// Border for the thumb
138    #[builder(default, setter(strip_option))]
139    pub thumb_border: Option<GlassBorder>,
140
141    /// Border for the track
142    #[builder(default, setter(strip_option))]
143    pub track_border: Option<GlassBorder>,
144
145    /// Padding around the thumb
146    #[builder(default = "Dp(3.0)")]
147    pub thumb_padding: Dp,
148    /// Optional accessibility label read by assistive technologies.
149    #[builder(default, setter(strip_option, into))]
150    pub accessibility_label: Option<String>,
151    /// Optional accessibility description.
152    #[builder(default, setter(strip_option, into))]
153    pub accessibility_description: Option<String>,
154}
155
156impl Default for GlassSwitchArgs {
157    fn default() -> Self {
158        GlassSwitchArgsBuilder::default().build().unwrap()
159    }
160}
161
162fn interpolate_color(off: Color, on: Color, progress: f32) -> Color {
163    Color {
164        r: off.r + (on.r - off.r) * progress,
165        g: off.g + (on.g - off.g) * progress,
166        b: off.b + (on.b - off.b) * progress,
167        a: off.a + (on.a - off.a) * progress,
168    }
169}
170
171fn update_progress_from_state(state: GlassSwitchState) {
172    let last_toggle_time = state.read().last_toggle_time;
173    if let Some(last_toggle_time) = last_toggle_time {
174        let elapsed = last_toggle_time.elapsed();
175        let fraction = (elapsed.as_secs_f32() / ANIMATION_DURATION.as_secs_f32()).min(1.0);
176        let checked = state.read().checked;
177        state.write().progress = if checked { fraction } else { 1.0 - fraction };
178    }
179}
180
181/// Return true if the given cursor position is inside the component bounds.
182fn is_cursor_inside(size: ComputedData, cursor_pos: Option<PxPosition>) -> bool {
183    cursor_pos
184        .map(|pos| {
185            pos.x.0 >= 0 && pos.x.0 < size.width.0 && pos.y.0 >= 0 && pos.y.0 < size.height.0
186        })
187        .unwrap_or(false)
188}
189
190/// Return true if there is a left-press event in the input.
191fn was_pressed_left(input: &tessera_ui::InputHandlerInput) -> bool {
192    input.cursor_events.iter().any(|e| {
193        matches!(
194            e.content,
195            CursorEventContent::Pressed(PressKeyEventType::Left)
196        )
197    })
198}
199
200fn handle_input_events(
201    state: GlassSwitchState,
202    on_toggle: Option<Arc<dyn Fn(bool) + Send + Sync>>,
203    input: &mut tessera_ui::InputHandlerInput,
204) {
205    let interactive = on_toggle.is_some();
206    // Update progress first
207    update_progress_from_state(state.clone());
208
209    // Cursor handling
210    let size = input.computed_data;
211    let is_cursor_in = is_cursor_inside(size, input.cursor_position_rel);
212
213    if is_cursor_in && interactive {
214        input.requests.cursor_icon = CursorIcon::Pointer;
215    }
216
217    // Handle press events: toggle state and call callback
218    let pressed = was_pressed_left(input);
219
220    if pressed && is_cursor_in {
221        toggle_glass_switch_state(&state, &on_toggle);
222    }
223}
224
225fn toggle_glass_switch_state(
226    state: &GlassSwitchState,
227    on_toggle: &Option<Arc<dyn Fn(bool) + Send + Sync>>,
228) -> bool {
229    let Some(on_toggle) = on_toggle else {
230        return false;
231    };
232    state.write().toggle();
233    let checked = state.read().checked;
234    on_toggle(checked);
235    true
236}
237
238fn apply_glass_switch_accessibility(
239    input: &mut tessera_ui::InputHandlerInput<'_>,
240    state: &GlassSwitchState,
241    on_toggle: &Option<Arc<dyn Fn(bool) + Send + Sync>>,
242    label: Option<&String>,
243    description: Option<&String>,
244) {
245    let checked = state.read().checked;
246    let mut builder = input.accessibility().role(Role::Switch);
247
248    if let Some(label) = label {
249        builder = builder.label(label.clone());
250    }
251    if let Some(description) = description {
252        builder = builder.description(description.clone());
253    }
254
255    builder = builder
256        .focusable()
257        .action(Action::Click)
258        .toggled(if checked {
259            Toggled::True
260        } else {
261            Toggled::False
262        });
263    builder.commit();
264
265    if on_toggle.is_some() {
266        let state = state.clone();
267        let on_toggle = on_toggle.clone();
268        input.set_accessibility_action_handler(move |action| {
269            if action == Action::Click {
270                toggle_glass_switch_state(&state, &on_toggle);
271            }
272        });
273    }
274}
275
276/// # glass_switch
277///
278/// Renders an interactive switch with a customizable glass effect and smooth animation.
279///
280/// ## Usage
281///
282/// Use to toggle a boolean state (on/off) with a visually distinct, modern look.
283///
284/// ## Parameters
285///
286/// - `args` — configures the switch's appearance and `on_toggle` callback; see [`GlassSwitchArgs`].
287/// - `state` — a clonable [`GlassSwitchState`] to manage the component's checked and animation state.
288///
289/// ## Examples
290///
291/// ```
292/// use std::sync::Arc;
293/// use tessera_ui_basic_components::glass_switch::{
294///     glass_switch, GlassSwitchArgsBuilder, GlassSwitchState,
295/// };
296///
297/// let state = GlassSwitchState::new(false);
298/// assert!(!state.is_checked());
299///
300/// // The on_toggle callback would be passed to the component.
301/// let on_toggle = Arc::new({
302///     let state = state.clone();
303///     move |_is_checked: bool| {
304///         state.toggle();
305///     }
306/// });
307///
308/// // In a real app, a click would trigger the callback, which toggles the state.
309/// // For this test, we can call toggle directly to simulate this.
310/// state.toggle();
311/// assert!(state.is_checked());
312/// ```
313#[tessera]
314pub fn glass_switch(args: impl Into<GlassSwitchArgs>, state: GlassSwitchState) {
315    let args: GlassSwitchArgs = args.into();
316    // Precompute pixel sizes to avoid repeated conversions
317    let width_px = args.width.to_px();
318    let height_px = args.height.to_px();
319    let thumb_dp = Dp(args.height.0 - (args.thumb_padding.0 * 2.0));
320    let thumb_px = thumb_dp.to_px();
321    let track_radius_dp = Dp(args.height.0 / 2.0);
322
323    // Track tint color interpolation based on progress
324    let progress = state.read().progress;
325    let track_color = interpolate_color(args.track_off_color, args.track_on_color, progress);
326
327    // Build and render track
328    let mut track_builder = FluidGlassArgsBuilder::default()
329        .width(DimensionValue::Fixed(width_px))
330        .height(DimensionValue::Fixed(height_px))
331        .tint_color(track_color)
332        .shape({
333            Shape::RoundedRectangle {
334                top_left: track_radius_dp,
335                top_right: track_radius_dp,
336                bottom_right: track_radius_dp,
337                bottom_left: track_radius_dp,
338                g2_k_value: 2.0, // Capsule shape
339            }
340        })
341        .blur_radius(8.0);
342    if let Some(border) = args.track_border {
343        track_builder = track_builder.border(border);
344    }
345    fluid_glass(track_builder.build().unwrap(), None, || {});
346
347    // Build and render thumb
348    let thumb_alpha =
349        args.thumb_off_alpha + (args.thumb_on_alpha - args.thumb_off_alpha) * progress;
350    let thumb_color = Color::new(1.0, 1.0, 1.0, thumb_alpha);
351    let mut thumb_builder = FluidGlassArgsBuilder::default()
352        .width(DimensionValue::Fixed(thumb_px))
353        .height(DimensionValue::Fixed(thumb_px))
354        .tint_color(thumb_color)
355        .refraction_height(1.0)
356        .shape(Shape::Ellipse);
357    if let Some(border) = args.thumb_border {
358        thumb_builder = thumb_builder.border(border);
359    }
360    fluid_glass(thumb_builder.build().unwrap(), None, || {});
361
362    let state_for_handler = state.clone();
363    let on_toggle = args.on_toggle.clone();
364    let accessibility_on_toggle = on_toggle.clone();
365    let accessibility_label = args.accessibility_label.clone();
366    let accessibility_description = args.accessibility_description.clone();
367    input_handler(Box::new(move |mut input| {
368        handle_input_events(state_for_handler.clone(), on_toggle.clone(), &mut input);
369        apply_glass_switch_accessibility(
370            &mut input,
371            &state_for_handler,
372            &accessibility_on_toggle,
373            accessibility_label.as_ref(),
374            accessibility_description.as_ref(),
375        );
376    }));
377
378    // Measurement and placement
379    measure(Box::new(move |input| {
380        // Expect track then thumb as children
381        let track_id = input.children_ids[0];
382        let thumb_id = input.children_ids[1];
383
384        let track_constraint = Constraint::new(
385            DimensionValue::Fixed(width_px),
386            DimensionValue::Fixed(height_px),
387        );
388        let thumb_constraint = Constraint::new(
389            DimensionValue::Wrap {
390                min: None,
391                max: None,
392            },
393            DimensionValue::Wrap {
394                min: None,
395                max: None,
396            },
397        );
398
399        // Measure both children
400        let nodes_constraints = vec![(track_id, track_constraint), (thumb_id, thumb_constraint)];
401        let sizes_map = input.measure_children(nodes_constraints)?;
402
403        let _track_size = sizes_map.get(&track_id).unwrap();
404        let thumb_size = sizes_map.get(&thumb_id).unwrap();
405        let self_width_px = width_px;
406        let self_height_px = height_px;
407        let thumb_padding_px = args.thumb_padding.to_px();
408
409        // Use eased progress for placement
410        let eased_progress = animation::easing(state.read().progress);
411
412        input.place_child(
413            track_id,
414            PxPosition::new(tessera_ui::Px(0), tessera_ui::Px(0)),
415        );
416
417        let start_x = thumb_padding_px;
418        let end_x = self_width_px - thumb_size.width - thumb_padding_px;
419        let thumb_x = start_x.0 as f32 + (end_x.0 - start_x.0) as f32 * eased_progress;
420        let thumb_y = (self_height_px - thumb_size.height) / 2;
421
422        input.place_child(
423            thumb_id,
424            PxPosition::new(tessera_ui::Px(thumb_x as i32), thumb_y),
425        );
426
427        Ok(ComputedData {
428            width: self_width_px,
429            height: self_height_px,
430        })
431    }));
432}