Skip to main content

tui/components/
panel.rs

1use crate::{Color, Line, Style, ViewContext};
2use unicode_width::UnicodeWidthStr;
3
4/// Width consumed by left ("│ ") and right (" │") borders.
5pub const BORDER_H_PAD: u16 = 4;
6
7/// A bordered panel for wrapping content blocks with title/footer chrome.
8///
9/// For borderless stacking with cursor tracking, use [`Layout`](super::layout::Layout).
10///
11/// # Example
12///
13/// ```
14/// use tui::{Panel, Line, ViewContext};
15///
16/// let mut panel = Panel::new(tui::Color::Grey)
17///     .title(" Settings ")
18///     .footer("[Enter] Save [Esc] Cancel")
19///     .gap(1);
20///
21/// panel.push(vec![Line::new("Name: Example")]);
22/// panel.push(vec![Line::new("Value: 42")]);
23///
24/// let ctx = ViewContext::new((40, 20));
25/// let lines = panel.render(&ctx);
26/// ```
27pub struct Panel {
28    blocks: Vec<Vec<Line>>,
29    title: Option<String>,
30    footer: Option<String>,
31    border_color: Color,
32    fill_height: Option<usize>,
33    gap: usize,
34}
35
36impl Panel {
37    pub fn new(border_color: Color) -> Self {
38        Self { blocks: Vec::new(), title: None, footer: None, border_color, fill_height: None, gap: 0 }
39    }
40
41    pub fn title(mut self, title: impl Into<String>) -> Self {
42        self.title = Some(title.into());
43        self
44    }
45
46    pub fn footer(mut self, footer: impl Into<String>) -> Self {
47        self.footer = Some(footer.into());
48        self
49    }
50
51    pub fn fill_height(mut self, h: usize) -> Self {
52        self.fill_height = Some(h);
53        self
54    }
55
56    pub fn gap(mut self, lines: usize) -> Self {
57        self.gap = lines;
58        self
59    }
60
61    pub fn push(&mut self, block: Vec<Line>) {
62        self.blocks.push(block);
63    }
64
65    /// Inner content width when borders are active.
66    pub fn inner_width(total_width: u16) -> u16 {
67        total_width.saturating_sub(BORDER_H_PAD)
68    }
69
70    /// Render blocks with borders/chrome.
71    pub fn render(&self, context: &ViewContext) -> Vec<Line> {
72        let width = context.size.width as usize;
73        let inner_width = width.saturating_sub(BORDER_H_PAD as usize);
74        let border_style = Style::fg(self.border_color);
75
76        let mut lines = Vec::new();
77
78        // ── Top border ──
79        let title_text = self.title.as_deref().unwrap_or("");
80        let bar_left = "┌─";
81        let bar_right_pad =
82            width.saturating_sub(UnicodeWidthStr::width(bar_left) + UnicodeWidthStr::width(title_text) + 1); // 1 for ┐
83        let title_line = format!("{bar_left}{title_text}{:─>bar_right_pad$}┐", "", bar_right_pad = bar_right_pad);
84        lines.push(Line::with_style(title_line, border_style));
85
86        // ── Blank line after top border ──
87        lines.push(empty_border_line(inner_width));
88
89        // ── Wrap pre-rendered blocks in borders ──
90        for (i, block) in self.blocks.iter().enumerate() {
91            if i > 0 {
92                for _ in 0..self.gap {
93                    lines.push(empty_border_line(inner_width));
94                }
95            }
96            for cl in block {
97                lines.push(wrap_in_border(cl, inner_width));
98            }
99        }
100
101        // ── Fill padding ──
102        if let Some(target_height) = self.fill_height {
103            // Reserve space for footer (1) + bottom border (1) = 2
104            let reserved = if self.footer.is_some() { 2 } else { 1 };
105            let target_content = target_height.saturating_sub(reserved);
106            while lines.len() < target_content {
107                lines.push(empty_border_line(inner_width));
108            }
109        }
110
111        // ── Footer ──
112        if let Some(ref footer_text) = self.footer {
113            let footer_pad = inner_width.saturating_sub(UnicodeWidthStr::width(footer_text.as_str()));
114            let footer_line_str = format!("│ {footer_text}{:footer_pad$} │", "", footer_pad = footer_pad);
115            lines.push(Line::with_style(footer_line_str, border_style));
116        }
117
118        // ── Bottom border ──
119        let bottom_inner = width.saturating_sub(2); // └ and ┘
120        let bottom_line = format!("└{:─>bottom_inner$}┘", "", bottom_inner = bottom_inner);
121        lines.push(Line::with_style(bottom_line, border_style));
122
123        // Clamp to fill_height if set
124        if let Some(target_height) = self.fill_height {
125            lines.truncate(target_height);
126        }
127
128        lines
129    }
130}
131
132/// Wrap a content line with `│ ... │` borders, extending any bg color through
133/// the padding so the highlight fills the full row width.
134fn wrap_in_border(content: &Line, inner_width: usize) -> Line {
135    let mut padded_content = content.clone();
136    padded_content.extend_bg_to_width(inner_width);
137
138    let mut line = Line::new("│ ".to_string());
139    line.append_line(&padded_content);
140    line.push_text(" │".to_string());
141    line
142}
143
144fn empty_border_line(inner_width: usize) -> Line {
145    Line::new(format!("│ {:inner_width$} │", "", inner_width = inner_width))
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[test]
153    fn title_renders_top_border_with_title_text() {
154        let mut container = Panel::new(Color::Grey).title(" Config ");
155        container.push(vec![Line::new("x")]);
156        let context = ViewContext::new((30, 10));
157        let lines = container.render(&context);
158        let top = lines[0].plain_text();
159        assert!(top.starts_with("┌─ Config "), "top: {top}");
160        assert!(top.ends_with('┐'), "top: {top}");
161    }
162
163    #[test]
164    fn footer_renders_footer_and_bottom_border() {
165        let mut container = Panel::new(Color::Grey).footer("[Esc] Close");
166        container.push(vec![Line::new("x")]);
167        let context = ViewContext::new((30, 10));
168        let lines = container.render(&context);
169        let last = lines.last().unwrap().plain_text();
170        assert!(last.starts_with('└'), "last: {last}");
171        assert!(last.ends_with('┘'), "last: {last}");
172        let footer = lines[lines.len() - 2].plain_text();
173        assert!(footer.contains("[Esc] Close"), "footer: {footer}");
174    }
175
176    #[test]
177    fn fill_height_pads_with_empty_bordered_rows() {
178        let mut container = Panel::new(Color::Grey).title(" T ").footer("F").fill_height(10);
179        container.push(vec![Line::new("x")]);
180        let context = ViewContext::new((30, 10));
181        let lines = container.render(&context);
182        assert_eq!(lines.len(), 10, "should fill to exactly 10 lines");
183    }
184
185    #[test]
186    fn border_color_styles_border_lines() {
187        let mut container = Panel::new(Color::Cyan).title(" T ");
188        container.push(vec![Line::new("x")]);
189        let context = ViewContext::new((30, 10));
190        let lines = container.render(&context);
191        // Top border should have Cyan fg
192        let top_span = &lines[0].spans()[0];
193        assert_eq!(top_span.style().fg, Some(Color::Cyan));
194        // Bottom border should have Cyan fg
195        let bottom_span = &lines.last().unwrap().spans()[0];
196        assert_eq!(bottom_span.style().fg, Some(Color::Cyan));
197    }
198
199    #[test]
200    fn bg_color_extends_through_padding() {
201        let bg = Color::DarkBlue;
202        let mut container = Panel::new(Color::Grey);
203        container.push(vec![Line::with_style("hi", Style::default().bg_color(bg))]);
204        let context = ViewContext::new((20, 10));
205        let lines = container.render(&context);
206        // Content row (top border + blank + first content = index 2)
207        let content_row = &lines[2];
208        let bg_span =
209            content_row.spans().iter().find(|s| s.style().bg == Some(bg)).expect("should have a span with bg color");
210        assert!(bg_span.text().len() > 2, "bg span should extend through padding, got: {:?}", bg_span.text());
211    }
212
213    #[test]
214    fn bordered_gap_inserts_empty_bordered_lines_between_children() {
215        let mut container = Panel::new(Color::Grey).gap(1);
216        container.push(vec![Line::new("a")]);
217        container.push(vec![Line::new("b")]);
218        let context = ViewContext::new((20, 10));
219        let lines = container.render(&context);
220        // top border + blank + "a" + gap_blank + "b" + bottom border = 6
221        assert_eq!(lines.len(), 6);
222        let gap_line = lines[3].plain_text();
223        assert!(gap_line.starts_with('│'), "gap: {gap_line}");
224        assert!(gap_line.ends_with('│'), "gap: {gap_line}");
225    }
226
227    #[test]
228    fn top_and_bottom_border_have_equal_visual_width() {
229        let mut container = Panel::new(Color::Grey).title(" Config ");
230        container.push(vec![Line::new("x")]);
231        let context = ViewContext::new((40, 10));
232        let lines = container.render(&context);
233        let top = lines.first().unwrap().plain_text();
234        let bottom = lines.last().unwrap().plain_text();
235        assert_eq!(
236            UnicodeWidthStr::width(top.as_str()),
237            UnicodeWidthStr::width(bottom.as_str()),
238            "top ({top}) and bottom ({bottom}) border should have equal visual width"
239        );
240    }
241}