tessera_ui_basic_components/
glass_switch.rs1use 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
29pub(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 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 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 pub fn is_checked(&self) -> bool {
81 self.inner.read().checked
82 }
83
84 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 pub fn toggle(&self) {
96 self.inner.write().toggle();
97 }
98
99 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 #[builder(default = "Color::new(0.2, 0.7, 1.0, 0.5)")]
125 pub track_on_color: Color,
126 #[builder(default = "Color::new(0.8, 0.8, 0.8, 0.5)")]
128 pub track_off_color: Color,
129
130 #[builder(default = "0.5")]
132 pub thumb_on_alpha: f32,
133 #[builder(default = "1.0")]
135 pub thumb_off_alpha: f32,
136
137 #[builder(default, setter(strip_option))]
139 pub thumb_border: Option<GlassBorder>,
140
141 #[builder(default, setter(strip_option))]
143 pub track_border: Option<GlassBorder>,
144
145 #[builder(default = "Dp(3.0)")]
147 pub thumb_padding: Dp,
148 #[builder(default, setter(strip_option, into))]
150 pub accessibility_label: Option<String>,
151 #[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
181fn 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
190fn 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_from_state(state.clone());
208
209 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 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#[tessera]
314pub fn glass_switch(args: impl Into<GlassSwitchArgs>, state: GlassSwitchState) {
315 let args: GlassSwitchArgs = args.into();
316 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 let progress = state.read().progress;
325 let track_color = interpolate_color(args.track_off_color, args.track_on_color, progress);
326
327 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, }
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 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 measure(Box::new(move |input| {
380 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 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 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}