Skip to main content

retroglyph_widgets/widget/
panel.rs

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