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