Skip to main content

rich/
panel.rs

1//! Panels — a box drawn around a renderable.
2//!
3//! Port of upstream `rich/panel.py`. A [`Panel`] frames a child renderable with
4//! a box border, inner padding, and an optional centered title.
5//!
6//! Slice scope: title + subtitle (with alignment), box + border style +
7//! padding, expand-to-width. `fit` (shrink-to-content) sizing is deferred.
8
9use crate::align::HorizontalAlign;
10use crate::cells::{cell_len, truncate};
11use crate::console::{Console, ConsoleOptions};
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;
17
18/// A bordered box around a renderable. Mirrors `rich.panel.Panel`.
19pub struct Panel {
20    child: Box<dyn Renderable>,
21    box_set: BoxSet,
22    title: Option<String>,
23    title_align: HorizontalAlign,
24    subtitle: Option<String>,
25    subtitle_align: HorizontalAlign,
26    padding: (usize, usize, usize, usize),
27    border_style: Style,
28    style: Style,
29}
30
31impl Panel {
32    /// A panel around `child` with default box (`ROUNDED`) and padding `(0,1)`.
33    pub fn new(child: Box<dyn Renderable>) -> Self {
34        Panel {
35            child,
36            box_set: ROUNDED,
37            title: None,
38            title_align: HorizontalAlign::Center,
39            subtitle: None,
40            subtitle_align: HorizontalAlign::Center,
41            padding: (0, 1, 0, 1),
42            border_style: Style::new(),
43            style: Style::new(),
44        }
45    }
46
47    /// Set a title (drawn into the top border, centered by default).
48    pub fn title(mut self, title: impl Into<String>) -> Self {
49        self.title = Some(title.into());
50        self
51    }
52
53    /// Set the title alignment within the top border.
54    pub fn title_align(mut self, align: HorizontalAlign) -> Self {
55        self.title_align = align;
56        self
57    }
58
59    /// Set a subtitle (drawn into the bottom border, centered by default).
60    pub fn subtitle(mut self, subtitle: impl Into<String>) -> Self {
61        self.subtitle = Some(subtitle.into());
62        self
63    }
64
65    /// Set the subtitle alignment within the bottom border.
66    pub fn subtitle_align(mut self, align: HorizontalAlign) -> Self {
67        self.subtitle_align = align;
68        self
69    }
70
71    /// Choose the box-drawing set.
72    pub fn box_set(mut self, box_set: BoxSet) -> Self {
73        self.box_set = box_set;
74        self
75    }
76
77    /// Set the inner padding `(top, right, bottom, left)`.
78    pub fn padding(mut self, padding: (usize, usize, usize, usize)) -> Self {
79        self.padding = padding;
80        self
81    }
82
83    /// Set the border style.
84    pub fn border_style(mut self, style: Style) -> Self {
85        self.border_style = style;
86        self
87    }
88
89    /// Build a top/bottom border, optionally embedding an aligned `label`.
90    fn border_line(
91        &self,
92        inner_width: usize,
93        left_corner: char,
94        fill_char: char,
95        right_corner: char,
96        label: Option<&String>,
97        align: HorizontalAlign,
98    ) -> String {
99        let mut border = String::new();
100        border.push(left_corner);
101        match label {
102            None => border.extend(std::iter::repeat_n(fill_char, inner_width)),
103            Some(label) => {
104                // Truncate the label so it (plus its flanking spaces) fits.
105                let label = truncate(label, inner_width.saturating_sub(2));
106                let padded = format!(" {label} ");
107                let fill = inner_width.saturating_sub(cell_len(&padded));
108                let (left, right) = match align {
109                    HorizontalAlign::Center => (fill / 2, fill - fill / 2),
110                    // Left/right keep a single box-char offset on the near side.
111                    HorizontalAlign::Left => (1.min(fill), fill.saturating_sub(1)),
112                    HorizontalAlign::Right => (fill.saturating_sub(1), 1.min(fill)),
113                };
114                border.extend(std::iter::repeat_n(fill_char, left));
115                border.push_str(&padded);
116                border.extend(std::iter::repeat_n(fill_char, right));
117            }
118        }
119        border.push(right_corner);
120        border
121    }
122}
123
124impl Renderable for Panel {
125    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
126        let width = options.max_width;
127        // Fall back to a terminal-safe box on legacy Windows / non-UTF-8.
128        let box_set = self.box_set.substitute(
129            console.legacy_windows(),
130            console.safe_box(),
131            console.ascii_only(),
132        );
133        let inner_width = width.saturating_sub(2);
134        let (pt, pr, pb, pl) = self.padding;
135        let child_width = inner_width.saturating_sub(pl).saturating_sub(pr);
136
137        let mut child_options = options.update_width(child_width);
138        // When a height is imposed (e.g. as a Layout leaf), the child fills the
139        // space left by the two borders and the top/bottom padding rows, so the
140        // panel expands to exactly `height` rows. Port of `Panel`'s
141        // `child_height = height - 2` (padding here lives outside the child).
142        child_options.height = options.height.map(|h| h.saturating_sub(2 + pt + pb));
143        let child_lines = console.render_lines(self.child.as_ref(), &child_options, true);
144
145        let border = Some(self.border_style.clone());
146        let inner_style = Some(self.style.clone());
147        let left_border = || Segment::new(box_set.mid_left.to_string(), border.clone());
148        let right_border = || Segment::new(box_set.mid_right.to_string(), border.clone());
149        let blank_inner = || Segment::new(" ".repeat(inner_width), inner_style.clone());
150
151        let mut rows: Vec<Vec<Segment>> = Vec::new();
152
153        // Top border (with title if present).
154        rows.push(vec![Segment::new(
155            self.border_line(
156                inner_width,
157                box_set.top_left,
158                box_set.top,
159                box_set.top_right,
160                self.title.as_ref(),
161                self.title_align,
162            ),
163            border.clone(),
164        )]);
165
166        // Top padding rows.
167        for _ in 0..pt {
168            rows.push(vec![left_border(), blank_inner(), right_border()]);
169        }
170
171        // Content rows: border + left pad + content + right pad + border.
172        for line in child_lines {
173            let mut row = vec![left_border()];
174            if pl > 0 {
175                row.push(Segment::new(" ".repeat(pl), inner_style.clone()));
176            }
177            row.extend(line);
178            if pr > 0 {
179                row.push(Segment::new(" ".repeat(pr), inner_style.clone()));
180            }
181            row.push(right_border());
182            rows.push(row);
183        }
184
185        // Bottom padding rows.
186        for _ in 0..pb {
187            rows.push(vec![left_border(), blank_inner(), right_border()]);
188        }
189
190        // Bottom border (with subtitle if present).
191        rows.push(vec![Segment::new(
192            self.border_line(
193                inner_width,
194                box_set.bottom_left,
195                box_set.bottom,
196                box_set.bottom_right,
197                self.subtitle.as_ref(),
198                self.subtitle_align,
199            ),
200            border.clone(),
201        )]);
202
203        join_rows(rows)
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use crate::r#box::SQUARE;
211    use crate::text::Text;
212
213    fn console() -> Console {
214        Console::builder()
215            .force_terminal(true)
216            .color_system(Some(crate::color::ColorSystem::Truecolor))
217            .width(20)
218            .build()
219    }
220
221    #[test]
222    fn plain_panel() {
223        let out = console().render_export(&Panel::new(Box::new(Text::new("hello"))));
224        assert_eq!(
225            out,
226            "╭──────────────────╮\n│ hello            │\n╰──────────────────╯\n"
227        );
228    }
229
230    #[test]
231    fn titled_panel() {
232        let out = console().render_export(&Panel::new(Box::new(Text::new("hello"))).title("T"));
233        assert_eq!(
234            out,
235            "╭─────── T ────────╮\n│ hello            │\n╰──────────────────╯\n"
236        );
237    }
238
239    #[test]
240    fn square_box() {
241        let out = console().render_export(&Panel::new(Box::new(Text::new("hi"))).box_set(SQUARE));
242        assert_eq!(
243            out,
244            "┌──────────────────┐\n│ hi               │\n└──────────────────┘\n"
245        );
246    }
247
248    #[test]
249    fn legacy_windows_substitutes_rounded_to_square() {
250        // On a legacy Windows console, ROUNDED falls back to SQUARE. Captured
251        // from real rich 15.0.0 (legacy_windows=True, width 12).
252        let legacy = Console::builder()
253            .force_terminal(true)
254            .color_system(Some(crate::color::ColorSystem::Truecolor))
255            .width(12)
256            .no_color(false)
257            .legacy_windows(true)
258            .build();
259        let out = legacy.render_export(&Panel::new(Box::new(Text::new("hi"))));
260        assert_eq!(out, "┌──────────┐\n│ hi       │\n└──────────┘\n");
261    }
262}