Skip to main content

retroglyph_widgets/widget/
panel.rs

1//! [`Panel`]: a bordered, titled panel.
2use retroglyph_core::{Backend, Color, Rect, Style, Terminal};
3use unicode_width::UnicodeWidthStr;
4
5use super::{BoxBorder, Widget};
6use crate::draw::fill_rect;
7use crate::text::truncate as truncate_to_cols;
8use crate::{Align, Theme};
9
10/// A bordered panel: a filled background with a box border and an optional
11/// title in the top edge.
12///
13/// `border_style` (the box outline and title) and `fill_style` (the
14/// interior background) both default to [`Style::new()`]; there is no
15/// title by default, and the title (if any) defaults to [`Align::Center`].
16/// Set whichever of these a caller needs via
17/// [`Panel::border_style`]/[`Panel::fill_style`]/[`Panel::title`]/[`Panel::title_align`].
18///
19/// # Examples
20///
21/// ```
22/// use retroglyph_core::{Headless, Rect, Terminal};
23/// use retroglyph_widgets::{Panel, Widget};
24///
25/// let mut term = Terminal::new(Headless::new(20, 5));
26/// Panel::new().title("Status").render(Rect::new(0, 0, 20, 5), &mut term);
27/// ```
28#[derive(Clone, Copy, Debug, Default)]
29pub struct Panel<'a> {
30    title: Option<&'a str>,
31    title_align: Align,
32    border_style: Style,
33    fill_style: Style,
34}
35
36impl<'a> Panel<'a> {
37    /// A plain, untitled panel in the default style.
38    #[must_use]
39    pub fn new() -> Self {
40        Self {
41            title_align: Align::Center,
42            ..Self::default()
43        }
44    }
45
46    /// Set the panel's title.
47    #[must_use]
48    pub const fn title(mut self, title: &'a str) -> Self {
49        self.title = Some(title);
50        self
51    }
52
53    /// Set how the title is aligned along the top border. Defaults to
54    /// [`Align::Center`].
55    #[must_use]
56    pub const fn title_align(mut self, align: Align) -> Self {
57        self.title_align = align;
58        self
59    }
60
61    /// Set the box outline and title's style.
62    #[must_use]
63    pub const fn border_style(mut self, style: Style) -> Self {
64        self.border_style = style;
65        self
66    }
67
68    /// Set the interior background's style.
69    #[must_use]
70    pub const fn fill_style(mut self, style: Style) -> Self {
71        self.fill_style = style;
72        self
73    }
74
75    /// Applies `theme`'s named roles to this panel's border and fill: `border_style` becomes
76    /// `theme.border` on `theme.title_bg` (the same background the title, if any, is drawn on),
77    /// and `fill_style` becomes `theme.panel_bg`.
78    ///
79    /// Like every other builder method here, whichever call comes last wins -- call `.theme(...)`
80    /// before any manual [`Panel::border_style`]/[`Panel::fill_style`] override you want to keep.
81    #[must_use]
82    pub fn theme(self, theme: Theme) -> Self {
83        self.theme_on(theme, theme.panel_bg)
84    }
85
86    /// Same as [`Panel::theme`], but `fill_style` is drawn on `bg` instead of `theme.panel_bg` --
87    /// for a panel whose interior should read as a different surface than `theme.panel_bg`
88    /// (`border_style` still uses `theme.title_bg`, unaffected by `bg`). [`Panel::theme`] is
89    /// exactly `theme_on(theme, theme.panel_bg)`.
90    #[must_use]
91    pub fn theme_on(mut self, theme: Theme, bg: Color) -> Self {
92        self.border_style = Style::new().fg(theme.border).bg(theme.title_bg);
93        self.fill_style = Style::new().bg(bg);
94        self
95    }
96}
97
98impl<B: Backend> Widget<B> for Panel<'_> {
99    fn render(self, area: Rect, term: &mut Terminal<B>) {
100        if area.width() < 2 || area.height() < 2 {
101            return;
102        }
103
104        // Fill interior (inside the border).
105        let inner = Rect::new(
106            area.left() + 1,
107            area.top() + 1,
108            area.width().saturating_sub(2),
109            area.height().saturating_sub(2),
110        );
111        fill_rect(term, inner, ' ', self.fill_style);
112
113        BoxBorder::new().style(self.border_style).render(area, term);
114
115        // Render the title into the top border if one was provided.
116        if let Some(t) = self.title {
117            let max_title_w = area.width().saturating_sub(4) as usize; // 2 border + 2 spaces
118            if max_title_w == 0 {
119                return;
120            }
121            // Truncate to fit.
122            let t = truncate_to_cols(t, max_title_w);
123            let t_w = t.width() as u16;
124            // The padded title (a space either side of the text) is aligned
125            // within the region between the two corners (`area.width() - 2`).
126            let padded = t_w + 2;
127            let title_x = area.left() + 1 + self.title_align.offset(area.width() - 2, padded);
128            let title_y = area.top();
129            term.reset_style()
130                .fg(self.border_style.foreground())
131                .bg(self.border_style.background());
132            term.put(title_x, title_y, ' ');
133            term.print(title_x + 1, title_y, t);
134            term.put(title_x + 1 + t_w, title_y, ' ');
135            term.reset_style();
136        }
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use retroglyph_core::{Color, Headless};
143
144    use super::*;
145
146    #[test]
147    fn draws_border_fill_and_title() {
148        let area = Rect::new(0, 0, 10, 4);
149        let border = Style::new().fg(Color::WHITE);
150        let fill = Style::new();
151
152        let mut term = Terminal::new(Headless::new(10, 4));
153        Panel::new()
154            .border_style(border)
155            .fill_style(fill)
156            .title("hi")
157            .render(area, &mut term);
158
159        assert_eq!(term.grid().get(0, 0).glyph(), '┌');
160        assert_eq!(term.grid().get(1, 1).glyph(), ' '); // interior filled
161        // Title centred in the top border somewhere.
162        let top_row: String = (0..10).map(|x| term.grid().get(x, 0).glyph()).collect();
163        assert!(top_row.contains("hi"));
164    }
165
166    #[test]
167    fn long_title_is_truncated_to_fit() {
168        let area = Rect::new(0, 0, 8, 3); // max_title_w = 8 - 4 = 4
169        let mut term = Terminal::new(Headless::new(8, 3));
170        Panel::new()
171            .title("a very long title")
172            .render(area, &mut term);
173
174        let top_row: String = (0..8).map(|x| term.grid().get(x, 0).glyph()).collect();
175        assert!(!top_row.contains("a very long title"));
176    }
177
178    #[test]
179    fn theme_maps_named_roles_onto_border_and_fill() {
180        let area = Rect::new(0, 0, 10, 4);
181        let mut term = Terminal::new(Headless::new(10, 4));
182        Panel::new().theme(Theme::DARK).render(area, &mut term);
183
184        assert_eq!(
185            term.grid().get(0, 0).style().foreground(),
186            Theme::DARK.border
187        );
188        assert_eq!(
189            term.grid().get(0, 0).style().background(),
190            Theme::DARK.title_bg
191        );
192        assert_eq!(
193            term.grid().get(1, 1).style().background(),
194            Theme::DARK.panel_bg
195        );
196    }
197
198    #[test]
199    fn theme_on_uses_the_given_backdrop_instead_of_panel_bg() {
200        let area = Rect::new(0, 0, 10, 4);
201        let mut term = Terminal::new(Headless::new(10, 4));
202        Panel::new()
203            .theme_on(Theme::DARK, Color::Default)
204            .render(area, &mut term);
205
206        assert_eq!(
207            term.grid().get(0, 0).style().foreground(),
208            Theme::DARK.border
209        );
210        assert_eq!(
211            term.grid().get(0, 0).style().background(),
212            Theme::DARK.title_bg
213        );
214        assert_eq!(term.grid().get(1, 1).style().background(), Color::Default);
215    }
216
217    #[test]
218    fn left_aligned_title_starts_after_the_corner() {
219        let area = Rect::new(0, 0, 12, 3);
220        let mut term = Terminal::new(Headless::new(12, 3));
221        Panel::new()
222            .title("hi")
223            .title_align(Align::Left)
224            .render(area, &mut term);
225
226        // Padded title " hi " starts at column 1 (just inside the corner):
227        // space at 1, text at 2..4, trailing space at 4.
228        assert_eq!(term.grid().get(1, 0).glyph(), ' ');
229        assert_eq!(term.grid().get(2, 0).glyph(), 'h');
230        assert_eq!(term.grid().get(3, 0).glyph(), 'i');
231    }
232
233    #[test]
234    fn right_aligned_title_ends_before_the_corner() {
235        let area = Rect::new(0, 0, 12, 3);
236        let mut term = Terminal::new(Headless::new(12, 3));
237        Panel::new()
238            .title("hi")
239            .title_align(Align::Right)
240            .render(area, &mut term);
241
242        // Padded title " hi " (4 cols) ends against the right corner at
243        // column 11: trailing space at 10, text at 8..10.
244        assert_eq!(term.grid().get(8, 0).glyph(), 'h');
245        assert_eq!(term.grid().get(9, 0).glyph(), 'i');
246        assert_eq!(term.grid().get(10, 0).glyph(), ' ');
247    }
248
249    #[test]
250    fn too_small_is_a_no_op() {
251        let area = Rect::new(0, 0, 1, 1);
252        let mut term = Terminal::new(Headless::new(1, 1));
253        Panel::new().render(area, &mut term);
254        assert_eq!(term.grid().get(0, 0).glyph(), ' ');
255    }
256
257    #[test]
258    fn wide_char_title_is_centred_by_display_width_not_byte_length() {
259        // "あ" is 1 char, 3 bytes (UTF-8), 2 display columns. A byte-length
260        // title width (the pre-fix bug) would reserve 3 columns for it and
261        // miscentre the title, and would place the trailing space one
262        // column further right than it should be.
263        let area = Rect::new(0, 0, 10, 3); // max_title_w = 10 - 4 = 6
264        let mut term = Terminal::new(Headless::new(10, 3));
265        Panel::new().title("あ").render(area, &mut term);
266
267        // title_x = 0 + (10 - 2 - 2) / 2 = 3; title glyph at 4, trailing
268        // space at 5. With the pre-fix byte-length bug (width 3) this would
269        // compute title_x = (10 - 3 - 2) / 2 = 2, off by one.
270        assert_eq!(term.grid().get(3, 0).glyph(), ' ');
271        assert_eq!(term.grid().get(4, 0).glyph(), 'あ');
272        assert_eq!(term.grid().get(5, 0).glyph(), ' ');
273    }
274}