Skip to main content

ftui_widgets/modal/
container.rs

1#![forbid(unsafe_code)]
2
3//! Modal container widget with backdrop, positioning, and size constraints.
4//!
5//! This widget renders:
6//! 1) a full-screen backdrop (tinted overlay), then
7//! 2) the content widget in a positioned rectangle.
8//!
9//! Optionally registers hit regions for backdrop vs content so callers can
10//! implement close-on-backdrop click behavior using the hit grid.
11
12use 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
22/// Hit region tag for the modal backdrop.
23pub const MODAL_HIT_BACKDROP: HitRegion = HitRegion::Custom(1);
24/// Hit region tag for the modal content.
25pub const MODAL_HIT_CONTENT: HitRegion = HitRegion::Custom(2);
26
27/// Modal action emitted by `ModalState::handle_event`.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum ModalAction {
30    /// The modal should close.
31    Close,
32    /// Backdrop was clicked.
33    BackdropClicked,
34    /// Escape was pressed.
35    EscapePressed,
36}
37
38/// Backdrop configuration (color + opacity).
39#[derive(Debug, Clone, Copy, PartialEq)]
40pub struct BackdropConfig {
41    /// Backdrop color (alpha will be scaled by `opacity`).
42    pub color: PackedRgba,
43    /// Opacity in `[0.0, 1.0]`.
44    pub opacity: f32,
45}
46
47impl BackdropConfig {
48    /// Create a new backdrop config.
49    pub fn new(color: PackedRgba, opacity: f32) -> Self {
50        Self { color, opacity }
51    }
52
53    /// Set backdrop color.
54    #[must_use]
55    pub fn color(mut self, color: PackedRgba) -> Self {
56        self.color = color;
57        self
58    }
59
60    /// Set backdrop opacity.
61    #[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/// Modal size constraints (min/max width/height).
78#[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    /// Create an unconstrained size spec.
88    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    /// Set minimum width.
98    #[must_use]
99    pub fn min_width(mut self, value: u16) -> Self {
100        self.min_width = Some(value);
101        self
102    }
103
104    /// Set maximum width.
105    #[must_use]
106    pub fn max_width(mut self, value: u16) -> Self {
107        self.max_width = Some(value);
108        self
109    }
110
111    /// Set minimum height.
112    #[must_use]
113    pub fn min_height(mut self, value: u16) -> Self {
114        self.min_height = Some(value);
115        self
116    }
117
118    /// Set maximum height.
119    #[must_use]
120    pub fn max_height(mut self, value: u16) -> Self {
121        self.max_height = Some(value);
122        self
123    }
124
125    /// Clamp the given size to these constraints (but never exceed available).
126    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/// Modal positioning options.
148#[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/// Modal configuration.
196#[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/// Stateful helper for modal close behavior.
258#[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    /// Handle events and return a modal action if triggered.
284    ///
285    /// The caller should pass the hit-test result for the mouse event
286    /// (usually from the last rendered frame).
287    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/// Modal container widget.
326///
327/// Invariants:
328/// - `content_rect()` is always clamped within the given `area`.
329/// - Size constraints are applied before positioning and never exceed `area`.
330///
331/// Failure modes:
332/// - If the available `area` is empty or constraints clamp to zero size,
333///   the content is not rendered.
334/// - `close_on_backdrop` requires `hit_id` to be set; otherwise backdrop clicks
335///   cannot be distinguished from content clicks.
336#[derive(Debug, Clone)]
337pub struct Modal<C> {
338    content: C,
339    config: ModalConfig,
340}
341
342impl<C> Modal<C> {
343    /// Create a new modal with content.
344    pub fn new(content: C) -> Self {
345        Self {
346            content,
347            config: ModalConfig::default(),
348        }
349    }
350
351    /// Set modal configuration.
352    #[must_use]
353    pub fn config(mut self, config: ModalConfig) -> Self {
354        self.config = config;
355        self
356    }
357
358    /// Set modal position.
359    #[must_use]
360    pub fn position(mut self, position: ModalPosition) -> Self {
361        self.config.position = position;
362        self
363    }
364
365    /// Set backdrop configuration.
366    #[must_use]
367    pub fn backdrop(mut self, backdrop: BackdropConfig) -> Self {
368        self.config.backdrop = backdrop;
369        self
370    }
371
372    /// Set size constraints.
373    #[must_use]
374    pub fn size(mut self, size: ModalSizeConstraints) -> Self {
375        self.config.size = size;
376        self
377    }
378
379    /// Set close-on-backdrop behavior.
380    #[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    /// Set close-on-escape behavior.
387    #[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    /// Set the hit id used for backdrop/content hit regions.
394    #[must_use]
395    pub fn hit_id(mut self, id: HitId) -> Self {
396        self.config.hit_id = Some(id);
397        self
398    }
399
400    /// Compute the content rectangle for the given area.
401    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        // Backdrop (full area), preserving existing glyphs.
418        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        // Register hit regions BEFORE content renders so the inner widget
425        // (e.g. Dialog) can overlay more specific hits (buttons) on top.
426        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    // --- BackdropConfig tests ---
576
577    #[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    // --- ModalSizeConstraints tests ---
594
595    #[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        // Available is larger than min: result = available
613        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        // min > available: clamped back to available
620        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    // --- ModalPosition tests ---
656
657    #[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); // centered: (40-10)/2 = 15
664        assert_eq!(rect.y, 2); // margin from top
665        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        // Custom position beyond area bounds gets clamped
681        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); // max_x = 40-10
686        assert_eq!(rect.y, 16); // max_y = 20-4
687    }
688
689    #[test]
690    fn position_center_offset_clamped() {
691        // Large negative offset gets clamped to area origin
692        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        // center_x = 10 + (40-10)/2 = 25
712        // center_y = 5 + (20-4)/2 = 13
713        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        // center_x = 5 + (20-8)/2 = 11
723        // y = 3 + 1 = 4
724        assert_eq!(rect, Rect::new(11, 4, 8, 4));
725    }
726
727    // --- ModalConfig tests ---
728
729    #[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    // --- ModalState tests ---
754
755    #[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        // Hit id doesn't match config
822        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        // close_on_backdrop is true, but config has no hit_id
837        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        // No hit (mouse missed all regions)
886        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    // --- Modal widget tests ---
897
898    #[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        // Empty area should be a no-op
912        modal.render(Rect::new(0, 0, 0, 0), &mut frame);
913        // No hits registered
914        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        // No hit_id -> no hit regions
930        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        // When content size exceeds area, it gets clamped
966        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        // min > available: clamped to available
976        assert_eq!(rect.width, 20);
977        assert_eq!(rect.height, 10);
978    }
979
980    // --- ModalAction tests ---
981
982    #[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    // --- Hit region constants ---
990
991    #[test]
992    fn hit_region_constants_are_distinct() {
993        assert_ne!(MODAL_HIT_BACKDROP, MODAL_HIT_CONTENT);
994    }
995}