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, StyleType};
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    /// A literal title (upstream `Panel(title=Text(…))`), in place of `title`.
25    title_value: Option<Text>,
26    title_align: HorizontalAlign,
27    subtitle: Option<String>,
28    /// A literal subtitle (upstream `Panel(subtitle=Text(…))`).
29    subtitle_value: Option<Text>,
30    subtitle_align: HorizontalAlign,
31    /// `None` takes the console's `safe_box` (upstream `safe_box`).
32    safe_box: Option<bool>,
33    padding: (usize, usize, usize, usize),
34    border_style: StyleType,
35    style: StyleType,
36    expand: bool,
37    width: Option<usize>,
38    height: Option<usize>,
39    highlight: bool,
40}
41
42impl Panel {
43    /// A panel around `child` with default box (`ROUNDED`) and padding `(0,1)`.
44    pub fn new(child: Box<dyn Renderable>) -> Self {
45        Panel {
46            child,
47            box_set: ROUNDED,
48            title: None,
49            title_value: None,
50            title_align: HorizontalAlign::Center,
51            subtitle: None,
52            subtitle_value: None,
53            subtitle_align: HorizontalAlign::Center,
54            safe_box: None,
55            padding: (0, 1, 0, 1),
56            border_style: StyleType::Style(Style::new()),
57            style: StyleType::Style(Style::new()),
58            expand: true,
59            width: None,
60            height: None,
61            highlight: false,
62        }
63    }
64
65    /// A panel that fits its content rather than expanding to the available
66    /// width. Port of `Panel.fit` (`expand=False`).
67    pub fn fit(child: Box<dyn Renderable>) -> Self {
68        Panel::new(child).expand(false)
69    }
70
71    /// Expand to the full available width (upstream `expand`, default on), or
72    /// fit the measured width of the content and title.
73    pub fn expand(mut self, expand: bool) -> Self {
74        self.expand = expand;
75        self
76    }
77
78    /// A fixed width for the whole panel, borders included (upstream `width`),
79    /// capped at the available width.
80    pub fn width(mut self, width: usize) -> Self {
81        self.width = Some(width);
82        self
83    }
84
85    /// Set a title (drawn into the top border, centered by default).
86    pub fn title(mut self, title: impl Into<String>) -> Self {
87        self.title = Some(title.into());
88        self
89    }
90
91    /// Set a literal [`Text`] title (upstream `Panel(title=Text(…))`): no
92    /// markup is parsed. Replaces a [`title`](Self::title).
93    pub fn title_as_text(mut self, title: Text) -> Self {
94        self.title_value = Some(title);
95        self
96    }
97
98    /// Set the title alignment within the top border.
99    pub fn title_align(mut self, align: HorizontalAlign) -> Self {
100        self.title_align = align;
101        self
102    }
103
104    /// Set a subtitle (drawn into the bottom border, centered by default).
105    pub fn subtitle(mut self, subtitle: impl Into<String>) -> Self {
106        self.subtitle = Some(subtitle.into());
107        self
108    }
109
110    /// Set a literal [`Text`] subtitle (upstream `Panel(subtitle=Text(…))`):
111    /// no markup is parsed. Replaces a [`subtitle`](Self::subtitle).
112    pub fn subtitle_as_text(mut self, subtitle: Text) -> Self {
113        self.subtitle_value = Some(subtitle);
114        self
115    }
116
117    /// Whether to substitute boxes a legacy Windows console cannot draw
118    /// (upstream `safe_box`; `None`, the default, takes the console's).
119    pub fn safe_box(mut self, safe_box: Option<bool>) -> Self {
120        self.safe_box = safe_box;
121        self
122    }
123
124    /// Set the subtitle alignment within the bottom border.
125    pub fn subtitle_align(mut self, align: HorizontalAlign) -> Self {
126        self.subtitle_align = align;
127        self
128    }
129
130    /// Choose the box-drawing set.
131    pub fn box_set(mut self, box_set: BoxSet) -> Self {
132        self.box_set = box_set;
133        self
134    }
135
136    /// Set the inner padding `(top, right, bottom, left)`.
137    pub fn padding(mut self, padding: (usize, usize, usize, usize)) -> Self {
138        self.padding = padding;
139        self
140    }
141
142    /// Highlight strings rendered inside the panel (upstream
143    /// `Panel(highlight=…)`, default off). Passed to the child as
144    /// [`ConsoleOptions::highlight`].
145    pub fn highlight(mut self, highlight: bool) -> Self {
146        self.highlight = highlight;
147        self
148    }
149
150    /// Set the border style: a [`Style`], or a theme name / definition.
151    /// It is combined over [`style`](Self::style).
152    pub fn border_style(mut self, style: impl Into<StyleType>) -> Self {
153        self.border_style = style.into();
154        self
155    }
156
157    /// The style of the whole panel, border and contents (upstream `style`,
158    /// default none): the background under the padded child, and beneath the
159    /// border style.
160    pub fn style(mut self, style: impl Into<StyleType>) -> Self {
161        self.style = style.into();
162        self
163    }
164
165    /// A fixed height for the whole panel, borders included (upstream
166    /// `height`); else the options' height, else the content's.
167    pub fn height(mut self, height: usize) -> Self {
168        self.height = Some(height);
169        self
170    }
171
172    /// Build a top/bottom border. Port of `Panel._title`, `_subtitle` and
173    /// `align_text`: markup is styled before its visible cell width is measured.
174    #[allow(clippy::too_many_arguments)]
175    fn border_line(
176        &self,
177        console: &Console,
178        border: &Style,
179        inner_width: usize,
180        corners: (char, char, char),
181        label: Option<Text>,
182        align: HorizontalAlign,
183    ) -> Vec<Segment> {
184        let (left_corner, fill_char, right_corner) = corners;
185        let border_style = Some(border.clone());
186        let Some(mut label) = label.filter(|_| inner_width > 2) else {
187            return vec![Segment::new(
188                format!(
189                    "{left_corner}{}{right_corner}",
190                    fill_char.to_string().repeat(inner_width)
191                ),
192                border_style,
193            )];
194        };
195
196        // `title_text.stylize_before(border_style)`, then `align_text`'s
197        // `text.stylize(text.style)` for a title with its own base style.
198        if label.base_style().is_null_style() {
199            label.set_base_style(border.clone());
200        } else {
201            let own = console.get_style(label.base_style()).unwrap_or_default();
202            let len = label.plain().len();
203            label.stylize_before(border.clone(), 0, len);
204            label.stylize(own, 0, len);
205        }
206        let label_width = inner_width - 2;
207        label.truncate(label_width, None, false);
208
209        let fill = label_width.saturating_sub(label.cell_len());
210        let (left, right) = match align {
211            HorizontalAlign::Center => (fill / 2, fill - fill / 2),
212            HorizontalAlign::Left => (0, fill),
213            HorizontalAlign::Right => (fill, 0),
214        };
215        let mut text =
216            Text::styled(fill_char.to_string().repeat(left), border.clone()).append_text(&label);
217        text.append(
218            &fill_char.to_string().repeat(right),
219            Some(border.clone().into()),
220        );
221        let mut segments = vec![Segment::new(
222            format!("{left_corner}{fill_char}"),
223            border_style.clone(),
224        )];
225        segments.extend(text.render(console.theme(), console.base_style()));
226        segments.push(Segment::new(
227            format!("{fill_char}{right_corner}"),
228            border_style,
229        ));
230        segments
231    }
232}
233
234/// The title/subtitle `Text`: port of `Panel._title` / `_subtitle`.
235/// Text.from_markup expands emoji independently of the console's emoji flag.
236/// Preserve markup offsets while flattening newlines to spaces.
237fn label_text(label: &str) -> Text {
238    let expanded = crate::emoji::replace(label);
239    let parsed = Text::from_markup(&expanded).unwrap_or_else(|_| Text::new(expanded));
240    label_from_text(&parsed)
241}
242
243/// `Panel._title` for a `Text` title: a copy with newlines flattened, tabs
244/// expanded and a space either side.
245fn label_from_text(parsed: &Text) -> Text {
246    let mut text = parsed.blank_copy();
247    text.append(&parsed.plain().replace('\n', " "), None);
248    for span in parsed.spans() {
249        text.push_span(span.clone());
250    }
251    text.expand_tabs(DEFAULT_TAB_SIZE);
252    text.pad(1, ' ');
253    text
254}
255
256/// Read access to what a panel holds: upstream's public `Panel` fields. Not
257/// upstream as methods, and they change nothing; they let an extension read
258/// a panel instead of its rendering.
259///
260/// ```
261/// use rich::{Panel, Text};
262/// use rich::table::Cell;
263///
264/// let panel = Panel::new(Box::new(Text::new("body"))).title("[b]Status[/]");
265/// assert!(matches!(panel.get_title(), Some(Cell::Markup(t)) if t == "[b]Status[/]"));
266/// assert!(panel.get_subtitle().is_none());
267/// assert_eq!(panel.renderable().printed_text().unwrap().plain(), "body");
268/// ```
269impl Panel {
270    /// The renderable inside the panel (upstream `Panel.renderable`).
271    pub fn renderable(&self) -> &dyn Renderable {
272        self.child.as_ref()
273    }
274
275    /// The title (upstream `Panel.title`): a [`Cell::Text`](crate::table::Cell::Text)
276    /// when set with [`title_as_text`](Self::title_as_text), which wins, else a
277    /// [`Cell::Markup`](crate::table::Cell::Markup) set with [`title`](Self::title).
278    pub fn get_title(&self) -> Option<crate::table::Cell> {
279        label_cell(self.title_value.as_ref(), self.title.as_deref())
280    }
281
282    /// The subtitle (upstream `Panel.subtitle`), as [`get_title`](Self::get_title).
283    pub fn get_subtitle(&self) -> Option<crate::table::Cell> {
284        label_cell(self.subtitle_value.as_ref(), self.subtitle.as_deref())
285    }
286
287    /// The title's alignment (upstream `Panel.title_align`).
288    pub fn get_title_align(&self) -> HorizontalAlign {
289        self.title_align
290    }
291
292    /// The subtitle's alignment (upstream `Panel.subtitle_align`).
293    pub fn get_subtitle_align(&self) -> HorizontalAlign {
294        self.subtitle_align
295    }
296}
297
298/// A title as a cell: the literal `Text` when set, else the markup.
299fn label_cell(text: Option<&Text>, markup: Option<&str>) -> Option<crate::table::Cell> {
300    match (text, markup) {
301        (Some(text), _) => Some(crate::table::Cell::Text(text.clone())),
302        (None, Some(markup)) => Some(crate::table::Cell::Markup(markup.to_string())),
303        (None, None) => None,
304    }
305}
306
307impl Panel {
308    /// The title as `Panel._title` builds it, when there is one.
309    fn title_text(&self) -> Option<Text> {
310        if let Some(title) = &self.title_value {
311            return (!title.plain().is_empty()).then(|| label_from_text(title));
312        }
313        self.title
314            .as_deref()
315            .filter(|title| !title.is_empty())
316            .map(label_text)
317    }
318
319    /// The subtitle as `Panel._subtitle` builds it, when there is one.
320    fn subtitle_text(&self) -> Option<Text> {
321        if let Some(subtitle) = &self.subtitle_value {
322            return (!subtitle.plain().is_empty()).then(|| label_from_text(subtitle));
323        }
324        self.subtitle
325            .as_deref()
326            .filter(|subtitle| !subtitle.is_empty())
327            .map(label_text)
328    }
329
330    /// `Measurement.get` of the child wrapped in upstream's
331    /// `Padding(renderable, padding)` (only when there is any padding).
332    fn measure_padded_child(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
333        let (top, right, bottom, left) = self.padding;
334        let max_width = options.max_width;
335        if max_width < 1 {
336            return Measurement::new(0, 0);
337        }
338        if top == 0 && right == 0 && bottom == 0 && left == 0 {
339            return Measurement::get(console, options, self.child.as_ref());
340        }
341        // `Padding.__rich_measure__`, then `Measurement.get`'s normalization.
342        let extra_width = left + right;
343        let width = if max_width < extra_width + 1 {
344            Measurement::new(max_width, max_width)
345        } else {
346            let child = Measurement::get(console, options, self.child.as_ref());
347            Measurement::new(child.minimum + extra_width, child.maximum + extra_width)
348                .with_maximum(max_width)
349        };
350        let width = width.normalize().with_maximum(max_width);
351        if width.maximum < 1 {
352            Measurement::new(0, 0)
353        } else {
354            width.normalize()
355        }
356    }
357}
358
359impl Renderable for Panel {
360    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
361        // Not upstream: a semantic region, only when a sink is installed.
362        crate::protocol::report_region(
363            console,
364            || {
365                let info = crate::protocol::RegionInfo::new(crate::protocol::RegionRole::Panel);
366                match self.title_text() {
367                    Some(title) => info.label(title.plain()),
368                    None => info,
369                }
370            },
371            || self.render_panel(console, options),
372        )
373    }
374
375    /// Port of `Panel.__rich_measure__`: the widest of the content and the
376    /// title, measured inside the borders and padding, plus both; or the
377    /// fixed `width`. Either way the panel asks for exactly one width.
378    fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
379        self.measure_panel(console, options)
380    }
381}
382
383impl Panel {
384    /// Port of `Panel.__rich_console__`.
385    fn render_panel(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
386        let width = match self.width {
387            Some(width) => width.min(options.max_width),
388            None => options.max_width,
389        };
390        // `style = console.get_style(self.style)`,
391        // `border_style = style + console.get_style(self.border_style)`.
392        let style = console.get_style(&self.style).unwrap_or_default();
393        let border_style =
394            style.combine(&console.get_style(&self.border_style).unwrap_or_default());
395        // `child_height = self.height or options.height or None`.
396        let height = self
397            .height
398            .filter(|&height| height > 0)
399            .or(options.height)
400            .filter(|&height| height > 0);
401        // A zero `width` still draws the two-cell empty box upstream (its
402        // `width - 2` is negative); a zero-width console renders nothing
403        // before any renderable is asked (`Console.render`).
404        // Fall back to a terminal-safe box on legacy Windows / non-UTF-8.
405        let box_set = self
406            .box_set
407            .substitute_for(options, self.safe_box.unwrap_or_else(|| console.safe_box()));
408        // The padded child fills `width - 2`, or, when not expanding, its
409        // measured width; a title may widen it up to the available width.
410        let mut inner_width = if self.expand {
411            width.saturating_sub(2)
412        } else {
413            self.measure_padded_child(console, &options.update_width(width.saturating_sub(2)))
414                .maximum
415        };
416        if let Some(title) = self.title_text() {
417            inner_width = options
418                .max_width
419                .saturating_sub(2)
420                .min(inner_width.max(title.cell_len() + 2));
421        }
422        // Upstream renders the padded child through `Console.render`, which
423        // yields nothing at all in no width: with no inner width there is no
424        // padding either, only the (height-padded) empty rows.
425        let (pt, pr, pb, pl) = if inner_width == 0 {
426            (0, 0, 0, 0)
427        } else {
428            self.padding
429        };
430        let child_width = inner_width.saturating_sub(pl).saturating_sub(pr);
431
432        let mut child_options = options.update_width(child_width);
433        // `options.update(width=…, height=…, highlight=self.highlight)`.
434        child_options.highlight = Some(self.highlight);
435        // When a height is imposed (e.g. as a Layout leaf), the child fills the
436        // space left by the two borders and the top/bottom padding rows, so the
437        // panel expands to exactly `height` rows. Port of `Panel`'s
438        // `child_height = height - 2` (padding here lives outside the child).
439        child_options.height = height.map(|h| h.saturating_sub(2 + pt + pb));
440        // Upstream: `console.render_lines(renderable, child_options, style=style)`.
441        let child_lines =
442            console.render_lines_styled(self.child.as_ref(), &child_options, Some(&style), true);
443
444        let border = Some(border_style.clone());
445        let inner_style = Some(style.clone());
446        let left_border = || Segment::new(box_set.mid_left.to_string(), border.clone());
447        let right_border = || Segment::new(box_set.mid_right.to_string(), border.clone());
448        let blank_inner = || Segment::new(" ".repeat(inner_width), inner_style.clone());
449
450        let mut rows: Vec<Vec<Segment>> = Vec::new();
451
452        // Top border (with title if present).
453        rows.push(self.border_line(
454            console,
455            &border_style,
456            inner_width,
457            (box_set.top_left, box_set.top, box_set.top_right),
458            self.title_text(),
459            self.title_align,
460        ));
461
462        // The padded child as upstream's `Padding` yields it: blank rows,
463        // then each line between the side padding. `Console.render_lines`
464        // then fits every row to the inner width and, under a height, the
465        // row count to `height - 2`.
466        let mut inner_rows: Vec<Vec<Segment>> = Vec::new();
467        for _ in 0..pt {
468            inner_rows.push(vec![blank_inner()]);
469        }
470        for line in child_lines {
471            let mut row = Vec::new();
472            if pl > 0 {
473                row.push(Segment::new(" ".repeat(pl), inner_style.clone()));
474            }
475            row.extend(line);
476            if pr > 0 {
477                row.push(Segment::new(" ".repeat(pr), inner_style.clone()));
478            }
479            inner_rows.push(row);
480        }
481        for _ in 0..pb {
482            inner_rows.push(vec![blank_inner()]);
483        }
484        if let Some(height) = height {
485            let height = height.saturating_sub(2);
486            inner_rows.truncate(height);
487            while inner_rows.len() < height {
488                inner_rows.push(vec![blank_inner()]);
489            }
490        }
491        for row in inner_rows {
492            let mut line = vec![left_border()];
493            line.extend(Segment::adjust_line_length(
494                &row,
495                inner_width,
496                inner_style.clone(),
497            ));
498            line.push(right_border());
499            rows.push(line);
500        }
501
502        // Bottom border (with subtitle if present).
503        rows.push(self.border_line(
504            console,
505            &border_style,
506            inner_width,
507            (box_set.bottom_left, box_set.bottom, box_set.bottom_right),
508            self.subtitle_text(),
509            self.subtitle_align,
510        ));
511
512        join_rows(rows)
513    }
514
515    fn measure_panel(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
516        let (_, right, _, left) = self.padding;
517        let padding = left + right;
518        let width = match self.width {
519            Some(width) => width,
520            None => {
521                // `measure_renderables(console, options.update_width(...),
522                // [renderable, _title])`, whose maximum is the widest maximum.
523                let inner = options.update_width(options.max_width.saturating_sub(padding + 2));
524                let child = Measurement::get(console, &inner, self.child.as_ref()).maximum;
525                let title = self
526                    .title_text()
527                    .map_or(0, |title| Measurement::get(console, &inner, &title).maximum);
528                child.max(title) + padding + 2
529            }
530        };
531        Measurement::new(width, width)
532    }
533}
534
535#[cfg(test)]
536mod tests {
537
538    #[test]
539    fn tiny_widths_match_upstream() {
540        // Expected output captured from rich 15.0.0 (`Console.print`).
541        let render = |panel: Panel, width| {
542            let console = Console::builder().width(width).color_system(None).build();
543            let out = console.render_to_string(&panel);
544            if out.is_empty() {
545                out
546            } else {
547                out + "\n"
548            }
549        };
550        for width in [0, 1, 2] {
551            let expected = ["", "╭\n╰\n", "╭╮\n╰╯\n"][width];
552            assert_eq!(
553                render(Panel::new(Box::new(crate::text::Text::new("hi"))), width),
554                expected,
555                "width {width}"
556            );
557            assert_eq!(
558                render(Panel::fit(Box::new(crate::text::Text::new("hi"))), width),
559                expected,
560                "fit width {width}"
561            );
562        }
563    }
564
565    use super::*;
566    use crate::r#box::SQUARE;
567    use crate::text::Text;
568
569    fn console() -> Console {
570        Console::builder()
571            .force_terminal(true)
572            .color_system(Some(crate::color::ColorSystem::Truecolor))
573            .width(20)
574            .build()
575    }
576
577    #[test]
578    fn plain_panel() {
579        let out = console().render_export(&Panel::new(Box::new(Text::new("hello"))));
580        assert_eq!(
581            out,
582            "╭──────────────────╮\n│ hello            │\n╰──────────────────╯\n"
583        );
584    }
585
586    #[test]
587    fn titled_panel() {
588        let out = console().render_export(&Panel::new(Box::new(Text::new("hello"))).title("T"));
589        assert_eq!(
590            out,
591            "╭─────── T ────────╮\n│ hello            │\n╰──────────────────╯\n"
592        );
593    }
594
595    #[test]
596    fn square_box() {
597        let out = console().render_export(&Panel::new(Box::new(Text::new("hi"))).box_set(SQUARE));
598        assert_eq!(
599            out,
600            "┌──────────────────┐\n│ hi               │\n└──────────────────┘\n"
601        );
602    }
603
604    #[test]
605    fn legacy_windows_substitutes_rounded_to_square() {
606        // On a legacy Windows console, ROUNDED falls back to SQUARE. Captured
607        // from real rich 15.0.0 (legacy_windows=True, width 12).
608        let legacy = Console::builder()
609            .force_terminal(true)
610            .color_system(Some(crate::color::ColorSystem::Truecolor))
611            .width(12)
612            .no_color(false)
613            .legacy_windows(true)
614            .build();
615        let out = legacy.render_export(&Panel::new(Box::new(Text::new("hi"))));
616        assert_eq!(out, "┌──────────┐\n│ hi       │\n└──────────┘\n");
617    }
618
619    #[test]
620    fn zero_inner_width_renders_no_body_and_empty_text_one_row() {
621        // Captured from real rich 15.0.0 (#449, #442).
622        let narrow = Console::builder()
623            .force_terminal(true)
624            .color_system(Some(crate::color::ColorSystem::Truecolor))
625            .width(4)
626            .highlight(false)
627            .build();
628        let panel = Panel::new(Box::new(Text::new("ab cd"))).box_set(crate::r#box::HEAVY);
629        assert_eq!(narrow.render_export(&panel), "┏━━┓\n┗━━┛\n");
630        let empty = Panel::new(Box::new(Text::new(""))).box_set(SQUARE);
631        assert_eq!(
632            Console::builder()
633                .force_terminal(true)
634                .color_system(Some(crate::color::ColorSystem::Truecolor))
635                .width(10)
636                .highlight(false)
637                .build()
638                .render_export(&empty),
639            "┌────────┐\n│        │\n└────────┘\n"
640        );
641    }
642}