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