Skip to main content

presentar_widgets/
modal.rs

1//! Modal dialog widget for overlay content.
2//!
3//! The Modal widget displays content in a centered overlay with a backdrop,
4//! supporting keyboard navigation, focus trap, and animation.
5
6use presentar_core::{
7    widget::{LayoutResult, TextStyle},
8    Brick, BrickAssertion, BrickBudget, BrickVerification, Canvas, Color, Constraints, Event, Key,
9    Point, Rect, Size, TypeId, Widget,
10};
11use serde::{Deserialize, Serialize};
12use std::any::Any;
13use std::time::Duration;
14
15/// Modal size variants.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
17pub enum ModalSize {
18    /// Small modal (300px)
19    Small,
20    /// Medium modal (500px)
21    #[default]
22    Medium,
23    /// Large modal (800px)
24    Large,
25    /// Full width (with padding)
26    FullWidth,
27    /// Custom width
28    Custom(u32),
29}
30
31impl ModalSize {
32    /// Get the max width for this size.
33    #[must_use]
34    pub const fn max_width(&self) -> f32 {
35        match self {
36            Self::Small => 300.0,
37            Self::Medium => 500.0,
38            Self::Large => 800.0,
39            Self::FullWidth => f32::MAX,
40            Self::Custom(w) => *w as f32,
41        }
42    }
43}
44
45/// Modal backdrop behavior.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
47pub enum BackdropBehavior {
48    /// Click backdrop to close modal
49    #[default]
50    CloseOnClick,
51    /// Backdrop click does nothing (modal must be closed explicitly)
52    Static,
53    /// No backdrop shown
54    None,
55}
56
57/// Modal dialog widget.
58#[derive(Serialize, Deserialize)]
59pub struct Modal {
60    /// Whether modal is open
61    pub open: bool,
62    /// Modal size
63    pub size: ModalSize,
64    /// Backdrop behavior
65    pub backdrop: BackdropBehavior,
66    /// Close on escape key
67    pub close_on_escape: bool,
68    /// Optional title
69    pub title: Option<String>,
70    /// Show close button
71    pub show_close_button: bool,
72    /// Backdrop color
73    pub backdrop_color: Color,
74    /// Modal background color
75    pub background_color: Color,
76    /// Border radius
77    pub border_radius: f32,
78    /// Padding
79    pub padding: f32,
80    /// Test ID
81    test_id_value: Option<String>,
82    /// Cached bounds
83    #[serde(skip)]
84    bounds: Rect,
85    /// Modal content bounds
86    #[serde(skip)]
87    content_bounds: Rect,
88    /// Boxed child widgets (content, footer), listed by `children()` so verification sees them
89    #[serde(skip)]
90    slots: Vec<Box<dyn Widget>>,
91    /// Index of the content widget in `slots`
92    #[serde(skip)]
93    content_idx: Option<usize>,
94    /// Index of the footer widget in `slots`
95    #[serde(skip)]
96    footer_idx: Option<usize>,
97    /// Animation progress (0.0 = closed, 1.0 = open)
98    #[serde(skip)]
99    animation_progress: f32,
100}
101
102impl Default for Modal {
103    fn default() -> Self {
104        Self {
105            open: false,
106            size: ModalSize::Medium,
107            backdrop: BackdropBehavior::CloseOnClick,
108            close_on_escape: true,
109            title: None,
110            show_close_button: true,
111            backdrop_color: Color::rgba(0.0, 0.0, 0.0, 0.5),
112            background_color: Color::WHITE,
113            border_radius: 8.0,
114            padding: 24.0,
115            test_id_value: None,
116            bounds: Rect::default(),
117            content_bounds: Rect::default(),
118            slots: Vec::new(),
119            content_idx: None,
120            footer_idx: None,
121            animation_progress: 0.0,
122        }
123    }
124}
125
126impl Modal {
127    /// Create a new modal dialog.
128    #[must_use]
129    pub fn new() -> Self {
130        Self::default()
131    }
132
133    /// Set modal open state.
134    #[must_use]
135    pub const fn open(mut self, open: bool) -> Self {
136        self.open = open;
137        self
138    }
139
140    /// Set modal size.
141    #[must_use]
142    pub const fn size(mut self, size: ModalSize) -> Self {
143        self.size = size;
144        self
145    }
146
147    /// Set backdrop behavior.
148    #[must_use]
149    pub const fn backdrop(mut self, behavior: BackdropBehavior) -> Self {
150        self.backdrop = behavior;
151        self
152    }
153
154    /// Set close on escape.
155    #[must_use]
156    pub const fn close_on_escape(mut self, enabled: bool) -> Self {
157        self.close_on_escape = enabled;
158        self
159    }
160
161    /// Set the title.
162    #[must_use]
163    pub fn title(mut self, title: impl Into<String>) -> Self {
164        self.title = Some(title.into());
165        self
166    }
167
168    /// Set show close button.
169    #[must_use]
170    pub const fn show_close_button(mut self, show: bool) -> Self {
171        self.show_close_button = show;
172        self
173    }
174
175    /// Set backdrop color.
176    #[must_use]
177    pub const fn backdrop_color(mut self, color: Color) -> Self {
178        self.backdrop_color = color;
179        self
180    }
181
182    /// Set background color.
183    #[must_use]
184    pub const fn background_color(mut self, color: Color) -> Self {
185        self.background_color = color;
186        self
187    }
188
189    /// Set border radius.
190    #[must_use]
191    pub const fn border_radius(mut self, radius: f32) -> Self {
192        self.border_radius = radius;
193        self
194    }
195
196    /// Set padding.
197    #[must_use]
198    pub const fn padding(mut self, padding: f32) -> Self {
199        self.padding = padding;
200        self
201    }
202
203    /// Set the content widget.
204    pub fn content(mut self, widget: impl Widget + 'static) -> Self {
205        Self::put(&mut self.slots, &mut self.content_idx, Box::new(widget));
206        self
207    }
208
209    /// Set the footer widget.
210    pub fn footer(mut self, widget: impl Widget + 'static) -> Self {
211        Self::put(&mut self.slots, &mut self.footer_idx, Box::new(widget));
212        self
213    }
214
215    /// Store `widget` in the slot `idx` names, replacing what was there.
216    fn put(slots: &mut Vec<Box<dyn Widget>>, idx: &mut Option<usize>, widget: Box<dyn Widget>) {
217        if let Some(i) = *idx {
218            slots[i] = widget;
219        } else {
220            slots.push(widget);
221            *idx = Some(slots.len() - 1);
222        }
223    }
224
225    fn slot(slots: &[Box<dyn Widget>], idx: Option<usize>) -> Option<&dyn Widget> {
226        idx.and_then(|i| slots.get(i)).map(|w| w.as_ref())
227    }
228
229    fn slot_mut(slots: &mut [Box<dyn Widget>], idx: Option<usize>) -> Option<&mut Box<dyn Widget>> {
230        idx.and_then(move |i| slots.get_mut(i))
231    }
232
233    /// Set the test ID.
234    #[must_use]
235    pub fn with_test_id(mut self, id: impl Into<String>) -> Self {
236        self.test_id_value = Some(id.into());
237        self
238    }
239
240    /// Open the modal.
241    pub fn show(&mut self) {
242        self.open = true;
243    }
244
245    /// Close the modal.
246    pub fn hide(&mut self) {
247        self.open = false;
248    }
249
250    /// Toggle the modal.
251    pub fn toggle(&mut self) {
252        self.open = !self.open;
253    }
254
255    /// Check if modal is open.
256    #[must_use]
257    pub const fn is_open(&self) -> bool {
258        self.open
259    }
260
261    /// Get animation progress.
262    #[must_use]
263    pub const fn animation_progress(&self) -> f32 {
264        self.animation_progress
265    }
266
267    /// Get content bounds.
268    #[must_use]
269    pub const fn content_bounds(&self) -> Rect {
270        self.content_bounds
271    }
272
273    /// Calculate modal dimensions based on viewport.
274    fn calculate_modal_bounds(&self, viewport: Rect) -> Rect {
275        let max_width = self.size.max_width();
276        let modal_width = max_width.min(viewport.width - 32.0); // 16px margin on each side
277
278        // Estimate height based on content + header + footer
279        let header_height = if self.title.is_some() { 56.0 } else { 0.0 };
280        let footer_height = if self.footer_idx.is_some() { 64.0 } else { 0.0 };
281        let content_height = 200.0; // Placeholder, will be measured properly
282        let total_height = self
283            .padding
284            .mul_add(2.0, header_height + content_height + footer_height);
285        let modal_height = total_height.min(viewport.height - 64.0); // 32px margin top/bottom
286
287        let x = viewport.x + (viewport.width - modal_width) / 2.0;
288        let y = viewport.y + (viewport.height - modal_height) / 2.0;
289
290        Rect::new(x, y, modal_width, modal_height)
291    }
292
293    /// Close the modal and build the `ModalClosed` message.
294    fn close_with(&mut self, reason: CloseReason) -> Option<Box<dyn Any + Send>> {
295        self.hide();
296        Some(Box::new(ModalClosed { reason }))
297    }
298
299    /// Whether a click at `position` lands on the close button.
300    fn hits_close_button(&self, position: &Point) -> bool {
301        let close_x = self.content_bounds.x + self.content_bounds.width - 40.0 - self.padding;
302        let close_y = self.content_bounds.y + self.padding;
303        position.x >= close_x
304            && position.x <= close_x + 24.0
305            && position.y >= close_y
306            && position.y <= close_y + 24.0
307    }
308
309    /// Whether a click at `position` lands inside the modal content.
310    fn hits_content(&self, position: &Point) -> bool {
311        position.x >= self.content_bounds.x
312            && position.x <= self.content_bounds.x + self.content_bounds.width
313            && position.y >= self.content_bounds.y
314            && position.y <= self.content_bounds.y + self.content_bounds.height
315    }
316
317    /// Handle backdrop and close-button clicks; `None` means not consumed.
318    fn on_mouse_down(&mut self, position: &Point) -> Option<Box<dyn Any + Send>> {
319        if self.backdrop == BackdropBehavior::CloseOnClick && !self.hits_content(position) {
320            return self.close_with(CloseReason::Backdrop);
321        }
322        if self.show_close_button && self.hits_close_button(position) {
323            return self.close_with(CloseReason::CloseButton);
324        }
325        None
326    }
327
328    /// Forward an event to the content slot, then the footer slot.
329    fn forward_event(&mut self, event: &Event) -> Option<Box<dyn Any + Send>> {
330        if let Some(content) = Self::slot_mut(&mut self.slots, self.content_idx) {
331            if let Some(msg) = content.event(event) {
332                return Some(msg);
333            }
334        }
335        let footer = Self::slot_mut(&mut self.slots, self.footer_idx)?;
336        footer.event(event)
337    }
338}
339
340impl Widget for Modal {
341    fn type_id(&self) -> TypeId {
342        TypeId::of::<Self>()
343    }
344
345    fn measure(&self, constraints: Constraints) -> Size {
346        // Modal overlays the entire viewport
347        constraints.constrain(Size::new(constraints.max_width, constraints.max_height))
348    }
349
350    fn layout(&mut self, bounds: Rect) -> LayoutResult {
351        self.bounds = bounds;
352
353        if self.open {
354            self.content_bounds = self.calculate_modal_bounds(bounds);
355
356            // Layout content
357            if let Some(content) = Self::slot_mut(&mut self.slots, self.content_idx) {
358                let header_height = if self.title.is_some() { 56.0 } else { 0.0 };
359                let footer_height = if self.footer_idx.is_some() { 64.0 } else { 0.0 };
360
361                let content_rect = Rect::new(
362                    self.content_bounds.x + self.padding,
363                    self.content_bounds.y + header_height + self.padding,
364                    self.padding.mul_add(-2.0, self.content_bounds.width),
365                    self.padding.mul_add(
366                        -2.0,
367                        self.content_bounds.height - header_height - footer_height,
368                    ),
369                );
370                content.layout(content_rect);
371            }
372
373            // Layout footer
374            if let Some(footer) = Self::slot_mut(&mut self.slots, self.footer_idx) {
375                let footer_rect = Rect::new(
376                    self.content_bounds.x + self.padding,
377                    self.content_bounds.y + self.content_bounds.height - 64.0 - self.padding,
378                    self.padding.mul_add(-2.0, self.content_bounds.width),
379                    64.0,
380                );
381                footer.layout(footer_rect);
382            }
383
384            // Animate towards open
385            self.animation_progress = (self.animation_progress + 0.15).min(1.0);
386        } else {
387            // Animate towards closed
388            self.animation_progress = (self.animation_progress - 0.15).max(0.0);
389        }
390
391        LayoutResult {
392            size: bounds.size(),
393        }
394    }
395
396    fn paint(&self, canvas: &mut dyn Canvas) {
397        if self.animation_progress <= 0.0 {
398            return;
399        }
400
401        let opacity = self.animation_progress;
402
403        // Draw backdrop
404        if self.backdrop != BackdropBehavior::None {
405            let backdrop_color = Color::rgba(
406                self.backdrop_color.r,
407                self.backdrop_color.g,
408                self.backdrop_color.b,
409                self.backdrop_color.a * opacity,
410            );
411            canvas.fill_rect(self.bounds, backdrop_color);
412        }
413
414        // Draw modal container with slight animation offset
415        let y_offset = (1.0 - opacity) * 20.0;
416        let animated_bounds = Rect::new(
417            self.content_bounds.x,
418            self.content_bounds.y + y_offset,
419            self.content_bounds.width,
420            self.content_bounds.height,
421        );
422
423        // Draw shadow (simplified) - draw first so it's behind
424        let shadow_color = Color::rgba(0.0, 0.0, 0.0, 0.1 * opacity);
425        let shadow_bounds = Rect::new(
426            animated_bounds.x + 4.0,
427            animated_bounds.y + 4.0,
428            animated_bounds.width,
429            animated_bounds.height,
430        );
431        canvas.fill_rect(shadow_bounds, shadow_color);
432
433        // Modal background
434        canvas.fill_rect(animated_bounds, self.background_color);
435
436        // Draw title
437        if let Some(ref title) = self.title {
438            let title_pos = Point::new(
439                animated_bounds.x + self.padding,
440                animated_bounds.y + self.padding + 16.0, // Baseline offset
441            );
442            let title_style = TextStyle {
443                size: 18.0,
444                color: Color::BLACK,
445                ..Default::default()
446            };
447            canvas.draw_text(title, title_pos, &title_style);
448        }
449
450        // Draw close button
451        if self.show_close_button {
452            let close_x = animated_bounds.x + animated_bounds.width - 40.0 - self.padding;
453            let close_y = animated_bounds.y + self.padding + 16.0;
454            let close_style = TextStyle {
455                size: 24.0,
456                color: Color::rgb(0.5, 0.5, 0.5),
457                ..Default::default()
458            };
459            canvas.draw_text("×", Point::new(close_x, close_y), &close_style);
460        }
461
462        // Draw content
463        if let Some(content) = Self::slot(&self.slots, self.content_idx) {
464            content.paint(canvas);
465        }
466
467        // Draw footer
468        if let Some(footer) = Self::slot(&self.slots, self.footer_idx) {
469            footer.paint(canvas);
470        }
471    }
472
473    fn event(&mut self, event: &Event) -> Option<Box<dyn Any + Send>> {
474        if !self.open {
475            return None;
476        }
477
478        let consumed = match event {
479            Event::KeyDown {
480                key: Key::Escape, ..
481            } if self.close_on_escape => self.close_with(CloseReason::Escape),
482            Event::MouseDown { position, .. } => self.on_mouse_down(position),
483            _ => None,
484        };
485        if consumed.is_some() {
486            return consumed;
487        }
488
489        self.forward_event(event)
490    }
491
492    fn children(&self) -> &[Box<dyn Widget>] {
493        &self.slots
494    }
495
496    fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
497        &mut self.slots
498    }
499
500    fn is_focusable(&self) -> bool {
501        self.open
502    }
503
504    fn test_id(&self) -> Option<&str> {
505        self.test_id_value.as_deref()
506    }
507
508    fn bounds(&self) -> Rect {
509        self.bounds
510    }
511}
512
513// PROBAR-SPEC-009: Brick Architecture - Tests define interface
514impl Brick for Modal {
515    fn brick_name(&self) -> &'static str {
516        "Modal"
517    }
518
519    fn assertions(&self) -> &[BrickAssertion] {
520        &[BrickAssertion::MaxLatencyMs(16)]
521    }
522
523    fn budget(&self) -> BrickBudget {
524        BrickBudget::uniform(16)
525    }
526
527    fn verify(&self) -> BrickVerification {
528        BrickVerification {
529            passed: self.assertions().to_vec(),
530            failed: vec![],
531            verification_time: Duration::from_micros(10),
532        }
533    }
534
535    fn to_html(&self) -> String {
536        r#"<div class="brick-modal"></div>"#.to_string()
537    }
538
539    fn to_css(&self) -> String {
540        ".brick-modal { display: block; position: fixed; }".to_string()
541    }
542
543    fn test_id(&self) -> Option<&str> {
544        self.test_id_value.as_deref()
545    }
546}
547
548/// Reason the modal was closed.
549#[derive(Debug, Clone, Copy, PartialEq, Eq)]
550pub enum CloseReason {
551    /// Closed via escape key
552    Escape,
553    /// Closed via backdrop click
554    Backdrop,
555    /// Closed via close button
556    CloseButton,
557    /// Closed programmatically
558    Programmatic,
559}
560
561/// Message emitted when modal is closed.
562#[derive(Debug, Clone)]
563pub struct ModalClosed {
564    /// Reason for closure
565    pub reason: CloseReason,
566}
567
568/// Message emitted when modal is opened.
569#[derive(Debug, Clone)]
570pub struct ModalOpened;
571
572#[cfg(test)]
573mod tests {
574    use super::*;
575
576    // =========================================================================
577    // ModalSize Tests
578    // =========================================================================
579
580    #[test]
581    fn test_modal_size_default() {
582        assert_eq!(ModalSize::default(), ModalSize::Medium);
583    }
584
585    #[test]
586    fn test_modal_size_max_width() {
587        assert_eq!(ModalSize::Small.max_width(), 300.0);
588        assert_eq!(ModalSize::Medium.max_width(), 500.0);
589        assert_eq!(ModalSize::Large.max_width(), 800.0);
590        assert_eq!(ModalSize::FullWidth.max_width(), f32::MAX);
591        assert_eq!(ModalSize::Custom(600).max_width(), 600.0);
592    }
593
594    // =========================================================================
595    // BackdropBehavior Tests
596    // =========================================================================
597
598    #[test]
599    fn test_backdrop_behavior_default() {
600        assert_eq!(BackdropBehavior::default(), BackdropBehavior::CloseOnClick);
601    }
602
603    // =========================================================================
604    // Modal Tests
605    // =========================================================================
606
607    #[test]
608    fn test_modal_new() {
609        let modal = Modal::new();
610        assert!(!modal.open);
611        assert_eq!(modal.size, ModalSize::Medium);
612        assert_eq!(modal.backdrop, BackdropBehavior::CloseOnClick);
613        assert!(modal.close_on_escape);
614        assert!(modal.title.is_none());
615        assert!(modal.show_close_button);
616    }
617
618    #[test]
619    fn test_modal_builder() {
620        let modal = Modal::new()
621            .open(true)
622            .size(ModalSize::Large)
623            .backdrop(BackdropBehavior::Static)
624            .close_on_escape(false)
625            .title("Test Modal")
626            .show_close_button(false)
627            .border_radius(16.0)
628            .padding(32.0);
629
630        assert!(modal.open);
631        assert_eq!(modal.size, ModalSize::Large);
632        assert_eq!(modal.backdrop, BackdropBehavior::Static);
633        assert!(!modal.close_on_escape);
634        assert_eq!(modal.title, Some("Test Modal".to_string()));
635        assert!(!modal.show_close_button);
636        assert_eq!(modal.border_radius, 16.0);
637        assert_eq!(modal.padding, 32.0);
638    }
639
640    #[test]
641    fn test_modal_show_hide() {
642        let mut modal = Modal::new();
643        assert!(!modal.is_open());
644
645        modal.show();
646        assert!(modal.is_open());
647
648        modal.hide();
649        assert!(!modal.is_open());
650    }
651
652    #[test]
653    fn test_modal_toggle() {
654        let mut modal = Modal::new();
655        assert!(!modal.is_open());
656
657        modal.toggle();
658        assert!(modal.is_open());
659
660        modal.toggle();
661        assert!(!modal.is_open());
662    }
663
664    #[test]
665    fn test_modal_measure() {
666        let modal = Modal::new();
667        let size = modal.measure(Constraints::loose(Size::new(1024.0, 768.0)));
668        assert_eq!(size, Size::new(1024.0, 768.0));
669    }
670
671    #[test]
672    fn test_modal_layout_closed() {
673        let mut modal = Modal::new();
674        let result = modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
675        assert_eq!(result.size, Size::new(1024.0, 768.0));
676        assert_eq!(modal.animation_progress, 0.0);
677    }
678
679    #[test]
680    fn test_modal_layout_open() {
681        let mut modal = Modal::new().open(true);
682        modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
683        assert!(modal.animation_progress > 0.0);
684    }
685
686    #[test]
687    fn test_modal_calculate_bounds() {
688        let modal = Modal::new().size(ModalSize::Medium);
689        let viewport = Rect::new(0.0, 0.0, 1024.0, 768.0);
690        let bounds = modal.calculate_modal_bounds(viewport);
691
692        // Modal should be centered
693        assert!(bounds.x > 0.0);
694        assert!(bounds.y > 0.0);
695        assert!(bounds.width <= 500.0);
696    }
697
698    #[test]
699    fn test_modal_type_id() {
700        let modal = Modal::new();
701        assert_eq!(Widget::type_id(&modal), TypeId::of::<Modal>());
702    }
703
704    #[test]
705    fn test_modal_is_focusable() {
706        let modal = Modal::new();
707        assert!(!modal.is_focusable()); // Not focusable when closed
708
709        let modal_open = Modal::new().open(true);
710        assert!(modal_open.is_focusable()); // Focusable when open
711    }
712
713    #[test]
714    fn test_modal_test_id() {
715        let modal = Modal::new().with_test_id("my-modal");
716        assert_eq!(Widget::test_id(&modal), Some("my-modal"));
717    }
718
719    #[test]
720    fn test_modal_children_empty() {
721        let modal = Modal::new();
722        assert!(modal.children().is_empty());
723    }
724
725    #[test]
726    fn test_modal_bounds() {
727        let mut modal = Modal::new();
728        modal.layout(Rect::new(10.0, 20.0, 1024.0, 768.0));
729        assert_eq!(modal.bounds(), Rect::new(10.0, 20.0, 1024.0, 768.0));
730    }
731
732    #[test]
733    fn test_modal_backdrop_color() {
734        let modal = Modal::new().backdrop_color(Color::rgba(0.0, 0.0, 0.0, 0.7));
735        assert_eq!(modal.backdrop_color.a, 0.7);
736    }
737
738    #[test]
739    fn test_modal_background_color() {
740        let modal = Modal::new().background_color(Color::rgb(0.9, 0.9, 0.9));
741        assert_eq!(modal.background_color.r, 0.9);
742    }
743
744    #[test]
745    fn test_modal_escape_closes() {
746        let mut modal = Modal::new().open(true);
747        modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
748
749        let result = modal.event(&Event::key_down(Key::Escape));
750        assert!(result.is_some());
751        assert!(!modal.is_open());
752    }
753
754    #[test]
755    fn test_modal_escape_disabled() {
756        let mut modal = Modal::new().open(true).close_on_escape(false);
757        modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
758
759        let result = modal.event(&Event::key_down(Key::Escape));
760        assert!(result.is_none());
761        assert!(modal.is_open());
762    }
763
764    #[test]
765    fn test_modal_animation_progress() {
766        let modal = Modal::new();
767        assert_eq!(modal.animation_progress(), 0.0);
768    }
769
770    #[test]
771    fn test_modal_content_bounds() {
772        let mut modal = Modal::new().open(true);
773        modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
774        let content_bounds = modal.content_bounds();
775        assert!(content_bounds.width > 0.0);
776        assert!(content_bounds.height > 0.0);
777    }
778
779    // =========================================================================
780    // CloseReason Tests
781    // =========================================================================
782
783    #[test]
784    fn test_close_reason_eq() {
785        assert_eq!(CloseReason::Escape, CloseReason::Escape);
786        assert_ne!(CloseReason::Escape, CloseReason::Backdrop);
787    }
788
789    // =========================================================================
790    // Message Tests
791    // =========================================================================
792
793    #[test]
794    fn test_modal_closed_message() {
795        let msg = ModalClosed {
796            reason: CloseReason::CloseButton,
797        };
798        assert_eq!(msg.reason, CloseReason::CloseButton);
799    }
800
801    #[test]
802    fn test_modal_opened_message() {
803        let msg = ModalOpened;
804        assert_eq!(format!("{msg:?}"), "ModalOpened");
805    }
806
807    // =========================================================================
808    // Additional Coverage Tests
809    // =========================================================================
810
811    #[test]
812    fn test_modal_backdrop_none() {
813        let modal = Modal::new().backdrop(BackdropBehavior::None);
814        assert_eq!(modal.backdrop, BackdropBehavior::None);
815    }
816
817    #[test]
818    fn test_modal_backdrop_static() {
819        let modal = Modal::new().backdrop(BackdropBehavior::Static);
820        assert_eq!(modal.backdrop, BackdropBehavior::Static);
821    }
822
823    #[test]
824    fn test_modal_size_small() {
825        assert_eq!(ModalSize::Small.max_width(), 300.0);
826    }
827
828    #[test]
829    fn test_modal_size_full_width() {
830        assert_eq!(ModalSize::FullWidth.max_width(), f32::MAX);
831    }
832
833    #[test]
834    fn test_modal_children_mut_empty() {
835        let mut modal = Modal::new();
836        assert!(modal.children_mut().is_empty());
837    }
838
839    #[test]
840    fn test_modal_calculate_bounds_with_title() {
841        let modal = Modal::new().title("Test Title");
842        let viewport = Rect::new(0.0, 0.0, 1024.0, 768.0);
843        let bounds = modal.calculate_modal_bounds(viewport);
844        assert!(bounds.height > 0.0);
845    }
846
847    #[test]
848    fn test_modal_layout_animation_closes() {
849        let mut modal = Modal::new().open(true);
850        modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
851        // Progress should increase
852        let prog1 = modal.animation_progress;
853        modal.open = false;
854        modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
855        // Progress should decrease
856        assert!(modal.animation_progress < prog1);
857    }
858
859    #[test]
860    fn test_modal_event_not_open_returns_none() {
861        let mut modal = Modal::new();
862        let result = modal.event(&Event::key_down(Key::Escape));
863        assert!(result.is_none());
864    }
865
866    #[test]
867    fn test_modal_other_key_does_nothing() {
868        let mut modal = Modal::new().open(true);
869        modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
870        let result = modal.event(&Event::key_down(Key::Tab));
871        assert!(result.is_none());
872        assert!(modal.is_open());
873    }
874
875    #[test]
876    fn test_close_reason_programmatic() {
877        let reason = CloseReason::Programmatic;
878        assert_eq!(reason, CloseReason::Programmatic);
879    }
880
881    #[test]
882    fn test_close_reason_close_button() {
883        let reason = CloseReason::CloseButton;
884        assert_eq!(reason, CloseReason::CloseButton);
885    }
886
887    #[test]
888    fn test_modal_size_custom_value() {
889        let size = ModalSize::Custom(750);
890        assert_eq!(size.max_width(), 750.0);
891    }
892
893    #[test]
894    fn test_modal_backdrop_eq() {
895        assert_eq!(
896            BackdropBehavior::CloseOnClick,
897            BackdropBehavior::CloseOnClick
898        );
899        assert_ne!(BackdropBehavior::CloseOnClick, BackdropBehavior::Static);
900    }
901
902    #[test]
903    fn test_modal_size_eq() {
904        assert_eq!(ModalSize::Medium, ModalSize::Medium);
905        assert_ne!(ModalSize::Small, ModalSize::Large);
906    }
907
908    // =========================================================================
909    // Brick Trait Tests
910    // =========================================================================
911
912    #[test]
913    fn test_modal_brick_name() {
914        let modal = Modal::new();
915        assert_eq!(modal.brick_name(), "Modal");
916    }
917
918    #[test]
919    fn test_modal_brick_assertions() {
920        let modal = Modal::new();
921        let assertions = modal.assertions();
922        assert!(!assertions.is_empty());
923        assert!(matches!(assertions[0], BrickAssertion::MaxLatencyMs(16)));
924    }
925
926    #[test]
927    fn test_modal_brick_budget() {
928        let modal = Modal::new();
929        let budget = modal.budget();
930        // Verify budget has reasonable values
931        assert!(budget.layout_ms > 0);
932        assert!(budget.paint_ms > 0);
933    }
934
935    #[test]
936    fn test_modal_brick_verify() {
937        let modal = Modal::new();
938        let verification = modal.verify();
939        assert!(!verification.passed.is_empty());
940        assert!(verification.failed.is_empty());
941    }
942
943    #[test]
944    fn test_modal_brick_to_html() {
945        let modal = Modal::new();
946        let html = modal.to_html();
947        assert!(html.contains("brick-modal"));
948    }
949
950    #[test]
951    fn test_modal_brick_to_css() {
952        let modal = Modal::new();
953        let css = modal.to_css();
954        assert!(css.contains(".brick-modal"));
955        assert!(css.contains("display: block"));
956        assert!(css.contains("position: fixed"));
957    }
958
959    #[test]
960    fn test_modal_brick_test_id() {
961        let modal = Modal::new().with_test_id("my-modal");
962        assert_eq!(Brick::test_id(&modal), Some("my-modal"));
963    }
964
965    #[test]
966    fn test_modal_brick_test_id_none() {
967        let modal = Modal::new();
968        assert!(Brick::test_id(&modal).is_none());
969    }
970
971    // =========================================================================
972    // Backdrop Click Tests
973    // =========================================================================
974
975    #[test]
976    fn test_modal_backdrop_click_closes() {
977        let mut modal = Modal::new().open(true);
978        modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
979
980        // Click outside the modal content (on backdrop)
981        let result = modal.event(&Event::MouseDown {
982            position: Point::new(10.0, 10.0),
983            button: presentar_core::MouseButton::Left,
984        });
985
986        assert!(result.is_some());
987        assert!(!modal.is_open());
988    }
989
990    #[test]
991    fn test_modal_backdrop_static_no_close() {
992        let mut modal = Modal::new().open(true).backdrop(BackdropBehavior::Static);
993        modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
994
995        // Click outside the modal content
996        let result = modal.event(&Event::MouseDown {
997            position: Point::new(10.0, 10.0),
998            button: presentar_core::MouseButton::Left,
999        });
1000
1001        assert!(result.is_none());
1002        assert!(modal.is_open());
1003    }
1004
1005    #[test]
1006    fn test_modal_click_inside_does_not_close() {
1007        let mut modal = Modal::new().open(true);
1008        modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
1009
1010        // Click inside the modal content
1011        let center_x = modal.content_bounds.x + modal.content_bounds.width / 2.0;
1012        let center_y = modal.content_bounds.y + modal.content_bounds.height / 2.0;
1013
1014        let result = modal.event(&Event::MouseDown {
1015            position: Point::new(center_x, center_y),
1016            button: presentar_core::MouseButton::Left,
1017        });
1018
1019        // No close message, modal stays open
1020        assert!(result.is_none());
1021        assert!(modal.is_open());
1022    }
1023
1024    // =========================================================================
1025    // Close Button Tests
1026    // =========================================================================
1027
1028    #[test]
1029    fn test_modal_close_button_click() {
1030        let mut modal = Modal::new().open(true);
1031        modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
1032
1033        // Calculate close button position
1034        let close_x = modal.content_bounds.x + modal.content_bounds.width - 40.0 - modal.padding;
1035        let close_y = modal.content_bounds.y + modal.padding;
1036
1037        let result = modal.event(&Event::MouseDown {
1038            position: Point::new(close_x + 10.0, close_y + 10.0),
1039            button: presentar_core::MouseButton::Left,
1040        });
1041
1042        assert!(result.is_some());
1043        assert!(!modal.is_open());
1044    }
1045
1046    #[test]
1047    fn test_modal_close_button_hidden() {
1048        let mut modal = Modal::new().open(true).show_close_button(false);
1049        modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
1050
1051        // Click where close button would be
1052        let close_x = modal.content_bounds.x + modal.content_bounds.width - 40.0 - modal.padding;
1053        let close_y = modal.content_bounds.y + modal.padding;
1054
1055        let result = modal.event(&Event::MouseDown {
1056            position: Point::new(close_x + 10.0, close_y + 10.0),
1057            button: presentar_core::MouseButton::Left,
1058        });
1059
1060        // Should not close because button is hidden
1061        assert!(result.is_none());
1062        assert!(modal.is_open());
1063    }
1064
1065    // =========================================================================
1066    // Animation Tests
1067    // =========================================================================
1068
1069    #[test]
1070    fn test_modal_animation_opens() {
1071        let mut modal = Modal::new().open(true);
1072        modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
1073        assert!(modal.animation_progress > 0.0);
1074
1075        // Another layout call should increase progress further
1076        modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
1077        assert!(modal.animation_progress >= 0.15);
1078    }
1079
1080    #[test]
1081    fn test_modal_animation_caps_at_one() {
1082        let mut modal = Modal::new().open(true);
1083        // Run layout multiple times
1084        for _ in 0..20 {
1085            modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
1086        }
1087        assert!((modal.animation_progress - 1.0).abs() < 0.01);
1088    }
1089
1090    #[test]
1091    fn test_modal_animation_closes_to_zero() {
1092        let mut modal = Modal::new().open(true);
1093        // Open fully
1094        for _ in 0..20 {
1095            modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
1096        }
1097
1098        modal.open = false;
1099        // Close animation
1100        for _ in 0..20 {
1101            modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
1102        }
1103        assert!(modal.animation_progress < 0.01);
1104    }
1105
1106    // =========================================================================
1107    // Calculate Bounds Tests
1108    // =========================================================================
1109
1110    #[test]
1111    fn test_modal_calculate_bounds_centered() {
1112        let modal = Modal::new().size(ModalSize::Medium);
1113        let viewport = Rect::new(0.0, 0.0, 1024.0, 768.0);
1114        let bounds = modal.calculate_modal_bounds(viewport);
1115
1116        // Modal should be horizontally centered
1117        let expected_x = (1024.0 - bounds.width) / 2.0;
1118        assert!((bounds.x - expected_x).abs() < 1.0);
1119    }
1120
1121    #[test]
1122    fn test_modal_calculate_bounds_small_viewport() {
1123        let modal = Modal::new().size(ModalSize::Large); // 800px wide
1124        let viewport = Rect::new(0.0, 0.0, 400.0, 300.0); // Smaller than modal
1125        let bounds = modal.calculate_modal_bounds(viewport);
1126
1127        // Modal should be constrained to viewport minus margins
1128        assert!(bounds.width <= 400.0 - 32.0);
1129    }
1130
1131    #[test]
1132    fn test_modal_calculate_bounds_with_footer() {
1133        let modal = Modal::new().title("Test");
1134        let viewport = Rect::new(0.0, 0.0, 1024.0, 768.0);
1135        let bounds = modal.calculate_modal_bounds(viewport);
1136
1137        // Height should include header
1138        assert!(bounds.height > 0.0);
1139    }
1140
1141    // =========================================================================
1142    // Size Variant Tests
1143    // =========================================================================
1144
1145    #[test]
1146    fn test_modal_size_large() {
1147        assert_eq!(ModalSize::Large.max_width(), 800.0);
1148    }
1149
1150    #[test]
1151    fn test_modal_size_custom_zero() {
1152        // Custom size of 0 should still work
1153        assert_eq!(ModalSize::Custom(0).max_width(), 0.0);
1154    }
1155
1156    // =========================================================================
1157    // CloseReason Tests
1158    // =========================================================================
1159
1160    #[test]
1161    fn test_close_reason_copy() {
1162        let reason = CloseReason::Escape;
1163        let copied: CloseReason = reason;
1164        assert_eq!(copied, CloseReason::Escape);
1165    }
1166
1167    #[test]
1168    fn test_close_reason_all_variants() {
1169        let reasons = [
1170            CloseReason::Escape,
1171            CloseReason::Backdrop,
1172            CloseReason::CloseButton,
1173            CloseReason::Programmatic,
1174        ];
1175        assert_eq!(reasons.len(), 4);
1176    }
1177
1178    // =========================================================================
1179    // Message Tests
1180    // =========================================================================
1181
1182    #[test]
1183    fn test_modal_closed_clone() {
1184        let msg = ModalClosed {
1185            reason: CloseReason::Escape,
1186        };
1187        let cloned = msg;
1188        assert_eq!(cloned.reason, CloseReason::Escape);
1189    }
1190
1191    #[test]
1192    fn test_modal_opened_clone() {
1193        let msg = ModalOpened;
1194        let cloned = msg;
1195        assert_eq!(format!("{cloned:?}"), "ModalOpened");
1196    }
1197
1198    #[test]
1199    fn test_modal_closed_debug() {
1200        let msg = ModalClosed {
1201            reason: CloseReason::Backdrop,
1202        };
1203        let debug_str = format!("{msg:?}");
1204        assert!(debug_str.contains("Backdrop"));
1205    }
1206
1207    #[test]
1208    fn test_modal_opened_debug() {
1209        let msg = ModalOpened;
1210        let debug_str = format!("{msg:?}");
1211        assert!(debug_str.contains("ModalOpened"));
1212    }
1213
1214    // =========================================================================
1215    // Default Trait Tests
1216    // =========================================================================
1217
1218    #[test]
1219    fn test_modal_default_values() {
1220        let modal = Modal::default();
1221        assert!(!modal.open);
1222        assert_eq!(modal.size, ModalSize::Medium);
1223        assert_eq!(modal.backdrop, BackdropBehavior::CloseOnClick);
1224        assert!(modal.close_on_escape);
1225        assert!(modal.title.is_none());
1226        assert!(modal.show_close_button);
1227        assert_eq!(modal.border_radius, 8.0);
1228        assert_eq!(modal.padding, 24.0);
1229    }
1230
1231    // =========================================================================
1232    // Widget Trait Edge Cases
1233    // =========================================================================
1234
1235    #[test]
1236    fn test_modal_measure_constraints() {
1237        let modal = Modal::new();
1238        let size = modal.measure(Constraints::tight(Size::new(800.0, 600.0)));
1239        assert_eq!(size.width, 800.0);
1240        assert_eq!(size.height, 600.0);
1241    }
1242
1243    #[test]
1244    fn test_modal_children_mut() {
1245        let mut modal = Modal::new();
1246        assert!(modal.children_mut().is_empty());
1247    }
1248
1249    #[test]
1250    fn test_modal_mouse_move_does_nothing() {
1251        let mut modal = Modal::new().open(true);
1252        modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
1253
1254        let result = modal.event(&Event::MouseMove {
1255            position: Point::new(100.0, 100.0),
1256        });
1257        assert!(result.is_none());
1258    }
1259
1260    #[test]
1261    fn test_modal_title_setter() {
1262        let modal = Modal::new().title("Test Modal");
1263        // Modal doesn't derive Debug, just test it exists
1264        let _ = modal;
1265    }
1266
1267    #[test]
1268    fn test_backdrop_behavior_copy() {
1269        let behavior = BackdropBehavior::Static;
1270        let copied: BackdropBehavior = behavior;
1271        assert_eq!(copied, BackdropBehavior::Static);
1272    }
1273
1274    #[test]
1275    fn test_modal_size_copy() {
1276        let size = ModalSize::Large;
1277        let copied: ModalSize = size;
1278        assert_eq!(copied, ModalSize::Large);
1279    }
1280
1281    #[test]
1282    fn test_close_reason_debug() {
1283        let reason = CloseReason::CloseButton;
1284        let debug_str = format!("{reason:?}");
1285        assert!(debug_str.contains("CloseButton"));
1286    }
1287}