retroglyph_widgets/widget/
panel.rs1use 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#[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 #[must_use]
43 pub fn new() -> Self {
44 Self {
45 title_align: Align::Center,
46 ..Self::default()
47 }
48 }
49
50 #[must_use]
52 pub const fn title(mut self, title: &'a str) -> Self {
53 self.title = Some(title);
54 self
55 }
56
57 #[must_use]
60 pub const fn title_align(mut self, align: Align) -> Self {
61 self.title_align = align;
62 self
63 }
64
65 #[must_use]
67 pub const fn border_style(mut self, style: Style) -> Self {
68 self.border_style = style;
69 self
70 }
71
72 #[must_use]
74 pub const fn fill_style(mut self, style: Style) -> Self {
75 self.fill_style = style;
76 self
77 }
78
79 #[must_use]
86 pub fn theme(self, theme: Theme) -> Self {
87 self.theme_on(theme, theme.panel_bg)
88 }
89
90 #[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 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 if let Some(t) = self.title {
123 let max_title_w = area.width().saturating_sub(4) as usize; if max_title_w == 0 {
125 return;
126 }
127 let t = truncate_to_cols(t, max_title_w);
129 #[allow(clippy::cast_possible_truncation)]
132 let t_w = t.width() as u16;
133 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(), ' '); 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); 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 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 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 let area = Rect::new(0, 0, 10, 3); let mut grid = Grid::new(10, 3);
272 Panel::new()
273 .title("あ")
274 .render(area, &mut Surface::new(&mut grid, area, 0));
275
276 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}