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