1use crate::align::HorizontalAlign;
10use crate::console::{Console, ConsoleOptions};
11use crate::padding::join_rows;
12use crate::protocol::Renderable;
13use crate::r#box::{Box as BoxSet, ROUNDED};
14use crate::segment::Segment;
15use crate::style::Style;
16use crate::text::{Text, DEFAULT_TAB_SIZE};
17
18pub struct Panel {
20 child: Box<dyn Renderable>,
21 box_set: BoxSet,
22 title: Option<String>,
23 title_align: HorizontalAlign,
24 subtitle: Option<String>,
25 subtitle_align: HorizontalAlign,
26 padding: (usize, usize, usize, usize),
27 border_style: Style,
28 style: Style,
29}
30
31impl Panel {
32 pub fn new(child: Box<dyn Renderable>) -> Self {
34 Panel {
35 child,
36 box_set: ROUNDED,
37 title: None,
38 title_align: HorizontalAlign::Center,
39 subtitle: None,
40 subtitle_align: HorizontalAlign::Center,
41 padding: (0, 1, 0, 1),
42 border_style: Style::new(),
43 style: Style::new(),
44 }
45 }
46
47 pub fn title(mut self, title: impl Into<String>) -> Self {
49 self.title = Some(title.into());
50 self
51 }
52
53 pub fn title_align(mut self, align: HorizontalAlign) -> Self {
55 self.title_align = align;
56 self
57 }
58
59 pub fn subtitle(mut self, subtitle: impl Into<String>) -> Self {
61 self.subtitle = Some(subtitle.into());
62 self
63 }
64
65 pub fn subtitle_align(mut self, align: HorizontalAlign) -> Self {
67 self.subtitle_align = align;
68 self
69 }
70
71 pub fn box_set(mut self, box_set: BoxSet) -> Self {
73 self.box_set = box_set;
74 self
75 }
76
77 pub fn padding(mut self, padding: (usize, usize, usize, usize)) -> Self {
79 self.padding = padding;
80 self
81 }
82
83 pub fn border_style(mut self, style: Style) -> Self {
85 self.border_style = style;
86 self
87 }
88
89 fn border_line(
92 &self,
93 console: &Console,
94 inner_width: usize,
95 corners: (char, char, char),
96 label: Option<&String>,
97 align: HorizontalAlign,
98 ) -> Vec<Segment> {
99 let (left_corner, fill_char, right_corner) = corners;
100 let border_style = Some(self.border_style.clone());
101 let Some(label) = label.filter(|label| !label.is_empty() && inner_width > 2) else {
102 return vec![Segment::new(
103 format!(
104 "{left_corner}{}{right_corner}",
105 fill_char.to_string().repeat(inner_width)
106 ),
107 border_style,
108 )];
109 };
110
111 let expanded = crate::emoji::replace(label);
114 let parsed = Text::from_markup(&expanded).unwrap_or_else(|_| Text::new(expanded));
115 let mut label = parsed.blank_copy();
116 label.append(&parsed.plain().replace('\n', " "), None);
117 for span in parsed.spans() {
118 label.push_span(span.clone());
119 }
120 label.expand_tabs(DEFAULT_TAB_SIZE);
121 label.pad(1, ' ');
122 label.set_base_style(self.border_style.clone());
123 let label_width = inner_width - 2;
124 label.truncate(label_width, None, false);
125
126 let fill = label_width.saturating_sub(label.cell_len());
127 let (left, right) = match align {
128 HorizontalAlign::Center => (fill / 2, fill - fill / 2),
129 HorizontalAlign::Left => (0, fill),
130 HorizontalAlign::Right => (fill, 0),
131 };
132 let mut text = Text::styled(
133 fill_char.to_string().repeat(left),
134 self.border_style.clone(),
135 )
136 .append_text(&label);
137 text.append(
138 &fill_char.to_string().repeat(right),
139 Some(self.border_style.clone().into()),
140 );
141 let mut segments = vec![Segment::new(
142 format!("{left_corner}{fill_char}"),
143 border_style.clone(),
144 )];
145 segments.extend(text.render(console.theme(), console.base_style()));
146 segments.push(Segment::new(
147 format!("{fill_char}{right_corner}"),
148 border_style,
149 ));
150 segments
151 }
152}
153
154impl Renderable for Panel {
155 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
156 let width = options.max_width;
157 let box_set = self.box_set.substitute(
159 console.legacy_windows(),
160 console.safe_box(),
161 console.ascii_only(),
162 );
163 let inner_width = width.saturating_sub(2);
164 let (pt, pr, pb, pl) = self.padding;
165 let child_width = inner_width.saturating_sub(pl).saturating_sub(pr);
166
167 let mut child_options = options.update_width(child_width);
168 child_options.height = options.height.map(|h| h.saturating_sub(2 + pt + pb));
173 let child_lines = console.render_lines(self.child.as_ref(), &child_options, true);
174
175 let border = Some(self.border_style.clone());
176 let inner_style = Some(self.style.clone());
177 let left_border = || Segment::new(box_set.mid_left.to_string(), border.clone());
178 let right_border = || Segment::new(box_set.mid_right.to_string(), border.clone());
179 let blank_inner = || Segment::new(" ".repeat(inner_width), inner_style.clone());
180
181 let mut rows: Vec<Vec<Segment>> = Vec::new();
182
183 rows.push(self.border_line(
185 console,
186 inner_width,
187 (box_set.top_left, box_set.top, box_set.top_right),
188 self.title.as_ref(),
189 self.title_align,
190 ));
191
192 for _ in 0..pt {
194 rows.push(vec![left_border(), blank_inner(), right_border()]);
195 }
196
197 for line in child_lines {
199 let mut row = vec![left_border()];
200 if pl > 0 {
201 row.push(Segment::new(" ".repeat(pl), inner_style.clone()));
202 }
203 row.extend(line);
204 if pr > 0 {
205 row.push(Segment::new(" ".repeat(pr), inner_style.clone()));
206 }
207 row.push(right_border());
208 rows.push(row);
209 }
210
211 for _ in 0..pb {
213 rows.push(vec![left_border(), blank_inner(), right_border()]);
214 }
215
216 rows.push(self.border_line(
218 console,
219 inner_width,
220 (box_set.bottom_left, box_set.bottom, box_set.bottom_right),
221 self.subtitle.as_ref(),
222 self.subtitle_align,
223 ));
224
225 join_rows(rows)
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232 use crate::r#box::SQUARE;
233 use crate::text::Text;
234
235 fn console() -> Console {
236 Console::builder()
237 .force_terminal(true)
238 .color_system(Some(crate::color::ColorSystem::Truecolor))
239 .width(20)
240 .build()
241 }
242
243 #[test]
244 fn plain_panel() {
245 let out = console().render_export(&Panel::new(Box::new(Text::new("hello"))));
246 assert_eq!(
247 out,
248 "╭──────────────────╮\n│ hello │\n╰──────────────────╯\n"
249 );
250 }
251
252 #[test]
253 fn titled_panel() {
254 let out = console().render_export(&Panel::new(Box::new(Text::new("hello"))).title("T"));
255 assert_eq!(
256 out,
257 "╭─────── T ────────╮\n│ hello │\n╰──────────────────╯\n"
258 );
259 }
260
261 #[test]
262 fn square_box() {
263 let out = console().render_export(&Panel::new(Box::new(Text::new("hi"))).box_set(SQUARE));
264 assert_eq!(
265 out,
266 "┌──────────────────┐\n│ hi │\n└──────────────────┘\n"
267 );
268 }
269
270 #[test]
271 fn legacy_windows_substitutes_rounded_to_square() {
272 let legacy = Console::builder()
275 .force_terminal(true)
276 .color_system(Some(crate::color::ColorSystem::Truecolor))
277 .width(12)
278 .no_color(false)
279 .legacy_windows(true)
280 .build();
281 let out = legacy.render_export(&Panel::new(Box::new(Text::new("hi"))));
282 assert_eq!(out, "┌──────────┐\n│ hi │\n└──────────┘\n");
283 }
284}