1use crate::r#box::{ASCII, BoxChars, ROUNDED, SQUARE};
7use crate::cells;
8use crate::console::{Console, ConsoleOptions};
9use crate::markup;
10use crate::renderables::Renderable;
11use crate::segment::{Segment, adjust_line_length};
12use crate::style::Style;
13use crate::text::{JustifyMethod, OverflowMethod, Text};
14
15use super::padding::PaddingDimensions;
16
17#[derive(Debug, Clone)]
19pub struct Panel<'a> {
20 content_lines: Vec<Vec<Segment<'a>>>,
22 box_style: &'static BoxChars,
24 safe_box: Option<bool>,
26 expand: bool,
28 style: Style,
30 border_style: Style,
32 width: Option<usize>,
34 height: Option<usize>,
36 padding: PaddingDimensions,
38 title: Option<Text>,
40 title_align: JustifyMethod,
42 subtitle: Option<Text>,
44 subtitle_align: JustifyMethod,
46}
47
48impl Default for Panel<'_> {
49 fn default() -> Self {
50 Self {
51 content_lines: Vec::new(),
52 box_style: &ROUNDED,
53 safe_box: None,
54 expand: true,
55 style: Style::new(),
56 border_style: Style::new(),
57 width: None,
58 height: None,
59 padding: PaddingDimensions::symmetric(0, 1),
60 title: None,
61 title_align: JustifyMethod::Center,
62 subtitle: None,
63 subtitle_align: JustifyMethod::Center,
64 }
65 }
66}
67
68impl<'a> Panel<'a> {
69 #[must_use]
71 pub fn new(content_lines: Vec<Vec<Segment<'a>>>) -> Self {
72 Self {
73 content_lines,
74 ..Self::default()
75 }
76 }
77
78 #[must_use]
84 pub fn from_text(text: &'a str) -> Self {
85 let lines: Vec<Vec<Segment<'a>>> = text
86 .lines()
87 .map(|line| vec![Segment::new(line, None)])
88 .collect();
89 Self::new(lines)
90 }
91
92 #[must_use]
97 pub fn from_rich_text(text: &'a Text, width: usize) -> Self {
98 let lines = text
100 .split_lines()
101 .into_iter()
102 .map(|line| {
103 line.render("")
104 .into_iter()
105 .map(super::super::segment::Segment::into_owned)
106 .collect()
107 })
108 .collect();
109
110 Self {
111 content_lines: lines,
112 width: Some(width),
113 ..Self::default()
114 }
115 }
116
117 #[must_use]
119 pub fn box_style(mut self, style: &'static BoxChars) -> Self {
120 self.box_style = style;
121 self
122 }
123
124 #[must_use]
126 pub fn rounded(mut self) -> Self {
127 self.box_style = &ROUNDED;
128 self
129 }
130
131 #[must_use]
133 pub fn square(mut self) -> Self {
134 self.box_style = &SQUARE;
135 self
136 }
137
138 #[must_use]
140 pub fn ascii(mut self) -> Self {
141 self.box_style = &ASCII;
142 self.safe_box = Some(true);
143 self
144 }
145
146 #[must_use]
148 pub fn safe_box(mut self, safe: bool) -> Self {
149 self.safe_box = Some(safe);
150 self
151 }
152
153 #[must_use]
155 pub fn expand(mut self, expand: bool) -> Self {
156 self.expand = expand;
157 self
158 }
159
160 #[must_use]
162 pub fn style(mut self, style: Style) -> Self {
163 self.style = style;
164 self
165 }
166
167 #[must_use]
169 pub fn border_style(mut self, style: Style) -> Self {
170 self.border_style = style;
171 self
172 }
173
174 #[must_use]
176 pub fn width(mut self, width: usize) -> Self {
177 self.width = Some(width);
178 self
179 }
180
181 #[must_use]
183 pub fn height(mut self, height: usize) -> Self {
184 self.height = Some(height);
185 self
186 }
187
188 #[must_use]
190 pub fn padding(mut self, padding: impl Into<PaddingDimensions>) -> Self {
191 self.padding = padding.into();
192 self
193 }
194
195 #[must_use]
201 pub fn title(mut self, title: impl Into<Text>) -> Self {
202 self.title = Some(title.into());
203 self
204 }
205
206 #[must_use]
208 pub fn title_align(mut self, align: JustifyMethod) -> Self {
209 self.title_align = align;
210 self
211 }
212
213 #[must_use]
226 pub fn title_from_markup(mut self, title: &str) -> Self {
227 self.title = Some(markup::render_or_plain(title));
228 self
229 }
230
231 #[must_use]
237 pub fn subtitle(mut self, subtitle: impl Into<Text>) -> Self {
238 self.subtitle = Some(subtitle.into());
239 self
240 }
241
242 #[must_use]
244 pub fn subtitle_align(mut self, align: JustifyMethod) -> Self {
245 self.subtitle_align = align;
246 self
247 }
248
249 #[must_use]
262 pub fn subtitle_from_markup(mut self, subtitle: &str) -> Self {
263 self.subtitle = Some(markup::render_or_plain(subtitle));
264 self
265 }
266
267 fn effective_box(&self) -> &'static BoxChars {
269 let safe = self.safe_box.unwrap_or(false);
270 if safe && !self.box_style.ascii {
271 &ASCII
272 } else {
273 self.box_style
274 }
275 }
276
277 fn content_width(&self) -> usize {
279 self.content_lines
280 .iter()
281 .map(|line: &Vec<Segment<'a>>| line.iter().map(|seg| cells::cell_len(&seg.text)).sum())
282 .max()
283 .unwrap_or(0)
284 }
285
286 #[must_use]
288 pub fn render(&self, max_width: usize) -> Vec<Segment<'a>> {
289 let box_chars = self.effective_box();
290
291 let panel_width = if self.expand {
293 self.width.unwrap_or(max_width).min(max_width)
294 } else {
295 let content_w = self.content_width();
296 let min_width = content_w + 2 + self.padding.horizontal();
297 self.width.unwrap_or(min_width).min(max_width)
298 };
299
300 let inner_width = panel_width.saturating_sub(2);
302
303 let mut pad_left = self.padding.left;
304 let mut pad_right = self.padding.right;
305 let max_content_width = self.content_width();
306 if max_content_width <= inner_width {
307 let available_for_padding = inner_width.saturating_sub(max_content_width);
308 if pad_left + pad_right > available_for_padding {
309 let mut remaining = available_for_padding;
310 pad_left = pad_left.min(remaining);
311 remaining = remaining.saturating_sub(pad_left);
312 pad_right = pad_right.min(remaining);
313 }
314 }
315
316 let content_width = inner_width.saturating_sub(pad_left + pad_right);
318
319 let mut pad_top = self.padding.top;
320 let mut pad_bottom = self.padding.bottom;
321 let mut content_lines = self.content_lines.clone();
322
323 if let Some(height) = self.height {
324 let max_inner_lines = height.saturating_sub(2);
325 if content_lines.len() > max_inner_lines {
326 content_lines.truncate(max_inner_lines);
327 pad_top = 0;
328 pad_bottom = 0;
329 } else {
330 let remaining_after_content = max_inner_lines - content_lines.len();
331 if pad_top + pad_bottom > remaining_after_content {
332 let mut remaining = remaining_after_content;
333 pad_top = pad_top.min(remaining);
334 remaining = remaining.saturating_sub(pad_top);
335 pad_bottom = pad_bottom.min(remaining);
336 }
337
338 let max_content_lines = max_inner_lines.saturating_sub(pad_top + pad_bottom);
339 if content_lines.len() < max_content_lines {
340 content_lines.extend(
341 std::iter::repeat_with(Vec::new)
342 .take(max_content_lines - content_lines.len()),
343 );
344 }
345 }
346 }
347
348 let mut segments = Vec::new();
349
350 segments.extend(self.render_top_border(box_chars, inner_width));
352 segments.push(Segment::line());
353
354 for _ in 0..pad_top {
356 segments.push(Segment::new(
357 box_chars.head[0].to_string(),
358 Some(self.border_style.clone()),
359 ));
360 segments.push(Segment::new(
361 " ".repeat(inner_width),
362 Some(self.style.clone()),
363 ));
364 segments.push(Segment::new(
365 box_chars.head[3].to_string(),
366 Some(self.border_style.clone()),
367 ));
368 segments.push(Segment::line());
369 }
370
371 let left_pad = " ".repeat(pad_left);
373 let right_pad = " ".repeat(pad_right);
374
375 for line in &content_lines {
376 segments.push(Segment::new(
378 box_chars.head[0].to_string(),
379 Some(self.border_style.clone()),
380 ));
381
382 if pad_left > 0 {
384 segments.push(Segment::new(left_pad.clone(), Some(self.style.clone())));
385 }
386
387 let mut content_segments: Vec<Segment<'a>> = line
389 .iter()
390 .cloned()
391 .map(|mut seg: Segment<'a>| {
392 if !seg.is_control() {
393 seg.style = Some(match seg.style.take() {
394 Some(existing) => self.style.combine(&existing),
395 None => self.style.clone(),
396 });
397 }
398 seg
399 })
400 .collect();
401
402 content_segments = adjust_line_length(
403 content_segments,
404 content_width,
405 Some(self.style.clone()),
406 true,
407 );
408
409 segments.extend(content_segments);
410
411 if pad_right > 0 {
413 segments.push(Segment::new(right_pad.clone(), Some(self.style.clone())));
414 }
415
416 segments.push(Segment::new(
418 box_chars.head[3].to_string(),
419 Some(self.border_style.clone()),
420 ));
421 segments.push(Segment::line());
422 }
423
424 for _ in 0..pad_bottom {
426 segments.push(Segment::new(
427 box_chars.head[0].to_string(),
428 Some(self.border_style.clone()),
429 ));
430 segments.push(Segment::new(
431 " ".repeat(inner_width),
432 Some(self.style.clone()),
433 ));
434 segments.push(Segment::new(
435 box_chars.head[3].to_string(),
436 Some(self.border_style.clone()),
437 ));
438 segments.push(Segment::line());
439 }
440
441 segments.extend(self.render_bottom_border(box_chars, inner_width));
443 segments.push(Segment::line());
444
445 segments
446 }
447
448 fn render_top_border(&self, box_chars: &BoxChars, inner_width: usize) -> Vec<Segment<'a>> {
450 let border_style = Some(self.border_style.clone());
451 if let Some(title) = &self.title {
452 let mut segments = Vec::new();
453 let max_text_width = if inner_width >= 4 {
454 inner_width.saturating_sub(4)
455 } else {
456 inner_width.saturating_sub(2)
457 };
458 let title_text = if inner_width >= 2 {
459 if title.cell_len() > max_text_width {
460 truncate_text_to_width(title, max_text_width)
461 } else {
462 title.clone()
463 }
464 } else {
465 truncate_text_to_width(title, inner_width)
466 };
467
468 let title_width = title_text.cell_len();
469 if inner_width < 2 {
470 segments.push(Segment::new(
471 box_chars.top[0].to_string(),
472 border_style.clone(),
473 ));
474 segments.extend(
475 title_text
476 .render("")
477 .into_iter()
478 .map(super::super::segment::Segment::into_owned),
479 );
480 let remaining = inner_width.saturating_sub(title_width);
481 if remaining > 0 {
482 segments.push(Segment::new(
483 box_chars.top[1].to_string().repeat(remaining),
484 border_style.clone(),
485 ));
486 }
487 segments.push(Segment::new(
488 box_chars.top[3].to_string(),
489 border_style.clone(),
490 ));
491 return segments;
492 }
493
494 let title_total_width = title_width.saturating_add(2);
495 let available = inner_width.saturating_sub(title_total_width);
496 let (left_rule, right_rule) = if available == 0 {
497 (0, 0)
498 } else {
499 match self.title_align {
500 JustifyMethod::Left | JustifyMethod::Default => {
501 (1, available.saturating_sub(1))
502 }
503 JustifyMethod::Right => (available.saturating_sub(1), 1),
504 JustifyMethod::Center | JustifyMethod::Full => {
505 let left = available / 2;
506 (left, available - left)
507 }
508 }
509 };
510
511 let left_border = if left_rule > 0 {
512 format!("{}{}", box_chars.top[0], box_chars.top[1])
513 } else {
514 box_chars.top[0].to_string()
515 };
516 segments.push(Segment::new(left_border, border_style.clone()));
517 if left_rule > 1 {
518 segments.push(Segment::new(
519 box_chars.top[1].to_string().repeat(left_rule - 1),
520 border_style.clone(),
521 ));
522 }
523
524 segments.push(Segment::new(" ", border_style.clone()));
526 segments.extend(
527 title_text
528 .render("")
529 .into_iter()
530 .map(super::super::segment::Segment::into_owned),
531 );
532 segments.push(Segment::new(" ", border_style.clone()));
533
534 if right_rule > 1 {
535 segments.push(Segment::new(
536 box_chars.top[1].to_string().repeat(right_rule - 1),
537 border_style.clone(),
538 ));
539 }
540 let right_border = if right_rule > 0 {
541 format!("{}{}", box_chars.top[1], box_chars.top[3])
542 } else {
543 box_chars.top[3].to_string()
544 };
545 segments.push(Segment::new(right_border, border_style));
546 return segments;
547 }
548
549 vec![Segment::new(
550 format!(
551 "{}{}{}",
552 box_chars.top[0],
553 box_chars.top[1].to_string().repeat(inner_width),
554 box_chars.top[3]
555 ),
556 border_style,
557 )]
558 }
559
560 fn render_bottom_border(&self, box_chars: &BoxChars, inner_width: usize) -> Vec<Segment<'a>> {
562 let mut segments = Vec::new();
563
564 if let Some(subtitle) = &self.subtitle {
565 segments.push(Segment::new(
567 box_chars.bottom[0].to_string(),
568 Some(self.border_style.clone()),
569 ));
570 let max_text_width = if inner_width >= 4 {
571 inner_width.saturating_sub(4)
572 } else {
573 inner_width.saturating_sub(2)
574 };
575 let subtitle_text = if inner_width >= 2 {
576 if subtitle.cell_len() > max_text_width {
577 truncate_text_to_width(subtitle, max_text_width)
578 } else {
579 subtitle.clone()
580 }
581 } else {
582 truncate_text_to_width(subtitle, inner_width)
583 };
584
585 let subtitle_width = subtitle_text.cell_len();
586 if inner_width < 2 {
587 segments.extend(
588 subtitle_text
589 .render("")
590 .into_iter()
591 .map(super::super::segment::Segment::into_owned),
592 );
593 let remaining = inner_width.saturating_sub(subtitle_width);
594 if remaining > 0 {
595 segments.push(Segment::new(
596 box_chars.bottom[1].to_string().repeat(remaining),
597 Some(self.border_style.clone()),
598 ));
599 }
600 } else {
601 let subtitle_total_width = subtitle_width.saturating_add(2);
602 let available = inner_width.saturating_sub(subtitle_total_width);
603 let (left_rule, right_rule) = if available == 0 {
604 (0, 0)
605 } else {
606 match self.subtitle_align {
607 JustifyMethod::Left | JustifyMethod::Default => {
608 (1, available.saturating_sub(1))
609 }
610 JustifyMethod::Right => (available.saturating_sub(1), 1),
611 JustifyMethod::Center | JustifyMethod::Full => {
612 let left = available / 2;
613 (left, available - left)
614 }
615 }
616 };
617
618 if left_rule > 0 {
619 segments.push(Segment::new(
620 box_chars.bottom[1].to_string().repeat(left_rule),
621 Some(self.border_style.clone()),
622 ));
623 }
624
625 segments.push(Segment::new(" ", Some(subtitle_text.style().clone())));
626 segments.extend(
627 subtitle_text
628 .render("")
629 .into_iter()
630 .map(super::super::segment::Segment::into_owned),
631 );
632 segments.push(Segment::new(" ", Some(subtitle_text.style().clone())));
633
634 if right_rule > 0 {
635 segments.push(Segment::new(
636 box_chars.bottom[1].to_string().repeat(right_rule),
637 Some(self.border_style.clone()),
638 ));
639 }
640 }
641 } else {
642 return vec![Segment::new(
644 format!(
645 "{}{}{}",
646 box_chars.bottom[0],
647 box_chars.bottom[1].to_string().repeat(inner_width),
648 box_chars.bottom[3]
649 ),
650 Some(self.border_style.clone()),
651 )];
652 }
653
654 segments.push(Segment::new(
656 box_chars.bottom[3].to_string(),
657 Some(self.border_style.clone()),
658 ));
659
660 segments
661 }
662
663 #[must_use]
665 pub fn render_plain(&self, max_width: usize) -> String {
666 self.render(max_width)
667 .into_iter()
668 .map(|seg| seg.text.into_owned())
669 .collect()
670 }
671}
672
673impl Renderable for Panel<'_> {
674 fn render<'b>(&'b self, console: &Console, options: &ConsoleOptions) -> Vec<Segment<'b>> {
675 if self.safe_box.is_some() {
676 return self.render(options.max_width).into_iter().collect();
677 }
678
679 let effective = self.clone().safe_box(console.safe_box());
681 effective.render(options.max_width).into_iter().collect()
682 }
683}
684
685fn truncate_text_to_width(text: &Text, max_width: usize) -> Text {
687 let mut truncated = text.clone();
688 truncated.truncate(max_width, OverflowMethod::Ellipsis, false);
689 truncated
690}
691
692#[must_use]
694pub fn fit_panel(text: &str) -> Panel<'_> {
695 Panel::from_text(text).expand(false)
696}
697
698#[cfg(test)]
699mod tests {
700 use super::*;
701 use crate::segment::split_lines;
702 use crate::style::Attributes;
703
704 #[test]
705 fn test_panel_from_text() {
706 let panel = Panel::from_text("Hello\nWorld");
707 assert_eq!(panel.content_lines.len(), 2);
708 }
709
710 #[test]
711 fn test_panel_render() {
712 let panel = Panel::from_text("Hello").width(20);
713 let segments = panel.render(80);
714 assert!(!segments.is_empty());
715
716 let text: String = segments.iter().map(|s| s.text.as_ref()).collect();
717 assert!(text.contains("Hello"));
718 assert!(text.contains('\u{256D}')); }
721
722 #[test]
723 fn test_panel_with_title() {
724 let panel = Panel::from_text("Content").title("Title").width(30);
725 let text = panel.render_plain(80);
726 assert!(text.contains("Title"));
727 assert!(text.contains("Content"));
728 }
729
730 #[test]
731 fn test_panel_ascii() {
732 let panel = Panel::from_text("Hello").ascii().width(20);
733 let text = panel.render_plain(80);
734 assert!(text.contains('+')); assert!(text.contains('-')); }
737
738 #[test]
739 fn test_panel_square() {
740 let panel = Panel::from_text("Hello").square().width(20);
741 let text = panel.render_plain(80);
742 assert!(text.contains('\u{250C}')); }
744
745 #[test]
746 fn test_panel_padding() {
747 let panel = Panel::from_text("Hi").padding((1, 2)).width(20);
748 let segments = panel.render(80);
749 let newlines = segments.iter().filter(|s| s.text == "\n").count();
751 assert!(newlines >= 5);
753 }
754
755 #[test]
756 fn test_panel_subtitle() {
757 let panel = Panel::from_text("Content").subtitle("Footer").width(30);
758 let text = panel.render_plain(80);
759 assert!(text.contains("Footer"));
760 }
761
762 #[test]
763 fn test_panel_truncates_to_width() {
764 let panel = Panel::from_text("This is a very long line")
765 .width(10)
766 .padding(0);
767
768 let segments = panel.render(10);
769 let lines = split_lines(segments.into_iter());
770
771 for line in lines {
772 let width: usize = line.iter().map(Segment::cell_length).sum();
773 if width > 0 {
774 assert_eq!(width, 10);
775 }
776 }
777 }
778
779 #[test]
780 fn test_panel_height_limits_content_lines() {
781 let panel = Panel::from_text("A\nB\nC").height(4).padding(0).width(10);
782
783 let segments = panel.render(10);
784 let lines = split_lines(segments.into_iter());
785 let non_empty_lines = lines
786 .iter()
787 .filter(|line| line.iter().map(Segment::cell_length).sum::<usize>() > 0)
788 .count();
789
790 assert_eq!(non_empty_lines, 4);
791 let text: String = lines
792 .iter()
793 .map(|line| line.iter().map(|seg| seg.text.as_ref()).collect::<String>())
794 .collect();
795 assert!(!text.contains('C'));
796 }
797
798 #[test]
799 fn test_panel_height_pads_content_lines() {
800 let panel = Panel::from_text("A").height(5).padding(0).width(10);
801
802 let segments = panel.render(10);
803 let lines = split_lines(segments.into_iter());
804 let non_empty_lines = lines
805 .iter()
806 .filter(|line| line.iter().map(Segment::cell_length).sum::<usize>() > 0)
807 .count();
808
809 assert_eq!(non_empty_lines, 5);
810 }
811
812 #[test]
813 fn test_panel_height_prefers_content_over_padding() {
814 let panel = Panel::from_text("A").height(4).padding((2, 0)).width(10);
815
816 let segments = panel.render(10);
817 let lines = split_lines(segments.into_iter());
818 let non_empty_lines = lines
819 .iter()
820 .filter(|line| line.iter().map(Segment::cell_length).sum::<usize>() > 0)
821 .count();
822
823 assert_eq!(non_empty_lines, 4);
824 let text: String = lines
825 .iter()
826 .map(|line| line.iter().map(|seg| seg.text.as_ref()).collect::<String>())
827 .collect();
828 assert!(text.contains('A'));
829 }
830
831 #[test]
832 fn test_fit_panel() {
833 let panel = fit_panel("Short");
834 assert!(!panel.expand);
835 }
836
837 #[test]
838 fn test_truncate_text_to_width() {
839 let text = Text::new("Hello World");
840 let truncated = truncate_text_to_width(&text, 5);
841 assert_eq!(truncated.plain(), "He...");
842 }
843
844 #[test]
845 fn test_panel_title_preserves_spans() {
846 let mut title = Text::new("AB");
847 title.stylize(0, 1, Style::new().italic());
848
849 let panel = Panel::from_text("Content").title(title).width(20);
850 let segments = panel.render(20);
851 let title_segment = segments
852 .iter()
853 .find(|seg| seg.text.contains('A'))
854 .expect("expected title segment");
855 let style = title_segment
856 .style
857 .as_ref()
858 .expect("expected styled segment");
859 assert!(style.attributes.contains(Attributes::ITALIC));
860 }
861
862 #[test]
863 fn test_panel_title_from_markup() {
864 let panel = Panel::from_text("Content")
865 .title_from_markup("[bold]Styled Title[/]")
866 .width(30);
867 let segments = panel.render(30);
868
869 let title_segment = segments
871 .iter()
872 .find(|seg| seg.text.contains("Styled Title"))
873 .expect("expected title segment with styled text");
874 let style = title_segment
875 .style
876 .as_ref()
877 .expect("expected styled segment");
878 assert!(
879 style.attributes.contains(Attributes::BOLD),
880 "title should be bold"
881 );
882 }
883
884 #[test]
885 fn test_panel_subtitle_from_markup() {
886 let panel = Panel::from_text("Content")
887 .subtitle_from_markup("[italic]Footer[/]")
888 .width(30);
889 let segments = panel.render(30);
890
891 let footer_segment = segments
893 .iter()
894 .find(|seg| seg.text.contains("Footer"))
895 .expect("expected subtitle segment with styled text");
896 let style = footer_segment
897 .style
898 .as_ref()
899 .expect("expected styled segment");
900 assert!(
901 style.attributes.contains(Attributes::ITALIC),
902 "subtitle should be italic"
903 );
904 }
905
906 #[test]
907 fn test_panel_title_from_markup_no_markup() {
908 let panel = Panel::from_text("Content")
910 .title_from_markup("Plain Title")
911 .width(30);
912 let text = panel.render_plain(30);
913 assert!(text.contains("Plain Title"));
914 }
915}