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    /// 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 {
412                key: Key::Escape, ..
413            } if self.close_on_escape => {
414                self.hide();
415                return Some(Box::new(ModalClosed {
416                    reason: CloseReason::Escape,
417                }));
418            }
419            Event::MouseDown { position, .. } => {
420                // Check if click is on backdrop
421                if self.backdrop == BackdropBehavior::CloseOnClick {
422                    let in_modal = position.x >= self.content_bounds.x
423                        && position.x <= self.content_bounds.x + self.content_bounds.width
424                        && position.y >= self.content_bounds.y
425                        && position.y <= self.content_bounds.y + self.content_bounds.height;
426
427                    if !in_modal {
428                        self.hide();
429                        return Some(Box::new(ModalClosed {
430                            reason: CloseReason::Backdrop,
431                        }));
432                    }
433                }
434
435                // Check if click is on close button
436                if self.show_close_button {
437                    let close_x =
438                        self.content_bounds.x + self.content_bounds.width - 40.0 - self.padding;
439                    let close_y = self.content_bounds.y + self.padding;
440                    let on_close_btn = position.x >= close_x
441                        && position.x <= close_x + 24.0
442                        && position.y >= close_y
443                        && position.y <= close_y + 24.0;
444
445                    if on_close_btn {
446                        self.hide();
447                        return Some(Box::new(ModalClosed {
448                            reason: CloseReason::CloseButton,
449                        }));
450                    }
451                }
452
453                // Forward to content
454                if let Some(ref mut content) = self.content {
455                    if let Some(msg) = content.event(event) {
456                        return Some(msg);
457                    }
458                }
459
460                // Forward to footer
461                if let Some(ref mut footer) = self.footer {
462                    if let Some(msg) = footer.event(event) {
463                        return Some(msg);
464                    }
465                }
466            }
467            _ => {
468                // Forward other events to content
469                if let Some(ref mut content) = self.content {
470                    if let Some(msg) = content.event(event) {
471                        return Some(msg);
472                    }
473                }
474
475                if let Some(ref mut footer) = self.footer {
476                    if let Some(msg) = footer.event(event) {
477                        return Some(msg);
478                    }
479                }
480            }
481        }
482
483        None
484    }
485
486    fn children(&self) -> &[Box<dyn Widget>] {
487        &[]
488    }
489
490    fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
491        &mut []
492    }
493
494    fn is_focusable(&self) -> bool {
495        self.open
496    }
497
498    fn test_id(&self) -> Option<&str> {
499        self.test_id_value.as_deref()
500    }
501
502    fn bounds(&self) -> Rect {
503        self.bounds
504    }
505}
506
507// PROBAR-SPEC-009: Brick Architecture - Tests define interface
508impl Brick for Modal {
509    fn brick_name(&self) -> &'static str {
510        "Modal"
511    }
512
513    fn assertions(&self) -> &[BrickAssertion] {
514        &[BrickAssertion::MaxLatencyMs(16)]
515    }
516
517    fn budget(&self) -> BrickBudget {
518        BrickBudget::uniform(16)
519    }
520
521    fn verify(&self) -> BrickVerification {
522        BrickVerification {
523            passed: self.assertions().to_vec(),
524            failed: vec![],
525            verification_time: Duration::from_micros(10),
526        }
527    }
528
529    fn to_html(&self) -> String {
530        r#"<div class="brick-modal"></div>"#.to_string()
531    }
532
533    fn to_css(&self) -> String {
534        ".brick-modal { display: block; position: fixed; }".to_string()
535    }
536
537    fn test_id(&self) -> Option<&str> {
538        self.test_id_value.as_deref()
539    }
540}
541
542/// Reason the modal was closed.
543#[derive(Debug, Clone, Copy, PartialEq, Eq)]
544pub enum CloseReason {
545    /// Closed via escape key
546    Escape,
547    /// Closed via backdrop click
548    Backdrop,
549    /// Closed via close button
550    CloseButton,
551    /// Closed programmatically
552    Programmatic,
553}
554
555/// Message emitted when modal is closed.
556#[derive(Debug, Clone)]
557pub struct ModalClosed {
558    /// Reason for closure
559    pub reason: CloseReason,
560}
561
562/// Message emitted when modal is opened.
563#[derive(Debug, Clone)]
564pub struct ModalOpened;
565
566#[cfg(test)]
567mod tests {
568    use super::*;
569
570    // =========================================================================
571    // ModalSize Tests
572    // =========================================================================
573
574    #[test]
575    fn test_modal_size_default() {
576        assert_eq!(ModalSize::default(), ModalSize::Medium);
577    }
578
579    #[test]
580    fn test_modal_size_max_width() {
581        assert_eq!(ModalSize::Small.max_width(), 300.0);
582        assert_eq!(ModalSize::Medium.max_width(), 500.0);
583        assert_eq!(ModalSize::Large.max_width(), 800.0);
584        assert_eq!(ModalSize::FullWidth.max_width(), f32::MAX);
585        assert_eq!(ModalSize::Custom(600).max_width(), 600.0);
586    }
587
588    // =========================================================================
589    // BackdropBehavior Tests
590    // =========================================================================
591
592    #[test]
593    fn test_backdrop_behavior_default() {
594        assert_eq!(BackdropBehavior::default(), BackdropBehavior::CloseOnClick);
595    }
596
597    // =========================================================================
598    // Modal Tests
599    // =========================================================================
600
601    #[test]
602    fn test_modal_new() {
603        let modal = Modal::new();
604        assert!(!modal.open);
605        assert_eq!(modal.size, ModalSize::Medium);
606        assert_eq!(modal.backdrop, BackdropBehavior::CloseOnClick);
607        assert!(modal.close_on_escape);
608        assert!(modal.title.is_none());
609        assert!(modal.show_close_button);
610    }
611
612    #[test]
613    fn test_modal_builder() {
614        let modal = Modal::new()
615            .open(true)
616            .size(ModalSize::Large)
617            .backdrop(BackdropBehavior::Static)
618            .close_on_escape(false)
619            .title("Test Modal")
620            .show_close_button(false)
621            .border_radius(16.0)
622            .padding(32.0);
623
624        assert!(modal.open);
625        assert_eq!(modal.size, ModalSize::Large);
626        assert_eq!(modal.backdrop, BackdropBehavior::Static);
627        assert!(!modal.close_on_escape);
628        assert_eq!(modal.title, Some("Test Modal".to_string()));
629        assert!(!modal.show_close_button);
630        assert_eq!(modal.border_radius, 16.0);
631        assert_eq!(modal.padding, 32.0);
632    }
633
634    #[test]
635    fn test_modal_show_hide() {
636        let mut modal = Modal::new();
637        assert!(!modal.is_open());
638
639        modal.show();
640        assert!(modal.is_open());
641
642        modal.hide();
643        assert!(!modal.is_open());
644    }
645
646    #[test]
647    fn test_modal_toggle() {
648        let mut modal = Modal::new();
649        assert!(!modal.is_open());
650
651        modal.toggle();
652        assert!(modal.is_open());
653
654        modal.toggle();
655        assert!(!modal.is_open());
656    }
657
658    #[test]
659    fn test_modal_measure() {
660        let modal = Modal::new();
661        let size = modal.measure(Constraints::loose(Size::new(1024.0, 768.0)));
662        assert_eq!(size, Size::new(1024.0, 768.0));
663    }
664
665    #[test]
666    fn test_modal_layout_closed() {
667        let mut modal = Modal::new();
668        let result = modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
669        assert_eq!(result.size, Size::new(1024.0, 768.0));
670        assert_eq!(modal.animation_progress, 0.0);
671    }
672
673    #[test]
674    fn test_modal_layout_open() {
675        let mut modal = Modal::new().open(true);
676        modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
677        assert!(modal.animation_progress > 0.0);
678    }
679
680    #[test]
681    fn test_modal_calculate_bounds() {
682        let modal = Modal::new().size(ModalSize::Medium);
683        let viewport = Rect::new(0.0, 0.0, 1024.0, 768.0);
684        let bounds = modal.calculate_modal_bounds(viewport);
685
686        // Modal should be centered
687        assert!(bounds.x > 0.0);
688        assert!(bounds.y > 0.0);
689        assert!(bounds.width <= 500.0);
690    }
691
692    #[test]
693    fn test_modal_type_id() {
694        let modal = Modal::new();
695        assert_eq!(Widget::type_id(&modal), TypeId::of::<Modal>());
696    }
697
698    #[test]
699    fn test_modal_is_focusable() {
700        let modal = Modal::new();
701        assert!(!modal.is_focusable()); // Not focusable when closed
702
703        let modal_open = Modal::new().open(true);
704        assert!(modal_open.is_focusable()); // Focusable when open
705    }
706
707    #[test]
708    fn test_modal_test_id() {
709        let modal = Modal::new().with_test_id("my-modal");
710        assert_eq!(Widget::test_id(&modal), Some("my-modal"));
711    }
712
713    #[test]
714    fn test_modal_children_empty() {
715        let modal = Modal::new();
716        assert!(modal.children().is_empty());
717    }
718
719    #[test]
720    fn test_modal_bounds() {
721        let mut modal = Modal::new();
722        modal.layout(Rect::new(10.0, 20.0, 1024.0, 768.0));
723        assert_eq!(modal.bounds(), Rect::new(10.0, 20.0, 1024.0, 768.0));
724    }
725
726    #[test]
727    fn test_modal_backdrop_color() {
728        let modal = Modal::new().backdrop_color(Color::rgba(0.0, 0.0, 0.0, 0.7));
729        assert_eq!(modal.backdrop_color.a, 0.7);
730    }
731
732    #[test]
733    fn test_modal_background_color() {
734        let modal = Modal::new().background_color(Color::rgb(0.9, 0.9, 0.9));
735        assert_eq!(modal.background_color.r, 0.9);
736    }
737
738    #[test]
739    fn test_modal_escape_closes() {
740        let mut modal = Modal::new().open(true);
741        modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
742
743        let result = modal.event(&Event::key_down(Key::Escape));
744        assert!(result.is_some());
745        assert!(!modal.is_open());
746    }
747
748    #[test]
749    fn test_modal_escape_disabled() {
750        let mut modal = Modal::new().open(true).close_on_escape(false);
751        modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
752
753        let result = modal.event(&Event::key_down(Key::Escape));
754        assert!(result.is_none());
755        assert!(modal.is_open());
756    }
757
758    #[test]
759    fn test_modal_animation_progress() {
760        let modal = Modal::new();
761        assert_eq!(modal.animation_progress(), 0.0);
762    }
763
764    #[test]
765    fn test_modal_content_bounds() {
766        let mut modal = Modal::new().open(true);
767        modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
768        let content_bounds = modal.content_bounds();
769        assert!(content_bounds.width > 0.0);
770        assert!(content_bounds.height > 0.0);
771    }
772
773    // =========================================================================
774    // CloseReason Tests
775    // =========================================================================
776
777    #[test]
778    fn test_close_reason_eq() {
779        assert_eq!(CloseReason::Escape, CloseReason::Escape);
780        assert_ne!(CloseReason::Escape, CloseReason::Backdrop);
781    }
782
783    // =========================================================================
784    // Message Tests
785    // =========================================================================
786
787    #[test]
788    fn test_modal_closed_message() {
789        let msg = ModalClosed {
790            reason: CloseReason::CloseButton,
791        };
792        assert_eq!(msg.reason, CloseReason::CloseButton);
793    }
794
795    #[test]
796    fn test_modal_opened_message() {
797        let msg = ModalOpened;
798        assert_eq!(format!("{msg:?}"), "ModalOpened");
799    }
800
801    // =========================================================================
802    // Additional Coverage Tests
803    // =========================================================================
804
805    #[test]
806    fn test_modal_backdrop_none() {
807        let modal = Modal::new().backdrop(BackdropBehavior::None);
808        assert_eq!(modal.backdrop, BackdropBehavior::None);
809    }
810
811    #[test]
812    fn test_modal_backdrop_static() {
813        let modal = Modal::new().backdrop(BackdropBehavior::Static);
814        assert_eq!(modal.backdrop, BackdropBehavior::Static);
815    }
816
817    #[test]
818    fn test_modal_size_small() {
819        assert_eq!(ModalSize::Small.max_width(), 300.0);
820    }
821
822    #[test]
823    fn test_modal_size_full_width() {
824        assert_eq!(ModalSize::FullWidth.max_width(), f32::MAX);
825    }
826
827    #[test]
828    fn test_modal_children_mut_empty() {
829        let mut modal = Modal::new();
830        assert!(modal.children_mut().is_empty());
831    }
832
833    #[test]
834    fn test_modal_calculate_bounds_with_title() {
835        let modal = Modal::new().title("Test Title");
836        let viewport = Rect::new(0.0, 0.0, 1024.0, 768.0);
837        let bounds = modal.calculate_modal_bounds(viewport);
838        assert!(bounds.height > 0.0);
839    }
840
841    #[test]
842    fn test_modal_layout_animation_closes() {
843        let mut modal = Modal::new().open(true);
844        modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
845        // Progress should increase
846        let prog1 = modal.animation_progress;
847        modal.open = false;
848        modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
849        // Progress should decrease
850        assert!(modal.animation_progress < prog1);
851    }
852
853    #[test]
854    fn test_modal_event_not_open_returns_none() {
855        let mut modal = Modal::new();
856        let result = modal.event(&Event::key_down(Key::Escape));
857        assert!(result.is_none());
858    }
859
860    #[test]
861    fn test_modal_other_key_does_nothing() {
862        let mut modal = Modal::new().open(true);
863        modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
864        let result = modal.event(&Event::key_down(Key::Tab));
865        assert!(result.is_none());
866        assert!(modal.is_open());
867    }
868
869    #[test]
870    fn test_close_reason_programmatic() {
871        let reason = CloseReason::Programmatic;
872        assert_eq!(reason, CloseReason::Programmatic);
873    }
874
875    #[test]
876    fn test_close_reason_close_button() {
877        let reason = CloseReason::CloseButton;
878        assert_eq!(reason, CloseReason::CloseButton);
879    }
880
881    #[test]
882    fn test_modal_size_custom_value() {
883        let size = ModalSize::Custom(750);
884        assert_eq!(size.max_width(), 750.0);
885    }
886
887    #[test]
888    fn test_modal_backdrop_eq() {
889        assert_eq!(
890            BackdropBehavior::CloseOnClick,
891            BackdropBehavior::CloseOnClick
892        );
893        assert_ne!(BackdropBehavior::CloseOnClick, BackdropBehavior::Static);
894    }
895
896    #[test]
897    fn test_modal_size_eq() {
898        assert_eq!(ModalSize::Medium, ModalSize::Medium);
899        assert_ne!(ModalSize::Small, ModalSize::Large);
900    }
901
902    // =========================================================================
903    // Brick Trait Tests
904    // =========================================================================
905
906    #[test]
907    fn test_modal_brick_name() {
908        let modal = Modal::new();
909        assert_eq!(modal.brick_name(), "Modal");
910    }
911
912    #[test]
913    fn test_modal_brick_assertions() {
914        let modal = Modal::new();
915        let assertions = modal.assertions();
916        assert!(!assertions.is_empty());
917        assert!(matches!(assertions[0], BrickAssertion::MaxLatencyMs(16)));
918    }
919
920    #[test]
921    fn test_modal_brick_budget() {
922        let modal = Modal::new();
923        let budget = modal.budget();
924        // Verify budget has reasonable values
925        assert!(budget.layout_ms > 0);
926        assert!(budget.paint_ms > 0);
927    }
928
929    #[test]
930    fn test_modal_brick_verify() {
931        let modal = Modal::new();
932        let verification = modal.verify();
933        assert!(!verification.passed.is_empty());
934        assert!(verification.failed.is_empty());
935    }
936
937    #[test]
938    fn test_modal_brick_to_html() {
939        let modal = Modal::new();
940        let html = modal.to_html();
941        assert!(html.contains("brick-modal"));
942    }
943
944    #[test]
945    fn test_modal_brick_to_css() {
946        let modal = Modal::new();
947        let css = modal.to_css();
948        assert!(css.contains(".brick-modal"));
949        assert!(css.contains("display: block"));
950        assert!(css.contains("position: fixed"));
951    }
952
953    #[test]
954    fn test_modal_brick_test_id() {
955        let modal = Modal::new().with_test_id("my-modal");
956        assert_eq!(Brick::test_id(&modal), Some("my-modal"));
957    }
958
959    #[test]
960    fn test_modal_brick_test_id_none() {
961        let modal = Modal::new();
962        assert!(Brick::test_id(&modal).is_none());
963    }
964
965    // =========================================================================
966    // Backdrop Click Tests
967    // =========================================================================
968
969    #[test]
970    fn test_modal_backdrop_click_closes() {
971        let mut modal = Modal::new().open(true);
972        modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
973
974        // Click outside the modal content (on backdrop)
975        let result = modal.event(&Event::MouseDown {
976            position: Point::new(10.0, 10.0),
977            button: presentar_core::MouseButton::Left,
978        });
979
980        assert!(result.is_some());
981        assert!(!modal.is_open());
982    }
983
984    #[test]
985    fn test_modal_backdrop_static_no_close() {
986        let mut modal = Modal::new().open(true).backdrop(BackdropBehavior::Static);
987        modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
988
989        // Click outside the modal content
990        let result = modal.event(&Event::MouseDown {
991            position: Point::new(10.0, 10.0),
992            button: presentar_core::MouseButton::Left,
993        });
994
995        assert!(result.is_none());
996        assert!(modal.is_open());
997    }
998
999    #[test]
1000    fn test_modal_click_inside_does_not_close() {
1001        let mut modal = Modal::new().open(true);
1002        modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
1003
1004        // Click inside the modal content
1005        let center_x = modal.content_bounds.x + modal.content_bounds.width / 2.0;
1006        let center_y = modal.content_bounds.y + modal.content_bounds.height / 2.0;
1007
1008        let result = modal.event(&Event::MouseDown {
1009            position: Point::new(center_x, center_y),
1010            button: presentar_core::MouseButton::Left,
1011        });
1012
1013        // No close message, modal stays open
1014        assert!(result.is_none());
1015        assert!(modal.is_open());
1016    }
1017
1018    // =========================================================================
1019    // Close Button Tests
1020    // =========================================================================
1021
1022    #[test]
1023    fn test_modal_close_button_click() {
1024        let mut modal = Modal::new().open(true);
1025        modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
1026
1027        // Calculate close button position
1028        let close_x = modal.content_bounds.x + modal.content_bounds.width - 40.0 - modal.padding;
1029        let close_y = modal.content_bounds.y + modal.padding;
1030
1031        let result = modal.event(&Event::MouseDown {
1032            position: Point::new(close_x + 10.0, close_y + 10.0),
1033            button: presentar_core::MouseButton::Left,
1034        });
1035
1036        assert!(result.is_some());
1037        assert!(!modal.is_open());
1038    }
1039
1040    #[test]
1041    fn test_modal_close_button_hidden() {
1042        let mut modal = Modal::new().open(true).show_close_button(false);
1043        modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
1044
1045        // Click where close button would be
1046        let close_x = modal.content_bounds.x + modal.content_bounds.width - 40.0 - modal.padding;
1047        let close_y = modal.content_bounds.y + modal.padding;
1048
1049        let result = modal.event(&Event::MouseDown {
1050            position: Point::new(close_x + 10.0, close_y + 10.0),
1051            button: presentar_core::MouseButton::Left,
1052        });
1053
1054        // Should not close because button is hidden
1055        assert!(result.is_none());
1056        assert!(modal.is_open());
1057    }
1058
1059    // =========================================================================
1060    // Animation Tests
1061    // =========================================================================
1062
1063    #[test]
1064    fn test_modal_animation_opens() {
1065        let mut modal = Modal::new().open(true);
1066        modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
1067        assert!(modal.animation_progress > 0.0);
1068
1069        // Another layout call should increase progress further
1070        modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
1071        assert!(modal.animation_progress >= 0.15);
1072    }
1073
1074    #[test]
1075    fn test_modal_animation_caps_at_one() {
1076        let mut modal = Modal::new().open(true);
1077        // Run layout multiple times
1078        for _ in 0..20 {
1079            modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
1080        }
1081        assert!((modal.animation_progress - 1.0).abs() < 0.01);
1082    }
1083
1084    #[test]
1085    fn test_modal_animation_closes_to_zero() {
1086        let mut modal = Modal::new().open(true);
1087        // Open fully
1088        for _ in 0..20 {
1089            modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
1090        }
1091
1092        modal.open = false;
1093        // Close animation
1094        for _ in 0..20 {
1095            modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
1096        }
1097        assert!(modal.animation_progress < 0.01);
1098    }
1099
1100    // =========================================================================
1101    // Calculate Bounds Tests
1102    // =========================================================================
1103
1104    #[test]
1105    fn test_modal_calculate_bounds_centered() {
1106        let modal = Modal::new().size(ModalSize::Medium);
1107        let viewport = Rect::new(0.0, 0.0, 1024.0, 768.0);
1108        let bounds = modal.calculate_modal_bounds(viewport);
1109
1110        // Modal should be horizontally centered
1111        let expected_x = (1024.0 - bounds.width) / 2.0;
1112        assert!((bounds.x - expected_x).abs() < 1.0);
1113    }
1114
1115    #[test]
1116    fn test_modal_calculate_bounds_small_viewport() {
1117        let modal = Modal::new().size(ModalSize::Large); // 800px wide
1118        let viewport = Rect::new(0.0, 0.0, 400.0, 300.0); // Smaller than modal
1119        let bounds = modal.calculate_modal_bounds(viewport);
1120
1121        // Modal should be constrained to viewport minus margins
1122        assert!(bounds.width <= 400.0 - 32.0);
1123    }
1124
1125    #[test]
1126    fn test_modal_calculate_bounds_with_footer() {
1127        let modal = Modal::new().title("Test");
1128        let viewport = Rect::new(0.0, 0.0, 1024.0, 768.0);
1129        let bounds = modal.calculate_modal_bounds(viewport);
1130
1131        // Height should include header
1132        assert!(bounds.height > 0.0);
1133    }
1134
1135    // =========================================================================
1136    // Size Variant Tests
1137    // =========================================================================
1138
1139    #[test]
1140    fn test_modal_size_large() {
1141        assert_eq!(ModalSize::Large.max_width(), 800.0);
1142    }
1143
1144    #[test]
1145    fn test_modal_size_custom_zero() {
1146        // Custom size of 0 should still work
1147        assert_eq!(ModalSize::Custom(0).max_width(), 0.0);
1148    }
1149
1150    // =========================================================================
1151    // CloseReason Tests
1152    // =========================================================================
1153
1154    #[test]
1155    fn test_close_reason_copy() {
1156        let reason = CloseReason::Escape;
1157        let copied: CloseReason = reason;
1158        assert_eq!(copied, CloseReason::Escape);
1159    }
1160
1161    #[test]
1162    fn test_close_reason_all_variants() {
1163        let reasons = [
1164            CloseReason::Escape,
1165            CloseReason::Backdrop,
1166            CloseReason::CloseButton,
1167            CloseReason::Programmatic,
1168        ];
1169        assert_eq!(reasons.len(), 4);
1170    }
1171
1172    // =========================================================================
1173    // Message Tests
1174    // =========================================================================
1175
1176    #[test]
1177    fn test_modal_closed_clone() {
1178        let msg = ModalClosed {
1179            reason: CloseReason::Escape,
1180        };
1181        let cloned = msg;
1182        assert_eq!(cloned.reason, CloseReason::Escape);
1183    }
1184
1185    #[test]
1186    fn test_modal_opened_clone() {
1187        let msg = ModalOpened;
1188        let cloned = msg;
1189        assert_eq!(format!("{cloned:?}"), "ModalOpened");
1190    }
1191
1192    #[test]
1193    fn test_modal_closed_debug() {
1194        let msg = ModalClosed {
1195            reason: CloseReason::Backdrop,
1196        };
1197        let debug_str = format!("{msg:?}");
1198        assert!(debug_str.contains("Backdrop"));
1199    }
1200
1201    #[test]
1202    fn test_modal_opened_debug() {
1203        let msg = ModalOpened;
1204        let debug_str = format!("{msg:?}");
1205        assert!(debug_str.contains("ModalOpened"));
1206    }
1207
1208    // =========================================================================
1209    // Default Trait Tests
1210    // =========================================================================
1211
1212    #[test]
1213    fn test_modal_default_values() {
1214        let modal = Modal::default();
1215        assert!(!modal.open);
1216        assert_eq!(modal.size, ModalSize::Medium);
1217        assert_eq!(modal.backdrop, BackdropBehavior::CloseOnClick);
1218        assert!(modal.close_on_escape);
1219        assert!(modal.title.is_none());
1220        assert!(modal.show_close_button);
1221        assert_eq!(modal.border_radius, 8.0);
1222        assert_eq!(modal.padding, 24.0);
1223    }
1224
1225    // =========================================================================
1226    // Widget Trait Edge Cases
1227    // =========================================================================
1228
1229    #[test]
1230    fn test_modal_measure_constraints() {
1231        let modal = Modal::new();
1232        let size = modal.measure(Constraints::tight(Size::new(800.0, 600.0)));
1233        assert_eq!(size.width, 800.0);
1234        assert_eq!(size.height, 600.0);
1235    }
1236
1237    #[test]
1238    fn test_modal_children_mut() {
1239        let mut modal = Modal::new();
1240        assert!(modal.children_mut().is_empty());
1241    }
1242
1243    #[test]
1244    fn test_modal_mouse_move_does_nothing() {
1245        let mut modal = Modal::new().open(true);
1246        modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
1247
1248        let result = modal.event(&Event::MouseMove {
1249            position: Point::new(100.0, 100.0),
1250        });
1251        assert!(result.is_none());
1252    }
1253
1254    #[test]
1255    fn test_modal_title_setter() {
1256        let modal = Modal::new().title("Test Modal");
1257        // Modal doesn't derive Debug, just test it exists
1258        let _ = modal;
1259    }
1260
1261    #[test]
1262    fn test_backdrop_behavior_copy() {
1263        let behavior = BackdropBehavior::Static;
1264        let copied: BackdropBehavior = behavior;
1265        assert_eq!(copied, BackdropBehavior::Static);
1266    }
1267
1268    #[test]
1269    fn test_modal_size_copy() {
1270        let size = ModalSize::Large;
1271        let copied: ModalSize = size;
1272        assert_eq!(copied, ModalSize::Large);
1273    }
1274
1275    #[test]
1276    fn test_close_reason_debug() {
1277        let reason = CloseReason::CloseButton;
1278        let debug_str = format!("{reason:?}");
1279        assert!(debug_str.contains("CloseButton"));
1280    }
1281}