photon_ui/components/
panel.rs1use crate::{
7 Component,
8 RenderError,
9 Rendered,
10 layout::Border,
11 theme::{
12 ColorMode,
13 Palette,
14 Style,
15 Theme,
16 },
17};
18
19pub struct Panel {
21 title: Option<String>,
22 lines: Vec<String>,
23 border: Border,
24 pad: u16,
25}
26
27impl Panel {
28 pub fn new() -> Self {
30 Self {
31 title: None,
32 lines: Vec::new(),
33 border: Border::ROUNDED,
34 pad: 1,
35 }
36 }
37
38 pub fn title(mut self, title: impl Into<String>) -> Self {
40 self.title = Some(title.into());
41 self
42 }
43
44 pub fn lines(mut self, lines: Vec<String>) -> Self {
46 self.lines = lines;
47 self
48 }
49
50 pub fn border(mut self, border: Border) -> Self {
52 self.border = border;
53 self
54 }
55
56 pub fn pad(mut self, pad: u16) -> Self {
58 self.pad = pad;
59 self
60 }
61
62 pub fn height(&self) -> u16 {
67 let mut h = self.lines.len() as u16;
68 if self.border.top != ' ' {
69 h += 1;
70 }
71 if self.border.bottom != ' ' {
72 h += 1;
73 }
74 h.max(1)
75 }
76
77 fn border_style(&self) -> Style {
79 Style::new().fg(Theme::current().border_default())
80 }
81}
82
83impl Default for Panel {
84 fn default() -> Self {
85 Self::new()
86 }
87}
88
89impl Component for Panel {
90 fn render(&self, width: u16) -> Result<Rendered, RenderError> {
91 let _theme = Theme::current();
92 let border_style = self.border_style();
93 let (border_w, _border_h) = self.border.size();
94 let pad = self.pad as usize;
95
96 let mode = ColorMode::detect();
97 let prefix = border_style.prefix(mode);
98 let suffix = Style::suffix();
99
100 let mut rendered = Rendered::empty();
101
102 let available = width.saturating_sub(border_w * 2) as usize;
104 let actual_pad = pad.min(available / 2);
107 let inner_width = available.saturating_sub(actual_pad * 2);
108 let total_width = inner_width + actual_pad * 2;
109
110 {
112 let mut top = String::new();
113 top.push_str(&prefix);
114 top.push(self.border.top_left);
115
116 let title_text = self.title.as_ref().map(|t| {
117 let max_title = total_width.saturating_sub(2);
118 let t = if t.len() > max_title {
119 &t[..max_title]
120 } else {
121 t
122 };
123 format!(" {} ", t)
124 });
125
126 if let Some(ref t) = title_text {
127 top.push_str(t);
128 let t_visible = crate::utils::visible_width(t);
129 let fill_count = total_width.saturating_sub(t_visible);
130 if fill_count > 0 {
131 top.push_str(&prefix);
132 top.push_str(&self.border.top.to_string().repeat(fill_count));
133 top.push_str(suffix);
134 }
135 } else {
136 top.push_str(&prefix);
137 top.push_str(&self.border.top.to_string().repeat(total_width));
138 top.push_str(suffix);
139 }
140
141 top.push_str(&prefix);
142 top.push(self.border.top_right);
143 top.push_str(suffix);
144 rendered.lines.push(top);
145 }
146
147 let content_height = self.lines.len().max(1);
149 for i in 0..content_height {
150 let mut line = String::new();
151 if self.border.left != ' ' {
152 line.push_str(&prefix);
153 line.push(self.border.left);
154 line.push_str(suffix);
155 }
156 for _ in 0..actual_pad {
157 line.push(' ');
158 }
159
160 let content = if i < self.lines.len() {
161 crate::utils::truncate_to_width(&self.lines[i], inner_width as u16, "")
162 } else {
163 String::new()
164 };
165 line.push_str(&content);
166
167 let content_visible = crate::utils::visible_width(&content);
168 for _ in content_visible..inner_width {
169 line.push(' ');
170 }
171
172 for _ in 0..actual_pad {
173 line.push(' ');
174 }
175
176 if self.border.right != ' ' && width > 1 {
177 line.push_str(&prefix);
178 line.push(self.border.right);
179 line.push_str(suffix);
180 }
181 rendered.lines.push(line);
182 }
183
184 {
186 let mut bottom = String::new();
187 bottom.push_str(&prefix);
188 bottom.push(self.border.bottom_left);
189 bottom.push_str(&self.border.bottom.to_string().repeat(total_width));
190 bottom.push(self.border.bottom_right);
191 bottom.push_str(suffix);
192 rendered.lines.push(bottom);
193 }
194
195 Ok(rendered)
196 }
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202 use crate::theme::Theme;
203
204 #[test]
205 fn panel_renders_rounded_border() {
206 Theme::with(Theme::Light, || {
207 let panel = Panel::new().lines(vec!["Hello".into()]);
208 let rendered = panel.render(9).unwrap();
209 assert_eq!(rendered.lines.len(), 3);
210 assert!(rendered.lines[0].contains('╭'));
211 assert!(rendered.lines[0].contains('╮'));
212 assert!(rendered.lines[1].contains('│'));
213 assert!(rendered.lines[2].contains('╰'));
214 assert!(rendered.lines[2].contains('╯'));
215 });
216 }
217
218 #[test]
219 fn panel_renders_thin_border() {
220 Theme::with(Theme::Light, || {
221 let panel = Panel::new().border(Border::THIN).lines(vec!["Hi".into()]);
222 let rendered = panel.render(6).unwrap();
223 assert!(rendered.lines[0].contains('┌'));
224 assert!(rendered.lines[0].contains('┐'));
225 assert!(rendered.lines[2].contains('└'));
226 assert!(rendered.lines[2].contains('┘'));
227 });
228 }
229
230 #[test]
231 fn panel_renders_thick_border() {
232 Theme::with(Theme::Light, || {
233 let panel = Panel::new().border(Border::THICK).lines(vec!["X".into()]);
234 let rendered = panel.render(5).unwrap();
235 assert!(rendered.lines[0].contains('┏'));
236 assert!(rendered.lines[0].contains('┓'));
237 assert!(rendered.lines[2].contains('┗'));
238 assert!(rendered.lines[2].contains('┛'));
239 });
240 }
241
242 #[test]
243 fn panel_renders_double_border() {
244 Theme::with(Theme::Light, || {
245 let panel = Panel::new().border(Border::DOUBLE).lines(vec!["X".into()]);
246 let rendered = panel.render(5).unwrap();
247 assert!(rendered.lines[0].contains('╔'));
248 assert!(rendered.lines[0].contains('╗'));
249 assert!(rendered.lines[2].contains('╚'));
250 assert!(rendered.lines[2].contains('╝'));
251 });
252 }
253
254 #[test]
255 fn panel_with_title() {
256 Theme::with(Theme::Light, || {
257 let panel = Panel::new().title("Test").lines(vec!["Content".into()]);
258 let rendered = panel.render(15).unwrap();
259 assert!(rendered.lines[0].contains("Test"));
261 assert!(rendered.lines[1].contains("Content"));
263 });
264 }
265
266 #[test]
267 fn panel_content_is_inside_border() {
268 Theme::with(Theme::Light, || {
269 let panel = Panel::new().lines(vec!["A".into()]);
270 let rendered = panel.render(5).unwrap();
271 let row1 = &rendered.lines[1];
273 assert!(row1.contains('A'));
274 assert!(row1.contains('│'));
275 });
276 }
277
278 #[test]
279 fn panel_with_padding() {
280 Theme::with(Theme::Light, || {
281 let panel = Panel::new().pad(2).lines(vec!["X".into()]);
282 let rendered = panel.render(7).unwrap();
283 assert_eq!(rendered.lines.len(), 3);
285 let row1 = &rendered.lines[1];
287 assert!(row1.contains('X'));
288 });
289 }
290
291 #[test]
292 fn panel_empty_lines_still_has_border() {
293 Theme::with(Theme::Light, || {
294 let panel = Panel::new();
295 let rendered = panel.render(5).unwrap();
296 assert!(rendered.lines[0].contains('╭'));
297 assert!(rendered.lines[0].contains('╮'));
298 });
299 }
300
301 #[test]
302 fn panel_trims_long_content() {
303 Theme::with(Theme::Light, || {
304 let panel = Panel::new().lines(vec!["This is way too long".into()]);
305 let rendered = panel.render(10).unwrap();
306 let row1 = &rendered.lines[1];
309 assert!(!row1.contains("way too long"));
310 });
311 }
312
313 #[test]
314 fn panel_uses_theme_color() {
315 Theme::with(Theme::Light, || {
316 let panel = Panel::new().lines(vec!["Hi".into()]);
317 let rendered = panel.render(6).unwrap();
318 assert!(rendered.lines[0].starts_with('\x1b'));
320 });
321 }
322
323 #[test]
324 fn panel_height_calculation() {
325 let panel = Panel::new().lines(vec!["a".into(), "b".into()]);
326 assert_eq!(panel.height(), 4);
328 }
329
330 #[test]
331 fn panel_default_is_rounded() {
332 let panel = Panel::default();
333 assert_eq!(panel.border, Border::ROUNDED);
334 }
335
336 #[test]
340 fn panel_respects_narrow_width() {
341 Theme::with(Theme::Light, || {
342 let panel = Panel::new().lines(vec!["X".into()]);
343 let rendered = panel.render(3).unwrap();
344 for (i, line) in rendered.lines.iter().enumerate() {
345 let vw = crate::utils::visible_width(line);
346 assert!(
347 vw <= 3,
348 "line {} exceeds width 3 (actual {}): {:?}",
349 i,
350 vw,
351 line
352 );
353 }
354 });
355 }
356}