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, StyleType};
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_value: Option<Text>,
26 title_align: HorizontalAlign,
27 subtitle: Option<String>,
28 subtitle_value: Option<Text>,
30 subtitle_align: HorizontalAlign,
31 safe_box: Option<bool>,
33 padding: (usize, usize, usize, usize),
34 border_style: StyleType,
35 style: StyleType,
36 expand: bool,
37 width: Option<usize>,
38 height: Option<usize>,
39 highlight: bool,
40}
41
42impl Panel {
43 pub fn new(child: Box<dyn Renderable>) -> Self {
45 Panel {
46 child,
47 box_set: ROUNDED,
48 title: None,
49 title_value: None,
50 title_align: HorizontalAlign::Center,
51 subtitle: None,
52 subtitle_value: None,
53 subtitle_align: HorizontalAlign::Center,
54 safe_box: None,
55 padding: (0, 1, 0, 1),
56 border_style: StyleType::Style(Style::new()),
57 style: StyleType::Style(Style::new()),
58 expand: true,
59 width: None,
60 height: None,
61 highlight: false,
62 }
63 }
64
65 pub fn fit(child: Box<dyn Renderable>) -> Self {
68 Panel::new(child).expand(false)
69 }
70
71 pub fn expand(mut self, expand: bool) -> Self {
74 self.expand = expand;
75 self
76 }
77
78 pub fn width(mut self, width: usize) -> Self {
81 self.width = Some(width);
82 self
83 }
84
85 pub fn title(mut self, title: impl Into<String>) -> Self {
87 self.title = Some(title.into());
88 self
89 }
90
91 pub fn title_as_text(mut self, title: Text) -> Self {
94 self.title_value = Some(title);
95 self
96 }
97
98 pub fn title_align(mut self, align: HorizontalAlign) -> Self {
100 self.title_align = align;
101 self
102 }
103
104 pub fn subtitle(mut self, subtitle: impl Into<String>) -> Self {
106 self.subtitle = Some(subtitle.into());
107 self
108 }
109
110 pub fn subtitle_as_text(mut self, subtitle: Text) -> Self {
113 self.subtitle_value = Some(subtitle);
114 self
115 }
116
117 pub fn safe_box(mut self, safe_box: Option<bool>) -> Self {
120 self.safe_box = safe_box;
121 self
122 }
123
124 pub fn subtitle_align(mut self, align: HorizontalAlign) -> Self {
126 self.subtitle_align = align;
127 self
128 }
129
130 pub fn box_set(mut self, box_set: BoxSet) -> Self {
132 self.box_set = box_set;
133 self
134 }
135
136 pub fn padding(mut self, padding: (usize, usize, usize, usize)) -> Self {
138 self.padding = padding;
139 self
140 }
141
142 pub fn highlight(mut self, highlight: bool) -> Self {
146 self.highlight = highlight;
147 self
148 }
149
150 pub fn border_style(mut self, style: impl Into<StyleType>) -> Self {
153 self.border_style = style.into();
154 self
155 }
156
157 pub fn style(mut self, style: impl Into<StyleType>) -> Self {
161 self.style = style.into();
162 self
163 }
164
165 pub fn height(mut self, height: usize) -> Self {
168 self.height = Some(height);
169 self
170 }
171
172 #[allow(clippy::too_many_arguments)]
175 fn border_line(
176 &self,
177 console: &Console,
178 border: &Style,
179 inner_width: usize,
180 corners: (char, char, char),
181 label: Option<Text>,
182 align: HorizontalAlign,
183 ) -> Vec<Segment> {
184 let (left_corner, fill_char, right_corner) = corners;
185 let border_style = Some(border.clone());
186 let Some(mut label) = label.filter(|_| inner_width > 2) else {
187 return vec![Segment::new(
188 format!(
189 "{left_corner}{}{right_corner}",
190 fill_char.to_string().repeat(inner_width)
191 ),
192 border_style,
193 )];
194 };
195
196 if label.base_style().is_null_style() {
199 label.set_base_style(border.clone());
200 } else {
201 let own = console.get_style(label.base_style()).unwrap_or_default();
202 let len = label.plain().len();
203 label.stylize_before(border.clone(), 0, len);
204 label.stylize(own, 0, len);
205 }
206 let label_width = inner_width - 2;
207 label.truncate(label_width, None, false);
208
209 let fill = label_width.saturating_sub(label.cell_len());
210 let (left, right) = match align {
211 HorizontalAlign::Center => (fill / 2, fill - fill / 2),
212 HorizontalAlign::Left => (0, fill),
213 HorizontalAlign::Right => (fill, 0),
214 };
215 let mut text =
216 Text::styled(fill_char.to_string().repeat(left), border.clone()).append_text(&label);
217 text.append(
218 &fill_char.to_string().repeat(right),
219 Some(border.clone().into()),
220 );
221 let mut segments = vec![Segment::new(
222 format!("{left_corner}{fill_char}"),
223 border_style.clone(),
224 )];
225 segments.extend(text.render(console.theme(), console.base_style()));
226 segments.push(Segment::new(
227 format!("{fill_char}{right_corner}"),
228 border_style,
229 ));
230 segments
231 }
232}
233
234fn label_text(label: &str) -> Text {
238 let expanded = crate::emoji::replace(label);
239 let parsed = Text::from_markup(&expanded).unwrap_or_else(|_| Text::new(expanded));
240 label_from_text(&parsed)
241}
242
243fn label_from_text(parsed: &Text) -> Text {
246 let mut text = parsed.blank_copy();
247 text.append(&parsed.plain().replace('\n', " "), None);
248 for span in parsed.spans() {
249 text.push_span(span.clone());
250 }
251 text.expand_tabs(DEFAULT_TAB_SIZE);
252 text.pad(1, ' ');
253 text
254}
255
256impl Panel {
257 fn title_text(&self) -> Option<Text> {
259 if let Some(title) = &self.title_value {
260 return (!title.plain().is_empty()).then(|| label_from_text(title));
261 }
262 self.title
263 .as_deref()
264 .filter(|title| !title.is_empty())
265 .map(label_text)
266 }
267
268 fn subtitle_text(&self) -> Option<Text> {
270 if let Some(subtitle) = &self.subtitle_value {
271 return (!subtitle.plain().is_empty()).then(|| label_from_text(subtitle));
272 }
273 self.subtitle
274 .as_deref()
275 .filter(|subtitle| !subtitle.is_empty())
276 .map(label_text)
277 }
278
279 fn measure_padded_child(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
282 let (top, right, bottom, left) = self.padding;
283 let max_width = options.max_width;
284 if max_width < 1 {
285 return Measurement::new(0, 0);
286 }
287 if top == 0 && right == 0 && bottom == 0 && left == 0 {
288 return Measurement::get(console, options, self.child.as_ref());
289 }
290 let extra_width = left + right;
292 let width = if max_width < extra_width + 1 {
293 Measurement::new(max_width, max_width)
294 } else {
295 let child = Measurement::get(console, options, self.child.as_ref());
296 Measurement::new(child.minimum + extra_width, child.maximum + extra_width)
297 .with_maximum(max_width)
298 };
299 let width = width.normalize().with_maximum(max_width);
300 if width.maximum < 1 {
301 Measurement::new(0, 0)
302 } else {
303 width.normalize()
304 }
305 }
306}
307
308impl Renderable for Panel {
309 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
310 crate::protocol::report_region(
312 console,
313 || {
314 let info = crate::protocol::RegionInfo::new(crate::protocol::RegionRole::Panel);
315 match self.title_text() {
316 Some(title) => info.label(title.plain()),
317 None => info,
318 }
319 },
320 || self.render_panel(console, options),
321 )
322 }
323
324 fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
328 self.measure_panel(console, options)
329 }
330}
331
332impl Panel {
333 fn render_panel(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
335 let width = match self.width {
336 Some(width) => width.min(options.max_width),
337 None => options.max_width,
338 };
339 let style = console.get_style(&self.style).unwrap_or_default();
342 let border_style =
343 style.combine(&console.get_style(&self.border_style).unwrap_or_default());
344 let height = self.height.or(options.height).filter(|&height| height > 0);
346 let box_set = self.box_set.substitute(
351 console.legacy_windows(),
352 self.safe_box.unwrap_or_else(|| console.safe_box()),
353 console.ascii_only(),
354 );
355 let mut inner_width = if self.expand {
358 width.saturating_sub(2)
359 } else {
360 self.measure_padded_child(console, &options.update_width(width.saturating_sub(2)))
361 .maximum
362 };
363 if let Some(title) = self.title_text() {
364 inner_width = options
365 .max_width
366 .saturating_sub(2)
367 .min(inner_width.max(title.cell_len() + 2));
368 }
369 let (pt, pr, pb, pl) = if inner_width == 0 {
373 (0, 0, 0, 0)
374 } else {
375 self.padding
376 };
377 let child_width = inner_width.saturating_sub(pl).saturating_sub(pr);
378
379 let mut child_options = options.update_width(child_width);
380 child_options.highlight = Some(self.highlight);
382 child_options.height = height.map(|h| h.saturating_sub(2 + pt + pb));
387 let child_lines =
389 console.render_lines_styled(self.child.as_ref(), &child_options, Some(&style), true);
390
391 let border = Some(border_style.clone());
392 let inner_style = Some(style.clone());
393 let left_border = || Segment::new(box_set.mid_left.to_string(), border.clone());
394 let right_border = || Segment::new(box_set.mid_right.to_string(), border.clone());
395 let blank_inner = || Segment::new(" ".repeat(inner_width), inner_style.clone());
396
397 let mut rows: Vec<Vec<Segment>> = Vec::new();
398
399 rows.push(self.border_line(
401 console,
402 &border_style,
403 inner_width,
404 (box_set.top_left, box_set.top, box_set.top_right),
405 self.title_text(),
406 self.title_align,
407 ));
408
409 let mut inner_rows: Vec<Vec<Segment>> = Vec::new();
414 for _ in 0..pt {
415 inner_rows.push(vec![blank_inner()]);
416 }
417 for line in child_lines {
418 let mut row = Vec::new();
419 if pl > 0 {
420 row.push(Segment::new(" ".repeat(pl), inner_style.clone()));
421 }
422 row.extend(line);
423 if pr > 0 {
424 row.push(Segment::new(" ".repeat(pr), inner_style.clone()));
425 }
426 inner_rows.push(row);
427 }
428 for _ in 0..pb {
429 inner_rows.push(vec![blank_inner()]);
430 }
431 if let Some(height) = height {
432 let height = height.saturating_sub(2);
433 inner_rows.truncate(height);
434 while inner_rows.len() < height {
435 inner_rows.push(vec![blank_inner()]);
436 }
437 }
438 for row in inner_rows {
439 let mut line = vec![left_border()];
440 line.extend(Segment::adjust_line_length(
441 &row,
442 inner_width,
443 inner_style.clone(),
444 ));
445 line.push(right_border());
446 rows.push(line);
447 }
448
449 rows.push(self.border_line(
451 console,
452 &border_style,
453 inner_width,
454 (box_set.bottom_left, box_set.bottom, box_set.bottom_right),
455 self.subtitle_text(),
456 self.subtitle_align,
457 ));
458
459 join_rows(rows)
460 }
461
462 fn measure_panel(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
463 let (_, right, _, left) = self.padding;
464 let padding = left + right;
465 let width = match self.width {
466 Some(width) => width,
467 None => {
468 let inner = options.update_width(options.max_width.saturating_sub(padding + 2));
471 let child = Measurement::get(console, &inner, self.child.as_ref()).maximum;
472 let title = self
473 .title_text()
474 .map_or(0, |title| Measurement::get(console, &inner, &title).maximum);
475 child.max(title) + padding + 2
476 }
477 };
478 Measurement::new(width, width)
479 }
480}
481
482#[cfg(test)]
483mod tests {
484
485 #[test]
486 fn tiny_widths_match_upstream() {
487 let render = |panel: Panel, width| {
489 let console = Console::builder().width(width).color_system(None).build();
490 let out = console.render_to_string(&panel);
491 if out.is_empty() {
492 out
493 } else {
494 out + "\n"
495 }
496 };
497 for width in [0, 1, 2] {
498 let expected = ["", "╭\n╰\n", "╭╮\n╰╯\n"][width];
499 assert_eq!(
500 render(Panel::new(Box::new(crate::text::Text::new("hi"))), width),
501 expected,
502 "width {width}"
503 );
504 assert_eq!(
505 render(Panel::fit(Box::new(crate::text::Text::new("hi"))), width),
506 expected,
507 "fit width {width}"
508 );
509 }
510 }
511
512 use super::*;
513 use crate::r#box::SQUARE;
514 use crate::text::Text;
515
516 fn console() -> Console {
517 Console::builder()
518 .force_terminal(true)
519 .color_system(Some(crate::color::ColorSystem::Truecolor))
520 .width(20)
521 .build()
522 }
523
524 #[test]
525 fn plain_panel() {
526 let out = console().render_export(&Panel::new(Box::new(Text::new("hello"))));
527 assert_eq!(
528 out,
529 "╭──────────────────╮\n│ hello │\n╰──────────────────╯\n"
530 );
531 }
532
533 #[test]
534 fn titled_panel() {
535 let out = console().render_export(&Panel::new(Box::new(Text::new("hello"))).title("T"));
536 assert_eq!(
537 out,
538 "╭─────── T ────────╮\n│ hello │\n╰──────────────────╯\n"
539 );
540 }
541
542 #[test]
543 fn square_box() {
544 let out = console().render_export(&Panel::new(Box::new(Text::new("hi"))).box_set(SQUARE));
545 assert_eq!(
546 out,
547 "┌──────────────────┐\n│ hi │\n└──────────────────┘\n"
548 );
549 }
550
551 #[test]
552 fn legacy_windows_substitutes_rounded_to_square() {
553 let legacy = Console::builder()
556 .force_terminal(true)
557 .color_system(Some(crate::color::ColorSystem::Truecolor))
558 .width(12)
559 .no_color(false)
560 .legacy_windows(true)
561 .build();
562 let out = legacy.render_export(&Panel::new(Box::new(Text::new("hi"))));
563 assert_eq!(out, "┌──────────┐\n│ hi │\n└──────────┘\n");
564 }
565
566 #[test]
567 fn zero_inner_width_renders_no_body_and_empty_text_one_row() {
568 let narrow = Console::builder()
570 .force_terminal(true)
571 .color_system(Some(crate::color::ColorSystem::Truecolor))
572 .width(4)
573 .highlight(false)
574 .build();
575 let panel = Panel::new(Box::new(Text::new("ab cd"))).box_set(crate::r#box::HEAVY);
576 assert_eq!(narrow.render_export(&panel), "┏━━┓\n┗━━┛\n");
577 let empty = Panel::new(Box::new(Text::new(""))).box_set(SQUARE);
578 assert_eq!(
579 Console::builder()
580 .force_terminal(true)
581 .color_system(Some(crate::color::ColorSystem::Truecolor))
582 .width(10)
583 .highlight(false)
584 .build()
585 .render_export(&empty),
586 "┌────────┐\n│ │\n└────────┘\n"
587 );
588 }
589}