tessera_ui_basic_components/
checkbox.rs

1//! A customizable, animated checkbox component.
2//!
3//! ## Usage
4//!
5//! Use in forms, settings, or lists to enable boolean selections.
6use std::{
7    sync::Arc,
8    time::{Duration, Instant},
9};
10
11use derive_builder::Builder;
12use parking_lot::RwLock;
13use tessera_ui::{
14    Color, DimensionValue, Dp,
15    accesskit::{Action, Role, Toggled},
16    tessera,
17};
18
19use crate::{
20    RippleState,
21    alignment::Alignment,
22    boxed::{BoxedArgsBuilder, boxed},
23    checkmark::{CheckmarkArgsBuilder, checkmark},
24    shape_def::Shape,
25    surface::{SurfaceArgsBuilder, surface},
26};
27
28#[derive(Clone, Default)]
29pub struct CheckboxState {
30    ripple: RippleState,
31    checkmark: Arc<RwLock<CheckmarkState>>,
32}
33
34impl CheckboxState {
35    pub fn new(initial_state: bool) -> Self {
36        Self {
37            ripple: RippleState::new(),
38            checkmark: Arc::new(RwLock::new(CheckmarkState::new(initial_state))),
39        }
40    }
41}
42
43/// Arguments for the `checkbox` component.
44#[derive(Builder, Clone)]
45#[builder(pattern = "owned")]
46pub struct CheckboxArgs {
47    /// Callback invoked when the checkbox is toggled.
48    #[builder(default = "Arc::new(|_| {})")]
49    pub on_toggle: Arc<dyn Fn(bool) + Send + Sync>,
50    /// Size of the checkbox (width and height).
51    ///
52    /// Expressed in `Dp` (density-independent pixels). The checkbox will use
53    /// the same value for width and height; default is `Dp(24.0)`.
54    #[builder(default = "Dp(24.0)")]
55    pub size: Dp,
56
57    #[builder(default = "Color::new(0.8, 0.8, 0.8, 1.0)")]
58    /// Background color when the checkbox is not checked.
59    ///
60    /// This sets the surface color shown for the unchecked state and is typically
61    /// a subtle neutral color.
62    pub color: Color,
63
64    #[builder(default = "Color::new(0.6, 0.7, 0.9, 1.0)")]
65    /// Background color used when the checkbox is checked.
66    ///
67    /// This color is shown behind the checkmark to indicate an active/selected
68    /// state. Choose a higher-contrast color relative to `color`.
69    pub checked_color: Color,
70
71    #[builder(default = "Color::from_rgb_u8(119, 72, 146)")]
72    /// Color used to draw the checkmark icon inside the checkbox.
73    ///
74    /// This is applied on top of the `checked_color` surface.
75    pub checkmark_color: Color,
76
77    #[builder(default = "5.0")]
78    /// Stroke width in physical pixels used to render the checkmark path.
79    ///
80    /// Higher values produce a thicker checkmark. The default value is tuned for
81    /// the default `size`.
82    pub checkmark_stroke_width: f32,
83
84    #[builder(default = "1.0")]
85    /// Initial animation progress of the checkmark (0.0 ..= 1.0).
86    ///
87    /// Used to drive the checkmark animation when toggling. `0.0` means not
88    /// visible; `1.0` means fully drawn. Values in-between show the intermediate
89    /// animation state.
90    pub checkmark_animation_progress: f32,
91
92    #[builder(
93        default = "Shape::RoundedRectangle{ top_left: Dp(4.0), top_right: Dp(4.0), bottom_right: Dp(4.0), bottom_left: Dp(4.0), g2_k_value: 3.0 }"
94    )]
95    pub shape: Shape,
96    /// Shape used for the outer checkbox surface (rounded rectangle, etc.).
97    ///
98    /// Use this to customize the corner radii or switch to alternate shapes.
99
100    #[builder(default)]
101    pub hover_color: Option<Color>,
102    /// Optional surface color to apply when the pointer hovers over the control.
103    ///
104    /// If `None`, the control does not apply a hover style by default.
105    /// Optional accessibility label read by assistive technologies.
106    ///
107    /// The label should be a short, human-readable string describing the
108    /// purpose of the checkbox (for example "Enable auto-save").
109    #[builder(default, setter(strip_option, into))]
110    pub accessibility_label: Option<String>,
111    /// Optional accessibility description read by assistive technologies.
112    ///
113    /// A longer description or contextual helper text that augments the
114    /// `accessibility_label` for users of assistive technology.
115    #[builder(default, setter(strip_option, into))]
116    pub accessibility_description: Option<String>,
117}
118
119impl Default for CheckboxArgs {
120    fn default() -> Self {
121        CheckboxArgsBuilder::default().build().unwrap()
122    }
123}
124
125// Animation duration for the checkmark stroke (milliseconds)
126const CHECKMARK_ANIMATION_DURATION: Duration = Duration::from_millis(200);
127
128/// State for checkmark animation (similar to `SwitchState`)
129pub struct CheckmarkState {
130    pub checked: bool,
131    progress: f32,
132    last_toggle_time: Option<Instant>,
133}
134
135impl Default for CheckmarkState {
136    fn default() -> Self {
137        Self::new(false)
138    }
139}
140
141impl CheckmarkState {
142    pub fn new(initial_state: bool) -> Self {
143        Self {
144            checked: initial_state,
145            progress: if initial_state { 1.0 } else { 0.0 },
146            last_toggle_time: None,
147        }
148    }
149
150    /// Toggle checked state and start animation
151    pub fn toggle(&mut self) {
152        self.checked = !self.checked;
153        self.last_toggle_time = Some(Instant::now());
154    }
155
156    /// Update progress based on elapsed time
157    pub fn update_progress(&mut self) {
158        if let Some(start) = self.last_toggle_time {
159            let elapsed = start.elapsed();
160            let fraction =
161                (elapsed.as_secs_f32() / CHECKMARK_ANIMATION_DURATION.as_secs_f32()).min(1.0);
162            self.progress = if self.checked {
163                fraction
164            } else {
165                1.0 - fraction
166            };
167            if fraction >= 1.0 {
168                self.last_toggle_time = None; // Animation ends
169            }
170        }
171    }
172
173    pub fn progress(&self) -> f32 {
174        self.progress
175    }
176}
177
178/// # checkbox
179///
180/// Renders an interactive checkbox with an animated checkmark.
181///
182/// ## Usage
183///
184/// Use to capture a boolean (true/false) choice from the user.
185///
186/// ## Parameters
187///
188/// - `args` — configures the checkbox's appearance and `on_toggle` callback; see [`CheckboxArgs`].
189/// - `state` — a clonable [`CheckboxState`] that manages the checkmark and ripple animations.
190///
191/// ## Examples
192///
193/// ```
194/// use std::sync::{Arc, atomic::{AtomicBool, Ordering}};
195/// use tessera_ui::{tessera, Color, Dp};
196/// use tessera_ui_basic_components::checkbox::{checkbox, CheckboxArgsBuilder, CheckboxState};
197///
198/// // A tiny UI demo that shows a checkbox and a text label that reflects its state.
199/// #[derive(Clone, Default)]
200/// struct DemoState {
201///     is_checked: Arc<AtomicBool>,
202///     checkbox_state: CheckboxState,
203/// }
204///
205/// #[tessera]
206/// fn checkbox_demo(state: DemoState) {
207///     // Build a simple checkbox whose on_toggle updates `is_checked`.
208///     let on_toggle = Arc::new({
209///         let is_checked = state.is_checked.clone();
210///         move |new_value| {
211///             is_checked.store(new_value, Ordering::SeqCst);
212///         }
213///     });
214///
215///     // Render the checkbox; the example shows a minimal pattern for interactive demos.
216///     checkbox(
217///         CheckboxArgsBuilder::default()
218///             .on_toggle(on_toggle)
219///             .build()
220///             .unwrap(),
221///         state.checkbox_state.clone(),
222///     );
223/// }
224/// ```
225#[tessera]
226pub fn checkbox(args: impl Into<CheckboxArgs>, state: CheckboxState) {
227    let args: CheckboxArgs = args.into();
228
229    // If a state is provided, set up an updater to advance the animation each frame
230    let checkmark_state = state.checkmark.clone();
231    input_handler(Box::new(move |_input| {
232        checkmark_state.write().update_progress();
233    }));
234
235    // Click handler: toggle animation state if present, otherwise simply forward toggle callback
236    let on_click = {
237        let state = state.clone();
238        let on_toggle = args.on_toggle.clone();
239        Arc::new(move || {
240            state.checkmark.write().toggle();
241            on_toggle(state.checkmark.read().checked);
242        })
243    };
244    let on_click_for_surface = on_click.clone();
245
246    let ripple_state = state.ripple.clone();
247
248    surface(
249        SurfaceArgsBuilder::default()
250            .width(DimensionValue::Fixed(args.size.to_px()))
251            .height(DimensionValue::Fixed(args.size.to_px()))
252            .style(
253                if state.checkmark.read().checked {
254                    args.checked_color
255                } else {
256                    args.color
257                }
258                .into(),
259            )
260            .hover_style(args.hover_color.map(|c| c.into()))
261            .shape(args.shape)
262            .on_click(on_click_for_surface)
263            .build()
264            .unwrap(),
265        Some(ripple_state),
266        {
267            let state_for_child = state.clone();
268            move || {
269                let progress = state_for_child.checkmark.read().progress();
270                if progress > 0.0 {
271                    surface(
272                        SurfaceArgsBuilder::default()
273                            .padding(Dp(2.0))
274                            .style(Color::TRANSPARENT.into())
275                            .build()
276                            .unwrap(),
277                        None,
278                        move || {
279                            boxed(
280                                BoxedArgsBuilder::default()
281                                    .alignment(Alignment::Center)
282                                    .build()
283                                    .unwrap(),
284                                |scope| {
285                                    scope.child(move || {
286                                        checkmark(
287                                            CheckmarkArgsBuilder::default()
288                                                .color(args.checkmark_color)
289                                                .stroke_width(args.checkmark_stroke_width)
290                                                .progress(progress)
291                                                .size(Dp(args.size.0 * 0.8))
292                                                .padding([2.0, 2.0])
293                                                .build()
294                                                .unwrap(),
295                                        )
296                                    });
297                                },
298                            );
299                        },
300                    )
301                }
302            }
303        },
304    );
305
306    let accessibility_label = args.accessibility_label.clone();
307    let accessibility_description = args.accessibility_description.clone();
308    let accessibility_state = state.clone();
309    let on_click_for_accessibility = on_click.clone();
310    input_handler(Box::new(move |input| {
311        let checked = accessibility_state.checkmark.read().checked;
312        let mut builder = input.accessibility().role(Role::CheckBox);
313
314        if let Some(label) = accessibility_label.as_ref() {
315            builder = builder.label(label.clone());
316        }
317        if let Some(description) = accessibility_description.as_ref() {
318            builder = builder.description(description.clone());
319        }
320
321        builder = builder
322            .focusable()
323            .action(Action::Click)
324            .toggled(if checked {
325                Toggled::True
326            } else {
327                Toggled::False
328            });
329
330        builder.commit();
331
332        input.set_accessibility_action_handler({
333            let on_click = on_click_for_accessibility.clone();
334            move |action| {
335                if action == Action::Click {
336                    on_click();
337                }
338            }
339        });
340    }));
341}