use std::{
sync::Arc,
time::{Duration, Instant},
};
use derive_builder::Builder;
use parking_lot::{RwLock, RwLockReadGuard, RwLockWriteGuard};
use tessera_ui::{
Color, ComputedData, Constraint, CursorEventContent, DimensionValue, Dp, PressKeyEventType,
PxPosition,
accesskit::{Action, Role, Toggled},
tessera,
winit::window::CursorIcon,
};
use crate::{
animation,
pipelines::ShapeCommand,
shape_def::Shape,
surface::{SurfaceArgsBuilder, surface},
};
const ANIMATION_DURATION: Duration = Duration::from_millis(150);
pub(crate) struct SwitchStateInner {
checked: bool,
progress: f32,
last_toggle_time: Option<Instant>,
}
impl Default for SwitchStateInner {
fn default() -> Self {
Self::new(false)
}
}
impl SwitchStateInner {
pub fn new(initial_state: bool) -> Self {
Self {
checked: initial_state,
progress: if initial_state { 1.0 } else { 0.0 },
last_toggle_time: None,
}
}
pub fn toggle(&mut self) {
self.checked = !self.checked;
self.last_toggle_time = Some(Instant::now());
}
}
#[derive(Clone)]
pub struct SwitchState {
inner: Arc<RwLock<SwitchStateInner>>,
}
impl SwitchState {
pub fn new(initial_state: bool) -> Self {
Self {
inner: Arc::new(RwLock::new(SwitchStateInner::new(initial_state))),
}
}
pub(crate) fn read(&self) -> RwLockReadGuard<'_, SwitchStateInner> {
self.inner.read()
}
pub(crate) fn write(&self) -> RwLockWriteGuard<'_, SwitchStateInner> {
self.inner.write()
}
pub fn is_checked(&self) -> bool {
self.inner.read().checked
}
pub fn set_checked(&self, checked: bool) {
let mut inner = self.inner.write();
if inner.checked != checked {
inner.checked = checked;
inner.progress = if checked { 1.0 } else { 0.0 };
inner.last_toggle_time = None;
}
}
pub fn toggle(&self) {
self.inner.write().toggle();
}
pub fn animation_progress(&self) -> f32 {
self.inner.read().progress
}
}
impl Default for SwitchState {
fn default() -> Self {
Self::new(false)
}
}
#[derive(Builder, Clone)]
#[builder(pattern = "owned")]
pub struct SwitchArgs {
#[builder(default, setter(strip_option))]
pub on_toggle: Option<Arc<dyn Fn(bool) + Send + Sync>>,
#[builder(default = "Dp(52.0)")]
pub width: Dp,
#[builder(default = "Dp(32.0)")]
pub height: Dp,
#[builder(default = "Color::new(0.8, 0.8, 0.8, 1.0)")]
pub track_color: Color,
#[builder(default = "Color::new(0.6, 0.7, 0.9, 1.0)")]
pub track_checked_color: Color,
#[builder(default = "Color::WHITE")]
pub thumb_color: Color,
#[builder(default = "Dp(3.0)")]
pub thumb_padding: Dp,
#[builder(default, setter(strip_option, into))]
pub accessibility_label: Option<String>,
#[builder(default, setter(strip_option, into))]
pub accessibility_description: Option<String>,
}
impl Default for SwitchArgs {
fn default() -> Self {
SwitchArgsBuilder::default().build().unwrap()
}
}
fn update_progress_from_state(state: &SwitchState) {
let last_toggle_time = state.read().last_toggle_time;
if let Some(last_toggle_time) = last_toggle_time {
let elapsed = last_toggle_time.elapsed();
let fraction = (elapsed.as_secs_f32() / ANIMATION_DURATION.as_secs_f32()).min(1.0);
let checked = state.read().checked;
state.write().progress = if checked { fraction } else { 1.0 - fraction };
}
}
fn is_cursor_in_component(size: ComputedData, pos_option: Option<tessera_ui::PxPosition>) -> bool {
pos_option
.map(|pos| {
pos.x.0 >= 0 && pos.x.0 < size.width.0 && pos.y.0 >= 0 && pos.y.0 < size.height.0
})
.unwrap_or(false)
}
fn handle_input_events_switch(
state: &SwitchState,
on_toggle: &Option<Arc<dyn Fn(bool) + Send + Sync>>,
input: &mut tessera_ui::InputHandlerInput,
) {
update_progress_from_state(state);
let size = input.computed_data;
let is_cursor_in = is_cursor_in_component(size, input.cursor_position_rel);
if is_cursor_in && on_toggle.is_some() {
input.requests.cursor_icon = CursorIcon::Pointer;
}
for e in input.cursor_events.iter() {
if matches!(
e.content,
CursorEventContent::Pressed(PressKeyEventType::Left)
) && is_cursor_in
{
toggle_switch_state(state, on_toggle);
}
}
}
fn toggle_switch_state(
state: &SwitchState,
on_toggle: &Option<Arc<dyn Fn(bool) + Send + Sync>>,
) -> bool {
let Some(on_toggle) = on_toggle else {
return false;
};
state.write().toggle();
let checked = state.read().checked;
on_toggle(checked);
true
}
fn apply_switch_accessibility(
input: &mut tessera_ui::InputHandlerInput<'_>,
state: &SwitchState,
on_toggle: &Option<Arc<dyn Fn(bool) + Send + Sync>>,
label: Option<&String>,
description: Option<&String>,
) {
let checked = state.read().checked;
let mut builder = input.accessibility().role(Role::Switch);
if let Some(label) = label {
builder = builder.label(label.clone());
}
if let Some(description) = description {
builder = builder.description(description.clone());
}
builder = builder
.focusable()
.action(Action::Click)
.toggled(if checked {
Toggled::True
} else {
Toggled::False
});
builder.commit();
if on_toggle.is_some() {
let state = state.clone();
let on_toggle = on_toggle.clone();
input.set_accessibility_action_handler(move |action| {
if action == Action::Click {
toggle_switch_state(&state, &on_toggle);
}
});
}
}
fn interpolate_color(off: Color, on: Color, progress: f32) -> Color {
Color {
r: off.r + (on.r - off.r) * progress,
g: off.g + (on.g - off.g) * progress,
b: off.b + (on.b - off.b) * progress,
a: off.a + (on.a - off.a) * progress,
}
}
#[tessera]
pub fn switch(args: impl Into<SwitchArgs>, state: SwitchState) {
let args: SwitchArgs = args.into();
let thumb_size = Dp(args.height.0 - (args.thumb_padding.0 * 2.0));
surface(
SurfaceArgsBuilder::default()
.width(DimensionValue::Fixed(thumb_size.to_px()))
.height(DimensionValue::Fixed(thumb_size.to_px()))
.style(args.thumb_color.into())
.shape(Shape::Ellipse)
.build()
.unwrap(),
None,
|| {},
);
let on_toggle = args.on_toggle.clone();
let accessibility_on_toggle = on_toggle.clone();
let accessibility_label = args.accessibility_label.clone();
let accessibility_description = args.accessibility_description.clone();
let progress = state.read().progress;
let state_for_handler = state.clone();
input_handler(Box::new(move |mut input| {
handle_input_events_switch(&state_for_handler, &on_toggle, &mut input);
apply_switch_accessibility(
&mut input,
&state_for_handler,
&accessibility_on_toggle,
accessibility_label.as_ref(),
accessibility_description.as_ref(),
);
}));
measure(Box::new(move |input| {
let thumb_id = input.children_ids[0];
let thumb_constraint = Constraint::new(
DimensionValue::Wrap {
min: None,
max: None,
},
DimensionValue::Wrap {
min: None,
max: None,
},
);
let thumb_size = input.measure_child(thumb_id, &thumb_constraint)?;
let self_width_px = args.width.to_px();
let self_height_px = args.height.to_px();
let thumb_padding_px = args.thumb_padding.to_px();
let start_x = thumb_padding_px;
let end_x = self_width_px - thumb_size.width - thumb_padding_px;
let eased = animation::easing(progress);
let thumb_x = start_x.0 as f32 + (end_x.0 - start_x.0) as f32 * eased;
let thumb_y = (self_height_px - thumb_size.height) / 2;
input.place_child(
thumb_id,
PxPosition::new(tessera_ui::Px(thumb_x as i32), thumb_y),
);
let track_color = interpolate_color(args.track_color, args.track_checked_color, progress);
let track_command = ShapeCommand::Rect {
color: track_color,
corner_radii: glam::Vec4::splat((self_height_px.0 as f32) / 2.0).into(),
g2_k_value: 2.0, shadow: None,
};
input.metadata_mut().push_draw_command(track_command);
Ok(ComputedData {
width: self_width_px,
height: self_height_px,
})
}));
}