presentar_widgets/
toggle.rs

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