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}