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        Style,
14        Theme,
15    },
16};
17
18/// A panel with a border around optional content.
19pub struct Panel {
20    title: Option<String>,
21    lines: Vec<String>,
22    border: Border,
23    pad: u16,
24}
25
26impl Panel {
27    /// Create an empty panel with the default rounded border.
28    pub fn new() -> Self {
29        Self {
30            title: None,
31            lines: Vec::new(),
32            border: Border::ROUNDED,
33            pad: 1,
34        }
35    }
36
37    /// Set the panel title (rendered in the top border).
38    pub fn title(mut self, title: impl Into<String>) -> Self {
39        self.title = Some(title.into());
40        self
41    }
42
43    /// Set content lines displayed inside the panel.
44    pub fn lines(mut self, lines: Vec<String>) -> Self {
45        self.lines = lines;
46        self
47    }
48
49    /// Set the border style.
50    pub fn border(mut self, border: Border) -> Self {
51        self.border = border;
52        self
53    }
54
55    /// Set inner padding (spaces between border and content).
56    pub fn pad(mut self, pad: u16) -> Self {
57        self.pad = pad;
58        self
59    }
60
61    /// Compute the total height needed for this panel.
62    ///
63    /// Padding is horizontal only; vertical space is determined by the
64    /// border (top/bottom rows) plus content lines.
65    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    /// Build the border style from the current theme.
77    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        // Available space between the two vertical borders.
101        let available = width.saturating_sub(border_w * 2) as usize;
102        // Reduce padding when the width is too small to accommodate full borders +
103        // padding.
104        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        // ── Top border (with optional title) ──
109        {
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        // ── Content rows ──
146        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        // ── Bottom border ──
183        {
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            // Title should appear in top line
258            assert!(rendered.lines[0].contains("Test"));
259            // Content should appear inside
260            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            // 5 cols: ╭ A ╮  → row 1 should have A between borders
270            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            // Padding is horizontal only; height = top border + content + bottom border = 3
282            assert_eq!(rendered.lines.len(), 3);
283            // Content should be indented by border + pad = 1 + 2 = 3 cols
284            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            // Inner width = 10 - 2*1 (border) - 2*1 (pad) = 6
305            // Content should be truncated
306            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            // Border should have ANSI codes
317            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        // top border + 2 content lines + bottom border = 4
325        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    /// Regression: Panel must never produce lines wider than the requested
335    /// width, even when the width is too small to accommodate borders +
336    /// padding.
337    #[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}