Skip to main content

photon_ui/components/
panel.rs

1//! A bordered panel component for wrapping content.
2//!
3//! Renders a rectangular frame with rounded corners (╭─╮││╰─╯) around
4//! optional content lines. Supports titles, custom borders, and theming.
5
6use crate::{
7    Component,
8    RenderError,
9    Rendered,
10    layout::Border,
11    theme::{
12        ColorMode,
13        Palette,
14        Style,
15        Theme,
16    },
17};
18
19/// A panel with a border around optional content.
20pub struct Panel {
21    title: Option<String>,
22    lines: Vec<String>,
23    border: Border,
24    pad: u16,
25}
26
27impl Panel {
28    /// Create an empty panel with the default rounded border.
29    pub fn new() -> Self {
30        Self {
31            title: None,
32            lines: Vec::new(),
33            border: Border::ROUNDED,
34            pad: 1,
35        }
36    }
37
38    /// Set the panel title (rendered in the top border).
39    pub fn title(mut self, title: impl Into<String>) -> Self {
40        self.title = Some(title.into());
41        self
42    }
43
44    /// Set content lines displayed inside the panel.
45    pub fn lines(mut self, lines: Vec<String>) -> Self {
46        self.lines = lines;
47        self
48    }
49
50    /// Set the border style.
51    pub fn border(mut self, border: Border) -> Self {
52        self.border = border;
53        self
54    }
55
56    /// Set inner padding (spaces between border and content).
57    pub fn pad(mut self, pad: u16) -> Self {
58        self.pad = pad;
59        self
60    }
61
62    /// Compute the total height needed for this panel.
63    ///
64    /// Padding is horizontal only; vertical space is determined by the
65    /// border (top/bottom rows) plus content lines.
66    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    /// Build the border style from the current theme.
78    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        // Available space between the two vertical borders.
103        let available = width.saturating_sub(border_w * 2) as usize;
104        // Reduce padding when the width is too small to accommodate full borders +
105        // padding.
106        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        // ── Top border (with optional title) ──
111        {
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        // ── Content rows ──
148        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        // ── Bottom border ──
185        {
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            // Title should appear in top line
260            assert!(rendered.lines[0].contains("Test"));
261            // Content should appear inside
262            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            // 5 cols: ╭ A ╮  → row 1 should have A between borders
272            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            // Padding is horizontal only; height = top border + content + bottom border = 3
284            assert_eq!(rendered.lines.len(), 3);
285            // Content should be indented by border + pad = 1 + 2 = 3 cols
286            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            // Inner width = 10 - 2*1 (border) - 2*1 (pad) = 6
307            // Content should be truncated
308            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            // Border should have ANSI codes
319            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        // top border + 2 content lines + bottom border = 4
327        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    /// Regression: Panel must never produce lines wider than the requested
337    /// width, even when the width is too small to accommodate borders +
338    /// padding.
339    #[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}