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}