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)]
72pub struct GlassSwitchState {
73 inner: Arc<RwLock<GlassSwitchStateInner>>,
74}
75
76impl GlassSwitchState {
77 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 pub fn is_checked(&self) -> bool {
94 self.inner.read().checked
95 }
96
97 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 pub fn toggle(&self) {
109 self.inner.write().toggle();
110 }
111
112 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#[derive(Builder, Clone)]
126#[builder(pattern = "owned")]
127pub struct GlassSwitchArgs {
128 #[builder(default, setter(strip_option))]
130 pub on_toggle: Option<Arc<dyn Fn(bool) + Send + Sync>>,
131
132 #[builder(default = "Dp(52.0)")]
134 pub width: Dp,
135
136 #[builder(default = "Dp(32.0)")]
138 pub height: Dp,
139
140 #[builder(default = "Color::new(0.2, 0.7, 1.0, 0.5)")]
142 pub track_on_color: Color,
143 #[builder(default = "Color::new(0.8, 0.8, 0.8, 0.5)")]
145 pub track_off_color: Color,
146
147 #[builder(default = "0.5")]
149 pub thumb_on_alpha: f32,
150 #[builder(default = "1.0")]
152 pub thumb_off_alpha: f32,
153
154 #[builder(default, setter(strip_option))]
156 pub thumb_border: Option<GlassBorder>,
157
158 #[builder(default, setter(strip_option))]
160 pub track_border: Option<GlassBorder>,
161
162 #[builder(default = "Dp(3.0)")]
164 pub thumb_padding: Dp,
165 #[builder(default, setter(strip_option, into))]
167 pub accessibility_label: Option<String>,
168 #[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
200fn 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
209fn 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_from_state(state.clone());
227
228 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 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#[tessera]
333pub fn glass_switch(args: impl Into<GlassSwitchArgs>, state: GlassSwitchState) {
334 let args: GlassSwitchArgs = args.into();
335 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 let progress = state.read().progress;
343 let track_color = interpolate_color(args.track_off_color, args.track_on_color, progress);
344
345 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 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 measure(Box::new(move |input| {
398 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 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 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}