tessera_ui_basic_components/
glass_switch.rs

1#![allow(clippy::needless_pass_by_value)]
2//! # Glass Switch Component Module
3//!
4//! This module provides a customizable, glassmorphic-style switch (toggle) UI component for the Tessera UI framework.
5//! The glass switch enables toggling a boolean state with smooth animated transitions and a frosted glass visual effect.
6//! It is suitable for modern user interfaces requiring visually appealing, interactive on/off controls, such as settings panels, forms, or dashboards.
7//! The component supports extensive customization, including size, color, border, and animation, and is designed for stateless usage with external state management.
8//! Typical usage involves integrating the switch into application UIs where a clear, elegant toggle is desired.
9//!
10//! See [`glass_switch()`](tessera-ui-basic-components/src/glass_switch.rs:142) for usage details and customization options.
11
12use std::{
13    sync::Arc,
14    time::{Duration, Instant},
15};
16
17use derive_builder::Builder;
18use parking_lot::Mutex;
19use tessera_ui::{
20    Color, ComputedData, Constraint, CursorEventContent, DimensionValue, Dp, PressKeyEventType,
21    PxPosition, winit::window::CursorIcon,
22};
23use tessera_ui_macros::tessera;
24
25use crate::{
26    animation,
27    fluid_glass::{FluidGlassArgsBuilder, GlassBorder, fluid_glass},
28    shape_def::Shape,
29};
30
31const ANIMATION_DURATION: Duration = Duration::from_millis(150);
32
33/// State for the `glass_switch` component, handling animation.
34pub struct GlassSwitchState {
35    pub checked: bool,
36    progress: Mutex<f32>,
37    last_toggle_time: Mutex<Option<Instant>>,
38}
39
40impl GlassSwitchState {
41    pub fn new(initial_state: bool) -> Self {
42        Self {
43            checked: initial_state,
44            progress: Mutex::new(if initial_state { 1.0 } else { 0.0 }),
45            last_toggle_time: Mutex::new(None),
46        }
47    }
48
49    pub fn toggle(&mut self) {
50        self.checked = !self.checked;
51        *self.last_toggle_time.lock() = Some(Instant::now());
52    }
53}
54
55#[derive(Builder, Clone)]
56#[builder(pattern = "owned")]
57pub struct GlassSwitchArgs {
58    #[builder(default)]
59    pub state: Option<Arc<Mutex<GlassSwitchState>>>,
60
61    #[builder(default = "false")]
62    pub checked: bool,
63
64    #[builder(default = "Arc::new(|_| {})")]
65    pub on_toggle: Arc<dyn Fn(bool) + Send + Sync>,
66
67    #[builder(default = "Dp(52.0)")]
68    pub width: Dp,
69
70    #[builder(default = "Dp(32.0)")]
71    pub height: Dp,
72
73    /// Track color when switch is ON
74    #[builder(default = "Color::new(0.2, 0.7, 1.0, 0.5)")]
75    pub track_on_color: Color,
76    /// Track color when switch is OFF
77    #[builder(default = "Color::new(0.8, 0.8, 0.8, 0.5)")]
78    pub track_off_color: Color,
79
80    /// Thumb alpha when switch is ON (opacity when ON)
81    #[builder(default = "0.5")]
82    pub thumb_on_alpha: f32,
83    /// Thumb alpha when switch is OFF (opacity when OFF)
84    #[builder(default = "1.0")]
85    pub thumb_off_alpha: f32,
86
87    /// Border for the thumb
88    #[builder(default, setter(strip_option))]
89    pub thumb_border: Option<GlassBorder>,
90
91    /// Border for the track
92    #[builder(default, setter(strip_option))]
93    pub track_border: Option<GlassBorder>,
94
95    /// Padding around the thumb
96    #[builder(default = "Dp(3.0)")]
97    pub thumb_padding: Dp,
98}
99
100impl Default for GlassSwitchArgs {
101    fn default() -> Self {
102        GlassSwitchArgsBuilder::default().build().unwrap()
103    }
104}
105
106#[tessera]
107/// A glass-like switch component for toggling a boolean state.
108///
109/// The `glass_switch` provides a visually appealing switch with a frosted glass effect.
110/// It animates smoothly between its "on" and "off" states and is fully customizable
111/// in terms of size, color, and border.
112///
113/// # Example
114///
115/// ```
116/// use std::sync::Arc;
117/// use tessera_ui_basic_components::glass_switch::{glass_switch, GlassSwitchArgs, GlassSwitchArgsBuilder};
118///
119/// // In a real app, you would manage the state.
120/// // This example shows how to create a switch that is initially off.
121/// glass_switch(
122///     GlassSwitchArgsBuilder::default()
123///         .checked(false)
124///         .on_toggle(Arc::new(|new_state| {
125///             // Update your application state here
126///             println!("Switch toggled to: {}", new_state);
127///         }))
128///         .build()
129///         .unwrap(),
130/// );
131///
132/// // An initially checked switch
133/// glass_switch(
134///     GlassSwitchArgsBuilder::default()
135///         .checked(true)
136///         .build()
137///         .unwrap(),
138/// );
139/// ```
140///
141/// # Arguments
142///
143/// * `args` - An instance of `GlassSwitchArgs` which can be built using `GlassSwitchArgsBuilder`.
144///   - `checked`: A `bool` indicating the current state of the switch (`true` for on, `false` for off).
145///   - `on_toggle`: A callback `Arc<dyn Fn(bool) + Send + Sync>` that is called when the switch is clicked.
146///     It receives the new boolean state.
147///   - Other arguments for customization like `width`, `height`, `track_on_color`, `track_off_color`, etc.
148///     are also available.
149pub fn glass_switch(args: impl Into<GlassSwitchArgs>) {
150    let args: GlassSwitchArgs = args.into();
151    let thumb_size = Dp(args.height.0 - (args.thumb_padding.0 * 2.0));
152
153    // Track (background) as the first child, rendered with fluid_glass
154    let progress = args
155        .state
156        .as_ref()
157        .map(|s| *s.lock().progress.lock())
158        .unwrap_or(if args.checked { 1.0 } else { 0.0 });
159    let track_color = Color {
160        r: args.track_off_color.r + (args.track_on_color.r - args.track_off_color.r) * progress,
161        g: args.track_off_color.g + (args.track_on_color.g - args.track_off_color.g) * progress,
162        b: args.track_off_color.b + (args.track_on_color.b - args.track_off_color.b) * progress,
163        a: args.track_off_color.a + (args.track_on_color.a - args.track_off_color.a) * progress,
164    };
165    let mut arg = FluidGlassArgsBuilder::default()
166        .width(DimensionValue::Fixed(args.width.to_px()))
167        .height(DimensionValue::Fixed(args.height.to_px()))
168        .tint_color(track_color)
169        .blur_radius(10.0)
170        .shape(Shape::RoundedRectangle {
171            corner_radius: args.height.to_px().to_f32() / 2.0,
172            g2_k_value: 2.0,
173        })
174        .blur_radius(8.0);
175    if let Some(border) = args.track_border {
176        arg = arg.border(border);
177    }
178    let track_glass_arg = arg.build().unwrap();
179    fluid_glass(track_glass_arg, None, || {});
180
181    // Thumb (slider) is always white, opacity changes with progress
182    let thumb_alpha =
183        args.thumb_off_alpha + (args.thumb_on_alpha - args.thumb_off_alpha) * progress;
184    let thumb_color = Color::new(1.0, 1.0, 1.0, thumb_alpha);
185    let mut thumb_glass_arg = FluidGlassArgsBuilder::default()
186        .width(DimensionValue::Fixed(thumb_size.to_px()))
187        .height(DimensionValue::Fixed(thumb_size.to_px()))
188        .tint_color(thumb_color)
189        .refraction_height(1.0)
190        .shape(Shape::Ellipse);
191    if let Some(border) = args.thumb_border {
192        thumb_glass_arg = thumb_glass_arg.border(border);
193    }
194    let thumb_glass_arg = thumb_glass_arg.build().unwrap();
195    fluid_glass(thumb_glass_arg, None, || {});
196
197    let on_toggle = args.on_toggle.clone();
198    let state = args.state.clone();
199    let checked = args.checked;
200
201    state_handler(Box::new(move |input| {
202        if let Some(state) = &state {
203            let state = state.lock();
204            let mut progress = state.progress.lock();
205            if let Some(last_toggle_time) = *state.last_toggle_time.lock() {
206                let elapsed = last_toggle_time.elapsed();
207                let animation_fraction =
208                    (elapsed.as_secs_f32() / ANIMATION_DURATION.as_secs_f32()).min(1.0);
209                *progress = if state.checked {
210                    animation_fraction
211                } else {
212                    1.0 - animation_fraction
213                };
214            }
215        }
216
217        let size = input.computed_data;
218        let is_cursor_in = if let Some(pos) = input.cursor_position_rel {
219            pos.x.0 >= 0 && pos.x.0 < size.width.0 && pos.y.0 >= 0 && pos.y.0 < size.height.0
220        } else {
221            false
222        };
223        if is_cursor_in {
224            input.requests.cursor_icon = CursorIcon::Pointer;
225        }
226        for e in input.cursor_events.iter() {
227            if let CursorEventContent::Pressed(PressKeyEventType::Left) = &e.content {
228                if is_cursor_in {
229                    if let Some(state) = &state {
230                        state.lock().toggle();
231                    }
232                    on_toggle(!checked);
233                }
234            }
235        }
236    }));
237
238    measure(Box::new(move |input| {
239        let track_id = input.children_ids[0]; // track is the first child
240        let thumb_id = input.children_ids[1]; // thumb is the second child
241        // Prepare constraints for both children
242        let track_constraint = Constraint::new(
243            DimensionValue::Fixed(args.width.to_px()),
244            DimensionValue::Fixed(args.height.to_px()),
245        );
246        let thumb_constraint = Constraint::new(
247            DimensionValue::Wrap {
248                min: None,
249                max: None,
250            },
251            DimensionValue::Wrap {
252                min: None,
253                max: None,
254            },
255        );
256        // Measure both children in parallel
257        let nodes_constraints = vec![(track_id, track_constraint), (thumb_id, thumb_constraint)];
258        let sizes_map = input.measure_children(nodes_constraints)?;
259
260        let _track_size = sizes_map.get(&track_id).unwrap();
261        let thumb_size = sizes_map.get(&thumb_id).unwrap();
262        let self_width_px = args.width.to_px();
263        let self_height_px = args.height.to_px();
264        let thumb_padding_px = args.thumb_padding.to_px();
265
266        let progress = animation::easing(
267            args.state
268                .as_ref()
269                .map(|s| *s.lock().progress.lock())
270                .unwrap_or(if args.checked { 1.0 } else { 0.0 }),
271        );
272        // Place track at origin
273        input.place_child(
274            track_id,
275            PxPosition::new(tessera_ui::Px(0), tessera_ui::Px(0)),
276        );
277        // Place thumb according to progress
278        let start_x = thumb_padding_px;
279        let end_x = self_width_px - thumb_size.width - thumb_padding_px;
280        let thumb_x = start_x.0 as f32 + (end_x.0 - start_x.0) as f32 * progress;
281        let thumb_y = (self_height_px - thumb_size.height) / 2;
282        input.place_child(
283            thumb_id,
284            PxPosition::new(tessera_ui::Px(thumb_x as i32), thumb_y),
285        );
286        Ok(ComputedData {
287            width: self_width_px,
288            height: self_height_px,
289        })
290    }));
291}