tessera_ui_basic_components/
glass_switch.rs

1use std::{
2    sync::Arc,
3    time::{Duration, Instant},
4};
5
6use derive_builder::Builder;
7use parking_lot::Mutex;
8use tessera_ui::{
9    Color, ComputedData, Constraint, CursorEventContent, DimensionValue, Dp, PressKeyEventType,
10    PxPosition, winit::window::CursorIcon,
11};
12use tessera_ui_macros::tessera;
13
14use crate::{
15    fluid_glass::{FluidGlassArgsBuilder, GlassBorder, fluid_glass},
16    shape_def::Shape,
17};
18
19const ANIMATION_DURATION: Duration = Duration::from_millis(150);
20
21/// State for the `glass_switch` component, handling animation.
22pub struct GlassSwitchState {
23    pub checked: bool,
24    progress: Mutex<f32>,
25    last_toggle_time: Mutex<Option<Instant>>,
26}
27
28impl GlassSwitchState {
29    pub fn new(initial_state: bool) -> Self {
30        Self {
31            checked: initial_state,
32            progress: Mutex::new(if initial_state { 1.0 } else { 0.0 }),
33            last_toggle_time: Mutex::new(None),
34        }
35    }
36
37    pub fn toggle(&mut self) {
38        self.checked = !self.checked;
39        *self.last_toggle_time.lock() = Some(Instant::now());
40    }
41}
42
43#[derive(Builder, Clone)]
44#[builder(pattern = "owned")]
45pub struct GlassSwitchArgs {
46    #[builder(default)]
47    pub state: Option<Arc<Mutex<GlassSwitchState>>>,
48
49    #[builder(default = "false")]
50    pub checked: bool,
51
52    #[builder(default = "Arc::new(|_| {})")]
53    pub on_toggle: Arc<dyn Fn(bool) + Send + Sync>,
54
55    #[builder(default = "Dp(52.0)")]
56    pub width: Dp,
57
58    #[builder(default = "Dp(32.0)")]
59    pub height: Dp,
60
61    /// Track color when switch is ON
62    #[builder(default = "Color::new(0.2, 0.7, 1.0, 0.5)")]
63    pub track_on_color: Color,
64    /// Track color when switch is OFF
65    #[builder(default = "Color::new(0.8, 0.8, 0.8, 0.5)")]
66    pub track_off_color: Color,
67
68    /// Thumb alpha when switch is ON (opacity when ON)
69    #[builder(default = "0.5")]
70    pub thumb_on_alpha: f32,
71    /// Thumb alpha when switch is OFF (opacity when OFF)
72    #[builder(default = "1.0")]
73    pub thumb_off_alpha: f32,
74
75    /// Border for the thumb
76    #[builder(
77        default = "Some(GlassBorder::new(Dp(2.0), Color::BLUE.with_alpha(0.5)))",
78        setter(strip_option)
79    )]
80    pub thumb_border: Option<GlassBorder>,
81
82    /// Border for the track
83    #[builder(
84        default = "Some(GlassBorder::new(Dp(2.0), Color::WHITE.with_alpha(0.5)))",
85        setter(strip_option)
86    )]
87    pub track_border: Option<GlassBorder>,
88
89    /// Padding around the thumb
90    #[builder(default = "Dp(3.0)")]
91    pub thumb_padding: Dp,
92}
93
94#[tessera]
95pub fn glass_switch(args: impl Into<GlassSwitchArgs>) {
96    let args: GlassSwitchArgs = args.into();
97    let thumb_size = Dp(args.height.0 - (args.thumb_padding.0 * 2.0));
98
99    // Track (background) as the first child, rendered with fluid_glass
100    let progress = args
101        .state
102        .as_ref()
103        .map(|s| *s.lock().progress.lock())
104        .unwrap_or(if args.checked { 1.0 } else { 0.0 });
105    let track_color = Color {
106        r: args.track_off_color.r + (args.track_on_color.r - args.track_off_color.r) * progress,
107        g: args.track_off_color.g + (args.track_on_color.g - args.track_off_color.g) * progress,
108        b: args.track_off_color.b + (args.track_on_color.b - args.track_off_color.b) * progress,
109        a: args.track_off_color.a + (args.track_on_color.a - args.track_off_color.a) * progress,
110    };
111    let mut arg = FluidGlassArgsBuilder::default()
112        .width(DimensionValue::Fixed(args.width.to_px()))
113        .height(DimensionValue::Fixed(args.height.to_px()))
114        .tint_color(track_color)
115        .blur_radius(10.0)
116        .shape(Shape::RoundedRectangle {
117            corner_radius: args.height.to_px().to_f32() / 2.0,
118            g2_k_value: 2.0,
119        })
120        .blur_radius(8.0);
121    if let Some(border) = args.track_border {
122        arg = arg.border(border);
123    }
124    let track_glass_arg = arg.build().unwrap();
125    fluid_glass(track_glass_arg, None, || {});
126
127    // Thumb (slider) is always white, opacity changes with progress
128    let thumb_alpha =
129        args.thumb_off_alpha + (args.thumb_on_alpha - args.thumb_off_alpha) * progress;
130    let thumb_color = Color::new(1.0, 1.0, 1.0, thumb_alpha);
131    let mut thumb_glass_arg = FluidGlassArgsBuilder::default()
132        .width(DimensionValue::Fixed(thumb_size.to_px()))
133        .height(DimensionValue::Fixed(thumb_size.to_px()))
134        .tint_color(thumb_color)
135        .refraction_height(1.0)
136        .shape(Shape::Ellipse);
137    if let Some(border) = args.thumb_border {
138        thumb_glass_arg = thumb_glass_arg.border(border);
139    }
140    let thumb_glass_arg = thumb_glass_arg.build().unwrap();
141    fluid_glass(thumb_glass_arg, None, || {});
142
143    let on_toggle = args.on_toggle.clone();
144    let state = args.state.clone();
145    let checked = args.checked;
146
147    state_handler(Box::new(move |input| {
148        if let Some(state) = &state {
149            let state = state.lock();
150            let mut progress = state.progress.lock();
151            if let Some(last_toggle_time) = *state.last_toggle_time.lock() {
152                let elapsed = last_toggle_time.elapsed();
153                let animation_fraction =
154                    (elapsed.as_secs_f32() / ANIMATION_DURATION.as_secs_f32()).min(1.0);
155                *progress = if state.checked {
156                    animation_fraction
157                } else {
158                    1.0 - animation_fraction
159                };
160            }
161        }
162
163        let size = input.computed_data;
164        let is_cursor_in = if let Some(pos) = input.cursor_position {
165            pos.x.0 >= 0 && pos.x.0 < size.width.0 && pos.y.0 >= 0 && pos.y.0 < size.height.0
166        } else {
167            false
168        };
169        if is_cursor_in {
170            input.requests.cursor_icon = CursorIcon::Pointer;
171        }
172        for e in input.cursor_events.iter() {
173            if let CursorEventContent::Pressed(PressKeyEventType::Left) = &e.content {
174                if is_cursor_in {
175                    if let Some(state) = &state {
176                        state.lock().toggle();
177                    }
178                    on_toggle(!checked);
179                }
180            }
181        }
182    }));
183
184    measure(Box::new(move |input| {
185        let track_id = input.children_ids[0]; // track is the first child
186        let thumb_id = input.children_ids[1]; // thumb is the second child
187        // Prepare constraints for both children
188        let track_constraint = Constraint::new(
189            DimensionValue::Fixed(args.width.to_px()),
190            DimensionValue::Fixed(args.height.to_px()),
191        );
192        let thumb_constraint = Constraint::new(
193            DimensionValue::Wrap {
194                min: None,
195                max: None,
196            },
197            DimensionValue::Wrap {
198                min: None,
199                max: None,
200            },
201        );
202        // Measure both children in parallel
203        let nodes_constraints = vec![(track_id, track_constraint), (thumb_id, thumb_constraint)];
204        let sizes_map = tessera_ui::measure_nodes(
205            nodes_constraints,
206            input.tree,
207            input.metadatas,
208            input.compute_resource_manager.clone(),
209            input.gpu,
210        );
211        let _track_size = sizes_map
212            .get(&track_id)
213            .and_then(|r| r.as_ref().ok())
214            .expect("track measurement failed");
215        let thumb_size = sizes_map
216            .get(&thumb_id)
217            .and_then(|r| r.as_ref().ok())
218            .expect("thumb measurement failed");
219        let self_width_px = args.width.to_px();
220        let self_height_px = args.height.to_px();
221        let thumb_padding_px = args.thumb_padding.to_px();
222        let progress = args
223            .state
224            .as_ref()
225            .map(|s| *s.lock().progress.lock())
226            .unwrap_or(if args.checked { 1.0 } else { 0.0 });
227        // Place track at origin
228        tessera_ui::place_node(
229            track_id,
230            PxPosition::new(tessera_ui::Px(0), tessera_ui::Px(0)),
231            input.metadatas,
232        );
233        // Place thumb according to progress
234        let start_x = thumb_padding_px;
235        let end_x = self_width_px - thumb_size.width - thumb_padding_px;
236        let thumb_x = start_x.0 as f32 + (end_x.0 - start_x.0) as f32 * progress;
237        let thumb_y = (self_height_px - thumb_size.height) / 2;
238        tessera_ui::place_node(
239            thumb_id,
240            PxPosition::new(tessera_ui::Px(thumb_x as i32), thumb_y),
241            input.metadatas,
242        );
243        Ok(ComputedData {
244            width: self_width_px,
245            height: self_height_px,
246        })
247    }));
248}