Skip to main content

retroglyph_ui/widget/
modal.rs

1//! [`Modal`]: a bordered, filled box centered on screen.
2use retroglyph_core::color::{Color, Style};
3use retroglyph_core::grid::Rect;
4
5use super::{BorderType, Panel, Widget};
6use crate::Surface;
7use crate::layout::centered_rect;
8use crate::style::Sides;
9use crate::{Align, Theme};
10
11/// A bordered, filled box centered in a screen [`Rect`].
12///
13/// Shorthand for a [`Panel`] sized `width` x `height` and centered via
14/// [`centered_rect`]. `border_style`/`fill_style` default to
15/// [`Theme::DARK`] (as if [`Modal::theme`] had been called) and there is no title by default: set
16/// whichever a caller needs via [`Modal::border_style`]/[`Modal::fill_style`]/[`Modal::title`],
17/// the same as [`Panel`].
18///
19/// [`Modal::render`] returns the inner content [`Rect`] (via [`Panel::inner`], the border inset
20/// plus this modal's [`Modal::padding`]) ready to hand to another widget (e.g. [`super::Log`]).
21///
22/// Draws only the box itself; everything outside it is left untouched (no
23/// dimming or backdrop fill, that would need to read and blend existing
24/// cells, a separate feature from this thin layout convenience). Not a
25/// [`Widget`]: [`Widget::render`] can't return a value, and the inner
26/// content rect is part of this type's contract.
27///
28/// [`Modal::render`] draws through whatever [`Surface`] it's given, on whatever layer that
29/// surface is already scoped to: it has no layer of its own to default. A modal painted over
30/// an active screen should be given a surface on [`Layer::Overlay`](retroglyph_core::surface::Layer::Overlay)
31/// (`surface.on_tier(Layer::Overlay)`), so it paints on top regardless of whether the screen or
32/// the modal renders first this frame; see [`Layer`](retroglyph_core::surface::Layer)'s docs for why that
33/// beats ordering the two draw calls.
34///
35/// # Examples
36///
37/// ```
38/// use retroglyph_core::grid::{Grid, Rect};
39/// use retroglyph_core::surface::Layer;
40/// use retroglyph_ui::{Modal, Surface};
41///
42/// let screen = Rect::new(0, 0, 20, 10);
43/// let mut grid = Grid::new(20, 10);
44/// let mut surface = Surface::new(&mut grid, screen, Layer::World.as_u8());
45/// let inner = Modal::new(10, 4)
46///     .title("Confirm")
47///     .render(screen, &mut surface.on_tier(Layer::Overlay));
48/// // `inner` is ready to hand to another widget, e.g. a `Log` or `Text`.
49/// assert_eq!(inner.width(), 8);
50/// ```
51#[derive(Clone, Copy, Debug)]
52pub struct Modal<'a> {
53    width: u16,
54    height: u16,
55    title: Option<&'a str>,
56    title_align: Align,
57    border_style: Style,
58    fill_style: Style,
59    border_type: BorderType,
60    padding: Sides,
61}
62
63impl<'a> Modal<'a> {
64    /// A `width` x `height` modal with no title, styled from [`Theme::DARK`] (as if
65    /// [`Modal::theme`] had been called).
66    #[must_use]
67    pub fn new(width: u16, height: u16) -> Self {
68        Self {
69            width,
70            height,
71            title: None,
72            title_align: Align::Center,
73            border_style: Style::new(),
74            fill_style: Style::new(),
75            border_type: BorderType::default(),
76            padding: Sides::ZERO,
77        }
78        .theme(Theme::DARK)
79    }
80
81    /// Set the modal's title.
82    #[must_use]
83    pub const fn title(mut self, title: &'a str) -> Self {
84        self.title = Some(title);
85        self
86    }
87
88    /// Set how the title is aligned along the top border. Defaults to
89    /// [`Align::Center`], the same as [`Panel::title_align`].
90    #[must_use]
91    pub const fn title_align(mut self, align: Align) -> Self {
92        self.title_align = align;
93        self
94    }
95
96    /// Set the box outline and title's style.
97    #[must_use]
98    pub const fn border_style(mut self, style: Style) -> Self {
99        self.border_style = style;
100        self
101    }
102
103    /// Set the interior background's style.
104    #[must_use]
105    pub const fn fill_style(mut self, style: Style) -> Self {
106        self.fill_style = style;
107        self
108    }
109
110    /// Set which box-drawing glyphs the border is drawn with. Defaults to
111    /// [`BorderType::Plain`], the same as [`Panel::border_type`].
112    #[must_use]
113    pub const fn border_type(mut self, border_type: BorderType) -> Self {
114        self.border_type = border_type;
115        self
116    }
117
118    /// Reserve `padding` between the border and the rect [`Modal::render`] returns, the same as
119    /// [`Panel::padding`] (a [`Modal`] is just a centered [`Panel`]). Defaults to [`Sides::ZERO`].
120    #[must_use]
121    pub const fn padding(mut self, padding: Sides) -> Self {
122        self.padding = padding;
123        self
124    }
125
126    /// Applies `theme`'s named roles to this modal's border and fill, the same mapping as
127    /// [`Panel::theme`] (a [`Modal`] is just a centered [`Panel`]): `border_style` becomes
128    /// `theme.border` on `theme.title_bg`, and `fill_style` becomes `theme.panel_bg`.
129    ///
130    /// Call before any manual [`Modal::border_style`]/[`Modal::fill_style`] override you want to
131    /// keep: whichever call comes last wins.
132    #[must_use]
133    pub fn theme(self, theme: Theme) -> Self {
134        self.theme_on(theme, theme.panel_bg)
135    }
136
137    /// Same as [`Modal::theme`], but `fill_style` is drawn on `bg` instead of `theme.panel_bg` --
138    /// the same [`Panel::theme_on`] escape hatch, for a modal whose interior should read as a
139    /// different surface than `theme.panel_bg` (`border_style` still uses `theme.title_bg`,
140    /// unaffected by `bg`). [`Modal::theme`] is exactly `theme_on(theme, theme.panel_bg)`.
141    #[must_use]
142    pub fn theme_on(mut self, theme: Theme, bg: Color) -> Self {
143        self.border_style = Style::new().fg(theme.border).bg(theme.title_bg);
144        self.fill_style = Style::new().bg(bg);
145        self
146    }
147
148    /// Draw the modal centered in `screen`, returning its inner content
149    /// [`Rect`].
150    pub fn render(self, screen: Rect, surface: &mut Surface<'_>) -> Rect {
151        let rect = centered_rect(screen, self.width, self.height);
152        let mut panel = Panel::new()
153            .border_style(self.border_style)
154            .fill_style(self.fill_style)
155            .title_align(self.title_align)
156            .border_type(self.border_type)
157            .padding(self.padding);
158        if let Some(title) = self.title {
159            panel = panel.title(title);
160        }
161        panel.render(&mut surface.scope(rect));
162        panel.inner(rect)
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use retroglyph_core::grid::{Grid, Pos};
169
170    use super::*;
171
172    #[test]
173    fn centers_the_box_and_returns_the_inner_content_rect() {
174        let screen = Rect::new(0, 0, 20, 10);
175        let mut grid = Grid::new(20, 10);
176        let inner = Modal::new(10, 4).render(screen, &mut Surface::new(&mut grid, screen, 0));
177
178        // Box is centered_rect(screen, 10, 4) = Rect::new(5, 3, 10, 4);
179        // the inner content rect is inset by the one-cell border.
180        assert_eq!(inner, Rect::new(6, 4, 8, 2));
181        // The border was actually drawn at the box's corners.
182        assert_eq!(grid[Pos::new(5, 3)].glyph(), '┌');
183        assert_eq!(grid[Pos::new(14, 3)].glyph(), '┐');
184    }
185
186    #[test]
187    fn padding_shrinks_the_returned_inner_rect() {
188        let screen = Rect::new(0, 0, 20, 10);
189        let mut grid = Grid::new(20, 10);
190        let inner = Modal::new(10, 4)
191            .padding(Sides::symmetric(0, 1))
192            .render(screen, &mut Surface::new(&mut grid, screen, 0));
193
194        // Box is centered_rect(screen, 10, 4) = Rect::new(5, 3, 10, 4); the border inset is
195        // Rect::new(6, 4, 8, 2), and symmetric(0, 1) padding trims 1 more cell off each side.
196        assert_eq!(inner, Rect::new(7, 4, 6, 2));
197    }
198
199    #[test]
200    fn draws_only_the_box_leaving_the_rest_of_the_screen_untouched() {
201        let screen = Rect::new(0, 0, 20, 10);
202        let mut grid = Grid::new(20, 10);
203        Modal::new(10, 4).render(screen, &mut Surface::new(&mut grid, screen, 0));
204
205        // A corner of the screen far from the centered box is untouched.
206        assert_eq!(grid[Pos::new(0, 0)].glyph(), ' ');
207    }
208
209    #[test]
210    fn theme_maps_named_roles_onto_border_and_fill() {
211        let screen = Rect::new(0, 0, 20, 10);
212        let mut grid = Grid::new(20, 10);
213        Modal::new(10, 4)
214            .theme(Theme::DARK)
215            .render(screen, &mut Surface::new(&mut grid, screen, 0));
216
217        // Box is centered_rect(screen, 10, 4) = Rect::new(5, 3, 10, 4).
218        assert_eq!(
219            grid[Pos::new(5, 3)].style().foreground(),
220            Theme::DARK.border
221        );
222        assert_eq!(
223            grid[Pos::new(5, 3)].style().background(),
224            Theme::DARK.title_bg
225        );
226        assert_eq!(
227            grid[Pos::new(6, 4)].style().background(),
228            Theme::DARK.panel_bg
229        );
230    }
231
232    #[test]
233    fn theme_on_uses_the_given_backdrop_instead_of_panel_bg() {
234        let screen = Rect::new(0, 0, 20, 10);
235        let mut grid = Grid::new(20, 10);
236        Modal::new(10, 4)
237            .theme_on(Theme::DARK, Color::Default)
238            .render(screen, &mut Surface::new(&mut grid, screen, 0));
239
240        assert_eq!(
241            grid[Pos::new(5, 3)].style().foreground(),
242            Theme::DARK.border
243        );
244        assert_eq!(
245            grid[Pos::new(5, 3)].style().background(),
246            Theme::DARK.title_bg
247        );
248        assert_eq!(grid[Pos::new(6, 4)].style().background(), Color::Default);
249    }
250
251    #[test]
252    fn border_type_selects_the_glyph_set() {
253        let screen = Rect::new(0, 0, 20, 10);
254        let mut grid = Grid::new(20, 10);
255        Modal::new(10, 4)
256            .border_type(BorderType::Thick)
257            .render(screen, &mut Surface::new(&mut grid, screen, 0));
258
259        // Box is centered_rect(screen, 10, 4) = Rect::new(5, 3, 10, 4).
260        assert_eq!(grid[Pos::new(5, 3)].glyph(), '┏');
261        assert_eq!(grid[Pos::new(14, 3)].glyph(), '┓');
262    }
263}