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}