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    pub fn color(mut self, color: PackedRgba) -> Self {
55        self.color = color;
56        self
57    }
58
59    /// Set backdrop opacity.
60    pub fn opacity(mut self, opacity: f32) -> Self {
61        self.opacity = opacity;
62        self
63    }
64}
65
66impl Default for BackdropConfig {
67    fn default() -> Self {
68        Self {
69            color: PackedRgba::rgb(0, 0, 0),
70            opacity: 0.6,
71        }
72    }
73}
74
75/// Modal size constraints (min/max width/height).
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
77pub struct ModalSizeConstraints {
78    pub min_width: Option<u16>,
79    pub max_width: Option<u16>,
80    pub min_height: Option<u16>,
81    pub max_height: Option<u16>,
82}
83
84impl ModalSizeConstraints {
85    /// Create an unconstrained size spec.
86    pub const fn new() -> Self {
87        Self {
88            min_width: None,
89            max_width: None,
90            min_height: None,
91            max_height: None,
92        }
93    }
94
95    /// Set minimum width.
96    pub fn min_width(mut self, value: u16) -> Self {
97        self.min_width = Some(value);
98        self
99    }
100
101    /// Set maximum width.
102    pub fn max_width(mut self, value: u16) -> Self {
103        self.max_width = Some(value);
104        self
105    }
106
107    /// Set minimum height.
108    pub fn min_height(mut self, value: u16) -> Self {
109        self.min_height = Some(value);
110        self
111    }
112
113    /// Set maximum height.
114    pub fn max_height(mut self, value: u16) -> Self {
115        self.max_height = Some(value);
116        self
117    }
118
119    /// Clamp the given size to these constraints (but never exceed available).
120    pub fn clamp(self, available: Size) -> Size {
121        let mut width = available.width;
122        let mut height = available.height;
123
124        if let Some(max_width) = self.max_width {
125            width = width.min(max_width);
126        }
127        if let Some(max_height) = self.max_height {
128            height = height.min(max_height);
129        }
130        if let Some(min_width) = self.min_width {
131            width = width.max(min_width).min(available.width);
132        }
133        if let Some(min_height) = self.min_height {
134            height = height.max(min_height).min(available.height);
135        }
136
137        Size::new(width, height)
138    }
139}
140
141/// Modal positioning options.
142#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
143pub enum ModalPosition {
144    #[default]
145    Center,
146    CenterOffset {
147        x: i16,
148        y: i16,
149    },
150    TopCenter {
151        margin: u16,
152    },
153    Custom {
154        x: u16,
155        y: u16,
156    },
157}
158
159impl ModalPosition {
160    fn resolve(self, area: Rect, size: Size) -> Rect {
161        let base_x = area.x as i32;
162        let base_y = area.y as i32;
163        let max_x = base_x + (area.width as i32 - size.width as i32);
164        let max_y = base_y + (area.height as i32 - size.height as i32);
165
166        let (mut x, mut y) = match self {
167            Self::Center => (
168                base_x + (area.width as i32 - size.width as i32) / 2,
169                base_y + (area.height as i32 - size.height as i32) / 2,
170            ),
171            Self::CenterOffset { x, y } => (
172                base_x + (area.width as i32 - size.width as i32) / 2 + x as i32,
173                base_y + (area.height as i32 - size.height as i32) / 2 + y as i32,
174            ),
175            Self::TopCenter { margin } => (
176                base_x + (area.width as i32 - size.width as i32) / 2,
177                base_y + margin as i32,
178            ),
179            Self::Custom { x, y } => (x as i32, y as i32),
180        };
181
182        x = x.clamp(base_x, max_x);
183        y = y.clamp(base_y, max_y);
184
185        Rect::new(x as u16, y as u16, size.width, size.height)
186    }
187}
188
189/// Modal configuration.
190#[derive(Debug, Clone)]
191pub struct ModalConfig {
192    pub position: ModalPosition,
193    pub backdrop: BackdropConfig,
194    pub size: ModalSizeConstraints,
195    pub close_on_backdrop: bool,
196    pub close_on_escape: bool,
197    pub hit_id: Option<HitId>,
198}
199
200impl Default for ModalConfig {
201    fn default() -> Self {
202        Self {
203            position: ModalPosition::Center,
204            backdrop: BackdropConfig::default(),
205            size: ModalSizeConstraints::default(),
206            close_on_backdrop: true,
207            close_on_escape: true,
208            hit_id: None,
209        }
210    }
211}
212
213impl ModalConfig {
214    pub fn position(mut self, position: ModalPosition) -> Self {
215        self.position = position;
216        self
217    }
218
219    pub fn backdrop(mut self, backdrop: BackdropConfig) -> Self {
220        self.backdrop = backdrop;
221        self
222    }
223
224    pub fn size(mut self, size: ModalSizeConstraints) -> Self {
225        self.size = size;
226        self
227    }
228
229    pub fn close_on_backdrop(mut self, close: bool) -> Self {
230        self.close_on_backdrop = close;
231        self
232    }
233
234    pub fn close_on_escape(mut self, close: bool) -> Self {
235        self.close_on_escape = close;
236        self
237    }
238
239    pub fn hit_id(mut self, id: HitId) -> Self {
240        self.hit_id = Some(id);
241        self
242    }
243}
244
245/// Stateful helper for modal close behavior.
246#[derive(Debug, Clone, Copy, PartialEq, Eq)]
247pub struct ModalState {
248    open: bool,
249}
250
251impl Default for ModalState {
252    fn default() -> Self {
253        Self { open: true }
254    }
255}
256
257impl ModalState {
258    pub fn is_open(&self) -> bool {
259        self.open
260    }
261
262    pub fn open(&mut self) {
263        self.open = true;
264    }
265
266    pub fn close(&mut self) {
267        self.open = false;
268    }
269
270    /// Handle events and return a modal action if triggered.
271    ///
272    /// The caller should pass the hit-test result for the mouse event
273    /// (usually from the last rendered frame).
274    pub fn handle_event(
275        &mut self,
276        event: &Event,
277        hit: Option<(HitId, HitRegion, HitData)>,
278        config: &ModalConfig,
279    ) -> Option<ModalAction> {
280        if !self.open {
281            return None;
282        }
283
284        match event {
285            Event::Key(KeyEvent {
286                code: KeyCode::Escape,
287                kind: KeyEventKind::Press,
288                ..
289            }) if config.close_on_escape => {
290                self.open = false;
291                return Some(ModalAction::EscapePressed);
292            }
293            Event::Mouse(MouseEvent {
294                kind: MouseEventKind::Down(MouseButton::Left),
295                ..
296            }) if config.close_on_backdrop => {
297                if let (Some((id, region, _)), Some(expected)) = (hit, config.hit_id)
298                    && id == expected
299                    && region == MODAL_HIT_BACKDROP
300                {
301                    self.open = false;
302                    return Some(ModalAction::BackdropClicked);
303                }
304            }
305            _ => {}
306        }
307
308        None
309    }
310}
311
312/// Modal container widget.
313///
314/// Invariants:
315/// - `content_rect()` is always clamped within the given `area`.
316/// - Size constraints are applied before positioning and never exceed `area`.
317///
318/// Failure modes:
319/// - If the available `area` is empty or constraints clamp to zero size,
320///   the content is not rendered.
321/// - `close_on_backdrop` requires `hit_id` to be set; otherwise backdrop clicks
322///   cannot be distinguished from content clicks.
323#[derive(Debug, Clone)]
324pub struct Modal<C> {
325    content: C,
326    config: ModalConfig,
327}
328
329impl<C> Modal<C> {
330    /// Create a new modal with content.
331    pub fn new(content: C) -> Self {
332        Self {
333            content,
334            config: ModalConfig::default(),
335        }
336    }
337
338    /// Set modal configuration.
339    pub fn config(mut self, config: ModalConfig) -> Self {
340        self.config = config;
341        self
342    }
343
344    /// Set modal position.
345    pub fn position(mut self, position: ModalPosition) -> Self {
346        self.config.position = position;
347        self
348    }
349
350    /// Set backdrop configuration.
351    pub fn backdrop(mut self, backdrop: BackdropConfig) -> Self {
352        self.config.backdrop = backdrop;
353        self
354    }
355
356    /// Set size constraints.
357    pub fn size(mut self, size: ModalSizeConstraints) -> Self {
358        self.config.size = size;
359        self
360    }
361
362    /// Set close-on-backdrop behavior.
363    pub fn close_on_backdrop(mut self, close: bool) -> Self {
364        self.config.close_on_backdrop = close;
365        self
366    }
367
368    /// Set close-on-escape behavior.
369    pub fn close_on_escape(mut self, close: bool) -> Self {
370        self.config.close_on_escape = close;
371        self
372    }
373
374    /// Set the hit id used for backdrop/content hit regions.
375    pub fn hit_id(mut self, id: HitId) -> Self {
376        self.config.hit_id = Some(id);
377        self
378    }
379
380    /// Compute the content rectangle for the given area.
381    pub fn content_rect(&self, area: Rect) -> Rect {
382        let available = Size::new(area.width, area.height);
383        let size = self.config.size.clamp(available);
384        if size.width == 0 || size.height == 0 {
385            return Rect::new(area.x, area.y, 0, 0);
386        }
387        self.config.position.resolve(area, size)
388    }
389}
390
391impl<C: Widget> Widget for Modal<C> {
392    fn render(&self, area: Rect, frame: &mut Frame) {
393        if area.is_empty() {
394            return;
395        }
396
397        // Backdrop (full area), preserving existing glyphs.
398        let opacity = self.config.backdrop.opacity.clamp(0.0, 1.0);
399        if opacity > 0.0 {
400            let bg = self.config.backdrop.color.with_opacity(opacity);
401            set_style_area(&mut frame.buffer, area, Style::new().bg(bg));
402        }
403
404        let content_area = self.content_rect(area);
405        if !content_area.is_empty() {
406            self.content.render(content_area, frame);
407        }
408
409        // Register hit regions for backdrop and content if requested.
410        if let Some(hit_id) = self.config.hit_id {
411            frame.register_hit(area, hit_id, MODAL_HIT_BACKDROP, 0);
412            if !content_area.is_empty() {
413                frame.register_hit(content_area, hit_id, MODAL_HIT_CONTENT, 0);
414            }
415        }
416    }
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422    use ftui_render::frame::Frame;
423    use ftui_render::grapheme_pool::GraphemePool;
424
425    #[derive(Debug, Clone)]
426    struct Stub;
427
428    impl Widget for Stub {
429        fn render(&self, _area: Rect, _frame: &mut Frame) {}
430    }
431
432    #[test]
433    fn center_positioning() {
434        let modal = Modal::new(Stub).size(
435            ModalSizeConstraints::new()
436                .min_width(10)
437                .max_width(10)
438                .min_height(4)
439                .max_height(4),
440        );
441        let area = Rect::new(0, 0, 40, 20);
442        let rect = modal.content_rect(area);
443        assert_eq!(rect, Rect::new(15, 8, 10, 4));
444    }
445
446    #[test]
447    fn offset_positioning() {
448        let modal = Modal::new(Stub)
449            .size(
450                ModalSizeConstraints::new()
451                    .min_width(10)
452                    .max_width(10)
453                    .min_height(4)
454                    .max_height(4),
455            )
456            .position(ModalPosition::CenterOffset { x: -2, y: 3 });
457        let area = Rect::new(0, 0, 40, 20);
458        let rect = modal.content_rect(area);
459        assert_eq!(rect, Rect::new(13, 11, 10, 4));
460    }
461
462    #[test]
463    fn size_constraints_respect_available() {
464        let modal = Modal::new(Stub).size(
465            ModalSizeConstraints::new()
466                .min_width(10)
467                .max_width(30)
468                .min_height(6)
469                .max_height(20),
470        );
471        let area = Rect::new(0, 0, 8, 4);
472        let rect = modal.content_rect(area);
473        assert_eq!(rect.width, 8);
474        assert_eq!(rect.height, 4);
475    }
476
477    #[test]
478    fn hit_regions_registered() {
479        let modal = Modal::new(Stub)
480            .size(
481                ModalSizeConstraints::new()
482                    .min_width(6)
483                    .max_width(6)
484                    .min_height(3)
485                    .max_height(3),
486            )
487            .hit_id(HitId::new(7));
488
489        let mut pool = GraphemePool::new();
490        let mut frame = Frame::with_hit_grid(20, 10, &mut pool);
491        let area = Rect::new(0, 0, 20, 10);
492        modal.render(area, &mut frame);
493
494        let backdrop_hit = frame.hit_test(0, 0);
495        assert_eq!(backdrop_hit, Some((HitId::new(7), MODAL_HIT_BACKDROP, 0)));
496
497        let content = modal.content_rect(area);
498        let cx = content.x + 1;
499        let cy = content.y + 1;
500        let content_hit = frame.hit_test(cx, cy);
501        assert_eq!(content_hit, Some((HitId::new(7), MODAL_HIT_CONTENT, 0)));
502    }
503
504    #[test]
505    fn backdrop_click_triggers_close() {
506        let mut state = ModalState::default();
507        let config = ModalConfig::default().hit_id(HitId::new(9));
508        let hit = Some((HitId::new(9), MODAL_HIT_BACKDROP, 0));
509        let event = Event::Mouse(MouseEvent::new(
510            MouseEventKind::Down(MouseButton::Left),
511            0,
512            0,
513        ));
514
515        let action = state.handle_event(&event, hit, &config);
516        assert_eq!(action, Some(ModalAction::BackdropClicked));
517        assert!(!state.is_open());
518    }
519
520    #[test]
521    fn content_rect_within_bounds_for_positions() {
522        let base_constraints = ModalSizeConstraints::new()
523            .min_width(2)
524            .min_height(2)
525            .max_width(30)
526            .max_height(10);
527        let positions = [
528            ModalPosition::Center,
529            ModalPosition::CenterOffset { x: 3, y: -2 },
530            ModalPosition::TopCenter { margin: 1 },
531            ModalPosition::Custom { x: 100, y: 100 },
532        ];
533        let areas = [
534            Rect::new(0, 0, 10, 6),
535            Rect::new(2, 3, 40, 20),
536            Rect::new(5, 1, 8, 4),
537        ];
538
539        for area in areas {
540            for &position in &positions {
541                let modal = Modal::new(Stub).size(base_constraints).position(position);
542                let rect = modal.content_rect(area);
543                if rect.is_empty() {
544                    continue;
545                }
546                assert!(rect.x >= area.x);
547                assert!(rect.y >= area.y);
548                assert!(rect.right() <= area.right());
549                assert!(rect.bottom() <= area.bottom());
550            }
551        }
552    }
553}