presentar_widgets/
checkbox.rs

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