Skip to main content

presentar_widgets/
button.rs

1//! Button widget for user interactions.
2
3use presentar_core::{
4    widget::{AccessibleRole, FontWeight, LayoutResult, TextStyle},
5    Brick, BrickAssertion, BrickBudget, BrickVerification, Canvas, Color, Constraints,
6    CornerRadius, Event, MouseButton, Point, Rect, Size, TypeId, Widget,
7};
8use serde::{Deserialize, Serialize};
9use std::any::Any;
10use std::time::Duration;
11
12/// Button widget with label and click handling.
13#[derive(Clone, Serialize, Deserialize)]
14pub struct Button {
15    /// Button label
16    label: String,
17    /// Background color (normal state)
18    background: Color,
19    /// Background color (hover state)
20    background_hover: Color,
21    /// Background color (pressed state)
22    background_pressed: Color,
23    /// Text color
24    text_color: Color,
25    /// Corner radius
26    corner_radius: CornerRadius,
27    /// Padding
28    padding: f32,
29    /// Font size
30    font_size: f32,
31    /// Whether button is disabled
32    disabled: bool,
33    /// Test ID
34    test_id_value: Option<String>,
35    /// Accessible name (overrides label)
36    accessible_name: Option<String>,
37    /// Current hover state
38    #[serde(skip)]
39    hovered: bool,
40    /// Current pressed state
41    #[serde(skip)]
42    pressed: bool,
43    /// Cached bounds
44    #[serde(skip)]
45    bounds: Rect,
46}
47
48/// Message emitted when button is clicked.
49#[derive(Debug, Clone)]
50pub struct ButtonClicked;
51
52impl Button {
53    /// Create a new button with label.
54    #[must_use]
55    pub fn new(label: impl Into<String>) -> Self {
56        Self {
57            label: label.into(),
58            background: Color::from_hex("#6366f1").unwrap_or(Color::BLACK),
59            background_hover: Color::from_hex("#4f46e5").unwrap_or(Color::BLACK),
60            background_pressed: Color::from_hex("#4338ca").unwrap_or(Color::BLACK),
61            text_color: Color::WHITE,
62            corner_radius: CornerRadius::uniform(4.0),
63            padding: 12.0,
64            font_size: 14.0,
65            disabled: false,
66            test_id_value: None,
67            accessible_name: None,
68            hovered: false,
69            pressed: false,
70            bounds: Rect::default(),
71        }
72    }
73
74    /// Set background color.
75    #[must_use]
76    pub const fn background(mut self, color: Color) -> Self {
77        self.background = color;
78        self
79    }
80
81    /// Set hover background color.
82    #[must_use]
83    pub const fn background_hover(mut self, color: Color) -> Self {
84        self.background_hover = color;
85        self
86    }
87
88    /// Set pressed background color.
89    #[must_use]
90    pub const fn background_pressed(mut self, color: Color) -> Self {
91        self.background_pressed = color;
92        self
93    }
94
95    /// Set text color.
96    #[must_use]
97    pub const fn text_color(mut self, color: Color) -> Self {
98        self.text_color = color;
99        self
100    }
101
102    /// Set corner radius.
103    #[must_use]
104    pub const fn corner_radius(mut self, radius: CornerRadius) -> Self {
105        self.corner_radius = radius;
106        self
107    }
108
109    /// Set padding.
110    #[must_use]
111    pub const fn padding(mut self, padding: f32) -> Self {
112        self.padding = padding;
113        self
114    }
115
116    /// Set font size.
117    #[must_use]
118    pub const fn font_size(mut self, size: f32) -> Self {
119        self.font_size = size;
120        self
121    }
122
123    /// Set disabled state.
124    #[must_use]
125    pub const fn disabled(mut self, disabled: bool) -> Self {
126        self.disabled = disabled;
127        self
128    }
129
130    /// Set test ID.
131    #[must_use]
132    pub fn with_test_id(mut self, id: impl Into<String>) -> Self {
133        self.test_id_value = Some(id.into());
134        self
135    }
136
137    /// Set accessible name.
138    #[must_use]
139    pub fn with_accessible_name(mut self, name: impl Into<String>) -> Self {
140        self.accessible_name = Some(name.into());
141        self
142    }
143
144    /// Get the current background color based on state.
145    fn current_background(&self) -> Color {
146        if self.disabled {
147            // Desaturated version
148            let gray = (self.background.r + self.background.g + self.background.b) / 3.0;
149            Color::rgb(gray, gray, gray)
150        } else if self.pressed {
151            self.background_pressed
152        } else if self.hovered {
153            self.background_hover
154        } else {
155            self.background
156        }
157    }
158
159    /// Estimate text size.
160    fn estimate_text_size(&self) -> Size {
161        let char_width = self.font_size * 0.6;
162        let width = self.label.len() as f32 * char_width;
163        let height = self.font_size * 1.2;
164        Size::new(width, height)
165    }
166}
167
168impl Widget for Button {
169    fn type_id(&self) -> TypeId {
170        TypeId::of::<Self>()
171    }
172
173    fn measure(&self, constraints: Constraints) -> Size {
174        let text_size = self.estimate_text_size();
175        let size = Size::new(
176            self.padding.mul_add(2.0, text_size.width),
177            self.padding.mul_add(2.0, text_size.height),
178        );
179        constraints.constrain(size)
180    }
181
182    fn layout(&mut self, bounds: Rect) -> LayoutResult {
183        self.bounds = bounds;
184        LayoutResult {
185            size: bounds.size(),
186        }
187    }
188
189    fn paint(&self, canvas: &mut dyn Canvas) {
190        // Draw background
191        canvas.fill_rect(self.bounds, self.current_background());
192
193        // Draw text centered
194        let text_size = self.estimate_text_size();
195        let text_pos = Point::new(
196            self.bounds.x + (self.bounds.width - text_size.width) / 2.0,
197            self.bounds.y + (self.bounds.height - text_size.height) / 2.0,
198        );
199
200        let style = TextStyle {
201            size: self.font_size,
202            color: if self.disabled {
203                Color::rgb(0.7, 0.7, 0.7)
204            } else {
205                self.text_color
206            },
207            weight: FontWeight::Medium,
208            ..Default::default()
209        };
210
211        canvas.draw_text(&self.label, text_pos, &style);
212    }
213
214    fn event(&mut self, event: &Event) -> Option<Box<dyn Any + Send>> {
215        if self.disabled {
216            return None;
217        }
218
219        match event {
220            Event::MouseEnter => {
221                self.hovered = true;
222                None
223            }
224            Event::MouseLeave => {
225                self.hovered = false;
226                self.pressed = false;
227                None
228            }
229            Event::MouseDown {
230                position,
231                button: MouseButton::Left,
232            } => {
233                if self.bounds.contains_point(position) {
234                    self.pressed = true;
235                }
236                None
237            }
238            Event::MouseUp {
239                position,
240                button: MouseButton::Left,
241            } => {
242                let was_pressed = self.pressed;
243                self.pressed = false;
244
245                if was_pressed && self.bounds.contains_point(position) {
246                    Some(Box::new(ButtonClicked))
247                } else {
248                    None
249                }
250            }
251            Event::KeyDown {
252                key: presentar_core::Key::Enter | presentar_core::Key::Space,
253                ..
254            } => {
255                self.pressed = true;
256                None
257            }
258            Event::KeyUp {
259                key: presentar_core::Key::Enter | presentar_core::Key::Space,
260                ..
261            } => {
262                self.pressed = false;
263                Some(Box::new(ButtonClicked))
264            }
265            _ => None,
266        }
267    }
268
269    fn children(&self) -> &[Box<dyn Widget>] {
270        &[]
271    }
272
273    fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
274        &mut []
275    }
276
277    fn is_interactive(&self) -> bool {
278        !self.disabled
279    }
280
281    fn is_focusable(&self) -> bool {
282        !self.disabled
283    }
284
285    fn accessible_name(&self) -> Option<&str> {
286        self.accessible_name.as_deref().or(Some(&self.label))
287    }
288
289    fn accessible_role(&self) -> AccessibleRole {
290        AccessibleRole::Button
291    }
292
293    fn test_id(&self) -> Option<&str> {
294        self.test_id_value.as_deref()
295    }
296}
297
298// PROBAR-SPEC-009: Brick Architecture - Tests define interface
299impl Brick for Button {
300    fn brick_name(&self) -> &'static str {
301        "Button"
302    }
303
304    fn assertions(&self) -> &[BrickAssertion] {
305        // Button must have visible text and adequate contrast
306        &[
307            BrickAssertion::TextVisible,
308            BrickAssertion::ContrastRatio(4.5), // WCAG AA
309        ]
310    }
311
312    fn budget(&self) -> BrickBudget {
313        BrickBudget::uniform(16) // 60fps
314    }
315
316    fn verify(&self) -> BrickVerification {
317        let mut passed = Vec::new();
318        let mut failed = Vec::new();
319
320        // Verify text contrast
321        let bg = self.current_background();
322        let contrast = bg.contrast_ratio(&self.text_color);
323        if contrast >= 4.5 {
324            passed.push(BrickAssertion::ContrastRatio(4.5));
325        } else {
326            failed.push((
327                BrickAssertion::ContrastRatio(4.5),
328                format!("Contrast ratio {contrast:.2}:1 < 4.5:1"),
329            ));
330        }
331
332        // Text visibility
333        if self.label.is_empty() {
334            failed.push((BrickAssertion::TextVisible, "Button has no label".into()));
335        } else {
336            passed.push(BrickAssertion::TextVisible);
337        }
338
339        BrickVerification {
340            passed,
341            failed,
342            verification_time: Duration::from_micros(10),
343        }
344    }
345
346    fn to_html(&self) -> String {
347        let disabled = if self.disabled { " disabled" } else { "" };
348        let test_id = self.test_id_value.as_deref().unwrap_or("button");
349        format!(
350            r#"<button class="brick-button" data-testid="{}" aria-label="{}"{}>{}</button>"#,
351            test_id,
352            self.accessible_name.as_deref().unwrap_or(&self.label),
353            disabled,
354            self.label
355        )
356    }
357
358    fn to_css(&self) -> String {
359        format!(
360            r".brick-button {{
361    background: {};
362    color: {};
363    padding: {}px;
364    font-size: {}px;
365    border: none;
366    border-radius: {}px;
367    cursor: pointer;
368}}
369.brick-button:hover {{ background: {}; }}
370.brick-button:active {{ background: {}; }}
371.brick-button:disabled {{ opacity: 0.5; cursor: not-allowed; }}",
372            self.background.to_hex(),
373            self.text_color.to_hex(),
374            self.padding,
375            self.font_size,
376            self.corner_radius.top_left,
377            self.background_hover.to_hex(),
378            self.background_pressed.to_hex(),
379        )
380    }
381
382    fn test_id(&self) -> Option<&str> {
383        self.test_id_value.as_deref()
384    }
385}
386
387#[cfg(test)]
388#[allow(clippy::unwrap_used, clippy::disallowed_methods)]
389mod tests {
390    use super::*;
391    use presentar_core::draw::DrawCommand;
392    use presentar_core::{RecordingCanvas, Widget};
393
394    #[test]
395    fn test_button_new() {
396        let b = Button::new("Click me");
397        assert_eq!(b.label, "Click me");
398        assert!(!b.disabled);
399    }
400
401    #[test]
402    fn test_button_builder() {
403        let b = Button::new("Test")
404            .padding(20.0)
405            .font_size(18.0)
406            .disabled(true)
407            .with_test_id("my-button");
408
409        assert_eq!(b.padding, 20.0);
410        assert_eq!(b.font_size, 18.0);
411        assert!(b.disabled);
412        assert_eq!(Widget::test_id(&b), Some("my-button"));
413    }
414
415    #[test]
416    fn test_button_accessible() {
417        let b = Button::new("OK");
418        assert_eq!(Widget::accessible_name(&b), Some("OK"));
419        assert_eq!(Widget::accessible_role(&b), AccessibleRole::Button);
420        assert!(Widget::is_focusable(&b));
421    }
422
423    #[test]
424    fn test_button_disabled_not_focusable() {
425        let b = Button::new("OK").disabled(true);
426        assert!(!Widget::is_focusable(&b));
427        assert!(!Widget::is_interactive(&b));
428    }
429
430    #[test]
431    fn test_button_measure() {
432        let b = Button::new("Test");
433        let size = b.measure(Constraints::loose(Size::new(1000.0, 1000.0)));
434        assert!(size.width > 0.0);
435        assert!(size.height > 0.0);
436    }
437
438    // ===== Paint Tests =====
439
440    #[test]
441    fn test_button_paint_draws_background() {
442        let mut button = Button::new("Click");
443        button.layout(Rect::new(0.0, 0.0, 100.0, 40.0));
444
445        let mut canvas = RecordingCanvas::new();
446        button.paint(&mut canvas);
447
448        // Should have at least 2 commands: background rect + text
449        assert!(canvas.command_count() >= 2);
450
451        // First command should be the background rect
452        match &canvas.commands()[0] {
453            DrawCommand::Rect { bounds, style, .. } => {
454                assert_eq!(bounds.width, 100.0);
455                assert_eq!(bounds.height, 40.0);
456                assert!(style.fill.is_some());
457            }
458            _ => panic!("Expected Rect command for background"),
459        }
460    }
461
462    #[test]
463    fn test_button_paint_draws_text() {
464        let mut button = Button::new("Hello");
465        button.layout(Rect::new(0.0, 0.0, 100.0, 40.0));
466
467        let mut canvas = RecordingCanvas::new();
468        button.paint(&mut canvas);
469
470        // Should have text command
471        let has_text = canvas
472            .commands()
473            .iter()
474            .any(|cmd| matches!(cmd, DrawCommand::Text { content, .. } if content == "Hello"));
475        assert!(has_text, "Should draw button label text");
476    }
477
478    #[test]
479    fn test_button_paint_disabled_uses_gray() {
480        let mut button = Button::new("Disabled").disabled(true);
481        button.layout(Rect::new(0.0, 0.0, 100.0, 40.0));
482
483        let mut canvas = RecordingCanvas::new();
484        button.paint(&mut canvas);
485
486        // Check text color is gray (disabled)
487        let text_cmd = canvas
488            .commands()
489            .iter()
490            .find(|cmd| matches!(cmd, DrawCommand::Text { .. }));
491
492        if let Some(DrawCommand::Text { style, .. }) = text_cmd {
493            // Disabled text should be grayish
494            assert!(style.color.r > 0.5 && style.color.g > 0.5 && style.color.b > 0.5);
495        } else {
496            panic!("Expected Text command");
497        }
498    }
499
500    #[test]
501    fn test_button_paint_hovered_uses_hover_color() {
502        let mut button = Button::new("Hover")
503            .background(Color::RED)
504            .background_hover(Color::BLUE);
505        button.layout(Rect::new(0.0, 0.0, 100.0, 40.0));
506
507        // Simulate hover
508        button.event(&Event::MouseEnter);
509
510        let mut canvas = RecordingCanvas::new();
511        button.paint(&mut canvas);
512
513        // Background should use hover color
514        match &canvas.commands()[0] {
515            DrawCommand::Rect { style, .. } => {
516                assert_eq!(style.fill, Some(Color::BLUE));
517            }
518            _ => panic!("Expected Rect command"),
519        }
520    }
521
522    #[test]
523    fn test_button_paint_pressed_uses_pressed_color() {
524        let mut button = Button::new("Press")
525            .background(Color::RED)
526            .background_pressed(Color::GREEN);
527        button.layout(Rect::new(0.0, 0.0, 100.0, 40.0));
528
529        // Simulate press
530        button.event(&Event::MouseEnter);
531        button.event(&Event::MouseDown {
532            position: Point::new(50.0, 20.0),
533            button: MouseButton::Left,
534        });
535
536        let mut canvas = RecordingCanvas::new();
537        button.paint(&mut canvas);
538
539        // Background should use pressed color
540        match &canvas.commands()[0] {
541            DrawCommand::Rect { style, .. } => {
542                assert_eq!(style.fill, Some(Color::GREEN));
543            }
544            _ => panic!("Expected Rect command"),
545        }
546    }
547
548    #[test]
549    fn test_button_paint_text_centered() {
550        let mut button = Button::new("X");
551        button.layout(Rect::new(0.0, 0.0, 100.0, 40.0));
552
553        let mut canvas = RecordingCanvas::new();
554        button.paint(&mut canvas);
555
556        // Text should be roughly centered
557        let text_cmd = canvas
558            .commands()
559            .iter()
560            .find(|cmd| matches!(cmd, DrawCommand::Text { .. }));
561
562        if let Some(DrawCommand::Text { position, .. }) = text_cmd {
563            // Text should be somewhere in the middle, not at edge
564            assert!(position.x > 10.0 && position.x < 90.0);
565            assert!(position.y > 5.0 && position.y < 35.0);
566        } else {
567            panic!("Expected Text command");
568        }
569    }
570
571    #[test]
572    fn test_button_paint_custom_colors() {
573        let mut button = Button::new("Custom")
574            .background(Color::rgb(1.0, 0.0, 0.0))
575            .text_color(Color::rgb(0.0, 1.0, 0.0));
576        button.layout(Rect::new(0.0, 0.0, 100.0, 40.0));
577
578        let mut canvas = RecordingCanvas::new();
579        button.paint(&mut canvas);
580
581        // Check background color
582        match &canvas.commands()[0] {
583            DrawCommand::Rect { style, .. } => {
584                let fill = style.fill.unwrap();
585                assert!((fill.r - 1.0).abs() < 0.01);
586                assert!(fill.g < 0.01);
587                assert!(fill.b < 0.01);
588            }
589            _ => panic!("Expected Rect command"),
590        }
591
592        // Check text color
593        let text_cmd = canvas
594            .commands()
595            .iter()
596            .find(|cmd| matches!(cmd, DrawCommand::Text { .. }));
597        if let Some(DrawCommand::Text { style, .. }) = text_cmd {
598            assert!(style.color.r < 0.01);
599            assert!((style.color.g - 1.0).abs() < 0.01);
600            assert!(style.color.b < 0.01);
601        }
602    }
603
604    // ===== Event Handling Tests =====
605
606    use presentar_core::Key;
607
608    #[test]
609    fn test_button_event_mouse_enter_sets_hovered() {
610        let mut button = Button::new("Test");
611        button.layout(Rect::new(0.0, 0.0, 100.0, 40.0));
612
613        assert!(!button.hovered);
614        let result = button.event(&Event::MouseEnter);
615        assert!(button.hovered);
616        assert!(result.is_none()); // No message emitted
617    }
618
619    #[test]
620    fn test_button_event_mouse_leave_clears_hovered() {
621        let mut button = Button::new("Test");
622        button.layout(Rect::new(0.0, 0.0, 100.0, 40.0));
623
624        button.event(&Event::MouseEnter);
625        assert!(button.hovered);
626
627        let result = button.event(&Event::MouseLeave);
628        assert!(!button.hovered);
629        assert!(result.is_none());
630    }
631
632    #[test]
633    fn test_button_event_mouse_leave_clears_pressed() {
634        let mut button = Button::new("Test");
635        button.layout(Rect::new(0.0, 0.0, 100.0, 40.0));
636
637        // Enter and press
638        button.event(&Event::MouseEnter);
639        button.event(&Event::MouseDown {
640            position: Point::new(50.0, 20.0),
641            button: MouseButton::Left,
642        });
643        assert!(button.pressed);
644
645        // Leave should clear pressed
646        button.event(&Event::MouseLeave);
647        assert!(!button.pressed);
648        assert!(!button.hovered);
649    }
650
651    #[test]
652    fn test_button_event_mouse_down_sets_pressed() {
653        let mut button = Button::new("Test");
654        button.layout(Rect::new(0.0, 0.0, 100.0, 40.0));
655
656        assert!(!button.pressed);
657        let result = button.event(&Event::MouseDown {
658            position: Point::new(50.0, 20.0),
659            button: MouseButton::Left,
660        });
661        assert!(button.pressed);
662        assert!(result.is_none()); // MouseDown doesn't emit click
663    }
664
665    #[test]
666    fn test_button_event_mouse_down_outside_bounds_no_press() {
667        let mut button = Button::new("Test");
668        button.layout(Rect::new(0.0, 0.0, 100.0, 40.0));
669
670        let result = button.event(&Event::MouseDown {
671            position: Point::new(150.0, 20.0), // Outside bounds
672            button: MouseButton::Left,
673        });
674        assert!(!button.pressed);
675        assert!(result.is_none());
676    }
677
678    #[test]
679    fn test_button_event_mouse_down_right_button_no_press() {
680        let mut button = Button::new("Test");
681        button.layout(Rect::new(0.0, 0.0, 100.0, 40.0));
682
683        let result = button.event(&Event::MouseDown {
684            position: Point::new(50.0, 20.0),
685            button: MouseButton::Right,
686        });
687        assert!(!button.pressed);
688        assert!(result.is_none());
689    }
690
691    #[test]
692    fn test_button_event_mouse_up_emits_clicked() {
693        let mut button = Button::new("Test");
694        button.layout(Rect::new(0.0, 0.0, 100.0, 40.0));
695
696        // Press down
697        button.event(&Event::MouseDown {
698            position: Point::new(50.0, 20.0),
699            button: MouseButton::Left,
700        });
701        assert!(button.pressed);
702
703        // Release inside bounds
704        let result = button.event(&Event::MouseUp {
705            position: Point::new(50.0, 20.0),
706            button: MouseButton::Left,
707        });
708        assert!(!button.pressed);
709        assert!(result.is_some());
710
711        // Verify it's a ButtonClicked message
712        let _msg: Box<ButtonClicked> = result.unwrap().downcast::<ButtonClicked>().unwrap();
713    }
714
715    #[test]
716    fn test_button_event_mouse_up_outside_bounds_no_click() {
717        let mut button = Button::new("Test");
718        button.layout(Rect::new(0.0, 0.0, 100.0, 40.0));
719
720        // Press down inside
721        button.event(&Event::MouseDown {
722            position: Point::new(50.0, 20.0),
723            button: MouseButton::Left,
724        });
725        assert!(button.pressed);
726
727        // Release outside bounds
728        let result = button.event(&Event::MouseUp {
729            position: Point::new(150.0, 20.0),
730            button: MouseButton::Left,
731        });
732        assert!(!button.pressed);
733        assert!(result.is_none()); // No click emitted
734    }
735
736    #[test]
737    fn test_button_event_mouse_up_without_prior_press_no_click() {
738        let mut button = Button::new("Test");
739        button.layout(Rect::new(0.0, 0.0, 100.0, 40.0));
740
741        // Mouse up without prior press
742        let result = button.event(&Event::MouseUp {
743            position: Point::new(50.0, 20.0),
744            button: MouseButton::Left,
745        });
746        assert!(result.is_none());
747    }
748
749    #[test]
750    fn test_button_event_mouse_up_right_button_no_effect() {
751        let mut button = Button::new("Test");
752        button.layout(Rect::new(0.0, 0.0, 100.0, 40.0));
753
754        // Press with left button
755        button.event(&Event::MouseDown {
756            position: Point::new(50.0, 20.0),
757            button: MouseButton::Left,
758        });
759
760        // Release with right button (should not trigger click)
761        let result = button.event(&Event::MouseUp {
762            position: Point::new(50.0, 20.0),
763            button: MouseButton::Right,
764        });
765        assert!(button.pressed); // Still pressed
766        assert!(result.is_none());
767    }
768
769    #[test]
770    fn test_button_event_key_down_enter_sets_pressed() {
771        let mut button = Button::new("Test");
772        button.layout(Rect::new(0.0, 0.0, 100.0, 40.0));
773
774        let result = button.event(&Event::key_down(Key::Enter));
775        assert!(button.pressed);
776        assert!(result.is_none()); // KeyDown doesn't emit click
777    }
778
779    #[test]
780    fn test_button_event_key_down_space_sets_pressed() {
781        let mut button = Button::new("Test");
782        button.layout(Rect::new(0.0, 0.0, 100.0, 40.0));
783
784        let result = button.event(&Event::key_down(Key::Space));
785        assert!(button.pressed);
786        assert!(result.is_none());
787    }
788
789    #[test]
790    fn test_button_event_key_up_enter_emits_clicked() {
791        let mut button = Button::new("Test");
792        button.layout(Rect::new(0.0, 0.0, 100.0, 40.0));
793
794        // Key down first
795        button.event(&Event::key_down(Key::Enter));
796        assert!(button.pressed);
797
798        // Key up emits click
799        let result = button.event(&Event::key_up(Key::Enter));
800        assert!(!button.pressed);
801        assert!(result.is_some());
802    }
803
804    #[test]
805    fn test_button_event_key_up_space_emits_clicked() {
806        let mut button = Button::new("Test");
807        button.layout(Rect::new(0.0, 0.0, 100.0, 40.0));
808
809        button.event(&Event::key_down(Key::Space));
810        let result = button.event(&Event::key_up(Key::Space));
811        assert!(!button.pressed);
812        assert!(result.is_some());
813    }
814
815    #[test]
816    fn test_button_event_key_other_no_effect() {
817        let mut button = Button::new("Test");
818        button.layout(Rect::new(0.0, 0.0, 100.0, 40.0));
819
820        let result = button.event(&Event::key_down(Key::Escape));
821        assert!(!button.pressed);
822        assert!(result.is_none());
823    }
824
825    #[test]
826    fn test_button_event_disabled_blocks_mouse_enter() {
827        let mut button = Button::new("Test").disabled(true);
828        button.layout(Rect::new(0.0, 0.0, 100.0, 40.0));
829
830        let result = button.event(&Event::MouseEnter);
831        assert!(!button.hovered);
832        assert!(result.is_none());
833    }
834
835    #[test]
836    fn test_button_event_disabled_blocks_mouse_down() {
837        let mut button = Button::new("Test").disabled(true);
838        button.layout(Rect::new(0.0, 0.0, 100.0, 40.0));
839
840        let result = button.event(&Event::MouseDown {
841            position: Point::new(50.0, 20.0),
842            button: MouseButton::Left,
843        });
844        assert!(!button.pressed);
845        assert!(result.is_none());
846    }
847
848    #[test]
849    fn test_button_event_disabled_blocks_key_down() {
850        let mut button = Button::new("Test").disabled(true);
851        button.layout(Rect::new(0.0, 0.0, 100.0, 40.0));
852
853        let result = button.event(&Event::key_down(Key::Enter));
854        assert!(!button.pressed);
855        assert!(result.is_none());
856    }
857
858    #[test]
859    fn test_button_event_disabled_blocks_key_up() {
860        let mut button = Button::new("Test").disabled(true);
861        button.layout(Rect::new(0.0, 0.0, 100.0, 40.0));
862
863        let result = button.event(&Event::key_up(Key::Enter));
864        assert!(result.is_none());
865    }
866
867    #[test]
868    fn test_button_click_full_interaction_flow() {
869        let mut button = Button::new("Submit");
870        button.layout(Rect::new(10.0, 10.0, 100.0, 40.0));
871
872        // Full click flow: enter -> down -> up -> leave
873        button.event(&Event::MouseEnter);
874        assert!(button.hovered);
875        assert!(!button.pressed);
876
877        button.event(&Event::MouseDown {
878            position: Point::new(50.0, 25.0),
879            button: MouseButton::Left,
880        });
881        assert!(button.hovered);
882        assert!(button.pressed);
883
884        let result = button.event(&Event::MouseUp {
885            position: Point::new(50.0, 25.0),
886            button: MouseButton::Left,
887        });
888        assert!(button.hovered);
889        assert!(!button.pressed);
890        assert!(result.is_some()); // Click emitted
891
892        button.event(&Event::MouseLeave);
893        assert!(!button.hovered);
894        assert!(!button.pressed);
895    }
896
897    #[test]
898    fn test_button_drag_out_and_release_no_click() {
899        let mut button = Button::new("Drag");
900        button.layout(Rect::new(0.0, 0.0, 100.0, 40.0));
901
902        // Press inside
903        button.event(&Event::MouseEnter);
904        button.event(&Event::MouseDown {
905            position: Point::new(50.0, 20.0),
906            button: MouseButton::Left,
907        });
908        assert!(button.pressed);
909
910        // Leave while pressed
911        button.event(&Event::MouseLeave);
912        assert!(!button.pressed); // Cleared by leave
913
914        // Release outside
915        let result = button.event(&Event::MouseUp {
916            position: Point::new(150.0, 20.0),
917            button: MouseButton::Left,
918        });
919        assert!(result.is_none()); // No click
920    }
921
922    #[test]
923    fn test_button_event_bounds_edge_cases() {
924        let mut button = Button::new("Edge");
925        button.layout(Rect::new(10.0, 20.0, 100.0, 40.0));
926
927        // Click at top-left corner (inside)
928        button.event(&Event::MouseDown {
929            position: Point::new(10.0, 20.0),
930            button: MouseButton::Left,
931        });
932        assert!(button.pressed);
933        button.pressed = false;
934
935        // Click at bottom-right corner (inside, at edge)
936        button.event(&Event::MouseDown {
937            position: Point::new(109.9, 59.9),
938            button: MouseButton::Left,
939        });
940        assert!(button.pressed);
941        button.pressed = false;
942
943        // Click just outside right edge
944        button.event(&Event::MouseDown {
945            position: Point::new(111.0, 30.0),
946            button: MouseButton::Left,
947        });
948        assert!(!button.pressed);
949    }
950}