retroglyph_widgets/widget/
panel.rs1use 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#[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 #[must_use]
39 pub fn new() -> Self {
40 Self {
41 title_align: Align::Center,
42 ..Self::default()
43 }
44 }
45
46 #[must_use]
48 pub const fn title(mut self, title: &'a str) -> Self {
49 self.title = Some(title);
50 self
51 }
52
53 #[must_use]
56 pub const fn title_align(mut self, align: Align) -> Self {
57 self.title_align = align;
58 self
59 }
60
61 #[must_use]
63 pub const fn border_style(mut self, style: Style) -> Self {
64 self.border_style = style;
65 self
66 }
67
68 #[must_use]
70 pub const fn fill_style(mut self, style: Style) -> Self {
71 self.fill_style = style;
72 self
73 }
74
75 #[must_use]
82 pub fn theme(self, theme: Theme) -> Self {
83 self.theme_on(theme, theme.panel_bg)
84 }
85
86 #[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 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 if let Some(t) = self.title {
117 let max_title_w = area.width().saturating_sub(4) as usize; if max_title_w == 0 {
119 return;
120 }
121 let t = truncate_to_cols(t, max_title_w);
123 let t_w = t.width() as u16;
124 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(), ' '); 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); 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 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 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 let area = Rect::new(0, 0, 10, 3); let mut term = Terminal::new(Headless::new(10, 3));
265 Panel::new().title("あ").render(area, &mut term);
266
267 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}