Skip to main content

retroglyph_widgets/widget/
modal.rs

1//! [`Modal`]: a bordered, filled box centered on screen.
2use retroglyph_core::{Backend, Color, Rect, Style, Terminal};
3
4use super::{Panel, Widget};
5use crate::layout::centered_rect;
6use crate::{Align, Theme};
7
8/// A bordered, filled box centered in a screen [`Rect`].
9///
10/// Shorthand for a [`Panel`] sized `width` x `height` and centered via
11/// [`centered_rect`]. `border_style`/`fill_style` default to
12/// [`Style::new()`] and there is no title by default -- set whichever a
13/// caller needs via [`Modal::border_style`]/[`Modal::fill_style`]/[`Modal::title`],
14/// the same as [`Panel`].
15///
16/// [`Modal::render`] returns the inner content [`Rect`] (inside the
17/// border, the same implicit one-cell inset [`Panel`] uses for its own
18/// interior) ready to hand to another widget (e.g. [`super::Log`]).
19///
20/// Draws only the box itself; everything outside it is left untouched (no
21/// dimming or backdrop fill -- that would need to read and blend existing
22/// cells, a separate feature from this thin layout convenience). Not a
23/// [`Widget`]: [`Widget::render`] can't return a value, and the inner
24/// content rect is part of this type's contract.
25#[derive(Clone, Copy, Debug)]
26pub struct Modal<'a> {
27    width: u16,
28    height: u16,
29    title: Option<&'a str>,
30    title_align: Align,
31    border_style: Style,
32    fill_style: Style,
33}
34
35impl<'a> Modal<'a> {
36    /// A `width` x `height` modal in the default style, with no title.
37    #[must_use]
38    pub fn new(width: u16, height: u16) -> Self {
39        Self {
40            width,
41            height,
42            title: None,
43            title_align: Align::Center,
44            border_style: Style::new(),
45            fill_style: Style::new(),
46        }
47    }
48
49    /// Set the modal's title.
50    #[must_use]
51    pub const fn title(mut self, title: &'a str) -> Self {
52        self.title = Some(title);
53        self
54    }
55
56    /// Set how the title is aligned along the top border. Defaults to
57    /// [`Align::Center`], the same as [`Panel::title_align`].
58    #[must_use]
59    pub const fn title_align(mut self, align: Align) -> Self {
60        self.title_align = align;
61        self
62    }
63
64    /// Set the box outline and title's style.
65    #[must_use]
66    pub const fn border_style(mut self, style: Style) -> Self {
67        self.border_style = style;
68        self
69    }
70
71    /// Set the interior background's style.
72    #[must_use]
73    pub const fn fill_style(mut self, style: Style) -> Self {
74        self.fill_style = style;
75        self
76    }
77
78    /// Applies `theme`'s named roles to this modal's border and fill, the same mapping as
79    /// [`Panel::theme`] (a [`Modal`] is just a centered [`Panel`]): `border_style` becomes
80    /// `theme.border` on `theme.title_bg`, and `fill_style` becomes `theme.panel_bg`.
81    ///
82    /// Call before any manual [`Modal::border_style`]/[`Modal::fill_style`] override you want to
83    /// keep -- whichever call comes last wins.
84    #[must_use]
85    pub fn theme(self, theme: Theme) -> Self {
86        self.theme_on(theme, theme.panel_bg)
87    }
88
89    /// Same as [`Modal::theme`], but `fill_style` is drawn on `bg` instead of `theme.panel_bg` --
90    /// the same [`Panel::theme_on`] escape hatch, for a modal whose interior should read as a
91    /// different surface than `theme.panel_bg` (`border_style` still uses `theme.title_bg`,
92    /// unaffected by `bg`). [`Modal::theme`] is exactly `theme_on(theme, theme.panel_bg)`.
93    #[must_use]
94    pub fn theme_on(mut self, theme: Theme, bg: Color) -> Self {
95        self.border_style = Style::new().fg(theme.border).bg(theme.title_bg);
96        self.fill_style = Style::new().bg(bg);
97        self
98    }
99
100    /// Draw the modal centered in `screen`, returning its inner content
101    /// [`Rect`].
102    pub fn render<B: Backend>(self, screen: Rect, term: &mut Terminal<B>) -> Rect {
103        let rect = centered_rect(screen, self.width, self.height);
104        let mut panel = Panel::new()
105            .border_style(self.border_style)
106            .fill_style(self.fill_style)
107            .title_align(self.title_align);
108        if let Some(title) = self.title {
109            panel = panel.title(title);
110        }
111        panel.render(rect, term);
112        Rect::new(
113            rect.left() + 1,
114            rect.top() + 1,
115            rect.width().saturating_sub(2),
116            rect.height().saturating_sub(2),
117        )
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use retroglyph_core::Headless;
124
125    use super::*;
126
127    #[test]
128    fn centers_the_box_and_returns_the_inner_content_rect() {
129        let screen = Rect::new(0, 0, 20, 10);
130        let mut term = Terminal::new(Headless::new(20, 10));
131        let inner = Modal::new(10, 4).render(screen, &mut term);
132
133        // Box is centered_rect(screen, 10, 4) = Rect::new(5, 3, 10, 4);
134        // the inner content rect is inset by the one-cell border.
135        assert_eq!(inner, Rect::new(6, 4, 8, 2));
136        // The border was actually drawn at the box's corners.
137        assert_eq!(term.grid().get(5, 3).glyph(), '┌');
138        assert_eq!(term.grid().get(14, 3).glyph(), '┐');
139    }
140
141    #[test]
142    fn draws_only_the_box_leaving_the_rest_of_the_screen_untouched() {
143        let screen = Rect::new(0, 0, 20, 10);
144        let mut term = Terminal::new(Headless::new(20, 10));
145        Modal::new(10, 4).render(screen, &mut term);
146
147        // A corner of the screen far from the centered box is untouched.
148        assert_eq!(term.grid().get(0, 0).glyph(), ' ');
149    }
150
151    #[test]
152    fn theme_maps_named_roles_onto_border_and_fill() {
153        let screen = Rect::new(0, 0, 20, 10);
154        let mut term = Terminal::new(Headless::new(20, 10));
155        Modal::new(10, 4)
156            .theme(Theme::DARK)
157            .render(screen, &mut term);
158
159        // Box is centered_rect(screen, 10, 4) = Rect::new(5, 3, 10, 4).
160        assert_eq!(
161            term.grid().get(5, 3).style().foreground(),
162            Theme::DARK.border
163        );
164        assert_eq!(
165            term.grid().get(5, 3).style().background(),
166            Theme::DARK.title_bg
167        );
168        assert_eq!(
169            term.grid().get(6, 4).style().background(),
170            Theme::DARK.panel_bg
171        );
172    }
173
174    #[test]
175    fn theme_on_uses_the_given_backdrop_instead_of_panel_bg() {
176        let screen = Rect::new(0, 0, 20, 10);
177        let mut term = Terminal::new(Headless::new(20, 10));
178        Modal::new(10, 4)
179            .theme_on(Theme::DARK, Color::Default)
180            .render(screen, &mut term);
181
182        assert_eq!(
183            term.grid().get(5, 3).style().foreground(),
184            Theme::DARK.border
185        );
186        assert_eq!(
187            term.grid().get(5, 3).style().background(),
188            Theme::DARK.title_bg
189        );
190        assert_eq!(term.grid().get(6, 4).style().background(), Color::Default);
191    }
192}