1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
17pub enum ModalSize {
18 Small,
20 #[default]
22 Medium,
23 Large,
25 FullWidth,
27 Custom(u32),
29}
30
31impl ModalSize {
32 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
47pub enum BackdropBehavior {
48 #[default]
50 CloseOnClick,
51 Static,
53 None,
55}
56
57#[derive(Serialize, Deserialize)]
59pub struct Modal {
60 pub open: bool,
62 pub size: ModalSize,
64 pub backdrop: BackdropBehavior,
66 pub close_on_escape: bool,
68 pub title: Option<String>,
70 pub show_close_button: bool,
72 pub backdrop_color: Color,
74 pub background_color: Color,
76 pub border_radius: f32,
78 pub padding: f32,
80 test_id_value: Option<String>,
82 #[serde(skip)]
84 bounds: Rect,
85 #[serde(skip)]
87 content_bounds: Rect,
88 #[serde(skip)]
90 slots: Vec<Box<dyn Widget>>,
91 #[serde(skip)]
93 content_idx: Option<usize>,
94 #[serde(skip)]
96 footer_idx: Option<usize>,
97 #[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 #[must_use]
129 pub fn new() -> Self {
130 Self::default()
131 }
132
133 #[must_use]
135 pub const fn open(mut self, open: bool) -> Self {
136 self.open = open;
137 self
138 }
139
140 #[must_use]
142 pub const fn size(mut self, size: ModalSize) -> Self {
143 self.size = size;
144 self
145 }
146
147 #[must_use]
149 pub const fn backdrop(mut self, behavior: BackdropBehavior) -> Self {
150 self.backdrop = behavior;
151 self
152 }
153
154 #[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 #[must_use]
163 pub fn title(mut self, title: impl Into<String>) -> Self {
164 self.title = Some(title.into());
165 self
166 }
167
168 #[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 #[must_use]
177 pub const fn backdrop_color(mut self, color: Color) -> Self {
178 self.backdrop_color = color;
179 self
180 }
181
182 #[must_use]
184 pub const fn background_color(mut self, color: Color) -> Self {
185 self.background_color = color;
186 self
187 }
188
189 #[must_use]
191 pub const fn border_radius(mut self, radius: f32) -> Self {
192 self.border_radius = radius;
193 self
194 }
195
196 #[must_use]
198 pub const fn padding(mut self, padding: f32) -> Self {
199 self.padding = padding;
200 self
201 }
202
203 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 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 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 #[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 pub fn show(&mut self) {
242 self.open = true;
243 }
244
245 pub fn hide(&mut self) {
247 self.open = false;
248 }
249
250 pub fn toggle(&mut self) {
252 self.open = !self.open;
253 }
254
255 #[must_use]
257 pub const fn is_open(&self) -> bool {
258 self.open
259 }
260
261 #[must_use]
263 pub const fn animation_progress(&self) -> f32 {
264 self.animation_progress
265 }
266
267 #[must_use]
269 pub const fn content_bounds(&self) -> Rect {
270 self.content_bounds
271 }
272
273 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); 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; 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); 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 fn close_with(&mut self, reason: CloseReason) -> Option<Box<dyn Any + Send>> {
295 self.hide();
296 Some(Box::new(ModalClosed { reason }))
297 }
298
299 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 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 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 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 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 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 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 self.animation_progress = (self.animation_progress + 0.15).min(1.0);
386 } else {
387 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 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 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 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 canvas.fill_rect(animated_bounds, self.background_color);
435
436 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, );
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 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 if let Some(content) = Self::slot(&self.slots, self.content_idx) {
464 content.paint(canvas);
465 }
466
467 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
513impl 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
550pub enum CloseReason {
551 Escape,
553 Backdrop,
555 CloseButton,
557 Programmatic,
559}
560
561#[derive(Debug, Clone)]
563pub struct ModalClosed {
564 pub reason: CloseReason,
566}
567
568#[derive(Debug, Clone)]
570pub struct ModalOpened;
571
572#[cfg(test)]
573mod tests {
574 use super::*;
575
576 #[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 #[test]
599 fn test_backdrop_behavior_default() {
600 assert_eq!(BackdropBehavior::default(), BackdropBehavior::CloseOnClick);
601 }
602
603 #[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 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()); let modal_open = Modal::new().open(true);
710 assert!(modal_open.is_focusable()); }
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 #[test]
784 fn test_close_reason_eq() {
785 assert_eq!(CloseReason::Escape, CloseReason::Escape);
786 assert_ne!(CloseReason::Escape, CloseReason::Backdrop);
787 }
788
789 #[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 #[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 let prog1 = modal.animation_progress;
853 modal.open = false;
854 modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
855 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 #[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 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 #[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 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 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 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 assert!(result.is_none());
1021 assert!(modal.is_open());
1022 }
1023
1024 #[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 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 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 assert!(result.is_none());
1062 assert!(modal.is_open());
1063 }
1064
1065 #[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 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 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 for _ in 0..20 {
1095 modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
1096 }
1097
1098 modal.open = false;
1099 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 #[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 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); let viewport = Rect::new(0.0, 0.0, 400.0, 300.0); let bounds = modal.calculate_modal_bounds(viewport);
1126
1127 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 assert!(bounds.height > 0.0);
1139 }
1140
1141 #[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 assert_eq!(ModalSize::Custom(0).max_width(), 0.0);
1154 }
1155
1156 #[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 #[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 #[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 #[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 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}