Skip to main content

rich_rust/renderables/
panel.rs

1//! Panel - bordered box containing content.
2//!
3//! A Panel renders content inside a decorative border with optional
4//! title and subtitle.
5
6use 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/// A bordered panel containing content.
17#[derive(Debug, Clone)]
18pub struct Panel<'a> {
19    /// Content lines to render inside the panel.
20    content_lines: Vec<Vec<Segment<'a>>>,
21    /// Box drawing style.
22    box_style: &'static BoxChars,
23    /// Use ASCII-safe characters.
24    safe_box: bool,
25    /// Expand to fill available width.
26    expand: bool,
27    /// Style for the panel background.
28    style: Style,
29    /// Style for the border.
30    border_style: Style,
31    /// Fixed width (None = auto).
32    width: Option<usize>,
33    /// Fixed height (None = auto).
34    height: Option<usize>,
35    /// Padding inside the border.
36    padding: PaddingDimensions,
37    /// Optional title.
38    title: Option<Text>,
39    /// Title alignment.
40    title_align: JustifyMethod,
41    /// Optional subtitle (bottom).
42    subtitle: Option<Text>,
43    /// Subtitle alignment.
44    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    /// Create a new panel with content lines.
69    #[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    /// Create a panel from plain text content.
78    ///
79    /// This does **NOT** parse Rich markup. If you want markup styling,
80    /// parse into a [`Text`] first with [`crate::markup::render_or_plain`]
81    /// and use [`Panel::from_rich_text`].
82    #[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    /// Create a panel from a pre-styled Text object.
92    ///
93    /// Use this when you already have a `Text` with spans (for example from
94    /// [`crate::markup::render_or_plain`]).
95    #[must_use]
96    pub fn from_rich_text(text: &'a Text, width: usize) -> Self {
97        // Split into logical lines first, then render each line to segments.
98        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    /// Set the box style.
117    #[must_use]
118    pub fn box_style(mut self, style: &'static BoxChars) -> Self {
119        self.box_style = style;
120        self
121    }
122
123    /// Use rounded box style.
124    #[must_use]
125    pub fn rounded(mut self) -> Self {
126        self.box_style = &ROUNDED;
127        self
128    }
129
130    /// Use square box style.
131    #[must_use]
132    pub fn square(mut self) -> Self {
133        self.box_style = &SQUARE;
134        self
135    }
136
137    /// Use ASCII-safe box style.
138    #[must_use]
139    pub fn ascii(mut self) -> Self {
140        self.box_style = &ASCII;
141        self.safe_box = true;
142        self
143    }
144
145    /// Force ASCII-safe rendering.
146    #[must_use]
147    pub fn safe_box(mut self, safe: bool) -> Self {
148        self.safe_box = safe;
149        self
150    }
151
152    /// Set whether to expand to fill width.
153    #[must_use]
154    pub fn expand(mut self, expand: bool) -> Self {
155        self.expand = expand;
156        self
157    }
158
159    /// Set the background style.
160    #[must_use]
161    pub fn style(mut self, style: Style) -> Self {
162        self.style = style;
163        self
164    }
165
166    /// Set the border style.
167    #[must_use]
168    pub fn border_style(mut self, style: Style) -> Self {
169        self.border_style = style;
170        self
171    }
172
173    /// Set fixed width.
174    #[must_use]
175    pub fn width(mut self, width: usize) -> Self {
176        self.width = Some(width);
177        self
178    }
179
180    /// Set fixed height.
181    #[must_use]
182    pub fn height(mut self, height: usize) -> Self {
183        self.height = Some(height);
184        self
185    }
186
187    /// Set padding.
188    #[must_use]
189    pub fn padding(mut self, padding: impl Into<PaddingDimensions>) -> Self {
190        self.padding = padding.into();
191        self
192    }
193
194    /// Set the title.
195    ///
196    /// Passing a `&str` uses `Text::new()` and does **NOT** parse markup.
197    /// For styled titles, pass a pre-styled `Text` (e.g. from
198    /// [`crate::markup::render_or_plain`]).
199    #[must_use]
200    pub fn title(mut self, title: impl Into<Text>) -> Self {
201        self.title = Some(title.into());
202        self
203    }
204
205    /// Set title alignment.
206    #[must_use]
207    pub fn title_align(mut self, align: JustifyMethod) -> Self {
208        self.title_align = align;
209        self
210    }
211
212    /// Set the subtitle.
213    ///
214    /// Passing a `&str` uses `Text::new()` and does **NOT** parse markup.
215    /// For styled subtitles, pass a pre-styled `Text` (e.g. from
216    /// [`crate::markup::render_or_plain`]).
217    #[must_use]
218    pub fn subtitle(mut self, subtitle: impl Into<Text>) -> Self {
219        self.subtitle = Some(subtitle.into());
220        self
221    }
222
223    /// Set subtitle alignment.
224    #[must_use]
225    pub fn subtitle_align(mut self, align: JustifyMethod) -> Self {
226        self.subtitle_align = align;
227        self
228    }
229
230    /// Get the effective box characters.
231    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    /// Calculate content width from content lines.
240    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    /// Render the panel to segments.
249    #[must_use]
250    pub fn render(&self, max_width: usize) -> Vec<Segment<'a>> {
251        let box_chars = self.effective_box();
252
253        // Calculate panel width
254        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        // Inner width (inside borders)
263        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        // Content width (inside borders and padding)
279        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        // Top border with optional title
313        segments.extend(self.render_top_border(box_chars, inner_width));
314        segments.push(Segment::line());
315
316        // Top padding
317        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        // Content lines
334        let left_pad = " ".repeat(pad_left);
335        let right_pad = " ".repeat(pad_right);
336
337        for line in &content_lines {
338            // Left border
339            segments.push(Segment::new(
340                box_chars.head[0].to_string(),
341                Some(self.border_style.clone()),
342            ));
343
344            // Left padding
345            if pad_left > 0 {
346                segments.push(Segment::new(left_pad.clone(), Some(self.style.clone())));
347            }
348
349            // Content (truncate/pad to content width)
350            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            // Right padding
374            if pad_right > 0 {
375                segments.push(Segment::new(right_pad.clone(), Some(self.style.clone())));
376            }
377
378            // Right border
379            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        // Bottom padding
387        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        // Bottom border with optional subtitle
404        segments.extend(self.render_bottom_border(box_chars, inner_width));
405        segments.push(Segment::line());
406
407        segments
408    }
409
410    /// Render the top border with optional title.
411    fn render_top_border(&self, box_chars: &BoxChars, inner_width: usize) -> Vec<Segment<'a>> {
412        let mut segments = Vec::new();
413
414        // Left corner
415        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            // No title, just a line
494            segments.push(Segment::new(
495                box_chars.top[1].to_string().repeat(inner_width),
496                Some(self.border_style.clone()),
497            ));
498        }
499
500        // Right corner
501        segments.push(Segment::new(
502            box_chars.top[3].to_string(),
503            Some(self.border_style.clone()),
504        ));
505
506        segments
507    }
508
509    /// Render the bottom border with optional subtitle.
510    fn render_bottom_border(&self, box_chars: &BoxChars, inner_width: usize) -> Vec<Segment<'a>> {
511        let mut segments = Vec::new();
512
513        // Left corner
514        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            // No subtitle, just a line
593            segments.push(Segment::new(
594                box_chars.bottom[1].to_string().repeat(inner_width),
595                Some(self.border_style.clone()),
596            ));
597        }
598
599        // Right corner
600        segments.push(Segment::new(
601            box_chars.bottom[3].to_string(),
602            Some(self.border_style.clone()),
603        ));
604
605        segments
606    }
607
608    /// Render to plain text.
609    #[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
624/// Truncate a Text object to a maximum cell width with ellipsis.
625fn 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/// Create a panel with content that fits (doesn't expand).
632#[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        // Should have rounded corners by default
658        assert!(text.contains('\u{256D}')); // ╭
659    }
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('+')); // ASCII corners
674        assert!(text.contains('-')); // ASCII horizontal
675    }
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}')); // ┌
682    }
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        // Count newlines to verify padding
689        let newlines = segments.iter().filter(|s| s.text == "\n").count();
690        // Should have: top border, 1 top pad, content, 1 bottom pad, bottom border
691        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}