1use presentar_core::{
7 widget::{LayoutResult, TextStyle},
8 Canvas, Color, Constraints, Event, Key, Point, Rect, Size, TypeId, Widget,
9};
10use serde::{Deserialize, Serialize};
11use std::any::Any;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
15pub enum ModalSize {
16 Small,
18 #[default]
20 Medium,
21 Large,
23 FullWidth,
25 Custom(u32),
27}
28
29impl ModalSize {
30 #[must_use]
32 pub const fn max_width(&self) -> f32 {
33 match self {
34 Self::Small => 300.0,
35 Self::Medium => 500.0,
36 Self::Large => 800.0,
37 Self::FullWidth => f32::MAX,
38 Self::Custom(w) => *w as f32,
39 }
40 }
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
45pub enum BackdropBehavior {
46 #[default]
48 CloseOnClick,
49 Static,
51 None,
53}
54
55#[derive(Serialize, Deserialize)]
57pub struct Modal {
58 pub open: bool,
60 pub size: ModalSize,
62 pub backdrop: BackdropBehavior,
64 pub close_on_escape: bool,
66 pub title: Option<String>,
68 pub show_close_button: bool,
70 pub backdrop_color: Color,
72 pub background_color: Color,
74 pub border_radius: f32,
76 pub padding: f32,
78 test_id_value: Option<String>,
80 #[serde(skip)]
82 bounds: Rect,
83 #[serde(skip)]
85 content_bounds: Rect,
86 #[serde(skip)]
88 content: Option<Box<dyn Widget>>,
89 #[serde(skip)]
91 footer: Option<Box<dyn Widget>>,
92 #[serde(skip)]
94 animation_progress: f32,
95}
96
97impl Default for Modal {
98 fn default() -> Self {
99 Self {
100 open: false,
101 size: ModalSize::Medium,
102 backdrop: BackdropBehavior::CloseOnClick,
103 close_on_escape: true,
104 title: None,
105 show_close_button: true,
106 backdrop_color: Color::rgba(0.0, 0.0, 0.0, 0.5),
107 background_color: Color::WHITE,
108 border_radius: 8.0,
109 padding: 24.0,
110 test_id_value: None,
111 bounds: Rect::default(),
112 content_bounds: Rect::default(),
113 content: None,
114 footer: None,
115 animation_progress: 0.0,
116 }
117 }
118}
119
120impl Modal {
121 #[must_use]
123 pub fn new() -> Self {
124 Self::default()
125 }
126
127 #[must_use]
129 pub const fn open(mut self, open: bool) -> Self {
130 self.open = open;
131 self
132 }
133
134 #[must_use]
136 pub const fn size(mut self, size: ModalSize) -> Self {
137 self.size = size;
138 self
139 }
140
141 #[must_use]
143 pub const fn backdrop(mut self, behavior: BackdropBehavior) -> Self {
144 self.backdrop = behavior;
145 self
146 }
147
148 #[must_use]
150 pub const fn close_on_escape(mut self, enabled: bool) -> Self {
151 self.close_on_escape = enabled;
152 self
153 }
154
155 #[must_use]
157 pub fn title(mut self, title: impl Into<String>) -> Self {
158 self.title = Some(title.into());
159 self
160 }
161
162 #[must_use]
164 pub const fn show_close_button(mut self, show: bool) -> Self {
165 self.show_close_button = show;
166 self
167 }
168
169 #[must_use]
171 pub const fn backdrop_color(mut self, color: Color) -> Self {
172 self.backdrop_color = color;
173 self
174 }
175
176 #[must_use]
178 pub const fn background_color(mut self, color: Color) -> Self {
179 self.background_color = color;
180 self
181 }
182
183 #[must_use]
185 pub const fn border_radius(mut self, radius: f32) -> Self {
186 self.border_radius = radius;
187 self
188 }
189
190 #[must_use]
192 pub const fn padding(mut self, padding: f32) -> Self {
193 self.padding = padding;
194 self
195 }
196
197 pub fn content(mut self, widget: impl Widget + 'static) -> Self {
199 self.content = Some(Box::new(widget));
200 self
201 }
202
203 pub fn footer(mut self, widget: impl Widget + 'static) -> Self {
205 self.footer = Some(Box::new(widget));
206 self
207 }
208
209 #[must_use]
211 pub fn with_test_id(mut self, id: impl Into<String>) -> Self {
212 self.test_id_value = Some(id.into());
213 self
214 }
215
216 pub fn show(&mut self) {
218 self.open = true;
219 }
220
221 pub fn hide(&mut self) {
223 self.open = false;
224 }
225
226 pub fn toggle(&mut self) {
228 self.open = !self.open;
229 }
230
231 #[must_use]
233 pub const fn is_open(&self) -> bool {
234 self.open
235 }
236
237 #[must_use]
239 pub const fn animation_progress(&self) -> f32 {
240 self.animation_progress
241 }
242
243 #[must_use]
245 pub const fn content_bounds(&self) -> Rect {
246 self.content_bounds
247 }
248
249 fn calculate_modal_bounds(&self, viewport: Rect) -> Rect {
251 let max_width = self.size.max_width();
252 let modal_width = max_width.min(viewport.width - 32.0); let header_height = if self.title.is_some() { 56.0 } else { 0.0 };
256 let footer_height = if self.footer.is_some() { 64.0 } else { 0.0 };
257 let content_height = 200.0; let total_height = self
259 .padding
260 .mul_add(2.0, header_height + content_height + footer_height);
261 let modal_height = total_height.min(viewport.height - 64.0); let x = viewport.x + (viewport.width - modal_width) / 2.0;
264 let y = viewport.y + (viewport.height - modal_height) / 2.0;
265
266 Rect::new(x, y, modal_width, modal_height)
267 }
268}
269
270impl Widget for Modal {
271 fn type_id(&self) -> TypeId {
272 TypeId::of::<Self>()
273 }
274
275 fn measure(&self, constraints: Constraints) -> Size {
276 constraints.constrain(Size::new(constraints.max_width, constraints.max_height))
278 }
279
280 fn layout(&mut self, bounds: Rect) -> LayoutResult {
281 self.bounds = bounds;
282
283 if self.open {
284 self.content_bounds = self.calculate_modal_bounds(bounds);
285
286 if let Some(ref mut content) = self.content {
288 let header_height = if self.title.is_some() { 56.0 } else { 0.0 };
289 let footer_height = if self.footer.is_some() { 64.0 } else { 0.0 };
290
291 let content_rect = Rect::new(
292 self.content_bounds.x + self.padding,
293 self.content_bounds.y + header_height + self.padding,
294 self.padding.mul_add(-2.0, self.content_bounds.width),
295 self.padding.mul_add(
296 -2.0,
297 self.content_bounds.height - header_height - footer_height,
298 ),
299 );
300 content.layout(content_rect);
301 }
302
303 if let Some(ref mut footer) = self.footer {
305 let footer_rect = Rect::new(
306 self.content_bounds.x + self.padding,
307 self.content_bounds.y + self.content_bounds.height - 64.0 - self.padding,
308 self.padding.mul_add(-2.0, self.content_bounds.width),
309 64.0,
310 );
311 footer.layout(footer_rect);
312 }
313
314 self.animation_progress = (self.animation_progress + 0.15).min(1.0);
316 } else {
317 self.animation_progress = (self.animation_progress - 0.15).max(0.0);
319 }
320
321 LayoutResult {
322 size: bounds.size(),
323 }
324 }
325
326 fn paint(&self, canvas: &mut dyn Canvas) {
327 if self.animation_progress <= 0.0 {
328 return;
329 }
330
331 let opacity = self.animation_progress;
332
333 if self.backdrop != BackdropBehavior::None {
335 let backdrop_color = Color::rgba(
336 self.backdrop_color.r,
337 self.backdrop_color.g,
338 self.backdrop_color.b,
339 self.backdrop_color.a * opacity,
340 );
341 canvas.fill_rect(self.bounds, backdrop_color);
342 }
343
344 let y_offset = (1.0 - opacity) * 20.0;
346 let animated_bounds = Rect::new(
347 self.content_bounds.x,
348 self.content_bounds.y + y_offset,
349 self.content_bounds.width,
350 self.content_bounds.height,
351 );
352
353 let shadow_color = Color::rgba(0.0, 0.0, 0.0, 0.1 * opacity);
355 let shadow_bounds = Rect::new(
356 animated_bounds.x + 4.0,
357 animated_bounds.y + 4.0,
358 animated_bounds.width,
359 animated_bounds.height,
360 );
361 canvas.fill_rect(shadow_bounds, shadow_color);
362
363 canvas.fill_rect(animated_bounds, self.background_color);
365
366 if let Some(ref title) = self.title {
368 let title_pos = Point::new(
369 animated_bounds.x + self.padding,
370 animated_bounds.y + self.padding + 16.0, );
372 let title_style = TextStyle {
373 size: 18.0,
374 color: Color::BLACK,
375 ..Default::default()
376 };
377 canvas.draw_text(title, title_pos, &title_style);
378 }
379
380 if self.show_close_button {
382 let close_x = animated_bounds.x + animated_bounds.width - 40.0 - self.padding;
383 let close_y = animated_bounds.y + self.padding + 16.0;
384 let close_style = TextStyle {
385 size: 24.0,
386 color: Color::rgb(0.5, 0.5, 0.5),
387 ..Default::default()
388 };
389 canvas.draw_text("×", Point::new(close_x, close_y), &close_style);
390 }
391
392 if let Some(ref content) = self.content {
394 content.paint(canvas);
395 }
396
397 if let Some(ref footer) = self.footer {
399 footer.paint(canvas);
400 }
401 }
402
403 fn event(&mut self, event: &Event) -> Option<Box<dyn Any + Send>> {
404 if !self.open {
405 return None;
406 }
407
408 match event {
409 Event::KeyDown { key: Key::Escape } if self.close_on_escape => {
410 self.hide();
411 return Some(Box::new(ModalClosed {
412 reason: CloseReason::Escape,
413 }));
414 }
415 Event::MouseDown { position, .. } => {
416 if self.backdrop == BackdropBehavior::CloseOnClick {
418 let in_modal = position.x >= self.content_bounds.x
419 && position.x <= self.content_bounds.x + self.content_bounds.width
420 && position.y >= self.content_bounds.y
421 && position.y <= self.content_bounds.y + self.content_bounds.height;
422
423 if !in_modal {
424 self.hide();
425 return Some(Box::new(ModalClosed {
426 reason: CloseReason::Backdrop,
427 }));
428 }
429 }
430
431 if self.show_close_button {
433 let close_x =
434 self.content_bounds.x + self.content_bounds.width - 40.0 - self.padding;
435 let close_y = self.content_bounds.y + self.padding;
436 let on_close_btn = position.x >= close_x
437 && position.x <= close_x + 24.0
438 && position.y >= close_y
439 && position.y <= close_y + 24.0;
440
441 if on_close_btn {
442 self.hide();
443 return Some(Box::new(ModalClosed {
444 reason: CloseReason::CloseButton,
445 }));
446 }
447 }
448
449 if let Some(ref mut content) = self.content {
451 if let Some(msg) = content.event(event) {
452 return Some(msg);
453 }
454 }
455
456 if let Some(ref mut footer) = self.footer {
458 if let Some(msg) = footer.event(event) {
459 return Some(msg);
460 }
461 }
462 }
463 _ => {
464 if let Some(ref mut content) = self.content {
466 if let Some(msg) = content.event(event) {
467 return Some(msg);
468 }
469 }
470
471 if let Some(ref mut footer) = self.footer {
472 if let Some(msg) = footer.event(event) {
473 return Some(msg);
474 }
475 }
476 }
477 }
478
479 None
480 }
481
482 fn children(&self) -> &[Box<dyn Widget>] {
483 &[]
484 }
485
486 fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
487 &mut []
488 }
489
490 fn is_focusable(&self) -> bool {
491 self.open
492 }
493
494 fn test_id(&self) -> Option<&str> {
495 self.test_id_value.as_deref()
496 }
497
498 fn bounds(&self) -> Rect {
499 self.bounds
500 }
501}
502
503#[derive(Debug, Clone, Copy, PartialEq, Eq)]
505pub enum CloseReason {
506 Escape,
508 Backdrop,
510 CloseButton,
512 Programmatic,
514}
515
516#[derive(Debug, Clone)]
518pub struct ModalClosed {
519 pub reason: CloseReason,
521}
522
523#[derive(Debug, Clone)]
525pub struct ModalOpened;
526
527#[cfg(test)]
528mod tests {
529 use super::*;
530
531 #[test]
536 fn test_modal_size_default() {
537 assert_eq!(ModalSize::default(), ModalSize::Medium);
538 }
539
540 #[test]
541 fn test_modal_size_max_width() {
542 assert_eq!(ModalSize::Small.max_width(), 300.0);
543 assert_eq!(ModalSize::Medium.max_width(), 500.0);
544 assert_eq!(ModalSize::Large.max_width(), 800.0);
545 assert_eq!(ModalSize::FullWidth.max_width(), f32::MAX);
546 assert_eq!(ModalSize::Custom(600).max_width(), 600.0);
547 }
548
549 #[test]
554 fn test_backdrop_behavior_default() {
555 assert_eq!(BackdropBehavior::default(), BackdropBehavior::CloseOnClick);
556 }
557
558 #[test]
563 fn test_modal_new() {
564 let modal = Modal::new();
565 assert!(!modal.open);
566 assert_eq!(modal.size, ModalSize::Medium);
567 assert_eq!(modal.backdrop, BackdropBehavior::CloseOnClick);
568 assert!(modal.close_on_escape);
569 assert!(modal.title.is_none());
570 assert!(modal.show_close_button);
571 }
572
573 #[test]
574 fn test_modal_builder() {
575 let modal = Modal::new()
576 .open(true)
577 .size(ModalSize::Large)
578 .backdrop(BackdropBehavior::Static)
579 .close_on_escape(false)
580 .title("Test Modal")
581 .show_close_button(false)
582 .border_radius(16.0)
583 .padding(32.0);
584
585 assert!(modal.open);
586 assert_eq!(modal.size, ModalSize::Large);
587 assert_eq!(modal.backdrop, BackdropBehavior::Static);
588 assert!(!modal.close_on_escape);
589 assert_eq!(modal.title, Some("Test Modal".to_string()));
590 assert!(!modal.show_close_button);
591 assert_eq!(modal.border_radius, 16.0);
592 assert_eq!(modal.padding, 32.0);
593 }
594
595 #[test]
596 fn test_modal_show_hide() {
597 let mut modal = Modal::new();
598 assert!(!modal.is_open());
599
600 modal.show();
601 assert!(modal.is_open());
602
603 modal.hide();
604 assert!(!modal.is_open());
605 }
606
607 #[test]
608 fn test_modal_toggle() {
609 let mut modal = Modal::new();
610 assert!(!modal.is_open());
611
612 modal.toggle();
613 assert!(modal.is_open());
614
615 modal.toggle();
616 assert!(!modal.is_open());
617 }
618
619 #[test]
620 fn test_modal_measure() {
621 let modal = Modal::new();
622 let size = modal.measure(Constraints::loose(Size::new(1024.0, 768.0)));
623 assert_eq!(size, Size::new(1024.0, 768.0));
624 }
625
626 #[test]
627 fn test_modal_layout_closed() {
628 let mut modal = Modal::new();
629 let result = modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
630 assert_eq!(result.size, Size::new(1024.0, 768.0));
631 assert_eq!(modal.animation_progress, 0.0);
632 }
633
634 #[test]
635 fn test_modal_layout_open() {
636 let mut modal = Modal::new().open(true);
637 modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
638 assert!(modal.animation_progress > 0.0);
639 }
640
641 #[test]
642 fn test_modal_calculate_bounds() {
643 let modal = Modal::new().size(ModalSize::Medium);
644 let viewport = Rect::new(0.0, 0.0, 1024.0, 768.0);
645 let bounds = modal.calculate_modal_bounds(viewport);
646
647 assert!(bounds.x > 0.0);
649 assert!(bounds.y > 0.0);
650 assert!(bounds.width <= 500.0);
651 }
652
653 #[test]
654 fn test_modal_type_id() {
655 let modal = Modal::new();
656 assert_eq!(Widget::type_id(&modal), TypeId::of::<Modal>());
657 }
658
659 #[test]
660 fn test_modal_is_focusable() {
661 let modal = Modal::new();
662 assert!(!modal.is_focusable()); let modal_open = Modal::new().open(true);
665 assert!(modal_open.is_focusable()); }
667
668 #[test]
669 fn test_modal_test_id() {
670 let modal = Modal::new().with_test_id("my-modal");
671 assert_eq!(modal.test_id(), Some("my-modal"));
672 }
673
674 #[test]
675 fn test_modal_children_empty() {
676 let modal = Modal::new();
677 assert!(modal.children().is_empty());
678 }
679
680 #[test]
681 fn test_modal_bounds() {
682 let mut modal = Modal::new();
683 modal.layout(Rect::new(10.0, 20.0, 1024.0, 768.0));
684 assert_eq!(modal.bounds(), Rect::new(10.0, 20.0, 1024.0, 768.0));
685 }
686
687 #[test]
688 fn test_modal_backdrop_color() {
689 let modal = Modal::new().backdrop_color(Color::rgba(0.0, 0.0, 0.0, 0.7));
690 assert_eq!(modal.backdrop_color.a, 0.7);
691 }
692
693 #[test]
694 fn test_modal_background_color() {
695 let modal = Modal::new().background_color(Color::rgb(0.9, 0.9, 0.9));
696 assert_eq!(modal.background_color.r, 0.9);
697 }
698
699 #[test]
700 fn test_modal_escape_closes() {
701 let mut modal = Modal::new().open(true);
702 modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
703
704 let result = modal.event(&Event::KeyDown { key: Key::Escape });
705 assert!(result.is_some());
706 assert!(!modal.is_open());
707 }
708
709 #[test]
710 fn test_modal_escape_disabled() {
711 let mut modal = Modal::new().open(true).close_on_escape(false);
712 modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
713
714 let result = modal.event(&Event::KeyDown { key: Key::Escape });
715 assert!(result.is_none());
716 assert!(modal.is_open());
717 }
718
719 #[test]
720 fn test_modal_animation_progress() {
721 let modal = Modal::new();
722 assert_eq!(modal.animation_progress(), 0.0);
723 }
724
725 #[test]
726 fn test_modal_content_bounds() {
727 let mut modal = Modal::new().open(true);
728 modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
729 let content_bounds = modal.content_bounds();
730 assert!(content_bounds.width > 0.0);
731 assert!(content_bounds.height > 0.0);
732 }
733
734 #[test]
739 fn test_close_reason_eq() {
740 assert_eq!(CloseReason::Escape, CloseReason::Escape);
741 assert_ne!(CloseReason::Escape, CloseReason::Backdrop);
742 }
743
744 #[test]
749 fn test_modal_closed_message() {
750 let msg = ModalClosed {
751 reason: CloseReason::CloseButton,
752 };
753 assert_eq!(msg.reason, CloseReason::CloseButton);
754 }
755
756 #[test]
757 fn test_modal_opened_message() {
758 let _msg = ModalOpened;
759 }
761
762 #[test]
767 fn test_modal_backdrop_none() {
768 let modal = Modal::new().backdrop(BackdropBehavior::None);
769 assert_eq!(modal.backdrop, BackdropBehavior::None);
770 }
771
772 #[test]
773 fn test_modal_backdrop_static() {
774 let modal = Modal::new().backdrop(BackdropBehavior::Static);
775 assert_eq!(modal.backdrop, BackdropBehavior::Static);
776 }
777
778 #[test]
779 fn test_modal_size_small() {
780 assert_eq!(ModalSize::Small.max_width(), 300.0);
781 }
782
783 #[test]
784 fn test_modal_size_full_width() {
785 assert_eq!(ModalSize::FullWidth.max_width(), f32::MAX);
786 }
787
788 #[test]
789 fn test_modal_children_mut_empty() {
790 let mut modal = Modal::new();
791 assert!(modal.children_mut().is_empty());
792 }
793
794 #[test]
795 fn test_modal_calculate_bounds_with_title() {
796 let modal = Modal::new().title("Test Title");
797 let viewport = Rect::new(0.0, 0.0, 1024.0, 768.0);
798 let bounds = modal.calculate_modal_bounds(viewport);
799 assert!(bounds.height > 0.0);
800 }
801
802 #[test]
803 fn test_modal_layout_animation_closes() {
804 let mut modal = Modal::new().open(true);
805 modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
806 let prog1 = modal.animation_progress;
808 modal.open = false;
809 modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
810 assert!(modal.animation_progress < prog1);
812 }
813
814 #[test]
815 fn test_modal_event_not_open_returns_none() {
816 let mut modal = Modal::new();
817 let result = modal.event(&Event::KeyDown { key: Key::Escape });
818 assert!(result.is_none());
819 }
820
821 #[test]
822 fn test_modal_other_key_does_nothing() {
823 let mut modal = Modal::new().open(true);
824 modal.layout(Rect::new(0.0, 0.0, 1024.0, 768.0));
825 let result = modal.event(&Event::KeyDown { key: Key::Tab });
826 assert!(result.is_none());
827 assert!(modal.is_open());
828 }
829
830 #[test]
831 fn test_close_reason_programmatic() {
832 let reason = CloseReason::Programmatic;
833 assert_eq!(reason, CloseReason::Programmatic);
834 }
835
836 #[test]
837 fn test_close_reason_close_button() {
838 let reason = CloseReason::CloseButton;
839 assert_eq!(reason, CloseReason::CloseButton);
840 }
841
842 #[test]
843 fn test_modal_size_custom_value() {
844 let size = ModalSize::Custom(750);
845 assert_eq!(size.max_width(), 750.0);
846 }
847
848 #[test]
849 fn test_modal_backdrop_eq() {
850 assert_eq!(
851 BackdropBehavior::CloseOnClick,
852 BackdropBehavior::CloseOnClick
853 );
854 assert_ne!(BackdropBehavior::CloseOnClick, BackdropBehavior::Static);
855 }
856
857 #[test]
858 fn test_modal_size_eq() {
859 assert_eq!(ModalSize::Medium, ModalSize::Medium);
860 assert_ne!(ModalSize::Small, ModalSize::Large);
861 }
862}