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