tessera_ui_basic_components/
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    pipelines::ShapeCommand,
16    shape_def::Shape,
17    surface::{SurfaceArgsBuilder, surface},
18};
19
20const ANIMATION_DURATION: Duration = Duration::from_millis(150);
21
22/// State for the `switch` component, handling animation.
23pub struct SwitchState {
24    pub checked: bool,
25    progress: Mutex<f32>,
26    last_toggle_time: Mutex<Option<Instant>>,
27}
28
29impl SwitchState {
30    pub fn new(initial_state: bool) -> Self {
31        Self {
32            checked: initial_state,
33            progress: Mutex::new(if initial_state { 1.0 } else { 0.0 }),
34            last_toggle_time: Mutex::new(None),
35        }
36    }
37
38    pub fn toggle(&mut self) {
39        self.checked = !self.checked;
40        *self.last_toggle_time.lock() = Some(Instant::now());
41    }
42}
43
44/// Arguments for the `switch` component.
45#[derive(Builder, Clone)]
46#[builder(pattern = "owned")]
47pub struct SwitchArgs {
48    #[builder(default)]
49    pub state: Option<Arc<Mutex<SwitchState>>>,
50
51    #[builder(default = "false")]
52    pub checked: bool,
53
54    #[builder(default = "Arc::new(|_| {})")]
55    pub on_toggle: Arc<dyn Fn(bool) + Send + Sync>,
56
57    #[builder(default = "Dp(52.0)")]
58    pub width: Dp,
59
60    #[builder(default = "Dp(32.0)")]
61    pub height: Dp,
62
63    #[builder(default = "Color::new(0.8, 0.8, 0.8, 1.0)")]
64    pub track_color: Color,
65
66    #[builder(default = "Color::new(0.6, 0.7, 0.9, 1.0)")]
67    pub track_checked_color: Color,
68
69    #[builder(default = "Color::WHITE")]
70    pub thumb_color: Color,
71
72    #[builder(default = "Dp(3.0)")]
73    pub thumb_padding: Dp,
74}
75
76#[tessera]
77pub fn switch(args: impl Into<SwitchArgs>) {
78    let args: SwitchArgs = args.into();
79    let thumb_size = Dp(args.height.0 - (args.thumb_padding.0 * 2.0));
80
81    surface(
82        SurfaceArgsBuilder::default()
83            .width(DimensionValue::Fixed(thumb_size.to_px()))
84            .height(DimensionValue::Fixed(thumb_size.to_px()))
85            .color(args.thumb_color)
86            .shape(Shape::Ellipse)
87            .build()
88            .unwrap(),
89        None,
90        || {},
91    );
92
93    let on_toggle = args.on_toggle.clone();
94    let state = args.state.clone();
95    let checked = args.checked;
96
97    state_handler(Box::new(move |input| {
98        if let Some(state) = &state {
99            let state = state.lock();
100            let mut progress = state.progress.lock();
101
102            if let Some(last_toggle_time) = *state.last_toggle_time.lock() {
103                let elapsed = last_toggle_time.elapsed();
104                let animation_fraction =
105                    (elapsed.as_secs_f32() / ANIMATION_DURATION.as_secs_f32()).min(1.0);
106
107                *progress = if state.checked {
108                    animation_fraction
109                } else {
110                    1.0 - animation_fraction
111                };
112            }
113        }
114
115        let size = input.computed_data;
116        let is_cursor_in = if let Some(pos) = input.cursor_position {
117            pos.x.0 >= 0 && pos.x.0 < size.width.0 && pos.y.0 >= 0 && pos.y.0 < size.height.0
118        } else {
119            false
120        };
121
122        if is_cursor_in {
123            input.requests.cursor_icon = CursorIcon::Pointer;
124        }
125
126        for e in input.cursor_events.iter() {
127            if let CursorEventContent::Pressed(PressKeyEventType::Left) = &e.content {
128                if is_cursor_in {
129                    on_toggle(!checked);
130                }
131            }
132        }
133    }));
134
135    measure(Box::new(move |input| {
136        let thumb_id = input.children_ids[0];
137        let thumb_constraint = Constraint::new(
138            DimensionValue::Wrap {
139                min: None,
140                max: None,
141            },
142            DimensionValue::Wrap {
143                min: None,
144                max: None,
145            },
146        );
147        let thumb_size = tessera_ui::measure_node(
148            thumb_id,
149            &thumb_constraint,
150            input.tree,
151            input.metadatas,
152            input.compute_resource_manager.clone(),
153            input.gpu,
154        )?;
155
156        let self_width_px = args.width.to_px();
157        let self_height_px = args.height.to_px();
158        let thumb_padding_px = args.thumb_padding.to_px();
159
160        let progress = args
161            .state
162            .as_ref()
163            .map(|s| *s.lock().progress.lock())
164            .unwrap_or(if args.checked { 1.0 } else { 0.0 });
165
166        let start_x = thumb_padding_px;
167        let end_x = self_width_px - thumb_size.width - thumb_padding_px;
168        let thumb_x = start_x.0 as f32 + (end_x.0 - start_x.0) as f32 * progress;
169
170        let thumb_y = (self_height_px - thumb_size.height) / 2;
171
172        tessera_ui::place_node(
173            thumb_id,
174            PxPosition::new(tessera_ui::Px(thumb_x as i32), thumb_y),
175            input.metadatas,
176        );
177
178        let track_color = if args.checked {
179            args.track_checked_color
180        } else {
181            args.track_color
182        };
183        let track_command = ShapeCommand::Rect {
184            color: track_color,
185            corner_radius: (self_height_px.0 as f32) / 2.0,
186            g2_k_value: 2.0, // Use G1 corners here specifically
187            shadow: None,
188        };
189        if let Some(mut metadata) = input.metadatas.get_mut(&input.current_node_id) {
190            metadata.push_draw_command(track_command);
191        }
192
193        Ok(ComputedData {
194            width: self_width_px,
195            height: self_height_px,
196        })
197    }));
198}