tessera_ui_basic_components/
glass_switch.rs1use 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
31pub 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 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 pub fn toggle(&mut self) {
56 self.checked = !self.checked;
57 self.last_toggle_time = Some(Instant::now());
58 }
59
60 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 #[builder(default = "Color::new(0.2, 0.7, 1.0, 0.5)")]
80 pub track_on_color: Color,
81 #[builder(default = "Color::new(0.8, 0.8, 0.8, 0.5)")]
83 pub track_off_color: Color,
84
85 #[builder(default = "0.5")]
87 pub thumb_on_alpha: f32,
88 #[builder(default = "1.0")]
90 pub thumb_off_alpha: f32,
91
92 #[builder(default, setter(strip_option))]
94 pub thumb_border: Option<GlassBorder>,
95
96 #[builder(default, setter(strip_option))]
98 pub track_border: Option<GlassBorder>,
99
100 #[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
130fn 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
139fn 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 return;
157 };
158
159 update_progress_from_state(state.clone());
161
162 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 let pressed = was_pressed_left(input);
172
173 if pressed && is_cursor_in {
174 state.write().toggle();
176 on_toggle(state.read().checked);
177 }
178}
179#[tessera]
180pub fn glass_switch(args: impl Into<GlassSwitchArgs>, state: Arc<RwLock<GlassSwitchState>>) {
222 let args: GlassSwitchArgs = args.into();
223 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 let progress = state.read().progress;
232 let track_color = interpolate_color(args.track_off_color, args.track_on_color, progress);
233
234 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, }
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 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 measure(Box::new(move |input| {
277 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 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 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}