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 {
270 pub fn renderable(&self) -> &dyn Renderable {
272 self.child.as_ref()
273 }
274
275 pub fn get_title(&self) -> Option<crate::table::Cell> {
279 label_cell(self.title_value.as_ref(), self.title.as_deref())
280 }
281
282 pub fn get_subtitle(&self) -> Option<crate::table::Cell> {
284 label_cell(self.subtitle_value.as_ref(), self.subtitle.as_deref())
285 }
286
287 pub fn get_title_align(&self) -> HorizontalAlign {
289 self.title_align
290 }
291
292 pub fn get_subtitle_align(&self) -> HorizontalAlign {
294 self.subtitle_align
295 }
296}
297
298fn label_cell(text: Option<&Text>, markup: Option<&str>) -> Option<crate::table::Cell> {
300 match (text, markup) {
301 (Some(text), _) => Some(crate::table::Cell::Text(text.clone())),
302 (None, Some(markup)) => Some(crate::table::Cell::Markup(markup.to_string())),
303 (None, None) => None,
304 }
305}
306
307impl Panel {
308 fn title_text(&self) -> Option<Text> {
310 if let Some(title) = &self.title_value {
311 return (!title.plain().is_empty()).then(|| label_from_text(title));
312 }
313 self.title
314 .as_deref()
315 .filter(|title| !title.is_empty())
316 .map(label_text)
317 }
318
319 fn subtitle_text(&self) -> Option<Text> {
321 if let Some(subtitle) = &self.subtitle_value {
322 return (!subtitle.plain().is_empty()).then(|| label_from_text(subtitle));
323 }
324 self.subtitle
325 .as_deref()
326 .filter(|subtitle| !subtitle.is_empty())
327 .map(label_text)
328 }
329
330 fn measure_padded_child(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
333 let (top, right, bottom, left) = self.padding;
334 let max_width = options.max_width;
335 if max_width < 1 {
336 return Measurement::new(0, 0);
337 }
338 if top == 0 && right == 0 && bottom == 0 && left == 0 {
339 return Measurement::get(console, options, self.child.as_ref());
340 }
341 let extra_width = left + right;
343 let width = if max_width < extra_width + 1 {
344 Measurement::new(max_width, max_width)
345 } else {
346 let child = Measurement::get(console, options, self.child.as_ref());
347 Measurement::new(child.minimum + extra_width, child.maximum + extra_width)
348 .with_maximum(max_width)
349 };
350 let width = width.normalize().with_maximum(max_width);
351 if width.maximum < 1 {
352 Measurement::new(0, 0)
353 } else {
354 width.normalize()
355 }
356 }
357}
358
359impl Renderable for Panel {
360 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
361 crate::protocol::report_region(
363 console,
364 || {
365 let info = crate::protocol::RegionInfo::new(crate::protocol::RegionRole::Panel);
366 match self.title_text() {
367 Some(title) => info.label(title.plain()),
368 None => info,
369 }
370 },
371 || self.render_panel(console, options),
372 )
373 }
374
375 fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
379 self.measure_panel(console, options)
380 }
381}
382
383impl Panel {
384 fn render_panel(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
386 let width = match self.width {
387 Some(width) => width.min(options.max_width),
388 None => options.max_width,
389 };
390 let style = console.get_style(&self.style).unwrap_or_default();
393 let border_style =
394 style.combine(&console.get_style(&self.border_style).unwrap_or_default());
395 let height = self
397 .height
398 .filter(|&height| height > 0)
399 .or(options.height)
400 .filter(|&height| height > 0);
401 let box_set = self
406 .box_set
407 .substitute_for(options, self.safe_box.unwrap_or_else(|| console.safe_box()));
408 let mut inner_width = if self.expand {
411 width.saturating_sub(2)
412 } else {
413 self.measure_padded_child(console, &options.update_width(width.saturating_sub(2)))
414 .maximum
415 };
416 if let Some(title) = self.title_text() {
417 inner_width = options
418 .max_width
419 .saturating_sub(2)
420 .min(inner_width.max(title.cell_len() + 2));
421 }
422 let (pt, pr, pb, pl) = if inner_width == 0 {
426 (0, 0, 0, 0)
427 } else {
428 self.padding
429 };
430 let child_width = inner_width.saturating_sub(pl).saturating_sub(pr);
431
432 let mut child_options = options.update_width(child_width);
433 child_options.highlight = Some(self.highlight);
435 child_options.height = height.map(|h| h.saturating_sub(2 + pt + pb));
440 let child_lines =
442 console.render_lines_styled(self.child.as_ref(), &child_options, Some(&style), true);
443
444 let border = Some(border_style.clone());
445 let inner_style = Some(style.clone());
446 let left_border = || Segment::new(box_set.mid_left.to_string(), border.clone());
447 let right_border = || Segment::new(box_set.mid_right.to_string(), border.clone());
448 let blank_inner = || Segment::new(" ".repeat(inner_width), inner_style.clone());
449
450 let mut rows: Vec<Vec<Segment>> = Vec::new();
451
452 rows.push(self.border_line(
454 console,
455 &border_style,
456 inner_width,
457 (box_set.top_left, box_set.top, box_set.top_right),
458 self.title_text(),
459 self.title_align,
460 ));
461
462 let mut inner_rows: Vec<Vec<Segment>> = Vec::new();
467 for _ in 0..pt {
468 inner_rows.push(vec![blank_inner()]);
469 }
470 for line in child_lines {
471 let mut row = Vec::new();
472 if pl > 0 {
473 row.push(Segment::new(" ".repeat(pl), inner_style.clone()));
474 }
475 row.extend(line);
476 if pr > 0 {
477 row.push(Segment::new(" ".repeat(pr), inner_style.clone()));
478 }
479 inner_rows.push(row);
480 }
481 for _ in 0..pb {
482 inner_rows.push(vec![blank_inner()]);
483 }
484 if let Some(height) = height {
485 let height = height.saturating_sub(2);
486 inner_rows.truncate(height);
487 while inner_rows.len() < height {
488 inner_rows.push(vec![blank_inner()]);
489 }
490 }
491 for row in inner_rows {
492 let mut line = vec![left_border()];
493 line.extend(Segment::adjust_line_length(
494 &row,
495 inner_width,
496 inner_style.clone(),
497 ));
498 line.push(right_border());
499 rows.push(line);
500 }
501
502 rows.push(self.border_line(
504 console,
505 &border_style,
506 inner_width,
507 (box_set.bottom_left, box_set.bottom, box_set.bottom_right),
508 self.subtitle_text(),
509 self.subtitle_align,
510 ));
511
512 join_rows(rows)
513 }
514
515 fn measure_panel(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
516 let (_, right, _, left) = self.padding;
517 let padding = left + right;
518 let width = match self.width {
519 Some(width) => width,
520 None => {
521 let inner = options.update_width(options.max_width.saturating_sub(padding + 2));
524 let child = Measurement::get(console, &inner, self.child.as_ref()).maximum;
525 let title = self
526 .title_text()
527 .map_or(0, |title| Measurement::get(console, &inner, &title).maximum);
528 child.max(title) + padding + 2
529 }
530 };
531 Measurement::new(width, width)
532 }
533}
534
535#[cfg(test)]
536mod tests {
537
538 #[test]
539 fn tiny_widths_match_upstream() {
540 let render = |panel: Panel, width| {
542 let console = Console::builder().width(width).color_system(None).build();
543 let out = console.render_to_string(&panel);
544 if out.is_empty() {
545 out
546 } else {
547 out + "\n"
548 }
549 };
550 for width in [0, 1, 2] {
551 let expected = ["", "╭\n╰\n", "╭╮\n╰╯\n"][width];
552 assert_eq!(
553 render(Panel::new(Box::new(crate::text::Text::new("hi"))), width),
554 expected,
555 "width {width}"
556 );
557 assert_eq!(
558 render(Panel::fit(Box::new(crate::text::Text::new("hi"))), width),
559 expected,
560 "fit width {width}"
561 );
562 }
563 }
564
565 use super::*;
566 use crate::r#box::SQUARE;
567 use crate::text::Text;
568
569 fn console() -> Console {
570 Console::builder()
571 .force_terminal(true)
572 .color_system(Some(crate::color::ColorSystem::Truecolor))
573 .width(20)
574 .build()
575 }
576
577 #[test]
578 fn plain_panel() {
579 let out = console().render_export(&Panel::new(Box::new(Text::new("hello"))));
580 assert_eq!(
581 out,
582 "╭──────────────────╮\n│ hello │\n╰──────────────────╯\n"
583 );
584 }
585
586 #[test]
587 fn titled_panel() {
588 let out = console().render_export(&Panel::new(Box::new(Text::new("hello"))).title("T"));
589 assert_eq!(
590 out,
591 "╭─────── T ────────╮\n│ hello │\n╰──────────────────╯\n"
592 );
593 }
594
595 #[test]
596 fn square_box() {
597 let out = console().render_export(&Panel::new(Box::new(Text::new("hi"))).box_set(SQUARE));
598 assert_eq!(
599 out,
600 "┌──────────────────┐\n│ hi │\n└──────────────────┘\n"
601 );
602 }
603
604 #[test]
605 fn legacy_windows_substitutes_rounded_to_square() {
606 let legacy = Console::builder()
609 .force_terminal(true)
610 .color_system(Some(crate::color::ColorSystem::Truecolor))
611 .width(12)
612 .no_color(false)
613 .legacy_windows(true)
614 .build();
615 let out = legacy.render_export(&Panel::new(Box::new(Text::new("hi"))));
616 assert_eq!(out, "┌──────────┐\n│ hi │\n└──────────┘\n");
617 }
618
619 #[test]
620 fn zero_inner_width_renders_no_body_and_empty_text_one_row() {
621 let narrow = Console::builder()
623 .force_terminal(true)
624 .color_system(Some(crate::color::ColorSystem::Truecolor))
625 .width(4)
626 .highlight(false)
627 .build();
628 let panel = Panel::new(Box::new(Text::new("ab cd"))).box_set(crate::r#box::HEAVY);
629 assert_eq!(narrow.render_export(&panel), "┏━━┓\n┗━━┛\n");
630 let empty = Panel::new(Box::new(Text::new(""))).box_set(SQUARE);
631 assert_eq!(
632 Console::builder()
633 .force_terminal(true)
634 .color_system(Some(crate::color::ColorSystem::Truecolor))
635 .width(10)
636 .highlight(false)
637 .build()
638 .render_export(&empty),
639 "┌────────┐\n│ │\n└────────┘\n"
640 );
641 }
642}