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` (and [`Panel::fit`]) and a fixed `width`.
8
9use crate::align::HorizontalAlign;
10use crate::console::{Console, ConsoleOptions};
11use crate::measure::Measurement;
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;
17use crate::text::{Text, DEFAULT_TAB_SIZE};
18
19/// A bordered box around a renderable. Mirrors `rich.panel.Panel`.
20pub struct Panel {
21    child: Box<dyn Renderable>,
22    box_set: BoxSet,
23    title: Option<String>,
24    title_align: HorizontalAlign,
25    subtitle: Option<String>,
26    subtitle_align: HorizontalAlign,
27    padding: (usize, usize, usize, usize),
28    border_style: Style,
29    style: Style,
30    expand: bool,
31    width: Option<usize>,
32}
33
34impl Panel {
35    /// A panel around `child` with default box (`ROUNDED`) and padding `(0,1)`.
36    pub fn new(child: Box<dyn Renderable>) -> Self {
37        Panel {
38            child,
39            box_set: ROUNDED,
40            title: None,
41            title_align: HorizontalAlign::Center,
42            subtitle: None,
43            subtitle_align: HorizontalAlign::Center,
44            padding: (0, 1, 0, 1),
45            border_style: Style::new(),
46            style: Style::new(),
47            expand: true,
48            width: None,
49        }
50    }
51
52    /// A panel that fits its content rather than expanding to the available
53    /// width. Port of `Panel.fit` (`expand=False`).
54    pub fn fit(child: Box<dyn Renderable>) -> Self {
55        Panel::new(child).expand(false)
56    }
57
58    /// Expand to the full available width (upstream `expand`, default on), or
59    /// fit the measured width of the content and title.
60    pub fn expand(mut self, expand: bool) -> Self {
61        self.expand = expand;
62        self
63    }
64
65    /// A fixed width for the whole panel, borders included (upstream `width`),
66    /// capped at the available width.
67    pub fn width(mut self, width: usize) -> Self {
68        self.width = Some(width);
69        self
70    }
71
72    /// Set a title (drawn into the top border, centered by default).
73    pub fn title(mut self, title: impl Into<String>) -> Self {
74        self.title = Some(title.into());
75        self
76    }
77
78    /// Set the title alignment within the top border.
79    pub fn title_align(mut self, align: HorizontalAlign) -> Self {
80        self.title_align = align;
81        self
82    }
83
84    /// Set a subtitle (drawn into the bottom border, centered by default).
85    pub fn subtitle(mut self, subtitle: impl Into<String>) -> Self {
86        self.subtitle = Some(subtitle.into());
87        self
88    }
89
90    /// Set the subtitle alignment within the bottom border.
91    pub fn subtitle_align(mut self, align: HorizontalAlign) -> Self {
92        self.subtitle_align = align;
93        self
94    }
95
96    /// Choose the box-drawing set.
97    pub fn box_set(mut self, box_set: BoxSet) -> Self {
98        self.box_set = box_set;
99        self
100    }
101
102    /// Set the inner padding `(top, right, bottom, left)`.
103    pub fn padding(mut self, padding: (usize, usize, usize, usize)) -> Self {
104        self.padding = padding;
105        self
106    }
107
108    /// Set the border style.
109    pub fn border_style(mut self, style: Style) -> Self {
110        self.border_style = style;
111        self
112    }
113
114    /// Build a top/bottom border. Port of `Panel._title`, `_subtitle` and
115    /// `align_text`: markup is styled before its visible cell width is measured.
116    fn border_line(
117        &self,
118        console: &Console,
119        inner_width: usize,
120        corners: (char, char, char),
121        label: Option<&String>,
122        align: HorizontalAlign,
123    ) -> Vec<Segment> {
124        let (left_corner, fill_char, right_corner) = corners;
125        let border_style = Some(self.border_style.clone());
126        let Some(label) = label.filter(|label| !label.is_empty() && inner_width > 2) else {
127            return vec![Segment::new(
128                format!(
129                    "{left_corner}{}{right_corner}",
130                    fill_char.to_string().repeat(inner_width)
131                ),
132                border_style,
133            )];
134        };
135
136        let mut label = label_text(label);
137        label.set_base_style(self.border_style.clone());
138        let label_width = inner_width - 2;
139        label.truncate(label_width, None, false);
140
141        let fill = label_width.saturating_sub(label.cell_len());
142        let (left, right) = match align {
143            HorizontalAlign::Center => (fill / 2, fill - fill / 2),
144            HorizontalAlign::Left => (0, fill),
145            HorizontalAlign::Right => (fill, 0),
146        };
147        let mut text = Text::styled(
148            fill_char.to_string().repeat(left),
149            self.border_style.clone(),
150        )
151        .append_text(&label);
152        text.append(
153            &fill_char.to_string().repeat(right),
154            Some(self.border_style.clone().into()),
155        );
156        let mut segments = vec![Segment::new(
157            format!("{left_corner}{fill_char}"),
158            border_style.clone(),
159        )];
160        segments.extend(text.render(console.theme(), console.base_style()));
161        segments.push(Segment::new(
162            format!("{fill_char}{right_corner}"),
163            border_style,
164        ));
165        segments
166    }
167}
168
169/// The title/subtitle `Text`: port of `Panel._title` / `_subtitle`.
170/// Text.from_markup expands emoji independently of the console's emoji flag.
171/// Preserve markup offsets while flattening newlines to spaces.
172fn label_text(label: &str) -> Text {
173    let expanded = crate::emoji::replace(label);
174    let parsed = Text::from_markup(&expanded).unwrap_or_else(|_| Text::new(expanded));
175    let mut text = parsed.blank_copy();
176    text.append(&parsed.plain().replace('\n', " "), None);
177    for span in parsed.spans() {
178        text.push_span(span.clone());
179    }
180    text.expand_tabs(DEFAULT_TAB_SIZE);
181    text.pad(1, ' ');
182    text
183}
184
185impl Panel {
186    /// The title as `Panel._title` builds it, when there is one.
187    fn title_text(&self) -> Option<Text> {
188        self.title
189            .as_deref()
190            .filter(|title| !title.is_empty())
191            .map(label_text)
192    }
193
194    /// `Measurement.get` of the child wrapped in upstream's
195    /// `Padding(renderable, padding)` (only when there is any padding).
196    fn measure_padded_child(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
197        let (top, right, bottom, left) = self.padding;
198        let max_width = options.max_width;
199        if max_width < 1 {
200            return Measurement::new(0, 0);
201        }
202        if top == 0 && right == 0 && bottom == 0 && left == 0 {
203            return Measurement::get(console, options, self.child.as_ref());
204        }
205        // `Padding.__rich_measure__`, then `Measurement.get`'s normalization.
206        let extra_width = left + right;
207        let width = if max_width < extra_width + 1 {
208            Measurement::new(max_width, max_width)
209        } else {
210            let child = Measurement::get(console, options, self.child.as_ref());
211            Measurement::new(child.minimum + extra_width, child.maximum + extra_width)
212                .with_maximum(max_width)
213        };
214        let width = width.normalize().with_maximum(max_width);
215        if width.maximum < 1 {
216            Measurement::new(0, 0)
217        } else {
218            width.normalize()
219        }
220    }
221}
222
223impl Renderable for Panel {
224    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
225        let width = match self.width {
226            Some(width) => width.min(options.max_width),
227            None => options.max_width,
228        };
229        // Fall back to a terminal-safe box on legacy Windows / non-UTF-8.
230        let box_set = self.box_set.substitute(
231            console.legacy_windows(),
232            console.safe_box(),
233            console.ascii_only(),
234        );
235        // The padded child fills `width - 2`, or, when not expanding, its
236        // measured width; a title may widen it up to the available width.
237        let mut inner_width = if self.expand {
238            width.saturating_sub(2)
239        } else {
240            self.measure_padded_child(console, &options.update_width(width.saturating_sub(2)))
241                .maximum
242        };
243        if let Some(title) = self.title_text() {
244            inner_width = options
245                .max_width
246                .saturating_sub(2)
247                .min(inner_width.max(title.cell_len() + 2));
248        }
249        let (pt, pr, pb, pl) = self.padding;
250        let child_width = inner_width.saturating_sub(pl).saturating_sub(pr);
251
252        let mut child_options = options.update_width(child_width);
253        // When a height is imposed (e.g. as a Layout leaf), the child fills the
254        // space left by the two borders and the top/bottom padding rows, so the
255        // panel expands to exactly `height` rows. Port of `Panel`'s
256        // `child_height = height - 2` (padding here lives outside the child).
257        child_options.height = options.height.map(|h| h.saturating_sub(2 + pt + pb));
258        // Upstream: `console.render_lines(renderable, child_options, style=style)`.
259        let child_lines = console.render_lines_styled(
260            self.child.as_ref(),
261            &child_options,
262            Some(&self.style),
263            true,
264        );
265
266        let border = Some(self.border_style.clone());
267        let inner_style = Some(self.style.clone());
268        let left_border = || Segment::new(box_set.mid_left.to_string(), border.clone());
269        let right_border = || Segment::new(box_set.mid_right.to_string(), border.clone());
270        let blank_inner = || Segment::new(" ".repeat(inner_width), inner_style.clone());
271
272        let mut rows: Vec<Vec<Segment>> = Vec::new();
273
274        // Top border (with title if present).
275        rows.push(self.border_line(
276            console,
277            inner_width,
278            (box_set.top_left, box_set.top, box_set.top_right),
279            self.title.as_ref(),
280            self.title_align,
281        ));
282
283        // Top padding rows.
284        for _ in 0..pt {
285            rows.push(vec![left_border(), blank_inner(), right_border()]);
286        }
287
288        // Content rows: border + left pad + content + right pad + border.
289        for line in child_lines {
290            let mut row = vec![left_border()];
291            if pl > 0 {
292                row.push(Segment::new(" ".repeat(pl), inner_style.clone()));
293            }
294            row.extend(line);
295            if pr > 0 {
296                row.push(Segment::new(" ".repeat(pr), inner_style.clone()));
297            }
298            row.push(right_border());
299            rows.push(row);
300        }
301
302        // Bottom padding rows.
303        for _ in 0..pb {
304            rows.push(vec![left_border(), blank_inner(), right_border()]);
305        }
306
307        // Bottom border (with subtitle if present).
308        rows.push(self.border_line(
309            console,
310            inner_width,
311            (box_set.bottom_left, box_set.bottom, box_set.bottom_right),
312            self.subtitle.as_ref(),
313            self.subtitle_align,
314        ));
315
316        join_rows(rows)
317    }
318
319    /// Port of `Panel.__rich_measure__`: the widest of the content and the
320    /// title, measured inside the borders and padding, plus both; or the
321    /// fixed `width`. Either way the panel asks for exactly one width.
322    fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
323        let (_, right, _, left) = self.padding;
324        let padding = left + right;
325        let width = match self.width {
326            Some(width) => width,
327            None => {
328                // `measure_renderables(console, options.update_width(...),
329                // [renderable, _title])`, whose maximum is the widest maximum.
330                let inner = options.update_width(options.max_width.saturating_sub(padding + 2));
331                let child = Measurement::get(console, &inner, self.child.as_ref()).maximum;
332                let title = self
333                    .title_text()
334                    .map_or(0, |title| Measurement::get(console, &inner, &title).maximum);
335                child.max(title) + padding + 2
336            }
337        };
338        Measurement::new(width, width)
339    }
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345    use crate::r#box::SQUARE;
346    use crate::text::Text;
347
348    fn console() -> Console {
349        Console::builder()
350            .force_terminal(true)
351            .color_system(Some(crate::color::ColorSystem::Truecolor))
352            .width(20)
353            .build()
354    }
355
356    #[test]
357    fn plain_panel() {
358        let out = console().render_export(&Panel::new(Box::new(Text::new("hello"))));
359        assert_eq!(
360            out,
361            "╭──────────────────╮\n│ hello            │\n╰──────────────────╯\n"
362        );
363    }
364
365    #[test]
366    fn titled_panel() {
367        let out = console().render_export(&Panel::new(Box::new(Text::new("hello"))).title("T"));
368        assert_eq!(
369            out,
370            "╭─────── T ────────╮\n│ hello            │\n╰──────────────────╯\n"
371        );
372    }
373
374    #[test]
375    fn square_box() {
376        let out = console().render_export(&Panel::new(Box::new(Text::new("hi"))).box_set(SQUARE));
377        assert_eq!(
378            out,
379            "┌──────────────────┐\n│ hi               │\n└──────────────────┘\n"
380        );
381    }
382
383    #[test]
384    fn legacy_windows_substitutes_rounded_to_square() {
385        // On a legacy Windows console, ROUNDED falls back to SQUARE. Captured
386        // from real rich 15.0.0 (legacy_windows=True, width 12).
387        let legacy = Console::builder()
388            .force_terminal(true)
389            .color_system(Some(crate::color::ColorSystem::Truecolor))
390            .width(12)
391            .no_color(false)
392            .legacy_windows(true)
393            .build();
394        let out = legacy.render_export(&Panel::new(Box::new(Text::new("hi"))));
395        assert_eq!(out, "┌──────────┐\n│ hi       │\n└──────────┘\n");
396    }
397
398    #[test]
399    fn zero_inner_width_renders_no_body_and_empty_text_one_row() {
400        // Captured from real rich 15.0.0 (#449, #442).
401        let narrow = Console::builder()
402            .force_terminal(true)
403            .color_system(Some(crate::color::ColorSystem::Truecolor))
404            .width(4)
405            .highlight(false)
406            .build();
407        let panel = Panel::new(Box::new(Text::new("ab cd"))).box_set(crate::r#box::HEAVY);
408        assert_eq!(narrow.render_export(&panel), "┏━━┓\n┗━━┛\n");
409        let empty = Panel::new(Box::new(Text::new(""))).box_set(SQUARE);
410        assert_eq!(
411            Console::builder()
412                .force_terminal(true)
413                .color_system(Some(crate::color::ColorSystem::Truecolor))
414                .width(10)
415                .highlight(false)
416                .build()
417                .render_export(&empty),
418            "┌────────┐\n│        │\n└────────┘\n"
419        );
420    }
421}