presentar_widgets/
checkbox.rs

1//! Checkbox widget for boolean input.
2
3use presentar_core::{
4    widget::{AccessibleRole, LayoutResult},
5    Canvas, Color, Constraints, Event, MouseButton, Rect, Size, TypeId, Widget,
6};
7use serde::{Deserialize, Serialize};
8use std::any::Any;
9
10/// Checkbox state (supports tri-state).
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
12pub enum CheckState {
13    /// Not checked
14    #[default]
15    Unchecked,
16    /// Checked
17    Checked,
18    /// Indeterminate (for partial selection in trees)
19    Indeterminate,
20}
21
22impl CheckState {
23    /// Toggle between checked and unchecked.
24    #[must_use]
25    pub const fn toggle(&self) -> Self {
26        match self {
27            Self::Unchecked => Self::Checked,
28            Self::Checked | Self::Indeterminate => Self::Unchecked,
29        }
30    }
31
32    /// Check if checked (true for Checked, false for others).
33    #[must_use]
34    pub const fn is_checked(&self) -> bool {
35        matches!(self, Self::Checked)
36    }
37
38    /// Check if indeterminate.
39    #[must_use]
40    pub const fn is_indeterminate(&self) -> bool {
41        matches!(self, Self::Indeterminate)
42    }
43}
44
45/// Message emitted when checkbox state changes.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub struct CheckboxChanged {
48    /// The new state
49    pub state: CheckState,
50}
51
52/// Checkbox widget.
53#[derive(Serialize, Deserialize)]
54pub struct Checkbox {
55    /// Current state
56    state: CheckState,
57    /// Whether disabled
58    disabled: bool,
59    /// Label text
60    label: String,
61    /// Box size
62    box_size: f32,
63    /// Spacing between box and label
64    spacing: f32,
65    /// Unchecked box color
66    box_color: Color,
67    /// Checked box color
68    checked_color: Color,
69    /// Check mark color
70    check_color: Color,
71    /// Label color
72    label_color: Color,
73    /// Disabled color
74    disabled_color: Color,
75    /// Test ID
76    test_id_value: Option<String>,
77    /// Accessible name
78    accessible_name_value: Option<String>,
79    /// Cached bounds
80    #[serde(skip)]
81    bounds: Rect,
82    /// Whether hovered
83    #[serde(skip)]
84    hovered: bool,
85}
86
87impl Default for Checkbox {
88    fn default() -> Self {
89        Self::new()
90    }
91}
92
93impl Checkbox {
94    /// Create a new checkbox.
95    #[must_use]
96    pub fn new() -> Self {
97        Self {
98            state: CheckState::Unchecked,
99            disabled: false,
100            label: String::new(),
101            box_size: 18.0,
102            spacing: 8.0,
103            box_color: Color::new(0.8, 0.8, 0.8, 1.0),
104            checked_color: Color::new(0.2, 0.47, 0.96, 1.0),
105            check_color: Color::WHITE,
106            label_color: Color::BLACK,
107            disabled_color: Color::new(0.6, 0.6, 0.6, 1.0),
108            test_id_value: None,
109            accessible_name_value: None,
110            bounds: Rect::default(),
111            hovered: false,
112        }
113    }
114
115    /// Set the checked state.
116    #[must_use]
117    pub const fn checked(mut self, checked: bool) -> Self {
118        self.state = if checked {
119            CheckState::Checked
120        } else {
121            CheckState::Unchecked
122        };
123        self
124    }
125
126    /// Set the state directly.
127    #[must_use]
128    pub const fn state(mut self, state: CheckState) -> Self {
129        self.state = state;
130        self
131    }
132
133    /// Set the label.
134    #[must_use]
135    pub fn label(mut self, label: impl Into<String>) -> Self {
136        self.label = label.into();
137        self
138    }
139
140    /// Set disabled state.
141    #[must_use]
142    pub const fn disabled(mut self, disabled: bool) -> Self {
143        self.disabled = disabled;
144        self
145    }
146
147    /// Set box size.
148    #[must_use]
149    pub fn box_size(mut self, size: f32) -> Self {
150        self.box_size = size.max(8.0);
151        self
152    }
153
154    /// Set spacing between box and label.
155    #[must_use]
156    pub fn spacing(mut self, spacing: f32) -> Self {
157        self.spacing = spacing.max(0.0);
158        self
159    }
160
161    /// Set checked box color.
162    #[must_use]
163    pub const fn checked_color(mut self, color: Color) -> Self {
164        self.checked_color = color;
165        self
166    }
167
168    /// Set check mark color.
169    #[must_use]
170    pub const fn check_color(mut self, color: Color) -> Self {
171        self.check_color = color;
172        self
173    }
174
175    /// Set label color.
176    #[must_use]
177    pub const fn label_color(mut self, color: Color) -> Self {
178        self.label_color = color;
179        self
180    }
181
182    /// Set test ID.
183    #[must_use]
184    pub fn with_test_id(mut self, id: impl Into<String>) -> Self {
185        self.test_id_value = Some(id.into());
186        self
187    }
188
189    /// Set accessible name.
190    #[must_use]
191    pub fn with_accessible_name(mut self, name: impl Into<String>) -> Self {
192        self.accessible_name_value = Some(name.into());
193        self
194    }
195
196    /// Get current state.
197    #[must_use]
198    pub const fn get_state(&self) -> CheckState {
199        self.state
200    }
201
202    /// Check if currently checked.
203    #[must_use]
204    pub const fn is_checked(&self) -> bool {
205        self.state.is_checked()
206    }
207
208    /// Check if indeterminate.
209    #[must_use]
210    pub const fn is_indeterminate(&self) -> bool {
211        self.state.is_indeterminate()
212    }
213
214    /// Get the label.
215    #[must_use]
216    pub fn get_label(&self) -> &str {
217        &self.label
218    }
219}
220
221impl Widget for Checkbox {
222    fn type_id(&self) -> TypeId {
223        TypeId::of::<Self>()
224    }
225
226    fn measure(&self, constraints: Constraints) -> Size {
227        // Estimate label width (rough approximation)
228        let label_width = if self.label.is_empty() {
229            0.0
230        } else {
231            self.label.len() as f32 * 8.0 // ~8px per character
232        };
233
234        let total_width = self.box_size + self.spacing + label_width;
235        let height = self.box_size;
236
237        constraints.constrain(Size::new(total_width, height))
238    }
239
240    fn layout(&mut self, bounds: Rect) -> LayoutResult {
241        self.bounds = bounds;
242        LayoutResult {
243            size: bounds.size(),
244        }
245    }
246
247    fn paint(&self, canvas: &mut dyn Canvas) {
248        let box_rect = Rect::new(
249            self.bounds.x,
250            self.bounds.y + (self.bounds.height - self.box_size) / 2.0,
251            self.box_size,
252            self.box_size,
253        );
254
255        // Draw checkbox box
256        let box_color = if self.disabled {
257            self.disabled_color
258        } else if self.state.is_checked() || self.state.is_indeterminate() {
259            self.checked_color
260        } else {
261            self.box_color
262        };
263
264        canvas.fill_rect(box_rect, box_color);
265
266        // Draw check mark or indeterminate line
267        if !self.disabled {
268            match self.state {
269                CheckState::Checked => {
270                    // Draw checkmark (simplified as a filled inner rect)
271                    let inner = Rect::new(
272                        self.box_size.mul_add(0.25, box_rect.x),
273                        self.box_size.mul_add(0.25, box_rect.y),
274                        self.box_size * 0.5,
275                        self.box_size * 0.5,
276                    );
277                    canvas.fill_rect(inner, self.check_color);
278                }
279                CheckState::Indeterminate => {
280                    // Draw horizontal line
281                    let line = Rect::new(
282                        self.box_size.mul_add(0.2, box_rect.x),
283                        self.box_size.mul_add(0.4, box_rect.y),
284                        self.box_size * 0.6,
285                        self.box_size * 0.2,
286                    );
287                    canvas.fill_rect(line, self.check_color);
288                }
289                CheckState::Unchecked => {}
290            }
291        }
292
293        // Draw label
294        if !self.label.is_empty() {
295            let label_x = self.bounds.x + self.box_size + self.spacing;
296            let label_y = self.bounds.y + (self.bounds.height - 16.0) / 2.0;
297            let label_color = if self.disabled {
298                self.disabled_color
299            } else {
300                self.label_color
301            };
302
303            let style = presentar_core::widget::TextStyle {
304                color: label_color,
305                ..Default::default()
306            };
307            canvas.draw_text(
308                &self.label,
309                presentar_core::Point::new(label_x, label_y),
310                &style,
311            );
312        }
313    }
314
315    fn event(&mut self, event: &Event) -> Option<Box<dyn Any + Send>> {
316        if self.disabled {
317            return None;
318        }
319
320        match event {
321            Event::MouseMove { position } => {
322                self.hovered = self.bounds.contains_point(position);
323            }
324            Event::MouseDown {
325                position,
326                button: MouseButton::Left,
327            } => {
328                if self.bounds.contains_point(position) {
329                    self.state = self.state.toggle();
330                    return Some(Box::new(CheckboxChanged { state: self.state }));
331                }
332            }
333            _ => {}
334        }
335
336        None
337    }
338
339    fn children(&self) -> &[Box<dyn Widget>] {
340        &[]
341    }
342
343    fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
344        &mut []
345    }
346
347    fn is_interactive(&self) -> bool {
348        !self.disabled
349    }
350
351    fn is_focusable(&self) -> bool {
352        !self.disabled
353    }
354
355    fn accessible_name(&self) -> Option<&str> {
356        self.accessible_name_value
357            .as_deref()
358            .or(if self.label.is_empty() {
359                None
360            } else {
361                Some(self.label.as_str())
362            })
363    }
364
365    fn accessible_role(&self) -> AccessibleRole {
366        AccessibleRole::Checkbox
367    }
368
369    fn test_id(&self) -> Option<&str> {
370        self.test_id_value.as_deref()
371    }
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377    use presentar_core::Widget;
378
379    // =========================================================================
380    // CheckState Tests - TESTS FIRST
381    // =========================================================================
382
383    #[test]
384    fn test_check_state_default() {
385        assert_eq!(CheckState::default(), CheckState::Unchecked);
386    }
387
388    #[test]
389    fn test_check_state_toggle() {
390        assert_eq!(CheckState::Unchecked.toggle(), CheckState::Checked);
391        assert_eq!(CheckState::Checked.toggle(), CheckState::Unchecked);
392        assert_eq!(CheckState::Indeterminate.toggle(), CheckState::Unchecked);
393    }
394
395    #[test]
396    fn test_check_state_is_checked() {
397        assert!(!CheckState::Unchecked.is_checked());
398        assert!(CheckState::Checked.is_checked());
399        assert!(!CheckState::Indeterminate.is_checked());
400    }
401
402    #[test]
403    fn test_check_state_is_indeterminate() {
404        assert!(!CheckState::Unchecked.is_indeterminate());
405        assert!(!CheckState::Checked.is_indeterminate());
406        assert!(CheckState::Indeterminate.is_indeterminate());
407    }
408
409    // =========================================================================
410    // CheckboxChanged Tests - TESTS FIRST
411    // =========================================================================
412
413    #[test]
414    fn test_checkbox_changed_message() {
415        let msg = CheckboxChanged {
416            state: CheckState::Checked,
417        };
418        assert_eq!(msg.state, CheckState::Checked);
419    }
420
421    // =========================================================================
422    // Checkbox Construction Tests - TESTS FIRST
423    // =========================================================================
424
425    #[test]
426    fn test_checkbox_new() {
427        let cb = Checkbox::new();
428        assert_eq!(cb.get_state(), CheckState::Unchecked);
429        assert!(!cb.is_checked());
430        assert!(!cb.disabled);
431        assert!(cb.get_label().is_empty());
432    }
433
434    #[test]
435    fn test_checkbox_default() {
436        let cb = Checkbox::default();
437        assert_eq!(cb.get_state(), CheckState::Unchecked);
438    }
439
440    #[test]
441    fn test_checkbox_builder() {
442        let cb = Checkbox::new()
443            .checked(true)
444            .label("Accept terms")
445            .disabled(false)
446            .box_size(20.0)
447            .spacing(10.0)
448            .with_test_id("terms-checkbox")
449            .with_accessible_name("Terms and Conditions");
450
451        assert!(cb.is_checked());
452        assert_eq!(cb.get_label(), "Accept terms");
453        assert!(!cb.disabled);
454        assert_eq!(Widget::test_id(&cb), Some("terms-checkbox"));
455        assert_eq!(cb.accessible_name(), Some("Terms and Conditions"));
456    }
457
458    #[test]
459    fn test_checkbox_state_builder() {
460        let cb = Checkbox::new().state(CheckState::Indeterminate);
461        assert!(cb.is_indeterminate());
462        assert!(!cb.is_checked());
463    }
464
465    // =========================================================================
466    // Checkbox State Tests - TESTS FIRST
467    // =========================================================================
468
469    #[test]
470    fn test_checkbox_checked_true() {
471        let cb = Checkbox::new().checked(true);
472        assert!(cb.is_checked());
473        assert_eq!(cb.get_state(), CheckState::Checked);
474    }
475
476    #[test]
477    fn test_checkbox_checked_false() {
478        let cb = Checkbox::new().checked(false);
479        assert!(!cb.is_checked());
480        assert_eq!(cb.get_state(), CheckState::Unchecked);
481    }
482
483    #[test]
484    fn test_checkbox_indeterminate() {
485        let cb = Checkbox::new().state(CheckState::Indeterminate);
486        assert!(cb.is_indeterminate());
487        assert!(!cb.is_checked());
488    }
489
490    // =========================================================================
491    // Checkbox Widget Trait Tests - TESTS FIRST
492    // =========================================================================
493
494    #[test]
495    fn test_checkbox_type_id() {
496        let cb = Checkbox::new();
497        assert_eq!(Widget::type_id(&cb), TypeId::of::<Checkbox>());
498    }
499
500    #[test]
501    fn test_checkbox_measure_no_label() {
502        let cb = Checkbox::new().box_size(18.0);
503        let size = cb.measure(Constraints::loose(Size::new(200.0, 100.0)));
504        assert_eq!(size.width, 18.0 + 8.0); // box + spacing
505        assert_eq!(size.height, 18.0);
506    }
507
508    #[test]
509    fn test_checkbox_measure_with_label() {
510        let cb = Checkbox::new().box_size(18.0).spacing(8.0).label("Test");
511        let size = cb.measure(Constraints::loose(Size::new(200.0, 100.0)));
512        // 18 (box) + 8 (spacing) + 4*8 (label ~32px)
513        assert!(size.width > 18.0);
514    }
515
516    #[test]
517    fn test_checkbox_is_interactive() {
518        let cb = Checkbox::new();
519        assert!(cb.is_interactive());
520
521        let cb = Checkbox::new().disabled(true);
522        assert!(!cb.is_interactive());
523    }
524
525    #[test]
526    fn test_checkbox_is_focusable() {
527        let cb = Checkbox::new();
528        assert!(cb.is_focusable());
529
530        let cb = Checkbox::new().disabled(true);
531        assert!(!cb.is_focusable());
532    }
533
534    #[test]
535    fn test_checkbox_accessible_role() {
536        let cb = Checkbox::new();
537        assert_eq!(cb.accessible_role(), AccessibleRole::Checkbox);
538    }
539
540    #[test]
541    fn test_checkbox_accessible_name_from_label() {
542        let cb = Checkbox::new().label("My checkbox");
543        assert_eq!(cb.accessible_name(), Some("My checkbox"));
544    }
545
546    #[test]
547    fn test_checkbox_accessible_name_override() {
548        let cb = Checkbox::new()
549            .label("Short")
550            .with_accessible_name("Full accessible name");
551        assert_eq!(cb.accessible_name(), Some("Full accessible name"));
552    }
553
554    #[test]
555    fn test_checkbox_children() {
556        let cb = Checkbox::new();
557        assert!(cb.children().is_empty());
558    }
559
560    // =========================================================================
561    // Checkbox Color Tests - TESTS FIRST
562    // =========================================================================
563
564    #[test]
565    fn test_checkbox_colors() {
566        let cb = Checkbox::new()
567            .checked_color(Color::RED)
568            .check_color(Color::GREEN)
569            .label_color(Color::BLUE);
570
571        assert_eq!(cb.checked_color, Color::RED);
572        assert_eq!(cb.check_color, Color::GREEN);
573        assert_eq!(cb.label_color, Color::BLUE);
574    }
575
576    // =========================================================================
577    // Checkbox Layout Tests - TESTS FIRST
578    // =========================================================================
579
580    #[test]
581    fn test_checkbox_layout() {
582        let mut cb = Checkbox::new();
583        let bounds = Rect::new(10.0, 20.0, 100.0, 30.0);
584        let result = cb.layout(bounds);
585        assert_eq!(result.size, bounds.size());
586        assert_eq!(cb.bounds, bounds);
587    }
588
589    // =========================================================================
590    // Checkbox Size Tests - TESTS FIRST
591    // =========================================================================
592
593    #[test]
594    fn test_checkbox_box_size_min() {
595        let cb = Checkbox::new().box_size(2.0);
596        assert_eq!(cb.box_size, 8.0); // Minimum is 8
597    }
598
599    #[test]
600    fn test_checkbox_spacing_min() {
601        let cb = Checkbox::new().spacing(-5.0);
602        assert_eq!(cb.spacing, 0.0); // Minimum is 0
603    }
604
605    // =========================================================================
606    // Paint Tests - TESTS FIRST
607    // =========================================================================
608
609    use presentar_core::draw::DrawCommand;
610    use presentar_core::RecordingCanvas;
611
612    #[test]
613    fn test_checkbox_paint_unchecked_draws_box() {
614        let mut cb = Checkbox::new().box_size(18.0);
615        cb.layout(Rect::new(0.0, 0.0, 100.0, 18.0));
616
617        let mut canvas = RecordingCanvas::new();
618        cb.paint(&mut canvas);
619
620        // Should draw box rect
621        assert!(canvas.command_count() >= 1);
622        match &canvas.commands()[0] {
623            DrawCommand::Rect { bounds, style, .. } => {
624                assert_eq!(bounds.width, 18.0);
625                assert_eq!(bounds.height, 18.0);
626                assert!(style.fill.is_some());
627            }
628            _ => panic!("Expected Rect command for checkbox box"),
629        }
630    }
631
632    #[test]
633    fn test_checkbox_paint_unchecked_no_checkmark() {
634        let mut cb = Checkbox::new().box_size(18.0);
635        cb.layout(Rect::new(0.0, 0.0, 100.0, 18.0));
636
637        let mut canvas = RecordingCanvas::new();
638        cb.paint(&mut canvas);
639
640        // Only box, no checkmark
641        assert_eq!(canvas.command_count(), 1);
642    }
643
644    #[test]
645    fn test_checkbox_paint_checked_draws_checkmark() {
646        let mut cb = Checkbox::new().box_size(18.0).checked(true);
647        cb.layout(Rect::new(0.0, 0.0, 100.0, 18.0));
648
649        let mut canvas = RecordingCanvas::new();
650        cb.paint(&mut canvas);
651
652        // Should draw box + checkmark
653        assert_eq!(canvas.command_count(), 2);
654
655        // Second rect is the checkmark (inner rect)
656        match &canvas.commands()[1] {
657            DrawCommand::Rect { bounds, .. } => {
658                // Checkmark is 50% of box size, centered
659                assert!((bounds.width - 9.0).abs() < 0.1);
660                assert!((bounds.height - 9.0).abs() < 0.1);
661            }
662            _ => panic!("Expected Rect command for checkmark"),
663        }
664    }
665
666    #[test]
667    fn test_checkbox_paint_indeterminate_draws_line() {
668        let mut cb = Checkbox::new()
669            .box_size(18.0)
670            .state(CheckState::Indeterminate);
671        cb.layout(Rect::new(0.0, 0.0, 100.0, 18.0));
672
673        let mut canvas = RecordingCanvas::new();
674        cb.paint(&mut canvas);
675
676        // Should draw box + indeterminate line
677        assert_eq!(canvas.command_count(), 2);
678
679        // Second rect is the indeterminate line
680        match &canvas.commands()[1] {
681            DrawCommand::Rect { bounds, .. } => {
682                // Line is 60% width, 20% height
683                assert!((bounds.width - 10.8).abs() < 0.1);
684                assert!((bounds.height - 3.6).abs() < 0.1);
685            }
686            _ => panic!("Expected Rect command for indeterminate line"),
687        }
688    }
689
690    #[test]
691    fn test_checkbox_paint_with_label() {
692        let mut cb = Checkbox::new().box_size(18.0).label("Test label");
693        cb.layout(Rect::new(0.0, 0.0, 200.0, 18.0));
694
695        let mut canvas = RecordingCanvas::new();
696        cb.paint(&mut canvas);
697
698        // Should draw box + label text
699        assert_eq!(canvas.command_count(), 2);
700
701        // Second command is the label
702        match &canvas.commands()[1] {
703            DrawCommand::Text { content, .. } => {
704                assert_eq!(content, "Test label");
705            }
706            _ => panic!("Expected Text command for label"),
707        }
708    }
709
710    #[test]
711    fn test_checkbox_paint_checked_with_label() {
712        let mut cb = Checkbox::new().box_size(18.0).checked(true).label("Accept");
713        cb.layout(Rect::new(0.0, 0.0, 200.0, 18.0));
714
715        let mut canvas = RecordingCanvas::new();
716        cb.paint(&mut canvas);
717
718        // Should draw box + checkmark + label
719        assert_eq!(canvas.command_count(), 3);
720
721        // Third command is the label
722        match &canvas.commands()[2] {
723            DrawCommand::Text { content, .. } => {
724                assert_eq!(content, "Accept");
725            }
726            _ => panic!("Expected Text command for label"),
727        }
728    }
729
730    #[test]
731    fn test_checkbox_paint_uses_checked_color() {
732        let mut cb = Checkbox::new()
733            .box_size(18.0)
734            .checked(true)
735            .checked_color(Color::RED);
736        cb.layout(Rect::new(0.0, 0.0, 100.0, 18.0));
737
738        let mut canvas = RecordingCanvas::new();
739        cb.paint(&mut canvas);
740
741        // Box should use checked color
742        match &canvas.commands()[0] {
743            DrawCommand::Rect { style, .. } => {
744                assert_eq!(style.fill, Some(Color::RED));
745            }
746            _ => panic!("Expected Rect command"),
747        }
748    }
749
750    #[test]
751    fn test_checkbox_paint_uses_check_color() {
752        let mut cb = Checkbox::new()
753            .box_size(18.0)
754            .checked(true)
755            .check_color(Color::GREEN);
756        cb.layout(Rect::new(0.0, 0.0, 100.0, 18.0));
757
758        let mut canvas = RecordingCanvas::new();
759        cb.paint(&mut canvas);
760
761        // Checkmark should use check color
762        match &canvas.commands()[1] {
763            DrawCommand::Rect { style, .. } => {
764                assert_eq!(style.fill, Some(Color::GREEN));
765            }
766            _ => panic!("Expected Rect command for checkmark"),
767        }
768    }
769
770    #[test]
771    fn test_checkbox_paint_disabled_no_checkmark() {
772        let mut cb = Checkbox::new().box_size(18.0).checked(true).disabled(true);
773        cb.layout(Rect::new(0.0, 0.0, 100.0, 18.0));
774
775        let mut canvas = RecordingCanvas::new();
776        cb.paint(&mut canvas);
777
778        // Disabled checkbox doesn't draw checkmark
779        assert_eq!(canvas.command_count(), 1);
780    }
781
782    #[test]
783    fn test_checkbox_paint_disabled_uses_disabled_color() {
784        let mut cb = Checkbox::new()
785            .box_size(18.0)
786            .disabled(true)
787            .label("Disabled");
788        let disabled_color = cb.disabled_color;
789        cb.layout(Rect::new(0.0, 0.0, 200.0, 18.0));
790
791        let mut canvas = RecordingCanvas::new();
792        cb.paint(&mut canvas);
793
794        // Box should use disabled color
795        match &canvas.commands()[0] {
796            DrawCommand::Rect { style, .. } => {
797                assert_eq!(style.fill, Some(disabled_color));
798            }
799            _ => panic!("Expected Rect command"),
800        }
801    }
802
803    #[test]
804    fn test_checkbox_paint_label_position() {
805        let mut cb = Checkbox::new().box_size(18.0).spacing(8.0).label("Label");
806        cb.layout(Rect::new(10.0, 20.0, 200.0, 18.0));
807
808        let mut canvas = RecordingCanvas::new();
809        cb.paint(&mut canvas);
810
811        // Label should be positioned after box + spacing
812        match &canvas.commands()[1] {
813            DrawCommand::Text { position, .. } => {
814                // label_x = bounds.x + box_size + spacing = 10 + 18 + 8 = 36
815                assert_eq!(position.x, 36.0);
816            }
817            _ => panic!("Expected Text command"),
818        }
819    }
820
821    #[test]
822    fn test_checkbox_paint_box_position_from_layout() {
823        let mut cb = Checkbox::new().box_size(18.0);
824        cb.layout(Rect::new(50.0, 100.0, 100.0, 18.0));
825
826        let mut canvas = RecordingCanvas::new();
827        cb.paint(&mut canvas);
828
829        match &canvas.commands()[0] {
830            DrawCommand::Rect { bounds, .. } => {
831                assert_eq!(bounds.x, 50.0);
832            }
833            _ => panic!("Expected Rect command"),
834        }
835    }
836
837    #[test]
838    fn test_checkbox_paint_custom_box_size() {
839        let mut cb = Checkbox::new().box_size(24.0).checked(true);
840        cb.layout(Rect::new(0.0, 0.0, 100.0, 24.0));
841
842        let mut canvas = RecordingCanvas::new();
843        cb.paint(&mut canvas);
844
845        // Box should be 24x24
846        match &canvas.commands()[0] {
847            DrawCommand::Rect { bounds, .. } => {
848                assert_eq!(bounds.width, 24.0);
849                assert_eq!(bounds.height, 24.0);
850            }
851            _ => panic!("Expected Rect command"),
852        }
853
854        // Checkmark should be 50% = 12x12
855        match &canvas.commands()[1] {
856            DrawCommand::Rect { bounds, .. } => {
857                assert_eq!(bounds.width, 12.0);
858                assert_eq!(bounds.height, 12.0);
859            }
860            _ => panic!("Expected Rect command for checkmark"),
861        }
862    }
863
864    // =========================================================================
865    // Event Handling Tests - TESTS FIRST
866    // =========================================================================
867
868    use presentar_core::{MouseButton, Point};
869
870    #[test]
871    fn test_checkbox_event_click_toggles_unchecked_to_checked() {
872        let mut cb = Checkbox::new().box_size(18.0);
873        cb.layout(Rect::new(0.0, 0.0, 100.0, 18.0));
874
875        assert!(!cb.is_checked());
876        let result = cb.event(&Event::MouseDown {
877            position: Point::new(9.0, 9.0),
878            button: MouseButton::Left,
879        });
880        assert!(cb.is_checked());
881        assert!(result.is_some());
882    }
883
884    #[test]
885    fn test_checkbox_event_click_toggles_checked_to_unchecked() {
886        let mut cb = Checkbox::new().box_size(18.0).checked(true);
887        cb.layout(Rect::new(0.0, 0.0, 100.0, 18.0));
888
889        assert!(cb.is_checked());
890        let result = cb.event(&Event::MouseDown {
891            position: Point::new(9.0, 9.0),
892            button: MouseButton::Left,
893        });
894        assert!(!cb.is_checked());
895        assert!(result.is_some());
896    }
897
898    #[test]
899    fn test_checkbox_event_click_indeterminate_to_unchecked() {
900        let mut cb = Checkbox::new()
901            .box_size(18.0)
902            .state(CheckState::Indeterminate);
903        cb.layout(Rect::new(0.0, 0.0, 100.0, 18.0));
904
905        assert!(cb.is_indeterminate());
906        let result = cb.event(&Event::MouseDown {
907            position: Point::new(9.0, 9.0),
908            button: MouseButton::Left,
909        });
910        // Indeterminate -> Unchecked per toggle() logic
911        assert!(!cb.is_checked());
912        assert!(!cb.is_indeterminate());
913        let msg = result.unwrap().downcast::<CheckboxChanged>().unwrap();
914        assert_eq!(msg.state, CheckState::Unchecked);
915    }
916
917    #[test]
918    fn test_checkbox_event_emits_checkbox_changed() {
919        let mut cb = Checkbox::new().box_size(18.0);
920        cb.layout(Rect::new(0.0, 0.0, 100.0, 18.0));
921
922        let result = cb.event(&Event::MouseDown {
923            position: Point::new(9.0, 9.0),
924            button: MouseButton::Left,
925        });
926
927        let msg = result.unwrap().downcast::<CheckboxChanged>().unwrap();
928        assert_eq!(msg.state, CheckState::Checked);
929    }
930
931    #[test]
932    fn test_checkbox_event_message_reflects_new_state() {
933        let mut cb = Checkbox::new().box_size(18.0).checked(true);
934        cb.layout(Rect::new(0.0, 0.0, 100.0, 18.0));
935
936        let result = cb.event(&Event::MouseDown {
937            position: Point::new(9.0, 9.0),
938            button: MouseButton::Left,
939        });
940
941        let msg = result.unwrap().downcast::<CheckboxChanged>().unwrap();
942        assert_eq!(msg.state, CheckState::Unchecked);
943    }
944
945    #[test]
946    fn test_checkbox_event_click_outside_bounds_no_toggle() {
947        let mut cb = Checkbox::new().box_size(18.0);
948        cb.layout(Rect::new(0.0, 0.0, 100.0, 18.0));
949
950        let result = cb.event(&Event::MouseDown {
951            position: Point::new(200.0, 9.0),
952            button: MouseButton::Left,
953        });
954        assert!(!cb.is_checked());
955        assert!(result.is_none());
956    }
957
958    #[test]
959    fn test_checkbox_event_right_click_no_toggle() {
960        let mut cb = Checkbox::new().box_size(18.0);
961        cb.layout(Rect::new(0.0, 0.0, 100.0, 18.0));
962
963        let result = cb.event(&Event::MouseDown {
964            position: Point::new(9.0, 9.0),
965            button: MouseButton::Right,
966        });
967        assert!(!cb.is_checked());
968        assert!(result.is_none());
969    }
970
971    #[test]
972    fn test_checkbox_event_mouse_move_sets_hover() {
973        let mut cb = Checkbox::new().box_size(18.0);
974        cb.layout(Rect::new(0.0, 0.0, 100.0, 18.0));
975
976        assert!(!cb.hovered);
977        cb.event(&Event::MouseMove {
978            position: Point::new(50.0, 9.0),
979        });
980        assert!(cb.hovered);
981    }
982
983    #[test]
984    fn test_checkbox_event_mouse_move_clears_hover() {
985        let mut cb = Checkbox::new().box_size(18.0);
986        cb.layout(Rect::new(0.0, 0.0, 100.0, 18.0));
987        cb.hovered = true;
988
989        cb.event(&Event::MouseMove {
990            position: Point::new(200.0, 200.0),
991        });
992        assert!(!cb.hovered);
993    }
994
995    #[test]
996    fn test_checkbox_event_disabled_blocks_click() {
997        let mut cb = Checkbox::new().box_size(18.0).disabled(true);
998        cb.layout(Rect::new(0.0, 0.0, 100.0, 18.0));
999
1000        let result = cb.event(&Event::MouseDown {
1001            position: Point::new(9.0, 9.0),
1002            button: MouseButton::Left,
1003        });
1004        assert!(!cb.is_checked());
1005        assert!(result.is_none());
1006    }
1007
1008    #[test]
1009    fn test_checkbox_event_disabled_blocks_hover() {
1010        let mut cb = Checkbox::new().box_size(18.0).disabled(true);
1011        cb.layout(Rect::new(0.0, 0.0, 100.0, 18.0));
1012
1013        cb.event(&Event::MouseMove {
1014            position: Point::new(50.0, 9.0),
1015        });
1016        assert!(!cb.hovered);
1017    }
1018
1019    #[test]
1020    fn test_checkbox_event_click_on_label_area_toggles() {
1021        let mut cb = Checkbox::new().box_size(18.0).label("Accept terms");
1022        cb.layout(Rect::new(0.0, 0.0, 150.0, 18.0));
1023
1024        // Click on label area (past box)
1025        let result = cb.event(&Event::MouseDown {
1026            position: Point::new(100.0, 9.0),
1027            button: MouseButton::Left,
1028        });
1029        assert!(cb.is_checked());
1030        assert!(result.is_some());
1031    }
1032
1033    #[test]
1034    fn test_checkbox_event_full_interaction_flow() {
1035        let mut cb = Checkbox::new().box_size(18.0);
1036        cb.layout(Rect::new(0.0, 0.0, 100.0, 18.0));
1037
1038        // 1. Start unchecked
1039        assert!(!cb.is_checked());
1040        assert!(!cb.hovered);
1041
1042        // 2. Hover
1043        cb.event(&Event::MouseMove {
1044            position: Point::new(50.0, 9.0),
1045        });
1046        assert!(cb.hovered);
1047
1048        // 3. Click to check
1049        let result = cb.event(&Event::MouseDown {
1050            position: Point::new(9.0, 9.0),
1051            button: MouseButton::Left,
1052        });
1053        assert!(cb.is_checked());
1054        let msg = result.unwrap().downcast::<CheckboxChanged>().unwrap();
1055        assert_eq!(msg.state, CheckState::Checked);
1056
1057        // 4. Click again to uncheck
1058        let result = cb.event(&Event::MouseDown {
1059            position: Point::new(9.0, 9.0),
1060            button: MouseButton::Left,
1061        });
1062        assert!(!cb.is_checked());
1063        let msg = result.unwrap().downcast::<CheckboxChanged>().unwrap();
1064        assert_eq!(msg.state, CheckState::Unchecked);
1065
1066        // 5. Move out
1067        cb.event(&Event::MouseMove {
1068            position: Point::new(200.0, 200.0),
1069        });
1070        assert!(!cb.hovered);
1071    }
1072
1073    #[test]
1074    fn test_checkbox_event_with_offset_bounds() {
1075        let mut cb = Checkbox::new().box_size(18.0);
1076        cb.layout(Rect::new(50.0, 100.0, 100.0, 18.0));
1077
1078        // Click inside bounds (relative to offset)
1079        let result = cb.event(&Event::MouseDown {
1080            position: Point::new(100.0, 109.0),
1081            button: MouseButton::Left,
1082        });
1083        assert!(cb.is_checked());
1084        assert!(result.is_some());
1085    }
1086}