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
256impl Panel {
257    /// The title as `Panel._title` builds it, when there is one.
258    fn title_text(&self) -> Option<Text> {
259        if let Some(title) = &self.title_value {
260            return (!title.plain().is_empty()).then(|| label_from_text(title));
261        }
262        self.title
263            .as_deref()
264            .filter(|title| !title.is_empty())
265            .map(label_text)
266    }
267
268    /// The subtitle as `Panel._subtitle` builds it, when there is one.
269    fn subtitle_text(&self) -> Option<Text> {
270        if let Some(subtitle) = &self.subtitle_value {
271            return (!subtitle.plain().is_empty()).then(|| label_from_text(subtitle));
272        }
273        self.subtitle
274            .as_deref()
275            .filter(|subtitle| !subtitle.is_empty())
276            .map(label_text)
277    }
278
279    /// `Measurement.get` of the child wrapped in upstream's
280    /// `Padding(renderable, padding)` (only when there is any padding).
281    fn measure_padded_child(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
282        let (top, right, bottom, left) = self.padding;
283        let max_width = options.max_width;
284        if max_width < 1 {
285            return Measurement::new(0, 0);
286        }
287        if top == 0 && right == 0 && bottom == 0 && left == 0 {
288            return Measurement::get(console, options, self.child.as_ref());
289        }
290        // `Padding.__rich_measure__`, then `Measurement.get`'s normalization.
291        let extra_width = left + right;
292        let width = if max_width < extra_width + 1 {
293            Measurement::new(max_width, max_width)
294        } else {
295            let child = Measurement::get(console, options, self.child.as_ref());
296            Measurement::new(child.minimum + extra_width, child.maximum + extra_width)
297                .with_maximum(max_width)
298        };
299        let width = width.normalize().with_maximum(max_width);
300        if width.maximum < 1 {
301            Measurement::new(0, 0)
302        } else {
303            width.normalize()
304        }
305    }
306}
307
308impl Renderable for Panel {
309    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
310        // Not upstream: a semantic region, only when a sink is installed.
311        crate::protocol::report_region(
312            console,
313            || {
314                let info = crate::protocol::RegionInfo::new(crate::protocol::RegionRole::Panel);
315                match self.title_text() {
316                    Some(title) => info.label(title.plain()),
317                    None => info,
318                }
319            },
320            || self.render_panel(console, options),
321        )
322    }
323
324    /// Port of `Panel.__rich_measure__`: the widest of the content and the
325    /// title, measured inside the borders and padding, plus both; or the
326    /// fixed `width`. Either way the panel asks for exactly one width.
327    fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
328        self.measure_panel(console, options)
329    }
330}
331
332impl Panel {
333    /// Port of `Panel.__rich_console__`.
334    fn render_panel(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
335        let width = match self.width {
336            Some(width) => width.min(options.max_width),
337            None => options.max_width,
338        };
339        // `style = console.get_style(self.style)`,
340        // `border_style = style + console.get_style(self.border_style)`.
341        let style = console.get_style(&self.style).unwrap_or_default();
342        let border_style =
343            style.combine(&console.get_style(&self.border_style).unwrap_or_default());
344        // `child_height = self.height or options.height or None`.
345        let height = self.height.or(options.height).filter(|&height| height > 0);
346        // A zero `width` still draws the two-cell empty box upstream (its
347        // `width - 2` is negative); a zero-width console renders nothing
348        // before any renderable is asked (`Console.render`).
349        // Fall back to a terminal-safe box on legacy Windows / non-UTF-8.
350        let box_set = self.box_set.substitute(
351            console.legacy_windows(),
352            self.safe_box.unwrap_or_else(|| console.safe_box()),
353            console.ascii_only(),
354        );
355        // The padded child fills `width - 2`, or, when not expanding, its
356        // measured width; a title may widen it up to the available width.
357        let mut inner_width = if self.expand {
358            width.saturating_sub(2)
359        } else {
360            self.measure_padded_child(console, &options.update_width(width.saturating_sub(2)))
361                .maximum
362        };
363        if let Some(title) = self.title_text() {
364            inner_width = options
365                .max_width
366                .saturating_sub(2)
367                .min(inner_width.max(title.cell_len() + 2));
368        }
369        // Upstream renders the padded child through `Console.render`, which
370        // yields nothing at all in no width: with no inner width there is no
371        // padding either, only the (height-padded) empty rows.
372        let (pt, pr, pb, pl) = if inner_width == 0 {
373            (0, 0, 0, 0)
374        } else {
375            self.padding
376        };
377        let child_width = inner_width.saturating_sub(pl).saturating_sub(pr);
378
379        let mut child_options = options.update_width(child_width);
380        // `options.update(width=…, height=…, highlight=self.highlight)`.
381        child_options.highlight = Some(self.highlight);
382        // When a height is imposed (e.g. as a Layout leaf), the child fills the
383        // space left by the two borders and the top/bottom padding rows, so the
384        // panel expands to exactly `height` rows. Port of `Panel`'s
385        // `child_height = height - 2` (padding here lives outside the child).
386        child_options.height = height.map(|h| h.saturating_sub(2 + pt + pb));
387        // Upstream: `console.render_lines(renderable, child_options, style=style)`.
388        let child_lines =
389            console.render_lines_styled(self.child.as_ref(), &child_options, Some(&style), true);
390
391        let border = Some(border_style.clone());
392        let inner_style = Some(style.clone());
393        let left_border = || Segment::new(box_set.mid_left.to_string(), border.clone());
394        let right_border = || Segment::new(box_set.mid_right.to_string(), border.clone());
395        let blank_inner = || Segment::new(" ".repeat(inner_width), inner_style.clone());
396
397        let mut rows: Vec<Vec<Segment>> = Vec::new();
398
399        // Top border (with title if present).
400        rows.push(self.border_line(
401            console,
402            &border_style,
403            inner_width,
404            (box_set.top_left, box_set.top, box_set.top_right),
405            self.title_text(),
406            self.title_align,
407        ));
408
409        // The padded child as upstream's `Padding` yields it: blank rows,
410        // then each line between the side padding. `Console.render_lines`
411        // then fits every row to the inner width and, under a height, the
412        // row count to `height - 2`.
413        let mut inner_rows: Vec<Vec<Segment>> = Vec::new();
414        for _ in 0..pt {
415            inner_rows.push(vec![blank_inner()]);
416        }
417        for line in child_lines {
418            let mut row = Vec::new();
419            if pl > 0 {
420                row.push(Segment::new(" ".repeat(pl), inner_style.clone()));
421            }
422            row.extend(line);
423            if pr > 0 {
424                row.push(Segment::new(" ".repeat(pr), inner_style.clone()));
425            }
426            inner_rows.push(row);
427        }
428        for _ in 0..pb {
429            inner_rows.push(vec![blank_inner()]);
430        }
431        if let Some(height) = height {
432            let height = height.saturating_sub(2);
433            inner_rows.truncate(height);
434            while inner_rows.len() < height {
435                inner_rows.push(vec![blank_inner()]);
436            }
437        }
438        for row in inner_rows {
439            let mut line = vec![left_border()];
440            line.extend(Segment::adjust_line_length(
441                &row,
442                inner_width,
443                inner_style.clone(),
444            ));
445            line.push(right_border());
446            rows.push(line);
447        }
448
449        // Bottom border (with subtitle if present).
450        rows.push(self.border_line(
451            console,
452            &border_style,
453            inner_width,
454            (box_set.bottom_left, box_set.bottom, box_set.bottom_right),
455            self.subtitle_text(),
456            self.subtitle_align,
457        ));
458
459        join_rows(rows)
460    }
461
462    fn measure_panel(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
463        let (_, right, _, left) = self.padding;
464        let padding = left + right;
465        let width = match self.width {
466            Some(width) => width,
467            None => {
468                // `measure_renderables(console, options.update_width(...),
469                // [renderable, _title])`, whose maximum is the widest maximum.
470                let inner = options.update_width(options.max_width.saturating_sub(padding + 2));
471                let child = Measurement::get(console, &inner, self.child.as_ref()).maximum;
472                let title = self
473                    .title_text()
474                    .map_or(0, |title| Measurement::get(console, &inner, &title).maximum);
475                child.max(title) + padding + 2
476            }
477        };
478        Measurement::new(width, width)
479    }
480}
481
482#[cfg(test)]
483mod tests {
484
485    #[test]
486    fn tiny_widths_match_upstream() {
487        // Expected output captured from rich 15.0.0 (`Console.print`).
488        let render = |panel: Panel, width| {
489            let console = Console::builder().width(width).color_system(None).build();
490            let out = console.render_to_string(&panel);
491            if out.is_empty() {
492                out
493            } else {
494                out + "\n"
495            }
496        };
497        for width in [0, 1, 2] {
498            let expected = ["", "╭\n╰\n", "╭╮\n╰╯\n"][width];
499            assert_eq!(
500                render(Panel::new(Box::new(crate::text::Text::new("hi"))), width),
501                expected,
502                "width {width}"
503            );
504            assert_eq!(
505                render(Panel::fit(Box::new(crate::text::Text::new("hi"))), width),
506                expected,
507                "fit width {width}"
508            );
509        }
510    }
511
512    use super::*;
513    use crate::r#box::SQUARE;
514    use crate::text::Text;
515
516    fn console() -> Console {
517        Console::builder()
518            .force_terminal(true)
519            .color_system(Some(crate::color::ColorSystem::Truecolor))
520            .width(20)
521            .build()
522    }
523
524    #[test]
525    fn plain_panel() {
526        let out = console().render_export(&Panel::new(Box::new(Text::new("hello"))));
527        assert_eq!(
528            out,
529            "╭──────────────────╮\n│ hello            │\n╰──────────────────╯\n"
530        );
531    }
532
533    #[test]
534    fn titled_panel() {
535        let out = console().render_export(&Panel::new(Box::new(Text::new("hello"))).title("T"));
536        assert_eq!(
537            out,
538            "╭─────── T ────────╮\n│ hello            │\n╰──────────────────╯\n"
539        );
540    }
541
542    #[test]
543    fn square_box() {
544        let out = console().render_export(&Panel::new(Box::new(Text::new("hi"))).box_set(SQUARE));
545        assert_eq!(
546            out,
547            "┌──────────────────┐\n│ hi               │\n└──────────────────┘\n"
548        );
549    }
550
551    #[test]
552    fn legacy_windows_substitutes_rounded_to_square() {
553        // On a legacy Windows console, ROUNDED falls back to SQUARE. Captured
554        // from real rich 15.0.0 (legacy_windows=True, width 12).
555        let legacy = Console::builder()
556            .force_terminal(true)
557            .color_system(Some(crate::color::ColorSystem::Truecolor))
558            .width(12)
559            .no_color(false)
560            .legacy_windows(true)
561            .build();
562        let out = legacy.render_export(&Panel::new(Box::new(Text::new("hi"))));
563        assert_eq!(out, "┌──────────┐\n│ hi       │\n└──────────┘\n");
564    }
565
566    #[test]
567    fn zero_inner_width_renders_no_body_and_empty_text_one_row() {
568        // Captured from real rich 15.0.0 (#449, #442).
569        let narrow = Console::builder()
570            .force_terminal(true)
571            .color_system(Some(crate::color::ColorSystem::Truecolor))
572            .width(4)
573            .highlight(false)
574            .build();
575        let panel = Panel::new(Box::new(Text::new("ab cd"))).box_set(crate::r#box::HEAVY);
576        assert_eq!(narrow.render_export(&panel), "┏━━┓\n┗━━┛\n");
577        let empty = Panel::new(Box::new(Text::new(""))).box_set(SQUARE);
578        assert_eq!(
579            Console::builder()
580                .force_terminal(true)
581                .color_system(Some(crate::color::ColorSystem::Truecolor))
582                .width(10)
583                .highlight(false)
584                .build()
585                .render_export(&empty),
586            "┌────────┐\n│        │\n└────────┘\n"
587        );
588    }
589}