1#![forbid(unsafe_code)]
2
3use crate::Widget;
13use crate::set_style_area;
14use ftui_core::event::{
15 Event, KeyCode, KeyEvent, KeyEventKind, MouseButton, MouseEvent, MouseEventKind,
16};
17use ftui_core::geometry::{Rect, Size};
18use ftui_render::cell::PackedRgba;
19use ftui_render::frame::{Frame, HitData, HitId, HitRegion};
20use ftui_style::Style;
21
22pub const MODAL_HIT_BACKDROP: HitRegion = HitRegion::Custom(1);
24pub const MODAL_HIT_CONTENT: HitRegion = HitRegion::Custom(2);
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum ModalAction {
30 Close,
32 BackdropClicked,
34 EscapePressed,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq)]
40pub struct BackdropConfig {
41 pub color: PackedRgba,
43 pub opacity: f32,
45}
46
47impl BackdropConfig {
48 pub fn new(color: PackedRgba, opacity: f32) -> Self {
50 Self { color, opacity }
51 }
52
53 #[must_use]
55 pub fn color(mut self, color: PackedRgba) -> Self {
56 self.color = color;
57 self
58 }
59
60 #[must_use]
62 pub fn opacity(mut self, opacity: f32) -> Self {
63 self.opacity = opacity;
64 self
65 }
66}
67
68impl Default for BackdropConfig {
69 fn default() -> Self {
70 Self {
71 color: PackedRgba::rgb(0, 0, 0),
72 opacity: 0.6,
73 }
74 }
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
79pub struct ModalSizeConstraints {
80 pub min_width: Option<u16>,
81 pub max_width: Option<u16>,
82 pub min_height: Option<u16>,
83 pub max_height: Option<u16>,
84}
85
86impl ModalSizeConstraints {
87 pub const fn new() -> Self {
89 Self {
90 min_width: None,
91 max_width: None,
92 min_height: None,
93 max_height: None,
94 }
95 }
96
97 #[must_use]
99 pub fn min_width(mut self, value: u16) -> Self {
100 self.min_width = Some(value);
101 self
102 }
103
104 #[must_use]
106 pub fn max_width(mut self, value: u16) -> Self {
107 self.max_width = Some(value);
108 self
109 }
110
111 #[must_use]
113 pub fn min_height(mut self, value: u16) -> Self {
114 self.min_height = Some(value);
115 self
116 }
117
118 #[must_use]
120 pub fn max_height(mut self, value: u16) -> Self {
121 self.max_height = Some(value);
122 self
123 }
124
125 pub fn clamp(self, available: Size) -> Size {
127 let mut width = available.width;
128 let mut height = available.height;
129
130 if let Some(max_width) = self.max_width {
131 width = width.min(max_width);
132 }
133 if let Some(max_height) = self.max_height {
134 height = height.min(max_height);
135 }
136 if let Some(min_width) = self.min_width {
137 width = width.max(min_width).min(available.width);
138 }
139 if let Some(min_height) = self.min_height {
140 height = height.max(min_height).min(available.height);
141 }
142
143 Size::new(width, height)
144 }
145}
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
149pub enum ModalPosition {
150 #[default]
151 Center,
152 CenterOffset {
153 x: i16,
154 y: i16,
155 },
156 TopCenter {
157 margin: u16,
158 },
159 Custom {
160 x: u16,
161 y: u16,
162 },
163}
164
165impl ModalPosition {
166 fn resolve(self, area: Rect, size: Size) -> Rect {
167 let base_x = area.x as i32;
168 let base_y = area.y as i32;
169 let max_x = base_x + (area.width as i32 - size.width as i32);
170 let max_y = base_y + (area.height as i32 - size.height as i32);
171
172 let (mut x, mut y) = match self {
173 Self::Center => (
174 base_x + (area.width as i32 - size.width as i32) / 2,
175 base_y + (area.height as i32 - size.height as i32) / 2,
176 ),
177 Self::CenterOffset { x, y } => (
178 base_x + (area.width as i32 - size.width as i32) / 2 + x as i32,
179 base_y + (area.height as i32 - size.height as i32) / 2 + y as i32,
180 ),
181 Self::TopCenter { margin } => (
182 base_x + (area.width as i32 - size.width as i32) / 2,
183 base_y + margin as i32,
184 ),
185 Self::Custom { x, y } => (x as i32, y as i32),
186 };
187
188 x = x.clamp(base_x, max_x);
189 y = y.clamp(base_y, max_y);
190
191 Rect::new(x as u16, y as u16, size.width, size.height)
192 }
193}
194
195#[derive(Debug, Clone)]
197pub struct ModalConfig {
198 pub position: ModalPosition,
199 pub backdrop: BackdropConfig,
200 pub size: ModalSizeConstraints,
201 pub close_on_backdrop: bool,
202 pub close_on_escape: bool,
203 pub hit_id: Option<HitId>,
204}
205
206impl Default for ModalConfig {
207 fn default() -> Self {
208 Self {
209 position: ModalPosition::Center,
210 backdrop: BackdropConfig::default(),
211 size: ModalSizeConstraints::default(),
212 close_on_backdrop: true,
213 close_on_escape: true,
214 hit_id: None,
215 }
216 }
217}
218
219impl ModalConfig {
220 #[must_use]
221 pub fn position(mut self, position: ModalPosition) -> Self {
222 self.position = position;
223 self
224 }
225
226 #[must_use]
227 pub fn backdrop(mut self, backdrop: BackdropConfig) -> Self {
228 self.backdrop = backdrop;
229 self
230 }
231
232 #[must_use]
233 pub fn size(mut self, size: ModalSizeConstraints) -> Self {
234 self.size = size;
235 self
236 }
237
238 #[must_use]
239 pub fn close_on_backdrop(mut self, close: bool) -> Self {
240 self.close_on_backdrop = close;
241 self
242 }
243
244 #[must_use]
245 pub fn close_on_escape(mut self, close: bool) -> Self {
246 self.close_on_escape = close;
247 self
248 }
249
250 #[must_use]
251 pub fn hit_id(mut self, id: HitId) -> Self {
252 self.hit_id = Some(id);
253 self
254 }
255}
256
257#[derive(Debug, Clone, Copy, PartialEq, Eq)]
259pub struct ModalState {
260 open: bool,
261}
262
263impl Default for ModalState {
264 fn default() -> Self {
265 Self { open: true }
266 }
267}
268
269impl ModalState {
270 #[inline]
271 pub fn is_open(&self) -> bool {
272 self.open
273 }
274
275 pub fn open(&mut self) {
276 self.open = true;
277 }
278
279 pub fn close(&mut self) {
280 self.open = false;
281 }
282
283 pub fn handle_event(
288 &mut self,
289 event: &Event,
290 hit: Option<(HitId, HitRegion, HitData)>,
291 config: &ModalConfig,
292 ) -> Option<ModalAction> {
293 if !self.open {
294 return None;
295 }
296
297 match event {
298 Event::Key(KeyEvent {
299 code: KeyCode::Escape,
300 kind: KeyEventKind::Press,
301 ..
302 }) if config.close_on_escape => {
303 self.open = false;
304 return Some(ModalAction::EscapePressed);
305 }
306 Event::Mouse(MouseEvent {
307 kind: MouseEventKind::Down(MouseButton::Left),
308 ..
309 }) if config.close_on_backdrop => {
310 if let (Some((id, region, _)), Some(expected)) = (hit, config.hit_id)
311 && id == expected
312 && region == MODAL_HIT_BACKDROP
313 {
314 self.open = false;
315 return Some(ModalAction::BackdropClicked);
316 }
317 }
318 _ => {}
319 }
320
321 None
322 }
323}
324
325#[derive(Debug, Clone)]
337pub struct Modal<C> {
338 content: C,
339 config: ModalConfig,
340}
341
342impl<C> Modal<C> {
343 pub fn new(content: C) -> Self {
345 Self {
346 content,
347 config: ModalConfig::default(),
348 }
349 }
350
351 #[must_use]
353 pub fn config(mut self, config: ModalConfig) -> Self {
354 self.config = config;
355 self
356 }
357
358 #[must_use]
360 pub fn position(mut self, position: ModalPosition) -> Self {
361 self.config.position = position;
362 self
363 }
364
365 #[must_use]
367 pub fn backdrop(mut self, backdrop: BackdropConfig) -> Self {
368 self.config.backdrop = backdrop;
369 self
370 }
371
372 #[must_use]
374 pub fn size(mut self, size: ModalSizeConstraints) -> Self {
375 self.config.size = size;
376 self
377 }
378
379 #[must_use]
381 pub fn close_on_backdrop(mut self, close: bool) -> Self {
382 self.config.close_on_backdrop = close;
383 self
384 }
385
386 #[must_use]
388 pub fn close_on_escape(mut self, close: bool) -> Self {
389 self.config.close_on_escape = close;
390 self
391 }
392
393 #[must_use]
395 pub fn hit_id(mut self, id: HitId) -> Self {
396 self.config.hit_id = Some(id);
397 self
398 }
399
400 pub fn content_rect(&self, area: Rect) -> Rect {
402 let available = Size::new(area.width, area.height);
403 let size = self.config.size.clamp(available);
404 if size.width == 0 || size.height == 0 {
405 return Rect::new(area.x, area.y, 0, 0);
406 }
407 self.config.position.resolve(area, size)
408 }
409}
410
411impl<C: Widget> Widget for Modal<C> {
412 fn render(&self, area: Rect, frame: &mut Frame) {
413 if area.is_empty() {
414 return;
415 }
416
417 let opacity = self.config.backdrop.opacity.clamp(0.0, 1.0);
419 if opacity > 0.0 {
420 let bg = self.config.backdrop.color.with_opacity(opacity);
421 set_style_area(&mut frame.buffer, area, Style::new().bg(bg));
422 }
423
424 let content_area = self.content_rect(area);
427 if let Some(hit_id) = self.config.hit_id {
428 frame.register_hit(area, hit_id, MODAL_HIT_BACKDROP, 0);
429 if !content_area.is_empty() {
430 frame.register_hit(content_area, hit_id, MODAL_HIT_CONTENT, 0);
431 }
432 }
433
434 if !content_area.is_empty() {
435 self.content.render(content_area, frame);
436 }
437 }
438}
439
440#[cfg(test)]
441mod tests {
442 use super::*;
443 use ftui_render::frame::Frame;
444 use ftui_render::grapheme_pool::GraphemePool;
445
446 #[derive(Debug, Clone)]
447 struct Stub;
448
449 impl Widget for Stub {
450 fn render(&self, _area: Rect, _frame: &mut Frame) {}
451 }
452
453 #[test]
454 fn center_positioning() {
455 let modal = Modal::new(Stub).size(
456 ModalSizeConstraints::new()
457 .min_width(10)
458 .max_width(10)
459 .min_height(4)
460 .max_height(4),
461 );
462 let area = Rect::new(0, 0, 40, 20);
463 let rect = modal.content_rect(area);
464 assert_eq!(rect, Rect::new(15, 8, 10, 4));
465 }
466
467 #[test]
468 fn offset_positioning() {
469 let modal = Modal::new(Stub)
470 .size(
471 ModalSizeConstraints::new()
472 .min_width(10)
473 .max_width(10)
474 .min_height(4)
475 .max_height(4),
476 )
477 .position(ModalPosition::CenterOffset { x: -2, y: 3 });
478 let area = Rect::new(0, 0, 40, 20);
479 let rect = modal.content_rect(area);
480 assert_eq!(rect, Rect::new(13, 11, 10, 4));
481 }
482
483 #[test]
484 fn size_constraints_respect_available() {
485 let modal = Modal::new(Stub).size(
486 ModalSizeConstraints::new()
487 .min_width(10)
488 .max_width(30)
489 .min_height(6)
490 .max_height(20),
491 );
492 let area = Rect::new(0, 0, 8, 4);
493 let rect = modal.content_rect(area);
494 assert_eq!(rect.width, 8);
495 assert_eq!(rect.height, 4);
496 }
497
498 #[test]
499 fn hit_regions_registered() {
500 let modal = Modal::new(Stub)
501 .size(
502 ModalSizeConstraints::new()
503 .min_width(6)
504 .max_width(6)
505 .min_height(3)
506 .max_height(3),
507 )
508 .hit_id(HitId::new(7));
509
510 let mut pool = GraphemePool::new();
511 let mut frame = Frame::with_hit_grid(20, 10, &mut pool);
512 let area = Rect::new(0, 0, 20, 10);
513 modal.render(area, &mut frame);
514
515 let backdrop_hit = frame.hit_test(0, 0);
516 assert_eq!(backdrop_hit, Some((HitId::new(7), MODAL_HIT_BACKDROP, 0)));
517
518 let content = modal.content_rect(area);
519 let cx = content.x + 1;
520 let cy = content.y + 1;
521 let content_hit = frame.hit_test(cx, cy);
522 assert_eq!(content_hit, Some((HitId::new(7), MODAL_HIT_CONTENT, 0)));
523 }
524
525 #[test]
526 fn backdrop_click_triggers_close() {
527 let mut state = ModalState::default();
528 let config = ModalConfig::default().hit_id(HitId::new(9));
529 let hit = Some((HitId::new(9), MODAL_HIT_BACKDROP, 0));
530 let event = Event::Mouse(MouseEvent::new(
531 MouseEventKind::Down(MouseButton::Left),
532 0,
533 0,
534 ));
535
536 let action = state.handle_event(&event, hit, &config);
537 assert_eq!(action, Some(ModalAction::BackdropClicked));
538 assert!(!state.is_open());
539 }
540
541 #[test]
542 fn content_rect_within_bounds_for_positions() {
543 let base_constraints = ModalSizeConstraints::new()
544 .min_width(2)
545 .min_height(2)
546 .max_width(30)
547 .max_height(10);
548 let positions = [
549 ModalPosition::Center,
550 ModalPosition::CenterOffset { x: 3, y: -2 },
551 ModalPosition::TopCenter { margin: 1 },
552 ModalPosition::Custom { x: 100, y: 100 },
553 ];
554 let areas = [
555 Rect::new(0, 0, 10, 6),
556 Rect::new(2, 3, 40, 20),
557 Rect::new(5, 1, 8, 4),
558 ];
559
560 for area in areas {
561 for &position in &positions {
562 let modal = Modal::new(Stub).size(base_constraints).position(position);
563 let rect = modal.content_rect(area);
564 if rect.is_empty() {
565 continue;
566 }
567 assert!(rect.x >= area.x);
568 assert!(rect.y >= area.y);
569 assert!(rect.right() <= area.right());
570 assert!(rect.bottom() <= area.bottom());
571 }
572 }
573 }
574
575 #[test]
578 fn backdrop_config_default() {
579 let bd = BackdropConfig::default();
580 assert_eq!(bd.color, PackedRgba::rgb(0, 0, 0));
581 assert!((bd.opacity - 0.6).abs() < f32::EPSILON);
582 }
583
584 #[test]
585 fn backdrop_config_new_and_builders() {
586 let bd = BackdropConfig::new(PackedRgba::rgb(255, 0, 0), 0.8)
587 .color(PackedRgba::rgb(0, 255, 0))
588 .opacity(0.3);
589 assert_eq!(bd.color, PackedRgba::rgb(0, 255, 0));
590 assert!((bd.opacity - 0.3).abs() < f32::EPSILON);
591 }
592
593 #[test]
596 fn size_constraints_unconstrained() {
597 let c = ModalSizeConstraints::new();
598 let result = c.clamp(Size::new(40, 20));
599 assert_eq!(result, Size::new(40, 20));
600 }
601
602 #[test]
603 fn size_constraints_max_only() {
604 let c = ModalSizeConstraints::new().max_width(10).max_height(5);
605 let result = c.clamp(Size::new(40, 20));
606 assert_eq!(result, Size::new(10, 5));
607 }
608
609 #[test]
610 fn size_constraints_min_only() {
611 let c = ModalSizeConstraints::new().min_width(10).min_height(5);
612 assert_eq!(c.clamp(Size::new(40, 20)), Size::new(40, 20));
614 }
615
616 #[test]
617 fn size_constraints_min_exceeds_available() {
618 let c = ModalSizeConstraints::new().min_width(50).min_height(30);
619 let result = c.clamp(Size::new(10, 6));
621 assert_eq!(result, Size::new(10, 6));
622 }
623
624 #[test]
625 fn size_constraints_zero_available() {
626 let c = ModalSizeConstraints::new()
627 .min_width(10)
628 .max_width(20)
629 .min_height(5)
630 .max_height(10);
631 let result = c.clamp(Size::new(0, 0));
632 assert_eq!(result, Size::new(0, 0));
633 }
634
635 #[test]
636 fn size_constraints_min_and_max_equal() {
637 let c = ModalSizeConstraints::new()
638 .min_width(10)
639 .max_width(10)
640 .min_height(5)
641 .max_height(5);
642 let result = c.clamp(Size::new(40, 20));
643 assert_eq!(result, Size::new(10, 5));
644 }
645
646 #[test]
647 fn size_constraints_default_is_unconstrained() {
648 let c = ModalSizeConstraints::default();
649 assert_eq!(c.min_width, None);
650 assert_eq!(c.max_width, None);
651 assert_eq!(c.min_height, None);
652 assert_eq!(c.max_height, None);
653 }
654
655 #[test]
658 fn position_top_center() {
659 let pos = ModalPosition::TopCenter { margin: 2 };
660 let area = Rect::new(0, 0, 40, 20);
661 let size = Size::new(10, 4);
662 let rect = pos.resolve(area, size);
663 assert_eq!(rect.x, 15); assert_eq!(rect.y, 2); assert_eq!(rect.width, 10);
666 assert_eq!(rect.height, 4);
667 }
668
669 #[test]
670 fn position_custom_within_bounds() {
671 let pos = ModalPosition::Custom { x: 5, y: 3 };
672 let area = Rect::new(0, 0, 40, 20);
673 let size = Size::new(10, 4);
674 let rect = pos.resolve(area, size);
675 assert_eq!(rect, Rect::new(5, 3, 10, 4));
676 }
677
678 #[test]
679 fn position_custom_clamped_to_area() {
680 let pos = ModalPosition::Custom { x: 100, y: 100 };
682 let area = Rect::new(0, 0, 40, 20);
683 let size = Size::new(10, 4);
684 let rect = pos.resolve(area, size);
685 assert_eq!(rect.x, 30); assert_eq!(rect.y, 16); }
688
689 #[test]
690 fn position_center_offset_clamped() {
691 let pos = ModalPosition::CenterOffset { x: -100, y: -100 };
693 let area = Rect::new(0, 0, 40, 20);
694 let size = Size::new(10, 4);
695 let rect = pos.resolve(area, size);
696 assert_eq!(rect.x, 0);
697 assert_eq!(rect.y, 0);
698 }
699
700 #[test]
701 fn position_default_is_center() {
702 assert_eq!(ModalPosition::default(), ModalPosition::Center);
703 }
704
705 #[test]
706 fn position_resolve_with_nonzero_area_origin() {
707 let pos = ModalPosition::Center;
708 let area = Rect::new(10, 5, 40, 20);
709 let size = Size::new(10, 4);
710 let rect = pos.resolve(area, size);
711 assert_eq!(rect, Rect::new(25, 13, 10, 4));
714 }
715
716 #[test]
717 fn position_top_center_with_area_offset() {
718 let pos = ModalPosition::TopCenter { margin: 1 };
719 let area = Rect::new(5, 3, 20, 10);
720 let size = Size::new(8, 4);
721 let rect = pos.resolve(area, size);
722 assert_eq!(rect, Rect::new(11, 4, 8, 4));
725 }
726
727 #[test]
730 fn modal_config_default_values() {
731 let config = ModalConfig::default();
732 assert_eq!(config.position, ModalPosition::Center);
733 assert!(config.close_on_backdrop);
734 assert!(config.close_on_escape);
735 assert!(config.hit_id.is_none());
736 }
737
738 #[test]
739 fn modal_config_builder_chain() {
740 let config = ModalConfig::default()
741 .position(ModalPosition::TopCenter { margin: 5 })
742 .backdrop(BackdropConfig::new(PackedRgba::rgb(255, 0, 0), 0.5))
743 .size(ModalSizeConstraints::new().max_width(20))
744 .close_on_backdrop(false)
745 .close_on_escape(false)
746 .hit_id(HitId::new(42));
747 assert_eq!(config.position, ModalPosition::TopCenter { margin: 5 });
748 assert!(!config.close_on_backdrop);
749 assert!(!config.close_on_escape);
750 assert_eq!(config.hit_id, Some(HitId::new(42)));
751 }
752
753 #[test]
756 fn modal_state_default_is_open() {
757 let state = ModalState::default();
758 assert!(state.is_open());
759 }
760
761 #[test]
762 fn modal_state_open_close_lifecycle() {
763 let mut state = ModalState::default();
764 assert!(state.is_open());
765 state.close();
766 assert!(!state.is_open());
767 state.open();
768 assert!(state.is_open());
769 }
770
771 #[test]
772 fn modal_state_escape_closes() {
773 let mut state = ModalState::default();
774 let config = ModalConfig::default();
775 let event = Event::Key(KeyEvent::new(KeyCode::Escape));
776 let action = state.handle_event(&event, None, &config);
777 assert_eq!(action, Some(ModalAction::EscapePressed));
778 assert!(!state.is_open());
779 }
780
781 #[test]
782 fn modal_state_escape_disabled() {
783 let mut state = ModalState::default();
784 let config = ModalConfig::default().close_on_escape(false);
785 let event = Event::Key(KeyEvent::new(KeyCode::Escape));
786 let action = state.handle_event(&event, None, &config);
787 assert_eq!(action, None);
788 assert!(state.is_open());
789 }
790
791 #[test]
792 fn modal_state_closed_ignores_events() {
793 let mut state = ModalState::default();
794 state.close();
795 let config = ModalConfig::default();
796 let event = Event::Key(KeyEvent::new(KeyCode::Escape));
797 let action = state.handle_event(&event, None, &config);
798 assert_eq!(action, None);
799 assert!(!state.is_open());
800 }
801
802 #[test]
803 fn modal_state_content_click_does_not_close() {
804 let mut state = ModalState::default();
805 let config = ModalConfig::default().hit_id(HitId::new(1));
806 let hit = Some((HitId::new(1), MODAL_HIT_CONTENT, 0));
807 let event = Event::Mouse(MouseEvent::new(
808 MouseEventKind::Down(MouseButton::Left),
809 5,
810 5,
811 ));
812 let action = state.handle_event(&event, hit, &config);
813 assert_eq!(action, None);
814 assert!(state.is_open());
815 }
816
817 #[test]
818 fn modal_state_backdrop_click_wrong_hit_id() {
819 let mut state = ModalState::default();
820 let config = ModalConfig::default().hit_id(HitId::new(1));
821 let hit = Some((HitId::new(999), MODAL_HIT_BACKDROP, 0));
823 let event = Event::Mouse(MouseEvent::new(
824 MouseEventKind::Down(MouseButton::Left),
825 0,
826 0,
827 ));
828 let action = state.handle_event(&event, hit, &config);
829 assert_eq!(action, None);
830 assert!(state.is_open());
831 }
832
833 #[test]
834 fn modal_state_backdrop_click_no_hit_id_in_config() {
835 let mut state = ModalState::default();
836 let config = ModalConfig::default();
838 let hit = Some((HitId::new(1), MODAL_HIT_BACKDROP, 0));
839 let event = Event::Mouse(MouseEvent::new(
840 MouseEventKind::Down(MouseButton::Left),
841 0,
842 0,
843 ));
844 let action = state.handle_event(&event, hit, &config);
845 assert_eq!(action, None);
846 assert!(state.is_open());
847 }
848
849 #[test]
850 fn modal_state_backdrop_click_disabled() {
851 let mut state = ModalState::default();
852 let config = ModalConfig::default()
853 .hit_id(HitId::new(1))
854 .close_on_backdrop(false);
855 let hit = Some((HitId::new(1), MODAL_HIT_BACKDROP, 0));
856 let event = Event::Mouse(MouseEvent::new(
857 MouseEventKind::Down(MouseButton::Left),
858 0,
859 0,
860 ));
861 let action = state.handle_event(&event, hit, &config);
862 assert_eq!(action, None);
863 assert!(state.is_open());
864 }
865
866 #[test]
867 fn modal_state_right_click_does_not_close() {
868 let mut state = ModalState::default();
869 let config = ModalConfig::default().hit_id(HitId::new(1));
870 let hit = Some((HitId::new(1), MODAL_HIT_BACKDROP, 0));
871 let event = Event::Mouse(MouseEvent::new(
872 MouseEventKind::Down(MouseButton::Right),
873 0,
874 0,
875 ));
876 let action = state.handle_event(&event, hit, &config);
877 assert_eq!(action, None);
878 assert!(state.is_open());
879 }
880
881 #[test]
882 fn modal_state_no_hit_data_backdrop_click() {
883 let mut state = ModalState::default();
884 let config = ModalConfig::default().hit_id(HitId::new(1));
885 let event = Event::Mouse(MouseEvent::new(
887 MouseEventKind::Down(MouseButton::Left),
888 0,
889 0,
890 ));
891 let action = state.handle_event(&event, None, &config);
892 assert_eq!(action, None);
893 assert!(state.is_open());
894 }
895
896 #[test]
899 fn modal_content_rect_zero_area() {
900 let modal = Modal::new(Stub);
901 let area = Rect::new(0, 0, 0, 0);
902 let rect = modal.content_rect(area);
903 assert!(rect.is_empty());
904 }
905
906 #[test]
907 fn modal_render_empty_area_does_nothing() {
908 let modal = Modal::new(Stub);
909 let mut pool = GraphemePool::new();
910 let mut frame = Frame::with_hit_grid(10, 10, &mut pool);
911 modal.render(Rect::new(0, 0, 0, 0), &mut frame);
913 assert_eq!(frame.hit_test(0, 0), None);
915 }
916
917 #[test]
918 fn modal_no_hit_regions_without_hit_id() {
919 let modal = Modal::new(Stub).size(
920 ModalSizeConstraints::new()
921 .min_width(4)
922 .max_width(4)
923 .min_height(2)
924 .max_height(2),
925 );
926 let mut pool = GraphemePool::new();
927 let mut frame = Frame::with_hit_grid(20, 10, &mut pool);
928 modal.render(Rect::new(0, 0, 20, 10), &mut frame);
929 assert_eq!(frame.hit_test(0, 0), None);
931 assert_eq!(frame.hit_test(10, 5), None);
932 }
933
934 #[test]
935 fn modal_builder_methods() {
936 let modal = Modal::new(Stub)
937 .position(ModalPosition::TopCenter { margin: 3 })
938 .backdrop(BackdropConfig::new(PackedRgba::rgb(0, 0, 0), 0.5))
939 .size(ModalSizeConstraints::new().max_width(10).max_height(5))
940 .close_on_backdrop(false)
941 .close_on_escape(false)
942 .hit_id(HitId::new(99));
943
944 assert_eq!(
945 modal.config.position,
946 ModalPosition::TopCenter { margin: 3 }
947 );
948 assert!(!modal.config.close_on_backdrop);
949 assert!(!modal.config.close_on_escape);
950 assert_eq!(modal.config.hit_id, Some(HitId::new(99)));
951 }
952
953 #[test]
954 fn modal_config_method_replaces_full_config() {
955 let config = ModalConfig::default()
956 .close_on_escape(false)
957 .hit_id(HitId::new(5));
958 let modal = Modal::new(Stub).config(config);
959 assert!(!modal.config.close_on_escape);
960 assert_eq!(modal.config.hit_id, Some(HitId::new(5)));
961 }
962
963 #[test]
964 fn modal_content_rect_size_bigger_than_area() {
965 let modal = Modal::new(Stub).size(
967 ModalSizeConstraints::new()
968 .min_width(100)
969 .max_width(100)
970 .min_height(100)
971 .max_height(100),
972 );
973 let area = Rect::new(0, 0, 20, 10);
974 let rect = modal.content_rect(area);
975 assert_eq!(rect.width, 20);
977 assert_eq!(rect.height, 10);
978 }
979
980 #[test]
983 fn modal_action_variants_are_distinct() {
984 assert_ne!(ModalAction::Close, ModalAction::BackdropClicked);
985 assert_ne!(ModalAction::Close, ModalAction::EscapePressed);
986 assert_ne!(ModalAction::BackdropClicked, ModalAction::EscapePressed);
987 }
988
989 #[test]
992 fn hit_region_constants_are_distinct() {
993 assert_ne!(MODAL_HIT_BACKDROP, MODAL_HIT_CONTENT);
994 }
995}