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