1use crate::{Color, Line, Style, ViewContext};
2use unicode_width::UnicodeWidthStr;
3
4pub const BORDER_H_PAD: u16 = 4;
6
7pub 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 pub fn inner_width(total_width: u16) -> u16 {
67 total_width.saturating_sub(BORDER_H_PAD)
68 }
69
70 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 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); 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 lines.push(empty_border_line(inner_width));
88
89 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 if let Some(target_height) = self.fill_height {
103 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 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 let bottom_inner = width.saturating_sub(2); let bottom_line = format!("└{:─>bottom_inner$}┘", "", bottom_inner = bottom_inner);
121 lines.push(Line::with_style(bottom_line, border_style));
122
123 if let Some(target_height) = self.fill_height {
125 lines.truncate(target_height);
126 }
127
128 lines
129 }
130}
131
132fn 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 let top_span = &lines[0].spans()[0];
193 assert_eq!(top_span.style().fg, Some(Color::Cyan));
194 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 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 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}