Skip to main content

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            } if self.bounds.contains_point(position) => {
330                self.state = self.state.toggle();
331                return Some(Box::new(CheckboxChanged { state: self.state }));
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// PROBAR-SPEC-009: Brick Architecture - Tests define interface
375impl Brick for Checkbox {
376    fn brick_name(&self) -> &'static str {
377        "Checkbox"
378    }
379
380    fn assertions(&self) -> &[BrickAssertion] {
381        &[BrickAssertion::MaxLatencyMs(16)]
382    }
383
384    fn budget(&self) -> BrickBudget {
385        BrickBudget::uniform(16)
386    }
387
388    fn verify(&self) -> BrickVerification {
389        BrickVerification {
390            passed: self.assertions().to_vec(),
391            failed: vec![],
392            verification_time: Duration::from_micros(10),
393        }
394    }
395
396    fn to_html(&self) -> String {
397        let test_id = self.test_id_value.as_deref().unwrap_or("checkbox");
398        let checked = if self.state.is_checked() {
399            " checked"
400        } else {
401            ""
402        };
403        let disabled = if self.disabled { " disabled" } else { "" };
404        format!(
405            r#"<input type="checkbox" class="brick-checkbox" data-testid="{}" aria-label="{}"{}{}/>"#,
406            test_id,
407            self.accessible_name_value.as_deref().unwrap_or(&self.label),
408            checked,
409            disabled
410        )
411    }
412
413    fn to_css(&self) -> String {
414        ".brick-checkbox { display: inline-block; }".into()
415    }
416
417    fn test_id(&self) -> Option<&str> {
418        self.test_id_value.as_deref()
419    }
420}
421
422#[cfg(test)]
423#[allow(clippy::unwrap_used, clippy::disallowed_methods)]
424mod tests {
425    use super::*;
426    use presentar_core::Widget;
427
428    // =========================================================================
429    // CheckState Tests - TESTS FIRST
430    // =========================================================================
431
432    #[test]
433    fn test_check_state_default() {
434        assert_eq!(CheckState::default(), CheckState::Unchecked);
435    }
436
437    #[test]
438    fn test_check_state_toggle() {
439        assert_eq!(CheckState::Unchecked.toggle(), CheckState::Checked);
440        assert_eq!(CheckState::Checked.toggle(), CheckState::Unchecked);
441        assert_eq!(CheckState::Indeterminate.toggle(), CheckState::Unchecked);
442    }
443
444    #[test]
445    fn test_check_state_is_checked() {
446        assert!(!CheckState::Unchecked.is_checked());
447        assert!(CheckState::Checked.is_checked());
448        assert!(!CheckState::Indeterminate.is_checked());
449    }
450
451    #[test]
452    fn test_check_state_is_indeterminate() {
453        assert!(!CheckState::Unchecked.is_indeterminate());
454        assert!(!CheckState::Checked.is_indeterminate());
455        assert!(CheckState::Indeterminate.is_indeterminate());
456    }
457
458    // =========================================================================
459    // CheckboxChanged Tests - TESTS FIRST
460    // =========================================================================
461
462    #[test]
463    fn test_checkbox_changed_message() {
464        let msg = CheckboxChanged {
465            state: CheckState::Checked,
466        };
467        assert_eq!(msg.state, CheckState::Checked);
468    }
469
470    // =========================================================================
471    // Checkbox Construction Tests - TESTS FIRST
472    // =========================================================================
473
474    #[test]
475    fn test_checkbox_new() {
476        let cb = Checkbox::new();
477        assert_eq!(cb.get_state(), CheckState::Unchecked);
478        assert!(!cb.is_checked());
479        assert!(!cb.disabled);
480        assert!(cb.get_label().is_empty());
481    }
482
483    #[test]
484    fn test_checkbox_default() {
485        let cb = Checkbox::default();
486        assert_eq!(cb.get_state(), CheckState::Unchecked);
487    }
488
489    #[test]
490    fn test_checkbox_builder() {
491        let cb = Checkbox::new()
492            .checked(true)
493            .label("Accept terms")
494            .disabled(false)
495            .box_size(20.0)
496            .spacing(10.0)
497            .with_test_id("terms-checkbox")
498            .with_accessible_name("Terms and Conditions");
499
500        assert!(cb.is_checked());
501        assert_eq!(cb.get_label(), "Accept terms");
502        assert!(!cb.disabled);
503        assert_eq!(Widget::test_id(&cb), Some("terms-checkbox"));
504        assert_eq!(cb.accessible_name(), Some("Terms and Conditions"));
505    }
506
507    #[test]
508    fn test_checkbox_state_builder() {
509        let cb = Checkbox::new().state(CheckState::Indeterminate);
510        assert!(cb.is_indeterminate());
511        assert!(!cb.is_checked());
512    }
513
514    // =========================================================================
515    // Checkbox State Tests - TESTS FIRST
516    // =========================================================================
517
518    #[test]
519    fn test_checkbox_checked_true() {
520        let cb = Checkbox::new().checked(true);
521        assert!(cb.is_checked());
522        assert_eq!(cb.get_state(), CheckState::Checked);
523    }
524
525    #[test]
526    fn test_checkbox_checked_false() {
527        let cb = Checkbox::new().checked(false);
528        assert!(!cb.is_checked());
529        assert_eq!(cb.get_state(), CheckState::Unchecked);
530    }
531
532    #[test]
533    fn test_checkbox_indeterminate() {
534        let cb = Checkbox::new().state(CheckState::Indeterminate);
535        assert!(cb.is_indeterminate());
536        assert!(!cb.is_checked());
537    }
538
539    // =========================================================================
540    // Checkbox Widget Trait Tests - TESTS FIRST
541    // =========================================================================
542
543    #[test]
544    fn test_checkbox_type_id() {
545        let cb = Checkbox::new();
546        assert_eq!(Widget::type_id(&cb), TypeId::of::<Checkbox>());
547    }
548
549    #[test]
550    fn test_checkbox_measure_no_label() {
551        let cb = Checkbox::new().box_size(18.0);
552        let size = cb.measure(Constraints::loose(Size::new(200.0, 100.0)));
553        assert_eq!(size.width, 18.0 + 8.0); // box + spacing
554        assert_eq!(size.height, 18.0);
555    }
556
557    #[test]
558    fn test_checkbox_measure_with_label() {
559        let cb = Checkbox::new().box_size(18.0).spacing(8.0).label("Test");
560        let size = cb.measure(Constraints::loose(Size::new(200.0, 100.0)));
561        // 18 (box) + 8 (spacing) + 4*8 (label ~32px)
562        assert!(size.width > 18.0);
563    }
564
565    #[test]
566    fn test_checkbox_is_interactive() {
567        let cb = Checkbox::new();
568        assert!(cb.is_interactive());
569
570        let cb = Checkbox::new().disabled(true);
571        assert!(!cb.is_interactive());
572    }
573
574    #[test]
575    fn test_checkbox_is_focusable() {
576        let cb = Checkbox::new();
577        assert!(cb.is_focusable());
578
579        let cb = Checkbox::new().disabled(true);
580        assert!(!cb.is_focusable());
581    }
582
583    #[test]
584    fn test_checkbox_accessible_role() {
585        let cb = Checkbox::new();
586        assert_eq!(cb.accessible_role(), AccessibleRole::Checkbox);
587    }
588
589    #[test]
590    fn test_checkbox_accessible_name_from_label() {
591        let cb = Checkbox::new().label("My checkbox");
592        assert_eq!(cb.accessible_name(), Some("My checkbox"));
593    }
594
595    #[test]
596    fn test_checkbox_accessible_name_override() {
597        let cb = Checkbox::new()
598            .label("Short")
599            .with_accessible_name("Full accessible name");
600        assert_eq!(cb.accessible_name(), Some("Full accessible name"));
601    }
602
603    #[test]
604    fn test_checkbox_children() {
605        let cb = Checkbox::new();
606        assert!(cb.children().is_empty());
607    }
608
609    // =========================================================================
610    // Checkbox Color Tests - TESTS FIRST
611    // =========================================================================
612
613    #[test]
614    fn test_checkbox_colors() {
615        let cb = Checkbox::new()
616            .checked_color(Color::RED)
617            .check_color(Color::GREEN)
618            .label_color(Color::BLUE);
619
620        assert_eq!(cb.checked_color, Color::RED);
621        assert_eq!(cb.check_color, Color::GREEN);
622        assert_eq!(cb.label_color, Color::BLUE);
623    }
624
625    // =========================================================================
626    // Checkbox Layout Tests - TESTS FIRST
627    // =========================================================================
628
629    #[test]
630    fn test_checkbox_layout() {
631        let mut cb = Checkbox::new();
632        let bounds = Rect::new(10.0, 20.0, 100.0, 30.0);
633        let result = cb.layout(bounds);
634        assert_eq!(result.size, bounds.size());
635        assert_eq!(cb.bounds, bounds);
636    }
637
638    // =========================================================================
639    // Checkbox Size Tests - TESTS FIRST
640    // =========================================================================
641
642    #[test]
643    fn test_checkbox_box_size_min() {
644        let cb = Checkbox::new().box_size(2.0);
645        assert_eq!(cb.box_size, 8.0); // Minimum is 8
646    }
647
648    #[test]
649    fn test_checkbox_spacing_min() {
650        let cb = Checkbox::new().spacing(-5.0);
651        assert_eq!(cb.spacing, 0.0); // Minimum is 0
652    }
653
654    // =========================================================================
655    // Paint Tests - TESTS FIRST
656    // =========================================================================
657
658    use presentar_core::draw::DrawCommand;
659    use presentar_core::RecordingCanvas;
660
661    #[test]
662    fn test_checkbox_paint_unchecked_draws_box() {
663        let mut cb = Checkbox::new().box_size(18.0);
664        cb.layout(Rect::new(0.0, 0.0, 100.0, 18.0));
665
666        let mut canvas = RecordingCanvas::new();
667        cb.paint(&mut canvas);
668
669        // Should draw box rect
670        assert!(canvas.command_count() >= 1);
671        match &canvas.commands()[0] {
672            DrawCommand::Rect { bounds, style, .. } => {
673                assert_eq!(bounds.width, 18.0);
674                assert_eq!(bounds.height, 18.0);
675                assert!(style.fill.is_some());
676            }
677            _ => panic!("Expected Rect command for checkbox box"),
678        }
679    }
680
681    #[test]
682    fn test_checkbox_paint_unchecked_no_checkmark() {
683        let mut cb = Checkbox::new().box_size(18.0);
684        cb.layout(Rect::new(0.0, 0.0, 100.0, 18.0));
685
686        let mut canvas = RecordingCanvas::new();
687        cb.paint(&mut canvas);
688
689        // Only box, no checkmark
690        assert_eq!(canvas.command_count(), 1);
691    }
692
693    #[test]
694    fn test_checkbox_paint_checked_draws_checkmark() {
695        let mut cb = Checkbox::new().box_size(18.0).checked(true);
696        cb.layout(Rect::new(0.0, 0.0, 100.0, 18.0));
697
698        let mut canvas = RecordingCanvas::new();
699        cb.paint(&mut canvas);
700
701        // Should draw box + checkmark
702        assert_eq!(canvas.command_count(), 2);
703
704        // Second rect is the checkmark (inner rect)
705        match &canvas.commands()[1] {
706            DrawCommand::Rect { bounds, .. } => {
707                // Checkmark is 50% of box size, centered
708                assert!((bounds.width - 9.0).abs() < 0.1);
709                assert!((bounds.height - 9.0).abs() < 0.1);
710            }
711            _ => panic!("Expected Rect command for checkmark"),
712        }
713    }
714
715    #[test]
716    fn test_checkbox_paint_indeterminate_draws_line() {
717        let mut cb = Checkbox::new()
718            .box_size(18.0)
719            .state(CheckState::Indeterminate);
720        cb.layout(Rect::new(0.0, 0.0, 100.0, 18.0));
721
722        let mut canvas = RecordingCanvas::new();
723        cb.paint(&mut canvas);
724
725        // Should draw box + indeterminate line
726        assert_eq!(canvas.command_count(), 2);
727
728        // Second rect is the indeterminate line
729        match &canvas.commands()[1] {
730            DrawCommand::Rect { bounds, .. } => {
731                // Line is 60% width, 20% height
732                assert!((bounds.width - 10.8).abs() < 0.1);
733                assert!((bounds.height - 3.6).abs() < 0.1);
734            }
735            _ => panic!("Expected Rect command for indeterminate line"),
736        }
737    }
738
739    #[test]
740    fn test_checkbox_paint_with_label() {
741        let mut cb = Checkbox::new().box_size(18.0).label("Test label");
742        cb.layout(Rect::new(0.0, 0.0, 200.0, 18.0));
743
744        let mut canvas = RecordingCanvas::new();
745        cb.paint(&mut canvas);
746
747        // Should draw box + label text
748        assert_eq!(canvas.command_count(), 2);
749
750        // Second command is the label
751        match &canvas.commands()[1] {
752            DrawCommand::Text { content, .. } => {
753                assert_eq!(content, "Test label");
754            }
755            _ => panic!("Expected Text command for label"),
756        }
757    }
758
759    #[test]
760    fn test_checkbox_paint_checked_with_label() {
761        let mut cb = Checkbox::new().box_size(18.0).checked(true).label("Accept");
762        cb.layout(Rect::new(0.0, 0.0, 200.0, 18.0));
763
764        let mut canvas = RecordingCanvas::new();
765        cb.paint(&mut canvas);
766
767        // Should draw box + checkmark + label
768        assert_eq!(canvas.command_count(), 3);
769
770        // Third command is the label
771        match &canvas.commands()[2] {
772            DrawCommand::Text { content, .. } => {
773                assert_eq!(content, "Accept");
774            }
775            _ => panic!("Expected Text command for label"),
776        }
777    }
778
779    #[test]
780    fn test_checkbox_paint_uses_checked_color() {
781        let mut cb = Checkbox::new()
782            .box_size(18.0)
783            .checked(true)
784            .checked_color(Color::RED);
785        cb.layout(Rect::new(0.0, 0.0, 100.0, 18.0));
786
787        let mut canvas = RecordingCanvas::new();
788        cb.paint(&mut canvas);
789
790        // Box should use checked color
791        match &canvas.commands()[0] {
792            DrawCommand::Rect { style, .. } => {
793                assert_eq!(style.fill, Some(Color::RED));
794            }
795            _ => panic!("Expected Rect command"),
796        }
797    }
798
799    #[test]
800    fn test_checkbox_paint_uses_check_color() {
801        let mut cb = Checkbox::new()
802            .box_size(18.0)
803            .checked(true)
804            .check_color(Color::GREEN);
805        cb.layout(Rect::new(0.0, 0.0, 100.0, 18.0));
806
807        let mut canvas = RecordingCanvas::new();
808        cb.paint(&mut canvas);
809
810        // Checkmark should use check color
811        match &canvas.commands()[1] {
812            DrawCommand::Rect { style, .. } => {
813                assert_eq!(style.fill, Some(Color::GREEN));
814            }
815            _ => panic!("Expected Rect command for checkmark"),
816        }
817    }
818
819    #[test]
820    fn test_checkbox_paint_disabled_no_checkmark() {
821        let mut cb = Checkbox::new().box_size(18.0).checked(true).disabled(true);
822        cb.layout(Rect::new(0.0, 0.0, 100.0, 18.0));
823
824        let mut canvas = RecordingCanvas::new();
825        cb.paint(&mut canvas);
826
827        // Disabled checkbox doesn't draw checkmark
828        assert_eq!(canvas.command_count(), 1);
829    }
830
831    #[test]
832    fn test_checkbox_paint_disabled_uses_disabled_color() {
833        let mut cb = Checkbox::new()
834            .box_size(18.0)
835            .disabled(true)
836            .label("Disabled");
837        let disabled_color = cb.disabled_color;
838        cb.layout(Rect::new(0.0, 0.0, 200.0, 18.0));
839
840        let mut canvas = RecordingCanvas::new();
841        cb.paint(&mut canvas);
842
843        // Box should use disabled color
844        match &canvas.commands()[0] {
845            DrawCommand::Rect { style, .. } => {
846                assert_eq!(style.fill, Some(disabled_color));
847            }
848            _ => panic!("Expected Rect command"),
849        }
850    }
851
852    #[test]
853    fn test_checkbox_paint_label_position() {
854        let mut cb = Checkbox::new().box_size(18.0).spacing(8.0).label("Label");
855        cb.layout(Rect::new(10.0, 20.0, 200.0, 18.0));
856
857        let mut canvas = RecordingCanvas::new();
858        cb.paint(&mut canvas);
859
860        // Label should be positioned after box + spacing
861        match &canvas.commands()[1] {
862            DrawCommand::Text { position, .. } => {
863                // label_x = bounds.x + box_size + spacing = 10 + 18 + 8 = 36
864                assert_eq!(position.x, 36.0);
865            }
866            _ => panic!("Expected Text command"),
867        }
868    }
869
870    #[test]
871    fn test_checkbox_paint_box_position_from_layout() {
872        let mut cb = Checkbox::new().box_size(18.0);
873        cb.layout(Rect::new(50.0, 100.0, 100.0, 18.0));
874
875        let mut canvas = RecordingCanvas::new();
876        cb.paint(&mut canvas);
877
878        match &canvas.commands()[0] {
879            DrawCommand::Rect { bounds, .. } => {
880                assert_eq!(bounds.x, 50.0);
881            }
882            _ => panic!("Expected Rect command"),
883        }
884    }
885
886    #[test]
887    fn test_checkbox_paint_custom_box_size() {
888        let mut cb = Checkbox::new().box_size(24.0).checked(true);
889        cb.layout(Rect::new(0.0, 0.0, 100.0, 24.0));
890
891        let mut canvas = RecordingCanvas::new();
892        cb.paint(&mut canvas);
893
894        // Box should be 24x24
895        match &canvas.commands()[0] {
896            DrawCommand::Rect { bounds, .. } => {
897                assert_eq!(bounds.width, 24.0);
898                assert_eq!(bounds.height, 24.0);
899            }
900            _ => panic!("Expected Rect command"),
901        }
902
903        // Checkmark should be 50% = 12x12
904        match &canvas.commands()[1] {
905            DrawCommand::Rect { bounds, .. } => {
906                assert_eq!(bounds.width, 12.0);
907                assert_eq!(bounds.height, 12.0);
908            }
909            _ => panic!("Expected Rect command for checkmark"),
910        }
911    }
912
913    // =========================================================================
914    // Event Handling Tests - TESTS FIRST
915    // =========================================================================
916
917    use presentar_core::{MouseButton, Point};
918
919    #[test]
920    fn test_checkbox_event_click_toggles_unchecked_to_checked() {
921        let mut cb = Checkbox::new().box_size(18.0);
922        cb.layout(Rect::new(0.0, 0.0, 100.0, 18.0));
923
924        assert!(!cb.is_checked());
925        let result = cb.event(&Event::MouseDown {
926            position: Point::new(9.0, 9.0),
927            button: MouseButton::Left,
928        });
929        assert!(cb.is_checked());
930        assert!(result.is_some());
931    }
932
933    #[test]
934    fn test_checkbox_event_click_toggles_checked_to_unchecked() {
935        let mut cb = Checkbox::new().box_size(18.0).checked(true);
936        cb.layout(Rect::new(0.0, 0.0, 100.0, 18.0));
937
938        assert!(cb.is_checked());
939        let result = cb.event(&Event::MouseDown {
940            position: Point::new(9.0, 9.0),
941            button: MouseButton::Left,
942        });
943        assert!(!cb.is_checked());
944        assert!(result.is_some());
945    }
946
947    #[test]
948    fn test_checkbox_event_click_indeterminate_to_unchecked() {
949        let mut cb = Checkbox::new()
950            .box_size(18.0)
951            .state(CheckState::Indeterminate);
952        cb.layout(Rect::new(0.0, 0.0, 100.0, 18.0));
953
954        assert!(cb.is_indeterminate());
955        let result = cb.event(&Event::MouseDown {
956            position: Point::new(9.0, 9.0),
957            button: MouseButton::Left,
958        });
959        // Indeterminate -> Unchecked per toggle() logic
960        assert!(!cb.is_checked());
961        assert!(!cb.is_indeterminate());
962        let msg = result.unwrap().downcast::<CheckboxChanged>().unwrap();
963        assert_eq!(msg.state, CheckState::Unchecked);
964    }
965
966    #[test]
967    fn test_checkbox_event_emits_checkbox_changed() {
968        let mut cb = Checkbox::new().box_size(18.0);
969        cb.layout(Rect::new(0.0, 0.0, 100.0, 18.0));
970
971        let result = cb.event(&Event::MouseDown {
972            position: Point::new(9.0, 9.0),
973            button: MouseButton::Left,
974        });
975
976        let msg = result.unwrap().downcast::<CheckboxChanged>().unwrap();
977        assert_eq!(msg.state, CheckState::Checked);
978    }
979
980    #[test]
981    fn test_checkbox_event_message_reflects_new_state() {
982        let mut cb = Checkbox::new().box_size(18.0).checked(true);
983        cb.layout(Rect::new(0.0, 0.0, 100.0, 18.0));
984
985        let result = cb.event(&Event::MouseDown {
986            position: Point::new(9.0, 9.0),
987            button: MouseButton::Left,
988        });
989
990        let msg = result.unwrap().downcast::<CheckboxChanged>().unwrap();
991        assert_eq!(msg.state, CheckState::Unchecked);
992    }
993
994    #[test]
995    fn test_checkbox_event_click_outside_bounds_no_toggle() {
996        let mut cb = Checkbox::new().box_size(18.0);
997        cb.layout(Rect::new(0.0, 0.0, 100.0, 18.0));
998
999        let result = cb.event(&Event::MouseDown {
1000            position: Point::new(200.0, 9.0),
1001            button: MouseButton::Left,
1002        });
1003        assert!(!cb.is_checked());
1004        assert!(result.is_none());
1005    }
1006
1007    #[test]
1008    fn test_checkbox_event_right_click_no_toggle() {
1009        let mut cb = Checkbox::new().box_size(18.0);
1010        cb.layout(Rect::new(0.0, 0.0, 100.0, 18.0));
1011
1012        let result = cb.event(&Event::MouseDown {
1013            position: Point::new(9.0, 9.0),
1014            button: MouseButton::Right,
1015        });
1016        assert!(!cb.is_checked());
1017        assert!(result.is_none());
1018    }
1019
1020    #[test]
1021    fn test_checkbox_event_mouse_move_sets_hover() {
1022        let mut cb = Checkbox::new().box_size(18.0);
1023        cb.layout(Rect::new(0.0, 0.0, 100.0, 18.0));
1024
1025        assert!(!cb.hovered);
1026        cb.event(&Event::MouseMove {
1027            position: Point::new(50.0, 9.0),
1028        });
1029        assert!(cb.hovered);
1030    }
1031
1032    #[test]
1033    fn test_checkbox_event_mouse_move_clears_hover() {
1034        let mut cb = Checkbox::new().box_size(18.0);
1035        cb.layout(Rect::new(0.0, 0.0, 100.0, 18.0));
1036        cb.hovered = true;
1037
1038        cb.event(&Event::MouseMove {
1039            position: Point::new(200.0, 200.0),
1040        });
1041        assert!(!cb.hovered);
1042    }
1043
1044    #[test]
1045    fn test_checkbox_event_disabled_blocks_click() {
1046        let mut cb = Checkbox::new().box_size(18.0).disabled(true);
1047        cb.layout(Rect::new(0.0, 0.0, 100.0, 18.0));
1048
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        assert!(result.is_none());
1055    }
1056
1057    #[test]
1058    fn test_checkbox_event_disabled_blocks_hover() {
1059        let mut cb = Checkbox::new().box_size(18.0).disabled(true);
1060        cb.layout(Rect::new(0.0, 0.0, 100.0, 18.0));
1061
1062        cb.event(&Event::MouseMove {
1063            position: Point::new(50.0, 9.0),
1064        });
1065        assert!(!cb.hovered);
1066    }
1067
1068    #[test]
1069    fn test_checkbox_event_click_on_label_area_toggles() {
1070        let mut cb = Checkbox::new().box_size(18.0).label("Accept terms");
1071        cb.layout(Rect::new(0.0, 0.0, 150.0, 18.0));
1072
1073        // Click on label area (past box)
1074        let result = cb.event(&Event::MouseDown {
1075            position: Point::new(100.0, 9.0),
1076            button: MouseButton::Left,
1077        });
1078        assert!(cb.is_checked());
1079        assert!(result.is_some());
1080    }
1081
1082    #[test]
1083    fn test_checkbox_event_full_interaction_flow() {
1084        let mut cb = Checkbox::new().box_size(18.0);
1085        cb.layout(Rect::new(0.0, 0.0, 100.0, 18.0));
1086
1087        // 1. Start unchecked
1088        assert!(!cb.is_checked());
1089        assert!(!cb.hovered);
1090
1091        // 2. Hover
1092        cb.event(&Event::MouseMove {
1093            position: Point::new(50.0, 9.0),
1094        });
1095        assert!(cb.hovered);
1096
1097        // 3. Click to check
1098        let result = cb.event(&Event::MouseDown {
1099            position: Point::new(9.0, 9.0),
1100            button: MouseButton::Left,
1101        });
1102        assert!(cb.is_checked());
1103        let msg = result.unwrap().downcast::<CheckboxChanged>().unwrap();
1104        assert_eq!(msg.state, CheckState::Checked);
1105
1106        // 4. Click again to uncheck
1107        let result = cb.event(&Event::MouseDown {
1108            position: Point::new(9.0, 9.0),
1109            button: MouseButton::Left,
1110        });
1111        assert!(!cb.is_checked());
1112        let msg = result.unwrap().downcast::<CheckboxChanged>().unwrap();
1113        assert_eq!(msg.state, CheckState::Unchecked);
1114
1115        // 5. Move out
1116        cb.event(&Event::MouseMove {
1117            position: Point::new(200.0, 200.0),
1118        });
1119        assert!(!cb.hovered);
1120    }
1121
1122    #[test]
1123    fn test_checkbox_event_with_offset_bounds() {
1124        let mut cb = Checkbox::new().box_size(18.0);
1125        cb.layout(Rect::new(50.0, 100.0, 100.0, 18.0));
1126
1127        // Click inside bounds (relative to offset)
1128        let result = cb.event(&Event::MouseDown {
1129            position: Point::new(100.0, 109.0),
1130            button: MouseButton::Left,
1131        });
1132        assert!(cb.is_checked());
1133        assert!(result.is_some());
1134    }
1135}