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::panel::PanelChrome;
6use super::{BorderType, Panel, PanelTitle, Widget};
7use crate::Surface;
8use crate::align::Align;
9use crate::layout::centered_rect;
10use crate::style::Sides;
11use crate::theme::Theme;
12
13/// A bordered, filled box centered in a screen [`Rect`].
14///
15/// Shorthand for a [`Panel`] sized `width` x `height` and centered via
16/// [`centered_rect`]. `border_style`/`fill_style` default to
17/// [`Theme::DARK`] (as if [`Modal::theme`] had been called) and there is no title by default: set
18/// whichever a caller needs via [`Modal::border_style`]/[`Modal::fill_style`]/[`Modal::title`],
19/// the same as [`Panel`].
20///
21/// [`Modal::render`] returns the inner content [`Rect`] (via [`Panel::inner`], the border inset
22/// plus this modal's [`Modal::padding`]) ready to hand to another widget (e.g. [`super::Log`]).
23///
24/// Draws only the box itself; everything outside it is left untouched (no
25/// dimming or backdrop fill, that would need to read and blend existing
26/// cells, a separate feature from this thin layout convenience). Not a
27/// [`Widget`]: [`Widget::render`] can't return a value, and the inner
28/// content rect is part of this type's contract.
29///
30/// [`Modal::render`] draws through whatever [`Surface`] it's given, on whatever layer that
31/// surface is already scoped to: it has no layer of its own to default. A modal painted over
32/// an active screen should be given a surface on [`Layer::Overlay`](retroglyph_core::surface::Layer::Overlay)
33/// (`surface.on_tier(Layer::Overlay)`), so it paints on top regardless of whether the screen or
34/// the modal renders first this frame; see [`Layer`](retroglyph_core::surface::Layer)'s docs for why that
35/// beats ordering the two draw calls.
36///
37/// # Examples
38///
39/// ```
40/// use retroglyph_core::grid::{Grid, Rect};
41/// use retroglyph_core::surface::Layer;
42/// use retroglyph_ui::widget::Modal;
43/// use retroglyph_ui::Surface;
44///
45/// let screen = Rect::new(0, 0, 20, 10);
46/// let mut grid = Grid::new(20, 10);
47/// let mut surface = Surface::new(&mut grid, screen, Layer::World.as_u8());
48/// let inner = Modal::new(10, 4)
49///     .title("Confirm")
50///     .render(screen, &mut surface.on_tier(Layer::Overlay));
51/// // `inner` is ready to hand to another widget, e.g. a `Log` or `Text`.
52/// assert_eq!(inner.width(), 8);
53/// ```
54#[derive(Clone, Copy, Debug)]
55pub struct Modal<'a> {
56    width: u16,
57    height: u16,
58    chrome: PanelChrome<'a>,
59    titles: &'a [PanelTitle<'a>],
60}
61
62impl<'a> Modal<'a> {
63    /// A `width` x `height` modal with no title, styled from [`Theme::DARK`] (as if
64    /// [`Modal::theme`] had been called).
65    #[must_use]
66    pub fn new(width: u16, height: u16) -> Self {
67        Self {
68            width,
69            height,
70            chrome: PanelChrome::default().title_align(Align::Center),
71            titles: &[],
72        }
73        .theme(Theme::DARK)
74    }
75
76    /// Set the modal's title.
77    #[must_use]
78    pub const fn title(mut self, title: &'a str) -> Self {
79        self.chrome = self.chrome.title(title);
80        self
81    }
82
83    /// Set how the title is aligned along the top border. Defaults to
84    /// [`Align::Center`], the same as [`Panel::title_align`].
85    #[must_use]
86    pub const fn title_align(mut self, align: Align) -> Self {
87        self.chrome = self.chrome.title_align(align);
88        self
89    }
90
91    /// Add titles beyond [`Modal::title`]'s single top one -- a bottom title, more than one on
92    /// an edge, or one aligned other than via [`Modal::title_align`] -- the [`Modal`] equivalent
93    /// of [`Panel::add_title`]. Additive: never disturbs `title`/`title_align`.
94    ///
95    /// Takes the whole set at once as a borrowed slice, built from [`PanelTitle::new`], rather
96    /// than accumulating one at a time like [`Panel::add_title`]: an owned `Vec` growing across
97    /// repeated calls is exactly what would cost [`Modal`] its [`Copy`] (see this struct's own
98    /// doc comment), so a later call here replaces the slice instead of appending to it. Drawn in
99    /// the same order and with the same per-edge truncation rules as [`Panel::add_title`].
100    #[must_use]
101    pub const fn titles(mut self, titles: &'a [PanelTitle<'a>]) -> Self {
102        self.titles = titles;
103        self
104    }
105
106    /// Set the box outline and title's style.
107    #[must_use]
108    pub const fn border_style(mut self, style: Style) -> Self {
109        self.chrome = self.chrome.border_style(style);
110        self
111    }
112
113    /// Set the interior background's style.
114    #[must_use]
115    pub const fn fill_style(mut self, style: Style) -> Self {
116        self.chrome = self.chrome.fill_style(style);
117        self
118    }
119
120    /// Set which box-drawing glyphs the border is drawn with. Defaults to
121    /// [`BorderType::Plain`], the same as [`Panel::border_type`].
122    #[must_use]
123    pub const fn border_type(mut self, border_type: BorderType) -> Self {
124        self.chrome = self.chrome.border_type(border_type);
125        self
126    }
127
128    /// Reserve `padding` between the border and the rect [`Modal::render`] returns, the same as
129    /// [`Panel::padding`] (a [`Modal`] is just a centered [`Panel`]). Defaults to [`Sides::ZERO`].
130    #[must_use]
131    pub const fn padding(mut self, padding: Sides) -> Self {
132        self.chrome = self.chrome.padding(padding);
133        self
134    }
135
136    /// Applies `theme`'s named roles to this modal's border and fill, the same mapping as
137    /// [`Panel::theme`] (a [`Modal`] is just a centered [`Panel`]): `border_style` becomes
138    /// `theme.border` on `theme.title_bg`, and `fill_style` becomes `theme.panel_bg`.
139    ///
140    /// Call before any manual [`Modal::border_style`]/[`Modal::fill_style`] override you want to
141    /// keep: whichever call comes last wins.
142    #[must_use]
143    pub fn theme(mut self, theme: Theme) -> Self {
144        self.chrome = self.chrome.theme(theme);
145        self
146    }
147
148    /// Same as [`Modal::theme`], but `fill_style` is drawn on `bg` instead of `theme.panel_bg` --
149    /// the same [`Panel::theme_on`] escape hatch, for a modal whose interior should read as a
150    /// different surface than `theme.panel_bg` (`border_style` still uses `theme.title_bg`,
151    /// unaffected by `bg`). [`Modal::theme`] is exactly `theme_on(theme, theme.panel_bg)`.
152    #[must_use]
153    pub fn theme_on(mut self, theme: Theme, bg: Color) -> Self {
154        self.chrome = self.chrome.theme_on(theme, bg);
155        self
156    }
157
158    /// Draw the modal centered in `screen`, returning its inner content
159    /// [`Rect`].
160    pub fn render(self, screen: Rect, surface: &mut Surface<'_>) -> Rect {
161        let rect = centered_rect(screen, self.width, self.height);
162        let mut panel = Panel::from_chrome(self.chrome);
163        for title in self.titles {
164            let (text, position, align) = title.parts();
165            panel = panel.add_title(text, position, align);
166        }
167        panel.render(&mut surface.scope(rect));
168        panel.inner(rect)
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use alloc::string::String;
175
176    use retroglyph_core::grid::{Grid, Pos};
177
178    use super::*;
179    use crate::widget::TitlePosition;
180
181    /// PanelChrome-sharing with `Panel` (retroglyph#1385) must not cost `Modal` its `Copy`: it's
182    /// the reason `Modal` embeds `PanelChrome` by value instead of a `Panel` (not `Copy`, owns a
183    /// `Vec` of titles).
184    #[test]
185    fn modal_is_copy() {
186        const fn assert_copy<T: Copy>() {}
187        assert_copy::<Modal<'_>>();
188    }
189
190    #[test]
191    fn centers_the_box_and_returns_the_inner_content_rect() {
192        let screen = Rect::new(0, 0, 20, 10);
193        let mut grid = Grid::new(20, 10);
194        let inner = Modal::new(10, 4).render(screen, &mut Surface::new(&mut grid, screen, 0));
195
196        // Box is centered_rect(screen, 10, 4) = Rect::new(5, 3, 10, 4);
197        // the inner content rect is inset by the one-cell border.
198        assert_eq!(inner, Rect::new(6, 4, 8, 2));
199        // The border was actually drawn at the box's corners.
200        assert_eq!(grid[Pos::new(5, 3)].glyph(), '┌');
201        assert_eq!(grid[Pos::new(14, 3)].glyph(), '┐');
202    }
203
204    #[test]
205    fn padding_shrinks_the_returned_inner_rect() {
206        let screen = Rect::new(0, 0, 20, 10);
207        let mut grid = Grid::new(20, 10);
208        let inner = Modal::new(10, 4)
209            .padding(Sides::symmetric(0, 1))
210            .render(screen, &mut Surface::new(&mut grid, screen, 0));
211
212        // Box is centered_rect(screen, 10, 4) = Rect::new(5, 3, 10, 4); the border inset is
213        // Rect::new(6, 4, 8, 2), and symmetric(0, 1) padding trims 1 more cell off each side.
214        assert_eq!(inner, Rect::new(7, 4, 6, 2));
215    }
216
217    #[test]
218    fn draws_only_the_box_leaving_the_rest_of_the_screen_untouched() {
219        let screen = Rect::new(0, 0, 20, 10);
220        let mut grid = Grid::new(20, 10);
221        Modal::new(10, 4).render(screen, &mut Surface::new(&mut grid, screen, 0));
222
223        // A corner of the screen far from the centered box is untouched.
224        assert_eq!(grid[Pos::new(0, 0)].glyph(), ' ');
225    }
226
227    #[test]
228    fn theme_maps_named_roles_onto_border_and_fill() {
229        let screen = Rect::new(0, 0, 20, 10);
230        let mut grid = Grid::new(20, 10);
231        Modal::new(10, 4)
232            .theme(Theme::DARK)
233            .render(screen, &mut Surface::new(&mut grid, screen, 0));
234
235        // Box is centered_rect(screen, 10, 4) = Rect::new(5, 3, 10, 4).
236        assert_eq!(
237            grid[Pos::new(5, 3)].style().foreground(),
238            Theme::DARK.border
239        );
240        assert_eq!(
241            grid[Pos::new(5, 3)].style().background(),
242            Theme::DARK.title_bg
243        );
244        assert_eq!(
245            grid[Pos::new(6, 4)].style().background(),
246            Theme::DARK.panel_bg
247        );
248    }
249
250    #[test]
251    fn theme_on_uses_the_given_backdrop_instead_of_panel_bg() {
252        let screen = Rect::new(0, 0, 20, 10);
253        let mut grid = Grid::new(20, 10);
254        Modal::new(10, 4)
255            .theme_on(Theme::DARK, Color::Default)
256            .render(screen, &mut Surface::new(&mut grid, screen, 0));
257
258        assert_eq!(
259            grid[Pos::new(5, 3)].style().foreground(),
260            Theme::DARK.border
261        );
262        assert_eq!(
263            grid[Pos::new(5, 3)].style().background(),
264            Theme::DARK.title_bg
265        );
266        assert_eq!(grid[Pos::new(6, 4)].style().background(), Color::Default);
267    }
268
269    #[test]
270    fn titles_draws_into_the_bottom_border_row() {
271        let screen = Rect::new(0, 0, 12, 8);
272        let mut grid = Grid::new(12, 8);
273        Modal::new(12, 4)
274            .titles(&[PanelTitle::new(
275                "hint",
276                TitlePosition::Bottom,
277                Align::Center,
278            )])
279            .render(screen, &mut Surface::new(&mut grid, screen, 0));
280
281        // Box is centered_rect(screen, 12, 4) = Rect::new(0, 2, 12, 4); bottom border row is y=5.
282        let bottom_row: String = (0..12).map(|x| grid[Pos::new(x, 5)].glyph()).collect();
283        assert!(bottom_row.contains("hint"));
284    }
285
286    #[test]
287    fn title_and_titles_coexist_on_different_edges() {
288        let screen = Rect::new(0, 0, 20, 8);
289        let mut grid = Grid::new(20, 8);
290        Modal::new(20, 4)
291            .title("Confirm")
292            .titles(&[PanelTitle::new(
293                "3 / 10",
294                TitlePosition::Bottom,
295                Align::Right,
296            )])
297            .render(screen, &mut Surface::new(&mut grid, screen, 0));
298
299        // Box is centered_rect(screen, 20, 4) = Rect::new(0, 2, 20, 4); rows 2 (top) and 5 (bottom).
300        let top_row: String = (0..20).map(|x| grid[Pos::new(x, 2)].glyph()).collect();
301        let bottom_row: String = (0..20).map(|x| grid[Pos::new(x, 5)].glyph()).collect();
302        assert!(top_row.contains("Confirm"));
303        assert!(bottom_row.contains("3 / 10"));
304    }
305
306    /// A later `.titles(...)` call replaces the slice rather than appending to it (see the
307    /// method's own doc comment): this is what keeps [`Modal`] [`Copy`] instead of growing a
308    /// `Vec` like [`Panel::add_title`].
309    #[test]
310    fn a_later_titles_call_replaces_the_earlier_one() {
311        let screen = Rect::new(0, 0, 12, 8);
312        let mut grid = Grid::new(12, 8);
313        Modal::new(12, 4)
314            .titles(&[PanelTitle::new(
315                "first",
316                TitlePosition::Bottom,
317                Align::Center,
318            )])
319            .titles(&[PanelTitle::new(
320                "second",
321                TitlePosition::Bottom,
322                Align::Center,
323            )])
324            .render(screen, &mut Surface::new(&mut grid, screen, 0));
325
326        let bottom_row: String = (0..12).map(|x| grid[Pos::new(x, 5)].glyph()).collect();
327        assert!(bottom_row.contains("second"));
328        assert!(!bottom_row.contains("first"));
329    }
330
331    #[test]
332    fn border_type_selects_the_glyph_set() {
333        let screen = Rect::new(0, 0, 20, 10);
334        let mut grid = Grid::new(20, 10);
335        Modal::new(10, 4)
336            .border_type(BorderType::Thick)
337            .render(screen, &mut Surface::new(&mut grid, screen, 0));
338
339        // Box is centered_rect(screen, 10, 4) = Rect::new(5, 3, 10, 4).
340        assert_eq!(grid[Pos::new(5, 3)].glyph(), '┏');
341        assert_eq!(grid[Pos::new(14, 3)].glyph(), '┓');
342    }
343}