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