Skip to main content

retroglyph_widgets/widget/
modal.rs

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