Skip to main content

retroglyph_widgets/widget/
panel.rs

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