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)]
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 #[must_use]
29 pub fn new() -> Self {
30 Self {
31 title_align: Align::Center,
32 ..Self::default()
33 }
34 }
35
36 #[must_use]
38 pub const fn title(mut self, title: &'a str) -> Self {
39 self.title = Some(title);
40 self
41 }
42
43 #[must_use]
46 pub const fn title_align(mut self, align: Align) -> Self {
47 self.title_align = align;
48 self
49 }
50
51 #[must_use]
53 pub const fn border_style(mut self, style: Style) -> Self {
54 self.border_style = style;
55 self
56 }
57
58 #[must_use]
60 pub const fn fill_style(mut self, style: Style) -> Self {
61 self.fill_style = style;
62 self
63 }
64
65 #[must_use]
72 pub fn theme(self, theme: Theme) -> Self {
73 self.theme_on(theme, theme.panel_bg)
74 }
75
76 #[must_use]
81 pub fn theme_on(mut self, theme: Theme, bg: Color) -> Self {
82 self.border_style = Style::new().fg(theme.border).bg(theme.title_bg);
83 self.fill_style = Style::new().bg(bg);
84 self
85 }
86}
87
88impl<B: Backend> Widget<B> for Panel<'_> {
89 fn render(self, area: Rect, term: &mut Terminal<B>) {
90 if area.width() < 2 || area.height() < 2 {
91 return;
92 }
93
94 let inner = Rect::new(
96 area.left() + 1,
97 area.top() + 1,
98 area.width().saturating_sub(2),
99 area.height().saturating_sub(2),
100 );
101 fill_rect(term, inner, ' ', self.fill_style);
102
103 BoxBorder::new().style(self.border_style).render(area, term);
104
105 if let Some(t) = self.title {
107 let max_title_w = area.width().saturating_sub(4) as usize; if max_title_w == 0 {
109 return;
110 }
111 let t = truncate_to_cols(t, max_title_w);
113 let t_w = t.width() as u16;
114 let padded = t_w + 2;
117 let title_x = area.left() + 1 + self.title_align.offset(area.width() - 2, padded);
118 let title_y = area.top();
119 term.reset_style()
120 .fg(self.border_style.foreground())
121 .bg(self.border_style.background());
122 term.put(title_x, title_y, ' ');
123 term.print(title_x + 1, title_y, t);
124 term.put(title_x + 1 + t_w, title_y, ' ');
125 term.reset_style();
126 }
127 }
128}
129
130#[cfg(test)]
131mod tests {
132 use retroglyph_core::{Color, Headless};
133
134 use super::*;
135
136 #[test]
137 fn draws_border_fill_and_title() {
138 let area = Rect::new(0, 0, 10, 4);
139 let border = Style::new().fg(Color::WHITE);
140 let fill = Style::new();
141
142 let mut term = Terminal::new(Headless::new(10, 4));
143 Panel::new()
144 .border_style(border)
145 .fill_style(fill)
146 .title("hi")
147 .render(area, &mut term);
148
149 assert_eq!(term.grid().get(0, 0).glyph(), '┌');
150 assert_eq!(term.grid().get(1, 1).glyph(), ' '); let top_row: String = (0..10).map(|x| term.grid().get(x, 0).glyph()).collect();
153 assert!(top_row.contains("hi"));
154 }
155
156 #[test]
157 fn long_title_is_truncated_to_fit() {
158 let area = Rect::new(0, 0, 8, 3); let mut term = Terminal::new(Headless::new(8, 3));
160 Panel::new()
161 .title("a very long title")
162 .render(area, &mut term);
163
164 let top_row: String = (0..8).map(|x| term.grid().get(x, 0).glyph()).collect();
165 assert!(!top_row.contains("a very long title"));
166 }
167
168 #[test]
169 fn theme_maps_named_roles_onto_border_and_fill() {
170 let area = Rect::new(0, 0, 10, 4);
171 let mut term = Terminal::new(Headless::new(10, 4));
172 Panel::new().theme(Theme::DARK).render(area, &mut term);
173
174 assert_eq!(
175 term.grid().get(0, 0).style().foreground(),
176 Theme::DARK.border
177 );
178 assert_eq!(
179 term.grid().get(0, 0).style().background(),
180 Theme::DARK.title_bg
181 );
182 assert_eq!(
183 term.grid().get(1, 1).style().background(),
184 Theme::DARK.panel_bg
185 );
186 }
187
188 #[test]
189 fn theme_on_uses_the_given_backdrop_instead_of_panel_bg() {
190 let area = Rect::new(0, 0, 10, 4);
191 let mut term = Terminal::new(Headless::new(10, 4));
192 Panel::new()
193 .theme_on(Theme::DARK, Color::Default)
194 .render(area, &mut term);
195
196 assert_eq!(
197 term.grid().get(0, 0).style().foreground(),
198 Theme::DARK.border
199 );
200 assert_eq!(
201 term.grid().get(0, 0).style().background(),
202 Theme::DARK.title_bg
203 );
204 assert_eq!(term.grid().get(1, 1).style().background(), Color::Default);
205 }
206
207 #[test]
208 fn left_aligned_title_starts_after_the_corner() {
209 let area = Rect::new(0, 0, 12, 3);
210 let mut term = Terminal::new(Headless::new(12, 3));
211 Panel::new()
212 .title("hi")
213 .title_align(Align::Left)
214 .render(area, &mut term);
215
216 assert_eq!(term.grid().get(1, 0).glyph(), ' ');
219 assert_eq!(term.grid().get(2, 0).glyph(), 'h');
220 assert_eq!(term.grid().get(3, 0).glyph(), 'i');
221 }
222
223 #[test]
224 fn right_aligned_title_ends_before_the_corner() {
225 let area = Rect::new(0, 0, 12, 3);
226 let mut term = Terminal::new(Headless::new(12, 3));
227 Panel::new()
228 .title("hi")
229 .title_align(Align::Right)
230 .render(area, &mut term);
231
232 assert_eq!(term.grid().get(8, 0).glyph(), 'h');
235 assert_eq!(term.grid().get(9, 0).glyph(), 'i');
236 assert_eq!(term.grid().get(10, 0).glyph(), ' ');
237 }
238
239 #[test]
240 fn too_small_is_a_no_op() {
241 let area = Rect::new(0, 0, 1, 1);
242 let mut term = Terminal::new(Headless::new(1, 1));
243 Panel::new().render(area, &mut term);
244 assert_eq!(term.grid().get(0, 0).glyph(), ' ');
245 }
246
247 #[test]
248 fn wide_char_title_is_centred_by_display_width_not_byte_length() {
249 let area = Rect::new(0, 0, 10, 3); let mut term = Terminal::new(Headless::new(10, 3));
255 Panel::new().title("あ").render(area, &mut term);
256
257 assert_eq!(term.grid().get(3, 0).glyph(), ' ');
261 assert_eq!(term.grid().get(4, 0).glyph(), 'あ');
262 assert_eq!(term.grid().get(5, 0).glyph(), ' ');
263 }
264}