1use crate::{Color, FitOptions, Frame, 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<PanelFooter>,
31 border_color: Color,
32 fill_height: Option<usize>,
33 gap: usize,
34}
35
36enum PanelFooter {
37 Text(String),
38 Line(Line),
39}
40
41impl Panel {
42 pub fn new(border_color: Color) -> Self {
43 Self { blocks: Vec::new(), title: None, footer: None, border_color, fill_height: None, gap: 0 }
44 }
45
46 pub fn title(mut self, title: impl Into<String>) -> Self {
47 self.title = Some(title.into());
48 self
49 }
50
51 pub fn footer(mut self, footer: impl Into<String>) -> Self {
52 self.footer = Some(PanelFooter::Text(footer.into()));
53 self
54 }
55
56 pub fn footer_line(mut self, footer: Line) -> Self {
57 self.footer = Some(PanelFooter::Line(footer));
58 self
59 }
60
61 pub fn fill_height(mut self, h: usize) -> Self {
62 self.fill_height = Some(h);
63 self
64 }
65
66 pub fn gap(mut self, lines: usize) -> Self {
67 self.gap = lines;
68 self
69 }
70
71 pub fn push(&mut self, block: Vec<Line>) {
72 self.blocks.push(block);
73 }
74
75 pub fn inner_width(total_width: u16) -> u16 {
77 total_width.saturating_sub(BORDER_H_PAD)
78 }
79
80 pub fn render(&self, context: &ViewContext) -> Frame {
82 let width = context.size.width as usize;
83 let inner_width = width.saturating_sub(BORDER_H_PAD as usize);
84 let inner_width_u16 = u16::try_from(inner_width).unwrap_or(u16::MAX);
85 let border_style = Style::fg(self.border_color);
86 let border_left = Line::new("│ ".to_string());
87 let border_right = Line::new(" │".to_string());
88
89 let blank_border = || Frame::new(vec![empty_border_line(inner_width)]);
90
91 let title_text = self.title.as_deref().unwrap_or("");
92 let bar_left = "┌─";
93 let bar_right_pad =
94 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);
96 let top_frame = Frame::new(vec![Line::with_style(title_line, border_style)]);
97
98 let mut body_frames: Vec<Frame> = vec![blank_border()];
99 for (i, block) in self.blocks.iter().enumerate() {
100 if i > 0 {
101 for _ in 0..self.gap {
102 body_frames.push(blank_border());
103 }
104 }
105 body_frames.push(Frame::new(block.clone()).fit(inner_width_u16, FitOptions::wrap().with_fill()).wrap_each(
106 inner_width_u16,
107 &border_left,
108 &border_right,
109 ));
110 }
111 let mut body_frame = Frame::vstack(body_frames);
112
113 if let Some(target_height) = self.fill_height {
114 let chrome_rows = if self.footer.is_some() { 3 } else { 2 };
116 let target_body = target_height.saturating_sub(chrome_rows);
117 let current = body_frame.lines().len();
118 if current < target_body {
119 let pad: Vec<Frame> = (0..(target_body - current)).map(|_| blank_border()).collect();
120 body_frame = Frame::vstack(std::iter::once(body_frame).chain(pad));
121 }
122 }
123
124 let mut chrome: Vec<Frame> = Vec::with_capacity(2);
125 if let Some(footer) = &self.footer {
126 chrome.push(Frame::new(vec![render_footer(footer, inner_width, border_style)]));
127 }
128 let bottom_inner = width.saturating_sub(2); let bottom_line = format!("└{:─>bottom_inner$}┘", "", bottom_inner = bottom_inner);
130 chrome.push(Frame::new(vec![Line::with_style(bottom_line, border_style)]));
131
132 let result = Frame::vstack(std::iter::once(top_frame).chain(std::iter::once(body_frame)).chain(chrome));
133
134 if let Some(target_height) = self.fill_height {
135 result.truncate_height(u16::try_from(target_height).unwrap_or(u16::MAX))
136 } else {
137 result
138 }
139 }
140}
141
142fn render_footer(footer: &PanelFooter, inner_width: usize, border_style: Style) -> Line {
143 match footer {
144 PanelFooter::Text(text) => {
145 let footer_pad = inner_width.saturating_sub(UnicodeWidthStr::width(text.as_str()));
146 let footer_line = format!("│ {text}{:footer_pad$} │", "", footer_pad = footer_pad);
147 Line::with_style(footer_line, border_style)
148 }
149 PanelFooter::Line(line) => {
150 let footer_pad = inner_width.saturating_sub(line.display_width());
151 let mut footer_line = Line::with_style("│ ", border_style);
152 footer_line.append_line(line);
153 footer_line.push_with_style(format!("{:footer_pad$} │", "", footer_pad = footer_pad), border_style);
154 footer_line
155 }
156 }
157}
158
159fn empty_border_line(inner_width: usize) -> Line {
160 Line::new(format!("│ {:inner_width$} │", "", inner_width = inner_width))
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166
167 #[test]
168 fn title_renders_top_border_with_title_text() {
169 let mut container = Panel::new(Color::Grey).title(" Config ");
170 container.push(vec![Line::new("x")]);
171 let context = ViewContext::new((30, 10));
172 let lines = container.render(&context).into_lines();
173 let top = lines[0].plain_text();
174 assert!(top.starts_with("┌─ Config "), "top: {top}");
175 assert!(top.ends_with('┐'), "top: {top}");
176 }
177
178 #[test]
179 fn footer_renders_footer_and_bottom_border() {
180 let mut container = Panel::new(Color::Grey).footer("[Esc] Close");
181 container.push(vec![Line::new("x")]);
182 let context = ViewContext::new((30, 10));
183 let lines = container.render(&context).into_lines();
184 let last = lines.last().unwrap().plain_text();
185 assert!(last.starts_with('└'), "last: {last}");
186 assert!(last.ends_with('┘'), "last: {last}");
187 let footer = lines[lines.len() - 2].plain_text();
188 assert!(footer.contains("[Esc] Close"), "footer: {footer}");
189 }
190
191 #[test]
192 fn fill_height_pads_with_empty_bordered_rows() {
193 let mut container = Panel::new(Color::Grey).title(" T ").footer("F").fill_height(10);
194 container.push(vec![Line::new("x")]);
195 let context = ViewContext::new((30, 10));
196 let lines = container.render(&context).into_lines();
197 assert_eq!(lines.len(), 10, "should fill to exactly 10 lines");
198 }
199
200 #[test]
201 fn border_color_styles_border_lines() {
202 let mut container = Panel::new(Color::Cyan).title(" T ");
203 container.push(vec![Line::new("x")]);
204 let context = ViewContext::new((30, 10));
205 let lines = container.render(&context).into_lines();
206 let top_span = &lines[0].spans()[0];
208 assert_eq!(top_span.style().fg, Some(Color::Cyan));
209 let bottom_span = &lines.last().unwrap().spans()[0];
211 assert_eq!(bottom_span.style().fg, Some(Color::Cyan));
212 }
213
214 #[test]
215 fn bg_color_extends_through_padding() {
216 let bg = Color::DarkBlue;
217 let mut container = Panel::new(Color::Grey);
218 container.push(vec![Line::with_style("hi", Style::default().bg_color(bg))]);
219 let context = ViewContext::new((20, 10));
220 let lines = container.render(&context).into_lines();
221 let content_row = &lines[2];
223 let bg_span =
224 content_row.spans().iter().find(|s| s.style().bg == Some(bg)).expect("should have a span with bg color");
225 assert!(bg_span.text().len() > 2, "bg span should extend through padding, got: {:?}", bg_span.text());
226 }
227
228 #[test]
229 fn bordered_gap_inserts_empty_bordered_lines_between_children() {
230 let mut container = Panel::new(Color::Grey).gap(1);
231 container.push(vec![Line::new("a")]);
232 container.push(vec![Line::new("b")]);
233 let context = ViewContext::new((20, 10));
234 let lines = container.render(&context).into_lines();
235 assert_eq!(lines.len(), 6);
237 let gap_line = lines[3].plain_text();
238 assert!(gap_line.starts_with('│'), "gap: {gap_line}");
239 assert!(gap_line.ends_with('│'), "gap: {gap_line}");
240 }
241
242 #[test]
243 fn overlong_content_wraps_inside_borders() {
244 let mut container = Panel::new(Color::Grey);
245 container.push(vec![Line::new("abcdefghijklmnop")]);
246 let context = ViewContext::new((14, 10));
248 let lines = container.render(&context).into_lines();
249
250 assert_eq!(lines.len(), 5);
252 assert_eq!(lines[2].plain_text(), "│ abcdefghij │");
253 assert_eq!(lines[3].plain_text(), "│ klmnop │");
254 }
255
256 #[test]
257 fn top_and_bottom_border_have_equal_visual_width() {
258 let mut container = Panel::new(Color::Grey).title(" Config ");
259 container.push(vec![Line::new("x")]);
260 let context = ViewContext::new((40, 10));
261 let lines = container.render(&context).into_lines();
262 let top = lines.first().unwrap().plain_text();
263 let bottom = lines.last().unwrap().plain_text();
264 assert_eq!(
265 UnicodeWidthStr::width(top.as_str()),
266 UnicodeWidthStr::width(bottom.as_str()),
267 "top ({top}) and bottom ({bottom}) border should have equal visual width"
268 );
269 }
270}