Skip to main content

jag_ui/elements/
checkbox.rs

1//! Checkbox element with label and validation support.
2
3use jag_draw::{Brush, Color, ColorLinPremul, Rect, RoundedRadii, RoundedRect};
4use jag_surface::Canvas;
5
6use crate::event::{
7    ElementState, EventHandler, EventResult, KeyCode, KeyboardEvent, MouseButton, MouseClickEvent,
8    MouseMoveEvent, ScrollEvent,
9};
10use crate::focus::FocusId;
11
12use super::Element;
13
14/// A toggle checkbox with optional label and validation state.
15pub struct Checkbox {
16    pub rect: Rect,
17    pub checked: bool,
18    pub focused: bool,
19    pub label: Option<String>,
20    pub label_size: f32,
21    pub label_color: ColorLinPremul,
22    pub box_fill: ColorLinPremul,
23    pub border_color: ColorLinPremul,
24    pub border_width: f32,
25    pub check_color: ColorLinPremul,
26    /// Whether the checkbox must be checked for form validation.
27    pub required: bool,
28    /// Static error message shown when validation fails.
29    pub error_message: Option<String>,
30    /// Dynamic validation error (set by form logic).
31    pub validation_error: Option<String>,
32    /// Focus identifier for this checkbox.
33    pub focus_id: FocusId,
34}
35
36impl Checkbox {
37    /// Create a checkbox with sensible defaults (unchecked, no label).
38    pub fn new() -> Self {
39        Self {
40            rect: Rect {
41                x: 0.0,
42                y: 0.0,
43                w: 18.0,
44                h: 18.0,
45            },
46            checked: false,
47            focused: false,
48            label: None,
49            label_size: 14.0,
50            label_color: ColorLinPremul::from_srgba_u8([255, 255, 255, 255]),
51            box_fill: ColorLinPremul::from_srgba_u8([40, 40, 40, 255]),
52            border_color: ColorLinPremul::from_srgba_u8([80, 80, 80, 255]),
53            border_width: 1.0,
54            check_color: ColorLinPremul::from_srgba_u8([59, 130, 246, 255]),
55            required: false,
56            error_message: None,
57            validation_error: None,
58            focus_id: FocusId(0),
59        }
60    }
61
62    /// Toggle the checked state.
63    pub fn toggle(&mut self) {
64        self.checked = !self.checked;
65    }
66
67    /// Hit-test the checkbox box itself.
68    pub fn hit_test_box(&self, x: f32, y: f32) -> bool {
69        x >= self.rect.x
70            && x <= self.rect.x + self.rect.w
71            && y >= self.rect.y
72            && y <= self.rect.y + self.rect.h
73    }
74
75    /// Hit-test the label area (if a label exists).
76    pub fn hit_test_label(&self, x: f32, y: f32) -> bool {
77        if let Some(label) = &self.label {
78            let label_x = self.rect.x + self.rect.w + 8.0;
79            let char_width = self.label_size * 0.5;
80            let label_width = label.len() as f32 * char_width;
81            let clickable_height = self.rect.h.max(self.label_size * 1.2);
82
83            x >= label_x
84                && x <= label_x + label_width
85                && y >= self.rect.y
86                && y <= self.rect.y + clickable_height
87        } else {
88            false
89        }
90    }
91}
92
93impl Default for Checkbox {
94    fn default() -> Self {
95        Self::new()
96    }
97}
98
99// ---------------------------------------------------------------------------
100// Element trait
101// ---------------------------------------------------------------------------
102
103impl Element for Checkbox {
104    fn rect(&self) -> Rect {
105        self.rect
106    }
107
108    fn set_rect(&mut self, rect: Rect) {
109        self.rect = rect;
110    }
111
112    fn render(&self, canvas: &mut Canvas, z: i32) {
113        let base_rrect = RoundedRect {
114            rect: self.rect,
115            radii: RoundedRadii {
116                tl: 2.0,
117                tr: 2.0,
118                br: 2.0,
119                bl: 2.0,
120            },
121        };
122
123        // Border + fill
124        if self.border_width > 0.0 {
125            let has_error = self.validation_error.is_some();
126            let border_color = if has_error {
127                Color::rgba(220, 38, 38, 255)
128            } else {
129                self.border_color
130            };
131            let border_width = if has_error {
132                self.border_width.max(2.0)
133            } else {
134                self.border_width
135            };
136
137            jag_surface::shapes::draw_snapped_rounded_rectangle(
138                canvas,
139                base_rrect,
140                Some(Brush::Solid(self.box_fill)),
141                Some(border_width),
142                Some(Brush::Solid(border_color)),
143                z,
144            );
145        } else {
146            canvas.fill_rect(
147                self.rect.x,
148                self.rect.y,
149                self.rect.w,
150                self.rect.h,
151                Brush::Solid(self.box_fill),
152                z,
153            );
154        }
155
156        // Focus outline
157        if self.focused {
158            let focus_rr = RoundedRect {
159                rect: self.rect,
160                radii: RoundedRadii {
161                    tl: 2.0,
162                    tr: 2.0,
163                    br: 2.0,
164                    bl: 2.0,
165                },
166            };
167            let focus = Brush::Solid(Color::rgba(63, 130, 246, 255));
168            jag_surface::shapes::draw_snapped_rounded_rectangle(
169                canvas,
170                focus_rr,
171                None,
172                Some(2.0),
173                Some(focus),
174                z + 2,
175            );
176        }
177
178        // Checked state: inner filled square with checkmark
179        if self.checked {
180            let inset = 2.0_f32;
181            let inner = Rect {
182                x: (self.rect.x + inset).round(),
183                y: (self.rect.y + inset).round(),
184                w: (self.rect.w - 2.0 * inset).max(0.0).round(),
185                h: (self.rect.h - 2.0 * inset).max(0.0).round(),
186            };
187            let inner_rr = RoundedRect {
188                rect: inner,
189                radii: RoundedRadii {
190                    tl: 1.5,
191                    tr: 1.5,
192                    br: 1.5,
193                    bl: 1.5,
194                },
195            };
196            canvas.rounded_rect(inner_rr, Brush::Solid(self.check_color), z + 2);
197
198            // Simple text checkmark instead of SVG (standalone, no asset deps).
199            let mark_size = inner.w * 0.7;
200            let mark_x = inner.x + (inner.w - mark_size * 0.5) * 0.5;
201            let mark_y = inner.y + inner.h * 0.75;
202            canvas.draw_text_run_weighted(
203                [mark_x, mark_y],
204                "\u{2713}".to_string(),
205                mark_size,
206                700.0,
207                ColorLinPremul::from_srgba_u8([255, 255, 255, 255]),
208                z + 3,
209            );
210        }
211
212        // Label
213        if let Some(text) = &self.label {
214            let tx = self.rect.x + self.rect.w + 8.0;
215            let ty = self.rect.y + self.rect.h * 0.5 + self.label_size * 0.32;
216            canvas.draw_text_run_weighted(
217                [tx, ty],
218                text.clone(),
219                self.label_size,
220                400.0,
221                self.label_color,
222                z + 3,
223            );
224        }
225
226        // Validation error message
227        if let Some(ref error_msg) = self.validation_error {
228            let error_size = (self.label_size * 0.85).max(12.0);
229            let baseline_offset = error_size * 0.8;
230            let top_gap = 3.0;
231            let control_height = self.rect.h.max(self.label_size * 1.2);
232            let error_y = self.rect.y + control_height + top_gap + baseline_offset;
233            let error_color = ColorLinPremul::from_srgba_u8([220, 38, 38, 255]);
234
235            canvas.draw_text_run_weighted(
236                [self.rect.x, error_y],
237                error_msg.clone(),
238                error_size,
239                400.0,
240                error_color,
241                z + 4,
242            );
243        }
244    }
245
246    fn focus_id(&self) -> Option<FocusId> {
247        Some(self.focus_id)
248    }
249}
250
251// ---------------------------------------------------------------------------
252// EventHandler trait
253// ---------------------------------------------------------------------------
254
255impl EventHandler for Checkbox {
256    fn handle_mouse_click(&mut self, event: &MouseClickEvent) -> EventResult {
257        if event.button != MouseButton::Left || event.state != ElementState::Pressed {
258            return EventResult::Ignored;
259        }
260        if self.hit_test_box(event.x, event.y) || self.hit_test_label(event.x, event.y) {
261            self.toggle();
262            EventResult::Handled
263        } else {
264            EventResult::Ignored
265        }
266    }
267
268    fn handle_keyboard(&mut self, event: &KeyboardEvent) -> EventResult {
269        if event.state != ElementState::Pressed || !self.focused {
270            return EventResult::Ignored;
271        }
272        match event.key {
273            KeyCode::Space | KeyCode::Enter => {
274                self.toggle();
275                EventResult::Handled
276            }
277            _ => EventResult::Ignored,
278        }
279    }
280
281    fn handle_mouse_move(&mut self, _event: &MouseMoveEvent) -> EventResult {
282        EventResult::Ignored
283    }
284
285    fn handle_scroll(&mut self, _event: &ScrollEvent) -> EventResult {
286        EventResult::Ignored
287    }
288
289    fn is_focused(&self) -> bool {
290        self.focused
291    }
292
293    fn set_focused(&mut self, focused: bool) {
294        self.focused = focused;
295    }
296
297    fn contains_point(&self, x: f32, y: f32) -> bool {
298        self.hit_test_box(x, y) || self.hit_test_label(x, y)
299    }
300}
301
302// ---------------------------------------------------------------------------
303// Tests
304// ---------------------------------------------------------------------------
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    #[test]
311    fn checkbox_toggle() {
312        let mut cb = Checkbox::new();
313        assert!(!cb.checked);
314        cb.toggle();
315        assert!(cb.checked);
316        cb.toggle();
317        assert!(!cb.checked);
318    }
319
320    #[test]
321    fn checkbox_default_is_new() {
322        let cb = Checkbox::default();
323        assert!(!cb.checked);
324        assert!(!cb.focused);
325        assert!(cb.label.is_none());
326    }
327
328    #[test]
329    fn checkbox_hit_test_box() {
330        let mut cb = Checkbox::new();
331        cb.rect = Rect {
332            x: 10.0,
333            y: 10.0,
334            w: 18.0,
335            h: 18.0,
336        };
337        assert!(cb.hit_test_box(15.0, 15.0));
338        assert!(!cb.hit_test_box(0.0, 0.0));
339    }
340
341    #[test]
342    fn checkbox_hit_test_label() {
343        let mut cb = Checkbox::new();
344        cb.rect = Rect {
345            x: 10.0,
346            y: 10.0,
347            w: 18.0,
348            h: 18.0,
349        };
350        cb.label = Some("Accept".to_string());
351        // label starts at x=10+18+8=36
352        assert!(cb.hit_test_label(40.0, 15.0));
353        assert!(!cb.hit_test_label(5.0, 15.0));
354    }
355
356    #[test]
357    fn checkbox_contains_point_covers_both() {
358        let mut cb = Checkbox::new();
359        cb.rect = Rect {
360            x: 10.0,
361            y: 10.0,
362            w: 18.0,
363            h: 18.0,
364        };
365        cb.label = Some("Check".to_string());
366        // Box area
367        assert!(cb.contains_point(15.0, 15.0));
368        // Label area
369        assert!(cb.contains_point(40.0, 15.0));
370        // Outside both
371        assert!(!cb.contains_point(0.0, 0.0));
372    }
373
374    #[test]
375    fn checkbox_focus() {
376        let mut cb = Checkbox::new();
377        assert!(!cb.is_focused());
378        cb.set_focused(true);
379        assert!(cb.is_focused());
380    }
381
382    #[test]
383    fn checkbox_keyboard_toggle() {
384        let mut cb = Checkbox::new();
385        cb.focused = true;
386        let evt = KeyboardEvent {
387            key: KeyCode::Space,
388            state: ElementState::Pressed,
389            modifiers: Default::default(),
390            text: None,
391        };
392        assert!(!cb.checked);
393        assert_eq!(cb.handle_keyboard(&evt), EventResult::Handled);
394        assert!(cb.checked);
395    }
396
397    #[test]
398    fn checkbox_keyboard_ignored_without_focus() {
399        let mut cb = Checkbox::new();
400        cb.focused = false;
401        let evt = KeyboardEvent {
402            key: KeyCode::Space,
403            state: ElementState::Pressed,
404            modifiers: Default::default(),
405            text: None,
406        };
407        assert_eq!(cb.handle_keyboard(&evt), EventResult::Ignored);
408        assert!(!cb.checked);
409    }
410}