Skip to main content

presentar_widgets/
toggle.rs

1//! Toggle switch widget.
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/// Message emitted when toggle state changes.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub struct ToggleChanged {
15    /// The new toggle state
16    pub on: bool,
17}
18
19/// Toggle switch widget (on/off).
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct Toggle {
22    /// Current state
23    on: bool,
24    /// Whether the toggle is disabled
25    disabled: bool,
26    /// Label text
27    label: String,
28    /// Track width
29    track_width: f32,
30    /// Track height
31    track_height: f32,
32    /// Thumb size (diameter)
33    thumb_size: f32,
34    /// Track color when off
35    track_off_color: Color,
36    /// Track color when on
37    track_on_color: Color,
38    /// Thumb color
39    thumb_color: Color,
40    /// Disabled color
41    disabled_color: Color,
42    /// Label color
43    label_color: Color,
44    /// Spacing between toggle and label
45    spacing: f32,
46    /// Accessible name
47    accessible_name_value: Option<String>,
48    /// Test ID
49    test_id_value: Option<String>,
50    /// Cached bounds
51    #[serde(skip)]
52    bounds: Rect,
53}
54
55impl Default for Toggle {
56    fn default() -> Self {
57        Self {
58            on: false,
59            disabled: false,
60            label: String::new(),
61            track_width: 44.0,
62            track_height: 24.0,
63            thumb_size: 20.0,
64            track_off_color: Color::new(0.7, 0.7, 0.7, 1.0),
65            track_on_color: Color::new(0.2, 0.47, 0.96, 1.0),
66            thumb_color: Color::WHITE,
67            disabled_color: Color::new(0.85, 0.85, 0.85, 1.0),
68            label_color: Color::BLACK,
69            spacing: 8.0,
70            accessible_name_value: None,
71            test_id_value: None,
72            bounds: Rect::default(),
73        }
74    }
75}
76
77impl Toggle {
78    /// Create a new toggle.
79    #[must_use]
80    pub fn new() -> Self {
81        Self::default()
82    }
83
84    /// Create a toggle with initial state.
85    #[must_use]
86    pub fn with_state(on: bool) -> Self {
87        Self::default().on(on)
88    }
89
90    /// Set the toggle state.
91    #[must_use]
92    pub const fn on(mut self, on: bool) -> Self {
93        self.on = on;
94        self
95    }
96
97    /// Set whether the toggle is disabled.
98    #[must_use]
99    pub const fn disabled(mut self, disabled: bool) -> Self {
100        self.disabled = disabled;
101        self
102    }
103
104    /// Set the label.
105    #[must_use]
106    pub fn label(mut self, label: impl Into<String>) -> Self {
107        self.label = label.into();
108        self
109    }
110
111    /// Set the track width.
112    #[must_use]
113    pub fn track_width(mut self, width: f32) -> Self {
114        self.track_width = width.max(20.0);
115        self
116    }
117
118    /// Set the track height.
119    #[must_use]
120    pub fn track_height(mut self, height: f32) -> Self {
121        self.track_height = height.max(12.0);
122        self
123    }
124
125    /// Set the thumb size.
126    #[must_use]
127    pub fn thumb_size(mut self, size: f32) -> Self {
128        self.thumb_size = size.max(8.0);
129        self
130    }
131
132    /// Set the track off color.
133    #[must_use]
134    pub const fn track_off_color(mut self, color: Color) -> Self {
135        self.track_off_color = color;
136        self
137    }
138
139    /// Set the track on color.
140    #[must_use]
141    pub const fn track_on_color(mut self, color: Color) -> Self {
142        self.track_on_color = color;
143        self
144    }
145
146    /// Set the thumb color.
147    #[must_use]
148    pub const fn thumb_color(mut self, color: Color) -> Self {
149        self.thumb_color = color;
150        self
151    }
152
153    /// Set the disabled color.
154    #[must_use]
155    pub const fn disabled_color(mut self, color: Color) -> Self {
156        self.disabled_color = color;
157        self
158    }
159
160    /// Set the label color.
161    #[must_use]
162    pub const fn label_color(mut self, color: Color) -> Self {
163        self.label_color = color;
164        self
165    }
166
167    /// Set the spacing between toggle and label.
168    #[must_use]
169    pub fn spacing(mut self, spacing: f32) -> Self {
170        self.spacing = spacing.max(0.0);
171        self
172    }
173
174    /// Set the accessible name.
175    #[must_use]
176    pub fn accessible_name(mut self, name: impl Into<String>) -> Self {
177        self.accessible_name_value = Some(name.into());
178        self
179    }
180
181    /// Set the test ID.
182    #[must_use]
183    pub fn test_id(mut self, id: impl Into<String>) -> Self {
184        self.test_id_value = Some(id.into());
185        self
186    }
187
188    /// Get current state.
189    #[must_use]
190    pub const fn is_on(&self) -> bool {
191        self.on
192    }
193
194    /// Get disabled state.
195    #[must_use]
196    pub const fn is_disabled(&self) -> bool {
197        self.disabled
198    }
199
200    /// Get the label.
201    #[must_use]
202    pub fn get_label(&self) -> &str {
203        &self.label
204    }
205
206    /// Get the track width.
207    #[must_use]
208    pub const fn get_track_width(&self) -> f32 {
209        self.track_width
210    }
211
212    /// Get the track height.
213    #[must_use]
214    pub const fn get_track_height(&self) -> f32 {
215        self.track_height
216    }
217
218    /// Get the thumb size.
219    #[must_use]
220    pub const fn get_thumb_size(&self) -> f32 {
221        self.thumb_size
222    }
223
224    /// Get the spacing.
225    #[must_use]
226    pub const fn get_spacing(&self) -> f32 {
227        self.spacing
228    }
229
230    /// Toggle the state.
231    pub fn toggle(&mut self) {
232        if !self.disabled {
233            self.on = !self.on;
234        }
235    }
236
237    /// Set the state.
238    pub fn set_on(&mut self, on: bool) {
239        self.on = on;
240    }
241
242    /// Calculate thumb X position.
243    fn thumb_x(&self) -> f32 {
244        let padding = (self.track_height - self.thumb_size) / 2.0;
245        if self.on {
246            self.bounds.x + self.track_width - self.thumb_size - padding
247        } else {
248            self.bounds.x + padding
249        }
250    }
251
252    /// Calculate thumb Y position (centered).
253    fn thumb_y(&self) -> f32 {
254        self.bounds.y + (self.track_height - self.thumb_size) / 2.0
255    }
256
257    /// Check if a point is within the toggle track.
258    fn hit_test(&self, x: f32, y: f32) -> bool {
259        x >= self.bounds.x
260            && x <= self.bounds.x + self.track_width
261            && y >= self.bounds.y
262            && y <= self.bounds.y + self.track_height
263    }
264}
265
266impl Widget for Toggle {
267    fn type_id(&self) -> TypeId {
268        TypeId::of::<Self>()
269    }
270
271    fn measure(&self, constraints: Constraints) -> Size {
272        let label_width = if self.label.is_empty() {
273            0.0
274        } else {
275            (self.label.len() as f32).mul_add(8.0, self.spacing)
276        };
277        let preferred = Size::new(self.track_width + label_width, self.track_height.max(20.0));
278        constraints.constrain(preferred)
279    }
280
281    fn layout(&mut self, bounds: Rect) -> LayoutResult {
282        self.bounds = bounds;
283        LayoutResult {
284            size: bounds.size(),
285        }
286    }
287
288    fn paint(&self, canvas: &mut dyn Canvas) {
289        // Determine track color
290        let track_color = if self.disabled {
291            self.disabled_color
292        } else if self.on {
293            self.track_on_color
294        } else {
295            self.track_off_color
296        };
297
298        // Draw track (rounded rectangle approximated as regular rect)
299        let track_rect = Rect::new(
300            self.bounds.x,
301            self.bounds.y,
302            self.track_width,
303            self.track_height,
304        );
305        canvas.fill_rect(track_rect, track_color);
306
307        // Draw thumb
308        let thumb_color = if self.disabled {
309            Color::new(0.9, 0.9, 0.9, 1.0)
310        } else {
311            self.thumb_color
312        };
313        let thumb_rect = Rect::new(
314            self.thumb_x(),
315            self.thumb_y(),
316            self.thumb_size,
317            self.thumb_size,
318        );
319        canvas.fill_rect(thumb_rect, thumb_color);
320    }
321
322    fn event(&mut self, event: &Event) -> Option<Box<dyn Any + Send>> {
323        if self.disabled {
324            return None;
325        }
326
327        if let Event::MouseDown {
328            position,
329            button: MouseButton::Left,
330        } = event
331        {
332            if self.hit_test(position.x, position.y) {
333                self.on = !self.on;
334                return Some(Box::new(ToggleChanged { on: self.on }));
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.as_deref().or_else(|| {
359            if self.label.is_empty() {
360                None
361            } else {
362                Some(&self.label)
363            }
364        })
365    }
366
367    fn accessible_role(&self) -> AccessibleRole {
368        // Toggle/switch is semantically similar to checkbox
369        AccessibleRole::Checkbox
370    }
371
372    fn test_id(&self) -> Option<&str> {
373        self.test_id_value.as_deref()
374    }
375}
376
377// PROBAR-SPEC-009: Brick Architecture - Tests define interface
378impl Brick for Toggle {
379    fn brick_name(&self) -> &'static str {
380        "Toggle"
381    }
382
383    fn assertions(&self) -> &[BrickAssertion] {
384        &[
385            BrickAssertion::MaxLatencyMs(16),
386            BrickAssertion::ContrastRatio(3.0), // WCAG AA for UI components
387        ]
388    }
389
390    fn budget(&self) -> BrickBudget {
391        BrickBudget::uniform(16)
392    }
393
394    fn verify(&self) -> BrickVerification {
395        let mut passed = Vec::new();
396        let mut failed = Vec::new();
397
398        // Verify thumb contrast against track
399        let track_color = if self.on {
400            self.track_on_color
401        } else {
402            self.track_off_color
403        };
404        let contrast = track_color.contrast_ratio(&self.thumb_color);
405        if contrast >= 3.0 {
406            passed.push(BrickAssertion::ContrastRatio(3.0));
407        } else {
408            failed.push((
409                BrickAssertion::ContrastRatio(3.0),
410                format!("Contrast ratio {contrast:.2}:1 < 3.0:1"),
411            ));
412        }
413
414        // Latency assertion always passes at verification time
415        passed.push(BrickAssertion::MaxLatencyMs(16));
416
417        BrickVerification {
418            passed,
419            failed,
420            verification_time: Duration::from_micros(10),
421        }
422    }
423
424    fn to_html(&self) -> String {
425        let test_id = self.test_id_value.as_deref().unwrap_or("toggle");
426        let checked = if self.on { " checked" } else { "" };
427        let disabled = if self.disabled { " disabled" } else { "" };
428        let aria_label = self
429            .accessible_name_value
430            .as_deref()
431            .or(if self.label.is_empty() {
432                None
433            } else {
434                Some(self.label.as_str())
435            })
436            .unwrap_or("");
437        format!(
438            r#"<input type="checkbox" role="switch" class="brick-toggle" data-testid="{test_id}" aria-label="{aria_label}"{checked}{disabled} />"#
439        )
440    }
441
442    fn to_css(&self) -> String {
443        format!(
444            r".brick-toggle {{
445    appearance: none;
446    width: {}px;
447    height: {}px;
448    background: {};
449    border-radius: {}px;
450    position: relative;
451    cursor: pointer;
452}}
453.brick-toggle:checked {{
454    background: {};
455}}
456.brick-toggle::before {{
457    content: '';
458    position: absolute;
459    width: {}px;
460    height: {}px;
461    background: {};
462    border-radius: 50%;
463    top: 50%;
464    transform: translateY(-50%);
465    left: 2px;
466    transition: left 0.2s;
467}}
468.brick-toggle:checked::before {{
469    left: calc(100% - {}px - 2px);
470}}
471.brick-toggle:disabled {{
472    opacity: 0.5;
473    cursor: not-allowed;
474}}",
475            self.track_width,
476            self.track_height,
477            self.track_off_color.to_hex(),
478            self.track_height / 2.0,
479            self.track_on_color.to_hex(),
480            self.thumb_size,
481            self.thumb_size,
482            self.thumb_color.to_hex(),
483            self.thumb_size,
484        )
485    }
486}
487
488#[cfg(test)]
489#[allow(clippy::unwrap_used, clippy::disallowed_methods)]
490mod tests {
491    use super::*;
492    use presentar_core::Point;
493
494    // ===== ToggleChanged Tests =====
495
496    #[test]
497    fn test_toggle_changed_message() {
498        let msg = ToggleChanged { on: true };
499        assert!(msg.on);
500
501        let msg = ToggleChanged { on: false };
502        assert!(!msg.on);
503    }
504
505    // ===== Toggle Construction Tests =====
506
507    #[test]
508    fn test_toggle_new() {
509        let toggle = Toggle::new();
510        assert!(!toggle.is_on());
511        assert!(!toggle.is_disabled());
512    }
513
514    #[test]
515    fn test_toggle_with_state_on() {
516        let toggle = Toggle::with_state(true);
517        assert!(toggle.is_on());
518    }
519
520    #[test]
521    fn test_toggle_with_state_off() {
522        let toggle = Toggle::with_state(false);
523        assert!(!toggle.is_on());
524    }
525
526    #[test]
527    fn test_toggle_default() {
528        let toggle = Toggle::default();
529        assert!(!toggle.is_on());
530        assert!(!toggle.is_disabled());
531        assert!(toggle.get_label().is_empty());
532    }
533
534    #[test]
535    fn test_toggle_builder() {
536        let toggle = Toggle::new()
537            .on(true)
538            .disabled(false)
539            .label("Dark Mode")
540            .track_width(50.0)
541            .track_height(28.0)
542            .thumb_size(24.0)
543            .track_off_color(Color::new(0.5, 0.5, 0.5, 1.0))
544            .track_on_color(Color::new(0.0, 0.8, 0.4, 1.0))
545            .thumb_color(Color::WHITE)
546            .disabled_color(Color::new(0.9, 0.9, 0.9, 1.0))
547            .label_color(Color::BLACK)
548            .spacing(12.0)
549            .accessible_name("Toggle dark mode")
550            .test_id("dark-mode-toggle");
551
552        assert!(toggle.is_on());
553        assert!(!toggle.is_disabled());
554        assert_eq!(toggle.get_label(), "Dark Mode");
555        assert_eq!(toggle.get_track_width(), 50.0);
556        assert_eq!(toggle.get_track_height(), 28.0);
557        assert_eq!(toggle.get_thumb_size(), 24.0);
558        assert_eq!(toggle.get_spacing(), 12.0);
559        assert_eq!(Widget::accessible_name(&toggle), Some("Toggle dark mode"));
560        assert_eq!(Widget::test_id(&toggle), Some("dark-mode-toggle"));
561    }
562
563    // ===== State Tests =====
564
565    #[test]
566    fn test_toggle_on() {
567        let toggle = Toggle::new().on(true);
568        assert!(toggle.is_on());
569    }
570
571    #[test]
572    fn test_toggle_off() {
573        let toggle = Toggle::new().on(false);
574        assert!(!toggle.is_on());
575    }
576
577    #[test]
578    fn test_toggle_set_on() {
579        let mut toggle = Toggle::new();
580        toggle.set_on(true);
581        assert!(toggle.is_on());
582        toggle.set_on(false);
583        assert!(!toggle.is_on());
584    }
585
586    #[test]
587    fn test_toggle_toggle_method() {
588        let mut toggle = Toggle::new();
589        assert!(!toggle.is_on());
590        toggle.toggle();
591        assert!(toggle.is_on());
592        toggle.toggle();
593        assert!(!toggle.is_on());
594    }
595
596    #[test]
597    fn test_toggle_disabled_cannot_toggle() {
598        let mut toggle = Toggle::new().disabled(true);
599        toggle.toggle();
600        assert!(!toggle.is_on()); // Still off, toggle had no effect
601    }
602
603    // ===== Dimension Tests =====
604
605    #[test]
606    fn test_toggle_track_width_min() {
607        let toggle = Toggle::new().track_width(10.0);
608        assert_eq!(toggle.get_track_width(), 20.0);
609    }
610
611    #[test]
612    fn test_toggle_track_height_min() {
613        let toggle = Toggle::new().track_height(5.0);
614        assert_eq!(toggle.get_track_height(), 12.0);
615    }
616
617    #[test]
618    fn test_toggle_thumb_size_min() {
619        let toggle = Toggle::new().thumb_size(2.0);
620        assert_eq!(toggle.get_thumb_size(), 8.0);
621    }
622
623    #[test]
624    fn test_toggle_spacing_min() {
625        let toggle = Toggle::new().spacing(-5.0);
626        assert_eq!(toggle.get_spacing(), 0.0);
627    }
628
629    // ===== Color Tests =====
630
631    #[test]
632    fn test_toggle_colors() {
633        let track_off = Color::new(0.3, 0.3, 0.3, 1.0);
634        let track_on = Color::new(0.0, 1.0, 0.5, 1.0);
635        let thumb = Color::new(1.0, 1.0, 1.0, 1.0);
636
637        let toggle = Toggle::new()
638            .track_off_color(track_off)
639            .track_on_color(track_on)
640            .thumb_color(thumb);
641
642        assert_eq!(toggle.track_off_color, track_off);
643        assert_eq!(toggle.track_on_color, track_on);
644        assert_eq!(toggle.thumb_color, thumb);
645    }
646
647    // ===== Thumb Position Tests =====
648
649    #[test]
650    fn test_toggle_thumb_position_off() {
651        let mut toggle = Toggle::new()
652            .track_width(44.0)
653            .track_height(24.0)
654            .thumb_size(20.0);
655        toggle.bounds = Rect::new(0.0, 0.0, 44.0, 24.0);
656
657        let padding = (24.0 - 20.0) / 2.0;
658        assert_eq!(toggle.thumb_x(), padding); // Left position
659    }
660
661    #[test]
662    fn test_toggle_thumb_position_on() {
663        let mut toggle = Toggle::new()
664            .on(true)
665            .track_width(44.0)
666            .track_height(24.0)
667            .thumb_size(20.0);
668        toggle.bounds = Rect::new(0.0, 0.0, 44.0, 24.0);
669
670        let padding = (24.0 - 20.0) / 2.0;
671        assert_eq!(toggle.thumb_x(), 44.0 - 20.0 - padding); // Right position
672    }
673
674    #[test]
675    fn test_toggle_thumb_y_centered() {
676        let mut toggle = Toggle::new().track_height(24.0).thumb_size(20.0);
677        toggle.bounds = Rect::new(10.0, 20.0, 44.0, 24.0);
678
679        assert_eq!(toggle.thumb_y(), 20.0 + (24.0 - 20.0) / 2.0);
680    }
681
682    // ===== Hit Test Tests =====
683
684    #[test]
685    fn test_toggle_hit_test_inside() {
686        let mut toggle = Toggle::new().track_width(44.0).track_height(24.0);
687        toggle.bounds = Rect::new(10.0, 10.0, 44.0, 24.0);
688
689        assert!(toggle.hit_test(20.0, 20.0));
690        assert!(toggle.hit_test(10.0, 10.0)); // Top-left corner
691        assert!(toggle.hit_test(54.0, 34.0)); // Bottom-right corner
692    }
693
694    #[test]
695    fn test_toggle_hit_test_outside() {
696        let mut toggle = Toggle::new().track_width(44.0).track_height(24.0);
697        toggle.bounds = Rect::new(10.0, 10.0, 44.0, 24.0);
698
699        assert!(!toggle.hit_test(5.0, 10.0)); // Left of track
700        assert!(!toggle.hit_test(60.0, 10.0)); // Right of track
701        assert!(!toggle.hit_test(20.0, 5.0)); // Above track
702        assert!(!toggle.hit_test(20.0, 40.0)); // Below track
703    }
704
705    // ===== Widget Trait Tests =====
706
707    #[test]
708    fn test_toggle_type_id() {
709        let toggle = Toggle::new();
710        assert_eq!(Widget::type_id(&toggle), TypeId::of::<Toggle>());
711    }
712
713    #[test]
714    fn test_toggle_measure_no_label() {
715        let toggle = Toggle::new().track_width(44.0).track_height(24.0);
716        let size = toggle.measure(Constraints::loose(Size::new(200.0, 100.0)));
717        assert_eq!(size.width, 44.0);
718        assert_eq!(size.height, 24.0);
719    }
720
721    #[test]
722    fn test_toggle_measure_with_label() {
723        let toggle = Toggle::new()
724            .track_width(44.0)
725            .track_height(24.0)
726            .label("On")
727            .spacing(8.0);
728        let size = toggle.measure(Constraints::loose(Size::new(200.0, 100.0)));
729        // Width = track_width + spacing + label_width (2 chars * 8)
730        assert_eq!(size.width, 44.0 + 8.0 + 16.0);
731    }
732
733    #[test]
734    fn test_toggle_layout() {
735        let mut toggle = Toggle::new();
736        let bounds = Rect::new(10.0, 20.0, 44.0, 24.0);
737        let result = toggle.layout(bounds);
738        assert_eq!(result.size, Size::new(44.0, 24.0));
739        assert_eq!(toggle.bounds, bounds);
740    }
741
742    #[test]
743    fn test_toggle_children() {
744        let toggle = Toggle::new();
745        assert!(toggle.children().is_empty());
746    }
747
748    #[test]
749    fn test_toggle_is_interactive() {
750        let toggle = Toggle::new();
751        assert!(toggle.is_interactive());
752
753        let toggle = Toggle::new().disabled(true);
754        assert!(!toggle.is_interactive());
755    }
756
757    #[test]
758    fn test_toggle_is_focusable() {
759        let toggle = Toggle::new();
760        assert!(toggle.is_focusable());
761
762        let toggle = Toggle::new().disabled(true);
763        assert!(!toggle.is_focusable());
764    }
765
766    #[test]
767    fn test_toggle_accessible_role() {
768        let toggle = Toggle::new();
769        assert_eq!(toggle.accessible_role(), AccessibleRole::Checkbox);
770    }
771
772    #[test]
773    fn test_toggle_accessible_name_from_label() {
774        let toggle = Toggle::new().label("Enable notifications");
775        assert_eq!(
776            Widget::accessible_name(&toggle),
777            Some("Enable notifications")
778        );
779    }
780
781    #[test]
782    fn test_toggle_accessible_name_override() {
783        let toggle = Toggle::new()
784            .label("Notifications")
785            .accessible_name("Toggle notifications on or off");
786        assert_eq!(
787            Widget::accessible_name(&toggle),
788            Some("Toggle notifications on or off")
789        );
790    }
791
792    #[test]
793    fn test_toggle_accessible_name_none() {
794        let toggle = Toggle::new();
795        assert_eq!(Widget::accessible_name(&toggle), None);
796    }
797
798    #[test]
799    fn test_toggle_test_id() {
800        let toggle = Toggle::new().test_id("settings-toggle");
801        assert_eq!(Widget::test_id(&toggle), Some("settings-toggle"));
802    }
803
804    // ===== Event Tests =====
805
806    #[test]
807    fn test_toggle_click_toggles_state() {
808        let mut toggle = Toggle::new();
809        toggle.bounds = Rect::new(0.0, 0.0, 44.0, 24.0);
810
811        let event = Event::MouseDown {
812            position: Point::new(22.0, 12.0),
813            button: MouseButton::Left,
814        };
815
816        let result = toggle.event(&event);
817        assert!(result.is_some());
818        assert!(toggle.is_on());
819
820        let result = toggle.event(&event);
821        assert!(result.is_some());
822        assert!(!toggle.is_on());
823    }
824
825    #[test]
826    fn test_toggle_click_outside_no_effect() {
827        let mut toggle = Toggle::new();
828        toggle.bounds = Rect::new(0.0, 0.0, 44.0, 24.0);
829
830        let event = Event::MouseDown {
831            position: Point::new(100.0, 100.0),
832            button: MouseButton::Left,
833        };
834
835        let result = toggle.event(&event);
836        assert!(result.is_none());
837        assert!(!toggle.is_on());
838    }
839
840    #[test]
841    fn test_toggle_right_click_no_effect() {
842        let mut toggle = Toggle::new();
843        toggle.bounds = Rect::new(0.0, 0.0, 44.0, 24.0);
844
845        let event = Event::MouseDown {
846            position: Point::new(22.0, 12.0),
847            button: MouseButton::Right,
848        };
849
850        let result = toggle.event(&event);
851        assert!(result.is_none());
852        assert!(!toggle.is_on());
853    }
854
855    #[test]
856    fn test_toggle_disabled_click_no_effect() {
857        let mut toggle = Toggle::new().disabled(true);
858        toggle.bounds = Rect::new(0.0, 0.0, 44.0, 24.0);
859
860        let event = Event::MouseDown {
861            position: Point::new(22.0, 12.0),
862            button: MouseButton::Left,
863        };
864
865        let result = toggle.event(&event);
866        assert!(result.is_none());
867        assert!(!toggle.is_on());
868    }
869
870    #[test]
871    fn test_toggle_changed_contains_new_state() {
872        let mut toggle = Toggle::new();
873        toggle.bounds = Rect::new(0.0, 0.0, 44.0, 24.0);
874
875        let event = Event::MouseDown {
876            position: Point::new(22.0, 12.0),
877            button: MouseButton::Left,
878        };
879
880        let result = toggle.event(&event);
881        let msg = result.unwrap().downcast::<ToggleChanged>().unwrap();
882        assert!(msg.on);
883
884        let result = toggle.event(&event);
885        let msg = result.unwrap().downcast::<ToggleChanged>().unwrap();
886        assert!(!msg.on);
887    }
888
889    // ===== Paint Tests =====
890
891    use presentar_core::draw::DrawCommand;
892    use presentar_core::RecordingCanvas;
893
894    #[test]
895    fn test_toggle_paint_draws_track_and_thumb() {
896        let mut toggle = Toggle::new()
897            .track_width(44.0)
898            .track_height(24.0)
899            .thumb_size(20.0);
900        toggle.layout(Rect::new(0.0, 0.0, 44.0, 24.0));
901
902        let mut canvas = RecordingCanvas::new();
903        toggle.paint(&mut canvas);
904
905        // Should draw track + thumb
906        assert_eq!(canvas.command_count(), 2);
907    }
908
909    #[test]
910    fn test_toggle_paint_track_off_color() {
911        let mut toggle = Toggle::new().track_off_color(Color::RED).on(false);
912        toggle.layout(Rect::new(0.0, 0.0, 44.0, 24.0));
913
914        let mut canvas = RecordingCanvas::new();
915        toggle.paint(&mut canvas);
916
917        // Track should use off color
918        match &canvas.commands()[0] {
919            DrawCommand::Rect { style, .. } => {
920                assert_eq!(style.fill, Some(Color::RED));
921            }
922            _ => panic!("Expected Rect command for track"),
923        }
924    }
925
926    #[test]
927    fn test_toggle_paint_track_on_color() {
928        let mut toggle = Toggle::new().track_on_color(Color::GREEN).on(true);
929        toggle.layout(Rect::new(0.0, 0.0, 44.0, 24.0));
930
931        let mut canvas = RecordingCanvas::new();
932        toggle.paint(&mut canvas);
933
934        // Track should use on color
935        match &canvas.commands()[0] {
936            DrawCommand::Rect { style, .. } => {
937                assert_eq!(style.fill, Some(Color::GREEN));
938            }
939            _ => panic!("Expected Rect command for track"),
940        }
941    }
942
943    #[test]
944    fn test_toggle_paint_track_disabled_color() {
945        let mut toggle = Toggle::new()
946            .disabled_color(Color::new(0.85, 0.85, 0.85, 1.0))
947            .disabled(true);
948        toggle.layout(Rect::new(0.0, 0.0, 44.0, 24.0));
949
950        let mut canvas = RecordingCanvas::new();
951        toggle.paint(&mut canvas);
952
953        // Track should use disabled color
954        match &canvas.commands()[0] {
955            DrawCommand::Rect { style, .. } => {
956                let fill = style.fill.unwrap();
957                assert!((fill.r - 0.85).abs() < 0.01);
958            }
959            _ => panic!("Expected Rect command for track"),
960        }
961    }
962
963    #[test]
964    fn test_toggle_paint_track_dimensions() {
965        let mut toggle = Toggle::new().track_width(50.0).track_height(28.0);
966        toggle.layout(Rect::new(0.0, 0.0, 50.0, 28.0));
967
968        let mut canvas = RecordingCanvas::new();
969        toggle.paint(&mut canvas);
970
971        match &canvas.commands()[0] {
972            DrawCommand::Rect { bounds, .. } => {
973                assert_eq!(bounds.width, 50.0);
974                assert_eq!(bounds.height, 28.0);
975            }
976            _ => panic!("Expected Rect command for track"),
977        }
978    }
979
980    #[test]
981    fn test_toggle_paint_thumb_size() {
982        let mut toggle = Toggle::new().thumb_size(20.0);
983        toggle.layout(Rect::new(0.0, 0.0, 44.0, 24.0));
984
985        let mut canvas = RecordingCanvas::new();
986        toggle.paint(&mut canvas);
987
988        match &canvas.commands()[1] {
989            DrawCommand::Rect { bounds, .. } => {
990                assert_eq!(bounds.width, 20.0);
991                assert_eq!(bounds.height, 20.0);
992            }
993            _ => panic!("Expected Rect command for thumb"),
994        }
995    }
996
997    #[test]
998    fn test_toggle_paint_thumb_position_off() {
999        let mut toggle = Toggle::new()
1000            .track_width(44.0)
1001            .track_height(24.0)
1002            .thumb_size(20.0)
1003            .on(false);
1004        toggle.layout(Rect::new(0.0, 0.0, 44.0, 24.0));
1005
1006        let mut canvas = RecordingCanvas::new();
1007        toggle.paint(&mut canvas);
1008
1009        // Thumb should be on the left
1010        let padding = (24.0 - 20.0) / 2.0; // 2.0
1011        match &canvas.commands()[1] {
1012            DrawCommand::Rect { bounds, .. } => {
1013                assert_eq!(bounds.x, padding);
1014            }
1015            _ => panic!("Expected Rect command for thumb"),
1016        }
1017    }
1018
1019    #[test]
1020    fn test_toggle_paint_thumb_position_on() {
1021        let mut toggle = Toggle::new()
1022            .track_width(44.0)
1023            .track_height(24.0)
1024            .thumb_size(20.0)
1025            .on(true);
1026        toggle.layout(Rect::new(0.0, 0.0, 44.0, 24.0));
1027
1028        let mut canvas = RecordingCanvas::new();
1029        toggle.paint(&mut canvas);
1030
1031        // Thumb should be on the right
1032        let padding = (24.0 - 20.0) / 2.0; // 2.0
1033        let expected_x = 44.0 - 20.0 - padding; // 22.0
1034        match &canvas.commands()[1] {
1035            DrawCommand::Rect { bounds, .. } => {
1036                assert_eq!(bounds.x, expected_x);
1037            }
1038            _ => panic!("Expected Rect command for thumb"),
1039        }
1040    }
1041
1042    #[test]
1043    fn test_toggle_paint_thumb_color() {
1044        let mut toggle = Toggle::new().thumb_color(Color::BLUE);
1045        toggle.layout(Rect::new(0.0, 0.0, 44.0, 24.0));
1046
1047        let mut canvas = RecordingCanvas::new();
1048        toggle.paint(&mut canvas);
1049
1050        match &canvas.commands()[1] {
1051            DrawCommand::Rect { style, .. } => {
1052                assert_eq!(style.fill, Some(Color::BLUE));
1053            }
1054            _ => panic!("Expected Rect command for thumb"),
1055        }
1056    }
1057
1058    #[test]
1059    fn test_toggle_paint_thumb_disabled_color() {
1060        let mut toggle = Toggle::new().disabled(true);
1061        toggle.layout(Rect::new(0.0, 0.0, 44.0, 24.0));
1062
1063        let mut canvas = RecordingCanvas::new();
1064        toggle.paint(&mut canvas);
1065
1066        // Disabled thumb should be grayish
1067        match &canvas.commands()[1] {
1068            DrawCommand::Rect { style, .. } => {
1069                let fill = style.fill.unwrap();
1070                assert!((fill.r - 0.9).abs() < 0.01);
1071                assert!((fill.g - 0.9).abs() < 0.01);
1072                assert!((fill.b - 0.9).abs() < 0.01);
1073            }
1074            _ => panic!("Expected Rect command for thumb"),
1075        }
1076    }
1077
1078    #[test]
1079    fn test_toggle_paint_position_from_layout() {
1080        let mut toggle = Toggle::new().track_width(44.0).track_height(24.0);
1081        toggle.layout(Rect::new(100.0, 50.0, 44.0, 24.0));
1082
1083        let mut canvas = RecordingCanvas::new();
1084        toggle.paint(&mut canvas);
1085
1086        // Track should be at layout position
1087        match &canvas.commands()[0] {
1088            DrawCommand::Rect { bounds, .. } => {
1089                assert_eq!(bounds.x, 100.0);
1090                assert_eq!(bounds.y, 50.0);
1091            }
1092            _ => panic!("Expected Rect command for track"),
1093        }
1094    }
1095
1096    #[test]
1097    fn test_toggle_paint_thumb_centered_vertically() {
1098        let mut toggle = Toggle::new().track_height(30.0).thumb_size(20.0);
1099        toggle.layout(Rect::new(0.0, 0.0, 44.0, 30.0));
1100
1101        let mut canvas = RecordingCanvas::new();
1102        toggle.paint(&mut canvas);
1103
1104        // Thumb Y should be centered
1105        let expected_y = (30.0 - 20.0) / 2.0; // 5.0
1106        match &canvas.commands()[1] {
1107            DrawCommand::Rect { bounds, .. } => {
1108                assert_eq!(bounds.y, expected_y);
1109            }
1110            _ => panic!("Expected Rect command for thumb"),
1111        }
1112    }
1113
1114    #[test]
1115    fn test_toggle_paint_custom_track_and_thumb() {
1116        let mut toggle = Toggle::new()
1117            .track_width(60.0)
1118            .track_height(32.0)
1119            .thumb_size(28.0)
1120            .track_on_color(Color::GREEN)
1121            .thumb_color(Color::WHITE)
1122            .on(true);
1123        toggle.layout(Rect::new(10.0, 20.0, 60.0, 32.0));
1124
1125        let mut canvas = RecordingCanvas::new();
1126        toggle.paint(&mut canvas);
1127
1128        // Track
1129        match &canvas.commands()[0] {
1130            DrawCommand::Rect { bounds, style, .. } => {
1131                assert_eq!(bounds.width, 60.0);
1132                assert_eq!(bounds.height, 32.0);
1133                assert_eq!(style.fill, Some(Color::GREEN));
1134            }
1135            _ => panic!("Expected Rect command for track"),
1136        }
1137
1138        // Thumb
1139        let padding = (32.0 - 28.0) / 2.0;
1140        let expected_thumb_x = 10.0 + 60.0 - 28.0 - padding;
1141        match &canvas.commands()[1] {
1142            DrawCommand::Rect { bounds, style, .. } => {
1143                assert_eq!(bounds.width, 28.0);
1144                assert_eq!(bounds.height, 28.0);
1145                assert_eq!(bounds.x, expected_thumb_x);
1146                assert_eq!(style.fill, Some(Color::WHITE));
1147            }
1148            _ => panic!("Expected Rect command for thumb"),
1149        }
1150    }
1151
1152    // =========================================================================
1153    // Event Handling Tests - TESTS FIRST
1154    // =========================================================================
1155
1156    #[test]
1157    fn test_toggle_event_click_turns_on() {
1158        let mut toggle = Toggle::new().track_width(44.0).track_height(24.0).on(false);
1159        toggle.layout(Rect::new(0.0, 0.0, 44.0, 24.0));
1160
1161        assert!(!toggle.is_on());
1162        let result = toggle.event(&Event::MouseDown {
1163            position: Point::new(22.0, 12.0),
1164            button: MouseButton::Left,
1165        });
1166        assert!(toggle.is_on());
1167        assert!(result.is_some());
1168    }
1169
1170    #[test]
1171    fn test_toggle_event_click_turns_off() {
1172        let mut toggle = Toggle::new().track_width(44.0).track_height(24.0).on(true);
1173        toggle.layout(Rect::new(0.0, 0.0, 44.0, 24.0));
1174
1175        assert!(toggle.is_on());
1176        let result = toggle.event(&Event::MouseDown {
1177            position: Point::new(22.0, 12.0),
1178            button: MouseButton::Left,
1179        });
1180        assert!(!toggle.is_on());
1181        assert!(result.is_some());
1182    }
1183
1184    #[test]
1185    fn test_toggle_event_emits_toggle_changed() {
1186        let mut toggle = Toggle::new().track_width(44.0).track_height(24.0).on(false);
1187        toggle.layout(Rect::new(0.0, 0.0, 44.0, 24.0));
1188
1189        let result = toggle.event(&Event::MouseDown {
1190            position: Point::new(22.0, 12.0),
1191            button: MouseButton::Left,
1192        });
1193
1194        let msg = result.unwrap().downcast::<ToggleChanged>().unwrap();
1195        assert!(msg.on);
1196    }
1197
1198    #[test]
1199    fn test_toggle_event_message_reflects_new_state() {
1200        let mut toggle = Toggle::new().track_width(44.0).track_height(24.0).on(true);
1201        toggle.layout(Rect::new(0.0, 0.0, 44.0, 24.0));
1202
1203        let result = toggle.event(&Event::MouseDown {
1204            position: Point::new(22.0, 12.0),
1205            button: MouseButton::Left,
1206        });
1207
1208        let msg = result.unwrap().downcast::<ToggleChanged>().unwrap();
1209        assert!(!msg.on);
1210    }
1211
1212    #[test]
1213    fn test_toggle_event_click_outside_track_no_toggle() {
1214        let mut toggle = Toggle::new().track_width(44.0).track_height(24.0).on(false);
1215        toggle.layout(Rect::new(0.0, 0.0, 100.0, 24.0));
1216
1217        // Click outside track (past track width)
1218        let result = toggle.event(&Event::MouseDown {
1219            position: Point::new(80.0, 12.0),
1220            button: MouseButton::Left,
1221        });
1222        assert!(!toggle.is_on());
1223        assert!(result.is_none());
1224    }
1225
1226    #[test]
1227    fn test_toggle_event_click_below_track_no_toggle() {
1228        let mut toggle = Toggle::new().track_width(44.0).track_height(24.0).on(false);
1229        toggle.layout(Rect::new(0.0, 0.0, 44.0, 50.0));
1230
1231        // Click below track
1232        let result = toggle.event(&Event::MouseDown {
1233            position: Point::new(22.0, 40.0),
1234            button: MouseButton::Left,
1235        });
1236        assert!(!toggle.is_on());
1237        assert!(result.is_none());
1238    }
1239
1240    #[test]
1241    fn test_toggle_event_right_click_no_toggle() {
1242        let mut toggle = Toggle::new().track_width(44.0).track_height(24.0).on(false);
1243        toggle.layout(Rect::new(0.0, 0.0, 44.0, 24.0));
1244
1245        let result = toggle.event(&Event::MouseDown {
1246            position: Point::new(22.0, 12.0),
1247            button: MouseButton::Right,
1248        });
1249        assert!(!toggle.is_on());
1250        assert!(result.is_none());
1251    }
1252
1253    #[test]
1254    fn test_toggle_event_disabled_blocks_click() {
1255        let mut toggle = Toggle::new()
1256            .track_width(44.0)
1257            .track_height(24.0)
1258            .on(false)
1259            .disabled(true);
1260        toggle.layout(Rect::new(0.0, 0.0, 44.0, 24.0));
1261
1262        let result = toggle.event(&Event::MouseDown {
1263            position: Point::new(22.0, 12.0),
1264            button: MouseButton::Left,
1265        });
1266        assert!(!toggle.is_on());
1267        assert!(result.is_none());
1268    }
1269
1270    #[test]
1271    fn test_toggle_event_hit_test_track_edges() {
1272        let mut toggle = Toggle::new().track_width(44.0).track_height(24.0).on(false);
1273        toggle.layout(Rect::new(0.0, 0.0, 44.0, 24.0));
1274
1275        // Top-left corner (just inside)
1276        let result = toggle.event(&Event::MouseDown {
1277            position: Point::new(0.0, 0.0),
1278            button: MouseButton::Left,
1279        });
1280        assert!(toggle.is_on());
1281        assert!(result.is_some());
1282
1283        toggle.on = false;
1284
1285        // Bottom-right corner (just inside)
1286        let result = toggle.event(&Event::MouseDown {
1287            position: Point::new(44.0, 24.0),
1288            button: MouseButton::Left,
1289        });
1290        assert!(toggle.is_on());
1291        assert!(result.is_some());
1292    }
1293
1294    #[test]
1295    fn test_toggle_event_with_offset_bounds() {
1296        let mut toggle = Toggle::new().track_width(44.0).track_height(24.0).on(false);
1297        toggle.layout(Rect::new(100.0, 50.0, 44.0, 24.0));
1298
1299        // Click relative to offset
1300        let result = toggle.event(&Event::MouseDown {
1301            position: Point::new(122.0, 62.0),
1302            button: MouseButton::Left,
1303        });
1304        assert!(toggle.is_on());
1305        assert!(result.is_some());
1306    }
1307
1308    #[test]
1309    fn test_toggle_event_full_interaction_flow() {
1310        let mut toggle = Toggle::new().track_width(44.0).track_height(24.0).on(false);
1311        toggle.layout(Rect::new(0.0, 0.0, 44.0, 24.0));
1312
1313        // 1. Start off
1314        assert!(!toggle.is_on());
1315
1316        // 2. Click to turn on
1317        let result = toggle.event(&Event::MouseDown {
1318            position: Point::new(22.0, 12.0),
1319            button: MouseButton::Left,
1320        });
1321        assert!(toggle.is_on());
1322        let msg = result.unwrap().downcast::<ToggleChanged>().unwrap();
1323        assert!(msg.on);
1324
1325        // 3. Click to turn off
1326        let result = toggle.event(&Event::MouseDown {
1327            position: Point::new(22.0, 12.0),
1328            button: MouseButton::Left,
1329        });
1330        assert!(!toggle.is_on());
1331        let msg = result.unwrap().downcast::<ToggleChanged>().unwrap();
1332        assert!(!msg.on);
1333
1334        // 4. Click again
1335        let result = toggle.event(&Event::MouseDown {
1336            position: Point::new(22.0, 12.0),
1337            button: MouseButton::Left,
1338        });
1339        assert!(toggle.is_on());
1340        assert!(result.is_some());
1341    }
1342
1343    #[test]
1344    fn test_toggle_event_mouse_move_no_effect() {
1345        let mut toggle = Toggle::new().track_width(44.0).track_height(24.0).on(false);
1346        toggle.layout(Rect::new(0.0, 0.0, 44.0, 24.0));
1347
1348        // MouseMove should not toggle
1349        let result = toggle.event(&Event::MouseMove {
1350            position: Point::new(22.0, 12.0),
1351        });
1352        assert!(!toggle.is_on());
1353        assert!(result.is_none());
1354    }
1355}