1use crate::r#box::{ASCII, BoxChars, ROUNDED, SQUARE};
7use crate::cells;
8use crate::console::{Console, ConsoleOptions};
9use crate::renderables::Renderable;
10use crate::segment::{Segment, adjust_line_length};
11use crate::style::Style;
12use crate::text::{JustifyMethod, OverflowMethod, Text};
13
14use super::padding::PaddingDimensions;
15
16#[derive(Debug, Clone)]
18pub struct Panel<'a> {
19 content_lines: Vec<Vec<Segment<'a>>>,
21 box_style: &'static BoxChars,
23 safe_box: bool,
25 expand: bool,
27 style: Style,
29 border_style: Style,
31 width: Option<usize>,
33 height: Option<usize>,
35 padding: PaddingDimensions,
37 title: Option<Text>,
39 title_align: JustifyMethod,
41 subtitle: Option<Text>,
43 subtitle_align: JustifyMethod,
45}
46
47impl Default for Panel<'_> {
48 fn default() -> Self {
49 Self {
50 content_lines: Vec::new(),
51 box_style: &ROUNDED,
52 safe_box: false,
53 expand: true,
54 style: Style::new(),
55 border_style: Style::new(),
56 width: None,
57 height: None,
58 padding: PaddingDimensions::symmetric(0, 1),
59 title: None,
60 title_align: JustifyMethod::Center,
61 subtitle: None,
62 subtitle_align: JustifyMethod::Center,
63 }
64 }
65}
66
67impl<'a> Panel<'a> {
68 #[must_use]
70 pub fn new(content_lines: Vec<Vec<Segment<'a>>>) -> Self {
71 Self {
72 content_lines,
73 ..Self::default()
74 }
75 }
76
77 #[must_use]
83 pub fn from_text(text: &'a str) -> Self {
84 let lines: Vec<Vec<Segment<'a>>> = text
85 .lines()
86 .map(|line| vec![Segment::new(line, None)])
87 .collect();
88 Self::new(lines)
89 }
90
91 #[must_use]
96 pub fn from_rich_text(text: &'a Text, width: usize) -> Self {
97 let lines = text
99 .split_lines()
100 .into_iter()
101 .map(|line| {
102 line.render("")
103 .into_iter()
104 .map(super::super::segment::Segment::into_owned)
105 .collect()
106 })
107 .collect();
108
109 Self {
110 content_lines: lines,
111 width: Some(width),
112 ..Self::default()
113 }
114 }
115
116 #[must_use]
118 pub fn box_style(mut self, style: &'static BoxChars) -> Self {
119 self.box_style = style;
120 self
121 }
122
123 #[must_use]
125 pub fn rounded(mut self) -> Self {
126 self.box_style = &ROUNDED;
127 self
128 }
129
130 #[must_use]
132 pub fn square(mut self) -> Self {
133 self.box_style = &SQUARE;
134 self
135 }
136
137 #[must_use]
139 pub fn ascii(mut self) -> Self {
140 self.box_style = &ASCII;
141 self.safe_box = true;
142 self
143 }
144
145 #[must_use]
147 pub fn safe_box(mut self, safe: bool) -> Self {
148 self.safe_box = safe;
149 self
150 }
151
152 #[must_use]
154 pub fn expand(mut self, expand: bool) -> Self {
155 self.expand = expand;
156 self
157 }
158
159 #[must_use]
161 pub fn style(mut self, style: Style) -> Self {
162 self.style = style;
163 self
164 }
165
166 #[must_use]
168 pub fn border_style(mut self, style: Style) -> Self {
169 self.border_style = style;
170 self
171 }
172
173 #[must_use]
175 pub fn width(mut self, width: usize) -> Self {
176 self.width = Some(width);
177 self
178 }
179
180 #[must_use]
182 pub fn height(mut self, height: usize) -> Self {
183 self.height = Some(height);
184 self
185 }
186
187 #[must_use]
189 pub fn padding(mut self, padding: impl Into<PaddingDimensions>) -> Self {
190 self.padding = padding.into();
191 self
192 }
193
194 #[must_use]
200 pub fn title(mut self, title: impl Into<Text>) -> Self {
201 self.title = Some(title.into());
202 self
203 }
204
205 #[must_use]
207 pub fn title_align(mut self, align: JustifyMethod) -> Self {
208 self.title_align = align;
209 self
210 }
211
212 #[must_use]
218 pub fn subtitle(mut self, subtitle: impl Into<Text>) -> Self {
219 self.subtitle = Some(subtitle.into());
220 self
221 }
222
223 #[must_use]
225 pub fn subtitle_align(mut self, align: JustifyMethod) -> Self {
226 self.subtitle_align = align;
227 self
228 }
229
230 fn effective_box(&self) -> &'static BoxChars {
232 if self.safe_box && !self.box_style.ascii {
233 &ASCII
234 } else {
235 self.box_style
236 }
237 }
238
239 fn content_width(&self) -> usize {
241 self.content_lines
242 .iter()
243 .map(|line: &Vec<Segment<'a>>| line.iter().map(|seg| cells::cell_len(&seg.text)).sum())
244 .max()
245 .unwrap_or(0)
246 }
247
248 #[must_use]
250 pub fn render(&self, max_width: usize) -> Vec<Segment<'a>> {
251 let box_chars = self.effective_box();
252
253 let panel_width = if self.expand {
255 self.width.unwrap_or(max_width).min(max_width)
256 } else {
257 let content_w = self.content_width();
258 let min_width = content_w + 2 + self.padding.horizontal();
259 self.width.unwrap_or(min_width).min(max_width)
260 };
261
262 let inner_width = panel_width.saturating_sub(2);
264
265 let mut pad_left = self.padding.left;
266 let mut pad_right = self.padding.right;
267 let max_content_width = self.content_width();
268 if max_content_width <= inner_width {
269 let available_for_padding = inner_width.saturating_sub(max_content_width);
270 if pad_left + pad_right > available_for_padding {
271 let mut remaining = available_for_padding;
272 pad_left = pad_left.min(remaining);
273 remaining = remaining.saturating_sub(pad_left);
274 pad_right = pad_right.min(remaining);
275 }
276 }
277
278 let content_width = inner_width.saturating_sub(pad_left + pad_right);
280
281 let mut pad_top = self.padding.top;
282 let mut pad_bottom = self.padding.bottom;
283 let mut content_lines = self.content_lines.clone();
284
285 if let Some(height) = self.height {
286 let max_inner_lines = height.saturating_sub(2);
287 if content_lines.len() > max_inner_lines {
288 content_lines.truncate(max_inner_lines);
289 pad_top = 0;
290 pad_bottom = 0;
291 } else {
292 let remaining_after_content = max_inner_lines - content_lines.len();
293 if pad_top + pad_bottom > remaining_after_content {
294 let mut remaining = remaining_after_content;
295 pad_top = pad_top.min(remaining);
296 remaining = remaining.saturating_sub(pad_top);
297 pad_bottom = pad_bottom.min(remaining);
298 }
299
300 let max_content_lines = max_inner_lines.saturating_sub(pad_top + pad_bottom);
301 if content_lines.len() < max_content_lines {
302 content_lines.extend(
303 std::iter::repeat_with(Vec::new)
304 .take(max_content_lines - content_lines.len()),
305 );
306 }
307 }
308 }
309
310 let mut segments = Vec::new();
311
312 segments.extend(self.render_top_border(box_chars, inner_width));
314 segments.push(Segment::line());
315
316 for _ in 0..pad_top {
318 segments.push(Segment::new(
319 box_chars.head[0].to_string(),
320 Some(self.border_style.clone()),
321 ));
322 segments.push(Segment::new(
323 " ".repeat(inner_width),
324 Some(self.style.clone()),
325 ));
326 segments.push(Segment::new(
327 box_chars.head[3].to_string(),
328 Some(self.border_style.clone()),
329 ));
330 segments.push(Segment::line());
331 }
332
333 let left_pad = " ".repeat(pad_left);
335 let right_pad = " ".repeat(pad_right);
336
337 for line in &content_lines {
338 segments.push(Segment::new(
340 box_chars.head[0].to_string(),
341 Some(self.border_style.clone()),
342 ));
343
344 if pad_left > 0 {
346 segments.push(Segment::new(left_pad.clone(), Some(self.style.clone())));
347 }
348
349 let mut content_segments: Vec<Segment<'a>> = line
351 .iter()
352 .cloned()
353 .map(|mut seg: Segment<'a>| {
354 if !seg.is_control() {
355 seg.style = Some(match seg.style.take() {
356 Some(existing) => self.style.combine(&existing),
357 None => self.style.clone(),
358 });
359 }
360 seg
361 })
362 .collect();
363
364 content_segments = adjust_line_length(
365 content_segments,
366 content_width,
367 Some(self.style.clone()),
368 true,
369 );
370
371 segments.extend(content_segments);
372
373 if pad_right > 0 {
375 segments.push(Segment::new(right_pad.clone(), Some(self.style.clone())));
376 }
377
378 segments.push(Segment::new(
380 box_chars.head[3].to_string(),
381 Some(self.border_style.clone()),
382 ));
383 segments.push(Segment::line());
384 }
385
386 for _ in 0..pad_bottom {
388 segments.push(Segment::new(
389 box_chars.head[0].to_string(),
390 Some(self.border_style.clone()),
391 ));
392 segments.push(Segment::new(
393 " ".repeat(inner_width),
394 Some(self.style.clone()),
395 ));
396 segments.push(Segment::new(
397 box_chars.head[3].to_string(),
398 Some(self.border_style.clone()),
399 ));
400 segments.push(Segment::line());
401 }
402
403 segments.extend(self.render_bottom_border(box_chars, inner_width));
405 segments.push(Segment::line());
406
407 segments
408 }
409
410 fn render_top_border(&self, box_chars: &BoxChars, inner_width: usize) -> Vec<Segment<'a>> {
412 let mut segments = Vec::new();
413
414 segments.push(Segment::new(
416 box_chars.top[0].to_string(),
417 Some(self.border_style.clone()),
418 ));
419
420 if let Some(title) = &self.title {
421 let max_text_width = if inner_width >= 4 {
422 inner_width.saturating_sub(4)
423 } else {
424 inner_width.saturating_sub(2)
425 };
426 let title_text = if inner_width >= 2 {
427 if title.cell_len() > max_text_width {
428 truncate_text_to_width(title, max_text_width)
429 } else {
430 title.clone()
431 }
432 } else {
433 truncate_text_to_width(title, inner_width)
434 };
435
436 let title_width = title_text.cell_len();
437 if inner_width < 2 {
438 segments.extend(
439 title_text
440 .render("")
441 .into_iter()
442 .map(super::super::segment::Segment::into_owned),
443 );
444 let remaining = inner_width.saturating_sub(title_width);
445 if remaining > 0 {
446 segments.push(Segment::new(
447 box_chars.top[1].to_string().repeat(remaining),
448 Some(self.border_style.clone()),
449 ));
450 }
451 } else {
452 let title_total_width = title_width.saturating_add(2);
453 let available = inner_width.saturating_sub(title_total_width);
454 let (left_rule, right_rule) = if available == 0 {
455 (0, 0)
456 } else {
457 match self.title_align {
458 JustifyMethod::Left | JustifyMethod::Default => {
459 (1, available.saturating_sub(1))
460 }
461 JustifyMethod::Right => (available.saturating_sub(1), 1),
462 JustifyMethod::Center | JustifyMethod::Full => {
463 let left = available / 2;
464 (left, available - left)
465 }
466 }
467 };
468
469 if left_rule > 0 {
470 segments.push(Segment::new(
471 box_chars.top[1].to_string().repeat(left_rule),
472 Some(self.border_style.clone()),
473 ));
474 }
475
476 segments.push(Segment::new(" ", Some(title_text.style().clone())));
477 segments.extend(
478 title_text
479 .render("")
480 .into_iter()
481 .map(super::super::segment::Segment::into_owned),
482 );
483 segments.push(Segment::new(" ", Some(title_text.style().clone())));
484
485 if right_rule > 0 {
486 segments.push(Segment::new(
487 box_chars.top[1].to_string().repeat(right_rule),
488 Some(self.border_style.clone()),
489 ));
490 }
491 }
492 } else {
493 segments.push(Segment::new(
495 box_chars.top[1].to_string().repeat(inner_width),
496 Some(self.border_style.clone()),
497 ));
498 }
499
500 segments.push(Segment::new(
502 box_chars.top[3].to_string(),
503 Some(self.border_style.clone()),
504 ));
505
506 segments
507 }
508
509 fn render_bottom_border(&self, box_chars: &BoxChars, inner_width: usize) -> Vec<Segment<'a>> {
511 let mut segments = Vec::new();
512
513 segments.push(Segment::new(
515 box_chars.bottom[0].to_string(),
516 Some(self.border_style.clone()),
517 ));
518
519 if let Some(subtitle) = &self.subtitle {
520 let max_text_width = if inner_width >= 4 {
521 inner_width.saturating_sub(4)
522 } else {
523 inner_width.saturating_sub(2)
524 };
525 let subtitle_text = if inner_width >= 2 {
526 if subtitle.cell_len() > max_text_width {
527 truncate_text_to_width(subtitle, max_text_width)
528 } else {
529 subtitle.clone()
530 }
531 } else {
532 truncate_text_to_width(subtitle, inner_width)
533 };
534
535 let subtitle_width = subtitle_text.cell_len();
536 if inner_width < 2 {
537 segments.extend(
538 subtitle_text
539 .render("")
540 .into_iter()
541 .map(super::super::segment::Segment::into_owned),
542 );
543 let remaining = inner_width.saturating_sub(subtitle_width);
544 if remaining > 0 {
545 segments.push(Segment::new(
546 box_chars.bottom[1].to_string().repeat(remaining),
547 Some(self.border_style.clone()),
548 ));
549 }
550 } else {
551 let subtitle_total_width = subtitle_width.saturating_add(2);
552 let available = inner_width.saturating_sub(subtitle_total_width);
553 let (left_rule, right_rule) = if available == 0 {
554 (0, 0)
555 } else {
556 match self.subtitle_align {
557 JustifyMethod::Left | JustifyMethod::Default => {
558 (1, available.saturating_sub(1))
559 }
560 JustifyMethod::Right => (available.saturating_sub(1), 1),
561 JustifyMethod::Center | JustifyMethod::Full => {
562 let left = available / 2;
563 (left, available - left)
564 }
565 }
566 };
567
568 if left_rule > 0 {
569 segments.push(Segment::new(
570 box_chars.bottom[1].to_string().repeat(left_rule),
571 Some(self.border_style.clone()),
572 ));
573 }
574
575 segments.push(Segment::new(" ", Some(subtitle_text.style().clone())));
576 segments.extend(
577 subtitle_text
578 .render("")
579 .into_iter()
580 .map(super::super::segment::Segment::into_owned),
581 );
582 segments.push(Segment::new(" ", Some(subtitle_text.style().clone())));
583
584 if right_rule > 0 {
585 segments.push(Segment::new(
586 box_chars.bottom[1].to_string().repeat(right_rule),
587 Some(self.border_style.clone()),
588 ));
589 }
590 }
591 } else {
592 segments.push(Segment::new(
594 box_chars.bottom[1].to_string().repeat(inner_width),
595 Some(self.border_style.clone()),
596 ));
597 }
598
599 segments.push(Segment::new(
601 box_chars.bottom[3].to_string(),
602 Some(self.border_style.clone()),
603 ));
604
605 segments
606 }
607
608 #[must_use]
610 pub fn render_plain(&self, max_width: usize) -> String {
611 self.render(max_width)
612 .into_iter()
613 .map(|seg| seg.text.into_owned())
614 .collect()
615 }
616}
617
618impl Renderable for Panel<'_> {
619 fn render<'b>(&'b self, _console: &Console, options: &ConsoleOptions) -> Vec<Segment<'b>> {
620 self.render(options.max_width).into_iter().collect()
621 }
622}
623
624fn truncate_text_to_width(text: &Text, max_width: usize) -> Text {
626 let mut truncated = text.clone();
627 truncated.truncate(max_width, OverflowMethod::Ellipsis, false);
628 truncated
629}
630
631#[must_use]
633pub fn fit_panel(text: &str) -> Panel<'_> {
634 Panel::from_text(text).expand(false)
635}
636
637#[cfg(test)]
638mod tests {
639 use super::*;
640 use crate::segment::split_lines;
641 use crate::style::Attributes;
642
643 #[test]
644 fn test_panel_from_text() {
645 let panel = Panel::from_text("Hello\nWorld");
646 assert_eq!(panel.content_lines.len(), 2);
647 }
648
649 #[test]
650 fn test_panel_render() {
651 let panel = Panel::from_text("Hello").width(20);
652 let segments = panel.render(80);
653 assert!(!segments.is_empty());
654
655 let text: String = segments.iter().map(|s| s.text.as_ref()).collect();
656 assert!(text.contains("Hello"));
657 assert!(text.contains('\u{256D}')); }
660
661 #[test]
662 fn test_panel_with_title() {
663 let panel = Panel::from_text("Content").title("Title").width(30);
664 let text = panel.render_plain(80);
665 assert!(text.contains("Title"));
666 assert!(text.contains("Content"));
667 }
668
669 #[test]
670 fn test_panel_ascii() {
671 let panel = Panel::from_text("Hello").ascii().width(20);
672 let text = panel.render_plain(80);
673 assert!(text.contains('+')); assert!(text.contains('-')); }
676
677 #[test]
678 fn test_panel_square() {
679 let panel = Panel::from_text("Hello").square().width(20);
680 let text = panel.render_plain(80);
681 assert!(text.contains('\u{250C}')); }
683
684 #[test]
685 fn test_panel_padding() {
686 let panel = Panel::from_text("Hi").padding((1, 2)).width(20);
687 let segments = panel.render(80);
688 let newlines = segments.iter().filter(|s| s.text == "\n").count();
690 assert!(newlines >= 5);
692 }
693
694 #[test]
695 fn test_panel_subtitle() {
696 let panel = Panel::from_text("Content").subtitle("Footer").width(30);
697 let text = panel.render_plain(80);
698 assert!(text.contains("Footer"));
699 }
700
701 #[test]
702 fn test_panel_truncates_to_width() {
703 let panel = Panel::from_text("This is a very long line")
704 .width(10)
705 .padding(0);
706
707 let segments = panel.render(10);
708 let lines = split_lines(segments.into_iter());
709
710 for line in lines {
711 let width: usize = line.iter().map(Segment::cell_length).sum();
712 if width > 0 {
713 assert_eq!(width, 10);
714 }
715 }
716 }
717
718 #[test]
719 fn test_panel_height_limits_content_lines() {
720 let panel = Panel::from_text("A\nB\nC").height(4).padding(0).width(10);
721
722 let segments = panel.render(10);
723 let lines = split_lines(segments.into_iter());
724 let non_empty_lines = lines
725 .iter()
726 .filter(|line| line.iter().map(Segment::cell_length).sum::<usize>() > 0)
727 .count();
728
729 assert_eq!(non_empty_lines, 4);
730 let text: String = lines
731 .iter()
732 .map(|line| line.iter().map(|seg| seg.text.as_ref()).collect::<String>())
733 .collect();
734 assert!(!text.contains('C'));
735 }
736
737 #[test]
738 fn test_panel_height_pads_content_lines() {
739 let panel = Panel::from_text("A").height(5).padding(0).width(10);
740
741 let segments = panel.render(10);
742 let lines = split_lines(segments.into_iter());
743 let non_empty_lines = lines
744 .iter()
745 .filter(|line| line.iter().map(Segment::cell_length).sum::<usize>() > 0)
746 .count();
747
748 assert_eq!(non_empty_lines, 5);
749 }
750
751 #[test]
752 fn test_panel_height_prefers_content_over_padding() {
753 let panel = Panel::from_text("A").height(4).padding((2, 0)).width(10);
754
755 let segments = panel.render(10);
756 let lines = split_lines(segments.into_iter());
757 let non_empty_lines = lines
758 .iter()
759 .filter(|line| line.iter().map(Segment::cell_length).sum::<usize>() > 0)
760 .count();
761
762 assert_eq!(non_empty_lines, 4);
763 let text: String = lines
764 .iter()
765 .map(|line| line.iter().map(|seg| seg.text.as_ref()).collect::<String>())
766 .collect();
767 assert!(text.contains('A'));
768 }
769
770 #[test]
771 fn test_fit_panel() {
772 let panel = fit_panel("Short");
773 assert!(!panel.expand);
774 }
775
776 #[test]
777 fn test_truncate_text_to_width() {
778 let text = Text::new("Hello World");
779 let truncated = truncate_text_to_width(&text, 5);
780 assert_eq!(truncated.plain(), "He...");
781 }
782
783 #[test]
784 fn test_panel_title_preserves_spans() {
785 let mut title = Text::new("AB");
786 title.stylize(0, 1, Style::new().italic());
787
788 let panel = Panel::from_text("Content").title(title).width(20);
789 let segments = panel.render(20);
790 let title_segment = segments
791 .iter()
792 .find(|seg| seg.text.contains('A'))
793 .expect("expected title segment");
794 let style = title_segment
795 .style
796 .as_ref()
797 .expect("expected styled segment");
798 assert!(style.attributes.contains(Attributes::ITALIC));
799 }
800}