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