1use crate::align::HorizontalAlign;
10use crate::console::{Console, ConsoleOptions};
11use crate::measure::Measurement;
12use crate::padding::join_rows;
13use crate::protocol::Renderable;
14use crate::r#box::{Box as BoxSet, ROUNDED};
15use crate::segment::Segment;
16use crate::style::Style;
17use crate::text::{Text, DEFAULT_TAB_SIZE};
18
19pub struct Panel {
21 child: Box<dyn Renderable>,
22 box_set: BoxSet,
23 title: Option<String>,
24 title_align: HorizontalAlign,
25 subtitle: Option<String>,
26 subtitle_align: HorizontalAlign,
27 padding: (usize, usize, usize, usize),
28 border_style: Style,
29 style: Style,
30 expand: bool,
31 width: Option<usize>,
32}
33
34impl Panel {
35 pub fn new(child: Box<dyn Renderable>) -> Self {
37 Panel {
38 child,
39 box_set: ROUNDED,
40 title: None,
41 title_align: HorizontalAlign::Center,
42 subtitle: None,
43 subtitle_align: HorizontalAlign::Center,
44 padding: (0, 1, 0, 1),
45 border_style: Style::new(),
46 style: Style::new(),
47 expand: true,
48 width: None,
49 }
50 }
51
52 pub fn fit(child: Box<dyn Renderable>) -> Self {
55 Panel::new(child).expand(false)
56 }
57
58 pub fn expand(mut self, expand: bool) -> Self {
61 self.expand = expand;
62 self
63 }
64
65 pub fn width(mut self, width: usize) -> Self {
68 self.width = Some(width);
69 self
70 }
71
72 pub fn title(mut self, title: impl Into<String>) -> Self {
74 self.title = Some(title.into());
75 self
76 }
77
78 pub fn title_align(mut self, align: HorizontalAlign) -> Self {
80 self.title_align = align;
81 self
82 }
83
84 pub fn subtitle(mut self, subtitle: impl Into<String>) -> Self {
86 self.subtitle = Some(subtitle.into());
87 self
88 }
89
90 pub fn subtitle_align(mut self, align: HorizontalAlign) -> Self {
92 self.subtitle_align = align;
93 self
94 }
95
96 pub fn box_set(mut self, box_set: BoxSet) -> Self {
98 self.box_set = box_set;
99 self
100 }
101
102 pub fn padding(mut self, padding: (usize, usize, usize, usize)) -> Self {
104 self.padding = padding;
105 self
106 }
107
108 pub fn border_style(mut self, style: Style) -> Self {
110 self.border_style = style;
111 self
112 }
113
114 fn border_line(
117 &self,
118 console: &Console,
119 inner_width: usize,
120 corners: (char, char, char),
121 label: Option<&String>,
122 align: HorizontalAlign,
123 ) -> Vec<Segment> {
124 let (left_corner, fill_char, right_corner) = corners;
125 let border_style = Some(self.border_style.clone());
126 let Some(label) = label.filter(|label| !label.is_empty() && inner_width > 2) else {
127 return vec![Segment::new(
128 format!(
129 "{left_corner}{}{right_corner}",
130 fill_char.to_string().repeat(inner_width)
131 ),
132 border_style,
133 )];
134 };
135
136 let mut label = label_text(label);
137 label.set_base_style(self.border_style.clone());
138 let label_width = inner_width - 2;
139 label.truncate(label_width, None, false);
140
141 let fill = label_width.saturating_sub(label.cell_len());
142 let (left, right) = match align {
143 HorizontalAlign::Center => (fill / 2, fill - fill / 2),
144 HorizontalAlign::Left => (0, fill),
145 HorizontalAlign::Right => (fill, 0),
146 };
147 let mut text = Text::styled(
148 fill_char.to_string().repeat(left),
149 self.border_style.clone(),
150 )
151 .append_text(&label);
152 text.append(
153 &fill_char.to_string().repeat(right),
154 Some(self.border_style.clone().into()),
155 );
156 let mut segments = vec![Segment::new(
157 format!("{left_corner}{fill_char}"),
158 border_style.clone(),
159 )];
160 segments.extend(text.render(console.theme(), console.base_style()));
161 segments.push(Segment::new(
162 format!("{fill_char}{right_corner}"),
163 border_style,
164 ));
165 segments
166 }
167}
168
169fn label_text(label: &str) -> Text {
173 let expanded = crate::emoji::replace(label);
174 let parsed = Text::from_markup(&expanded).unwrap_or_else(|_| Text::new(expanded));
175 let mut text = parsed.blank_copy();
176 text.append(&parsed.plain().replace('\n', " "), None);
177 for span in parsed.spans() {
178 text.push_span(span.clone());
179 }
180 text.expand_tabs(DEFAULT_TAB_SIZE);
181 text.pad(1, ' ');
182 text
183}
184
185impl Panel {
186 fn title_text(&self) -> Option<Text> {
188 self.title
189 .as_deref()
190 .filter(|title| !title.is_empty())
191 .map(label_text)
192 }
193
194 fn measure_padded_child(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
197 let (top, right, bottom, left) = self.padding;
198 let max_width = options.max_width;
199 if max_width < 1 {
200 return Measurement::new(0, 0);
201 }
202 if top == 0 && right == 0 && bottom == 0 && left == 0 {
203 return Measurement::get(console, options, self.child.as_ref());
204 }
205 let extra_width = left + right;
207 let width = if max_width < extra_width + 1 {
208 Measurement::new(max_width, max_width)
209 } else {
210 let child = Measurement::get(console, options, self.child.as_ref());
211 Measurement::new(child.minimum + extra_width, child.maximum + extra_width)
212 .with_maximum(max_width)
213 };
214 let width = width.normalize().with_maximum(max_width);
215 if width.maximum < 1 {
216 Measurement::new(0, 0)
217 } else {
218 width.normalize()
219 }
220 }
221}
222
223impl Renderable for Panel {
224 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
225 let width = match self.width {
226 Some(width) => width.min(options.max_width),
227 None => options.max_width,
228 };
229 let box_set = self.box_set.substitute(
231 console.legacy_windows(),
232 console.safe_box(),
233 console.ascii_only(),
234 );
235 let mut inner_width = if self.expand {
238 width.saturating_sub(2)
239 } else {
240 self.measure_padded_child(console, &options.update_width(width.saturating_sub(2)))
241 .maximum
242 };
243 if let Some(title) = self.title_text() {
244 inner_width = options
245 .max_width
246 .saturating_sub(2)
247 .min(inner_width.max(title.cell_len() + 2));
248 }
249 let (pt, pr, pb, pl) = self.padding;
250 let child_width = inner_width.saturating_sub(pl).saturating_sub(pr);
251
252 let mut child_options = options.update_width(child_width);
253 child_options.height = options.height.map(|h| h.saturating_sub(2 + pt + pb));
258 let child_lines = console.render_lines_styled(
260 self.child.as_ref(),
261 &child_options,
262 Some(&self.style),
263 true,
264 );
265
266 let border = Some(self.border_style.clone());
267 let inner_style = Some(self.style.clone());
268 let left_border = || Segment::new(box_set.mid_left.to_string(), border.clone());
269 let right_border = || Segment::new(box_set.mid_right.to_string(), border.clone());
270 let blank_inner = || Segment::new(" ".repeat(inner_width), inner_style.clone());
271
272 let mut rows: Vec<Vec<Segment>> = Vec::new();
273
274 rows.push(self.border_line(
276 console,
277 inner_width,
278 (box_set.top_left, box_set.top, box_set.top_right),
279 self.title.as_ref(),
280 self.title_align,
281 ));
282
283 for _ in 0..pt {
285 rows.push(vec![left_border(), blank_inner(), right_border()]);
286 }
287
288 for line in child_lines {
290 let mut row = vec![left_border()];
291 if pl > 0 {
292 row.push(Segment::new(" ".repeat(pl), inner_style.clone()));
293 }
294 row.extend(line);
295 if pr > 0 {
296 row.push(Segment::new(" ".repeat(pr), inner_style.clone()));
297 }
298 row.push(right_border());
299 rows.push(row);
300 }
301
302 for _ in 0..pb {
304 rows.push(vec![left_border(), blank_inner(), right_border()]);
305 }
306
307 rows.push(self.border_line(
309 console,
310 inner_width,
311 (box_set.bottom_left, box_set.bottom, box_set.bottom_right),
312 self.subtitle.as_ref(),
313 self.subtitle_align,
314 ));
315
316 join_rows(rows)
317 }
318
319 fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
323 let (_, right, _, left) = self.padding;
324 let padding = left + right;
325 let width = match self.width {
326 Some(width) => width,
327 None => {
328 let inner = options.update_width(options.max_width.saturating_sub(padding + 2));
331 let child = Measurement::get(console, &inner, self.child.as_ref()).maximum;
332 let title = self
333 .title_text()
334 .map_or(0, |title| Measurement::get(console, &inner, &title).maximum);
335 child.max(title) + padding + 2
336 }
337 };
338 Measurement::new(width, width)
339 }
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345 use crate::r#box::SQUARE;
346 use crate::text::Text;
347
348 fn console() -> Console {
349 Console::builder()
350 .force_terminal(true)
351 .color_system(Some(crate::color::ColorSystem::Truecolor))
352 .width(20)
353 .build()
354 }
355
356 #[test]
357 fn plain_panel() {
358 let out = console().render_export(&Panel::new(Box::new(Text::new("hello"))));
359 assert_eq!(
360 out,
361 "╭──────────────────╮\n│ hello │\n╰──────────────────╯\n"
362 );
363 }
364
365 #[test]
366 fn titled_panel() {
367 let out = console().render_export(&Panel::new(Box::new(Text::new("hello"))).title("T"));
368 assert_eq!(
369 out,
370 "╭─────── T ────────╮\n│ hello │\n╰──────────────────╯\n"
371 );
372 }
373
374 #[test]
375 fn square_box() {
376 let out = console().render_export(&Panel::new(Box::new(Text::new("hi"))).box_set(SQUARE));
377 assert_eq!(
378 out,
379 "┌──────────────────┐\n│ hi │\n└──────────────────┘\n"
380 );
381 }
382
383 #[test]
384 fn legacy_windows_substitutes_rounded_to_square() {
385 let legacy = Console::builder()
388 .force_terminal(true)
389 .color_system(Some(crate::color::ColorSystem::Truecolor))
390 .width(12)
391 .no_color(false)
392 .legacy_windows(true)
393 .build();
394 let out = legacy.render_export(&Panel::new(Box::new(Text::new("hi"))));
395 assert_eq!(out, "┌──────────┐\n│ hi │\n└──────────┘\n");
396 }
397
398 #[test]
399 fn zero_inner_width_renders_no_body_and_empty_text_one_row() {
400 let narrow = Console::builder()
402 .force_terminal(true)
403 .color_system(Some(crate::color::ColorSystem::Truecolor))
404 .width(4)
405 .highlight(false)
406 .build();
407 let panel = Panel::new(Box::new(Text::new("ab cd"))).box_set(crate::r#box::HEAVY);
408 assert_eq!(narrow.render_export(&panel), "┏━━┓\n┗━━┛\n");
409 let empty = Panel::new(Box::new(Text::new(""))).box_set(SQUARE);
410 assert_eq!(
411 Console::builder()
412 .force_terminal(true)
413 .color_system(Some(crate::color::ColorSystem::Truecolor))
414 .width(10)
415 .highlight(false)
416 .build()
417 .render_export(&empty),
418 "┌────────┐\n│ │\n└────────┘\n"
419 );
420 }
421}