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::console::{Console, ConsoleOptions};
11use crate::padding::join_rows;
12use crate::protocol::Renderable;
13use crate::r#box::{Box as BoxSet, ROUNDED};
14use crate::segment::Segment;
15use crate::style::Style;
16use crate::text::{Text, DEFAULT_TAB_SIZE};
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. Port of `Panel._title`, `_subtitle` and
90    /// `align_text`: markup is styled before its visible cell width is measured.
91    fn border_line(
92        &self,
93        console: &Console,
94        inner_width: usize,
95        corners: (char, char, char),
96        label: Option<&String>,
97        align: HorizontalAlign,
98    ) -> Vec<Segment> {
99        let (left_corner, fill_char, right_corner) = corners;
100        let border_style = Some(self.border_style.clone());
101        let Some(label) = label.filter(|label| !label.is_empty() && inner_width > 2) else {
102            return vec![Segment::new(
103                format!(
104                    "{left_corner}{}{right_corner}",
105                    fill_char.to_string().repeat(inner_width)
106                ),
107                border_style,
108            )];
109        };
110
111        // Text.from_markup expands emoji independently of the console's emoji
112        // flag. Preserve markup offsets while flattening newlines to spaces.
113        let expanded = crate::emoji::replace(label);
114        let parsed = Text::from_markup(&expanded).unwrap_or_else(|_| Text::new(expanded));
115        let mut label = parsed.blank_copy();
116        label.append(&parsed.plain().replace('\n', " "), None);
117        for span in parsed.spans() {
118            label.push_span(span.clone());
119        }
120        label.expand_tabs(DEFAULT_TAB_SIZE);
121        label.pad(1, ' ');
122        label.set_base_style(self.border_style.clone());
123        let label_width = inner_width - 2;
124        label.truncate(label_width, None, false);
125
126        let fill = label_width.saturating_sub(label.cell_len());
127        let (left, right) = match align {
128            HorizontalAlign::Center => (fill / 2, fill - fill / 2),
129            HorizontalAlign::Left => (0, fill),
130            HorizontalAlign::Right => (fill, 0),
131        };
132        let mut text = Text::styled(
133            fill_char.to_string().repeat(left),
134            self.border_style.clone(),
135        )
136        .append_text(&label);
137        text.append(
138            &fill_char.to_string().repeat(right),
139            Some(self.border_style.clone().into()),
140        );
141        let mut segments = vec![Segment::new(
142            format!("{left_corner}{fill_char}"),
143            border_style.clone(),
144        )];
145        segments.extend(text.render(console.theme(), console.base_style()));
146        segments.push(Segment::new(
147            format!("{fill_char}{right_corner}"),
148            border_style,
149        ));
150        segments
151    }
152}
153
154impl Renderable for Panel {
155    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
156        let width = options.max_width;
157        // Fall back to a terminal-safe box on legacy Windows / non-UTF-8.
158        let box_set = self.box_set.substitute(
159            console.legacy_windows(),
160            console.safe_box(),
161            console.ascii_only(),
162        );
163        let inner_width = width.saturating_sub(2);
164        let (pt, pr, pb, pl) = self.padding;
165        let child_width = inner_width.saturating_sub(pl).saturating_sub(pr);
166
167        let mut child_options = options.update_width(child_width);
168        // When a height is imposed (e.g. as a Layout leaf), the child fills the
169        // space left by the two borders and the top/bottom padding rows, so the
170        // panel expands to exactly `height` rows. Port of `Panel`'s
171        // `child_height = height - 2` (padding here lives outside the child).
172        child_options.height = options.height.map(|h| h.saturating_sub(2 + pt + pb));
173        let child_lines = console.render_lines(self.child.as_ref(), &child_options, true);
174
175        let border = Some(self.border_style.clone());
176        let inner_style = Some(self.style.clone());
177        let left_border = || Segment::new(box_set.mid_left.to_string(), border.clone());
178        let right_border = || Segment::new(box_set.mid_right.to_string(), border.clone());
179        let blank_inner = || Segment::new(" ".repeat(inner_width), inner_style.clone());
180
181        let mut rows: Vec<Vec<Segment>> = Vec::new();
182
183        // Top border (with title if present).
184        rows.push(self.border_line(
185            console,
186            inner_width,
187            (box_set.top_left, box_set.top, box_set.top_right),
188            self.title.as_ref(),
189            self.title_align,
190        ));
191
192        // Top padding rows.
193        for _ in 0..pt {
194            rows.push(vec![left_border(), blank_inner(), right_border()]);
195        }
196
197        // Content rows: border + left pad + content + right pad + border.
198        for line in child_lines {
199            let mut row = vec![left_border()];
200            if pl > 0 {
201                row.push(Segment::new(" ".repeat(pl), inner_style.clone()));
202            }
203            row.extend(line);
204            if pr > 0 {
205                row.push(Segment::new(" ".repeat(pr), inner_style.clone()));
206            }
207            row.push(right_border());
208            rows.push(row);
209        }
210
211        // Bottom padding rows.
212        for _ in 0..pb {
213            rows.push(vec![left_border(), blank_inner(), right_border()]);
214        }
215
216        // Bottom border (with subtitle if present).
217        rows.push(self.border_line(
218            console,
219            inner_width,
220            (box_set.bottom_left, box_set.bottom, box_set.bottom_right),
221            self.subtitle.as_ref(),
222            self.subtitle_align,
223        ));
224
225        join_rows(rows)
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232    use crate::r#box::SQUARE;
233    use crate::text::Text;
234
235    fn console() -> Console {
236        Console::builder()
237            .force_terminal(true)
238            .color_system(Some(crate::color::ColorSystem::Truecolor))
239            .width(20)
240            .build()
241    }
242
243    #[test]
244    fn plain_panel() {
245        let out = console().render_export(&Panel::new(Box::new(Text::new("hello"))));
246        assert_eq!(
247            out,
248            "╭──────────────────╮\n│ hello            │\n╰──────────────────╯\n"
249        );
250    }
251
252    #[test]
253    fn titled_panel() {
254        let out = console().render_export(&Panel::new(Box::new(Text::new("hello"))).title("T"));
255        assert_eq!(
256            out,
257            "╭─────── T ────────╮\n│ hello            │\n╰──────────────────╯\n"
258        );
259    }
260
261    #[test]
262    fn square_box() {
263        let out = console().render_export(&Panel::new(Box::new(Text::new("hi"))).box_set(SQUARE));
264        assert_eq!(
265            out,
266            "┌──────────────────┐\n│ hi               │\n└──────────────────┘\n"
267        );
268    }
269
270    #[test]
271    fn legacy_windows_substitutes_rounded_to_square() {
272        // On a legacy Windows console, ROUNDED falls back to SQUARE. Captured
273        // from real rich 15.0.0 (legacy_windows=True, width 12).
274        let legacy = Console::builder()
275            .force_terminal(true)
276            .color_system(Some(crate::color::ColorSystem::Truecolor))
277            .width(12)
278            .no_color(false)
279            .legacy_windows(true)
280            .build();
281        let out = legacy.render_export(&Panel::new(Box::new(Text::new("hi"))));
282        assert_eq!(out, "┌──────────┐\n│ hi       │\n└──────────┘\n");
283    }
284}