Skip to main content

rusty_rich/
panel.rs

1//! Panel — a bordered container. Equivalent to Rich's `panel.py`.
2
3use crate::align::AlignMethod;
4use crate::box_drawing::{get_safe_box, BoxStyle, BOX_ROUNDED};
5use crate::console::{ConsoleOptions, DynRenderable, RenderResult, Renderable};
6use crate::segment::Segment;
7use crate::style::Style;
8
9// ---------------------------------------------------------------------------
10// Panel
11// ---------------------------------------------------------------------------
12
13/// A renderable that draws a border around its contents.
14#[derive(Clone)]
15pub struct Panel {
16    /// The content inside the panel.
17    pub renderable: DynRenderable,
18    /// The box style defining the border.
19    pub box_style: BoxStyle,
20    /// Optional title displayed in the top border.
21    pub title: Option<String>,
22    /// Alignment of the title.
23    pub title_align: AlignMethod,
24    /// Optional subtitle displayed in the bottom border.
25    pub subtitle: Option<String>,
26    /// Alignment of the subtitle.
27    pub subtitle_align: AlignMethod,
28    /// If true, expand to fill available width.
29    pub expand: bool,
30    /// Style for the content area.
31    pub style: Style,
32    /// Style for the border.
33    pub border_style: Style,
34    /// Optional fixed width.
35    pub width: Option<usize>,
36    /// Optional fixed height.
37    pub height: Option<usize>,
38    /// Padding (top, right, bottom, left).
39    pub padding: (usize, usize, usize, usize),
40    /// If true, highlight string titles.
41    pub highlight: bool,
42}
43
44impl Panel {
45    /// Create a new Panel with the given content.
46    pub fn new(renderable: impl Renderable + Send + Sync + 'static) -> Self {
47        Self {
48            renderable: DynRenderable::new(renderable),
49            box_style: BOX_ROUNDED.clone(),
50            title: None,
51            title_align: AlignMethod::Center,
52            subtitle: None,
53            subtitle_align: AlignMethod::Center,
54            expand: true,
55            style: Style::new(),
56            border_style: Style::new(),
57            width: None,
58            height: None,
59            padding: (0, 1, 0, 1), // top, right, bottom, left
60            highlight: false,
61        }
62    }
63
64    /// Builder: set the box style.
65    pub fn box_style(mut self, bs: BoxStyle) -> Self {
66        self.box_style = bs;
67        self
68    }
69
70    /// Builder: set the title.
71    pub fn title(mut self, title: impl Into<String>) -> Self {
72        self.title = Some(title.into());
73        self
74    }
75
76    /// Builder: set the subtitle.
77    pub fn subtitle(mut self, subtitle: impl Into<String>) -> Self {
78        self.subtitle = Some(subtitle.into());
79        self
80    }
81
82    /// Builder: set the border style.
83    pub fn border_style(mut self, style: Style) -> Self {
84        self.border_style = style;
85        self
86    }
87
88    /// Builder: set the content style.
89    pub fn style(mut self, style: Style) -> Self {
90        self.style = style;
91        self
92    }
93
94    /// Builder: set width.
95    pub fn width(mut self, width: usize) -> Self {
96        self.width = Some(width);
97        self
98    }
99
100    /// Builder: set height.
101    pub fn height(mut self, height: usize) -> Self {
102        self.height = Some(height);
103        self
104    }
105
106    /// Builder: set padding.
107    pub fn padding(mut self, top: usize, right: usize, bottom: usize, left: usize) -> Self {
108        self.padding = (top, right, bottom, left);
109        self
110    }
111
112    /// Builder: don't expand to fill width.
113    pub fn fit(mut self) -> Self {
114        self.expand = false;
115        self
116    }
117
118    /// Builder: set title alignment.
119    pub fn title_align(mut self, align: AlignMethod) -> Self {
120        self.title_align = align;
121        self
122    }
123}
124
125impl std::fmt::Debug for Panel {
126    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127        f.debug_struct("Panel")
128            .field("title", &self.title)
129            .field("width", &self.width)
130            .field("height", &self.height)
131            .finish()
132    }
133}
134
135impl Renderable for Panel {
136    fn render(&self, options: &ConsoleOptions) -> RenderResult {
137        let box_style = get_safe_box(&self.box_style, options.ascii_only);
138        let padding = self.padding;
139        let inner_max_width = options.max_width.saturating_sub(2 + padding.1 + padding.3);
140
141        // Render the content
142        let inner_options = options
143            .update_width(inner_max_width.max(1));
144        let content = self.renderable.render(&inner_options);
145
146        // Calculate content width and height
147        let content_width: usize = content
148            .lines
149            .iter()
150            .map(|line| {
151                line.iter()
152                    .map(|s| s.cell_length())
153                    .sum::<usize>()
154            })
155            .max()
156            .unwrap_or(0);
157
158        let panel_width = if self.expand {
159            options.max_width
160        } else {
161            (content_width + 2 + padding.1 + padding.3).min(options.max_width).max(3)
162        };
163
164        // Build the panel
165        let mut lines: Vec<Vec<Segment>> = Vec::new();
166        let border = &box_style;
167        let border_ansi = self.border_style.to_ansi();
168        let border_reset = if border_ansi.is_empty() { "" } else { "\x1b[0m" };
169
170        // Helper: create a border segment
171        let bs = |ch: char| -> Segment {
172            let text = format!("{border_ansi}{ch}{border_reset}");
173            Segment::new(text)
174        };
175        let _bs_text = |s: &str| -> Segment {
176            let text = format!("{border_ansi}{s}{border_reset}");
177            Segment::new(text)
178        };
179
180        // Top border (with optional title)
181        let top_line = self.render_top_border(
182            &box_style, panel_width, &border_ansi, &border_reset,
183        );
184        lines.push(top_line);
185
186        // Pad top
187        for _ in 0..padding.0 {
188            let pad_line = self.render_pad_line(&box_style, panel_width, &border_ansi, &border_reset);
189            lines.push(pad_line);
190        }
191
192        // Content lines
193        for content_line in &content.lines {
194            let mut line: Vec<Segment> = Vec::new();
195            // Left border
196            line.push(bs(border.mid_vertical));
197            // Left padding
198            if padding.3 > 0 {
199                line.push(Segment::new(" ".repeat(padding.3)));
200            }
201
202            // Content (possibly truncated to fit)
203            let available = panel_width.saturating_sub(2 + padding.1 + padding.3);
204            let seg_width: usize = content_line.iter().map(|s| s.cell_length()).sum();
205            line.extend(content_line.iter().take(seg_width.min(available)).cloned());
206
207            // Fill remaining space
208            let fill = available.saturating_sub(seg_width);
209            if fill > 0 {
210                line.push(Segment::new(" ".repeat(fill)));
211            }
212
213            // Right padding
214            if padding.1 > 0 {
215                line.push(Segment::new(" ".repeat(padding.1)));
216            }
217            // Right border
218            line.push(bs(border.mid_right));
219            line.push(Segment::line());
220            lines.push(line);
221        }
222
223        // Pad bottom
224        for _ in 0..padding.2 {
225            let pad_line = self.render_pad_line(&box_style, panel_width, &border_ansi, &border_reset);
226            lines.push(pad_line);
227        }
228
229        // Bottom border (with optional subtitle)
230        let bottom_line = self.render_bottom_border(
231            &box_style, panel_width, &border_ansi, &border_reset,
232        );
233        lines.push(bottom_line);
234
235        RenderResult { lines, items: Vec::new() }
236    }
237}
238
239impl Panel {
240    fn render_top_border(
241        &self,
242        b: &BoxStyle,
243        width: usize,
244        border_ansi: &str,
245        border_reset: &str,
246    ) -> Vec<Segment> {
247        let mut line = Vec::new();
248        let inner = width.saturating_sub(2);
249
250        if let Some(ref title) = self.title {
251            let title_w = unicode_width::UnicodeWidthStr::width(title.as_str());
252            if title_w + 2 <= inner {
253                let rem = inner - title_w - 2;
254                let (left_w, right_w) = match self.title_align {
255                    AlignMethod::Left => (1, rem - 1),
256                    AlignMethod::Right => (rem - 1, 1),
257                    AlignMethod::Center => {
258                        let l = rem / 2;
259                        (l, rem - l)
260                    }
261                    AlignMethod::Full => (1, rem - 1),
262                };
263
264                let bl = format!("{border_ansi}{}{border_reset}", b.top_left);
265                let br = format!("{border_ansi}{}{border_reset}", b.top_right);
266                let bt = format!("{border_ansi}{}{border_reset}", b.top);
267
268                line.push(Segment::new(bl));
269                line.push(Segment::new(bt.repeat(left_w)));
270                line.push(Segment::new(format!(" {title} ")));
271                line.push(Segment::new(bt.repeat(right_w)));
272                line.push(Segment::new(br));
273                line.push(Segment::line());
274                return line;
275            }
276        }
277
278        // No title, or title too long
279        let bl = format!("{border_ansi}{}{border_reset}", b.top_left);
280        let br = format!("{border_ansi}{}{border_reset}", b.top_right);
281        let bt = format!("{border_ansi}{}{border_reset}", b.top);
282
283        line.push(Segment::new(bl));
284        line.push(Segment::new(bt.repeat(inner)));
285        line.push(Segment::new(br));
286        line.push(Segment::line());
287        line
288    }
289
290    fn render_bottom_border(
291        &self,
292        b: &BoxStyle,
293        width: usize,
294        border_ansi: &str,
295        border_reset: &str,
296    ) -> Vec<Segment> {
297        let mut line = Vec::new();
298        let inner = width.saturating_sub(2);
299
300        if let Some(ref subtitle) = self.subtitle {
301            let sub_w = unicode_width::UnicodeWidthStr::width(subtitle.as_str());
302            if sub_w + 2 <= inner {
303                let rem = inner - sub_w - 2;
304                let (left_w, right_w) = match self.subtitle_align {
305                    AlignMethod::Left => (1, rem - 1),
306                    AlignMethod::Right => (rem - 1, 1),
307                    AlignMethod::Center => {
308                        let l = rem / 2;
309                        (l, rem - l)
310                    }
311                    AlignMethod::Full => (1, rem - 1),
312                };
313
314                let bl = format!("{border_ansi}{}{border_reset}", b.bottom_left);
315                let br = format!("{border_ansi}{}{border_reset}", b.bottom_right);
316                let bb = format!("{border_ansi}{}{border_reset}", b.bottom);
317
318                line.push(Segment::new(bl));
319                line.push(Segment::new(bb.repeat(left_w)));
320                line.push(Segment::new(format!(" {subtitle} ")));
321                line.push(Segment::new(bb.repeat(right_w)));
322                line.push(Segment::new(br));
323                line.push(Segment::line());
324                return line;
325            }
326        }
327
328        let bl = format!("{border_ansi}{}{border_reset}", b.bottom_left);
329        let br = format!("{border_ansi}{}{border_reset}", b.bottom_right);
330        let bb = format!("{border_ansi}{}{border_reset}", b.bottom);
331
332        line.push(Segment::new(bl));
333        line.push(Segment::new(bb.repeat(inner)));
334        line.push(Segment::new(br));
335        line.push(Segment::line());
336        line
337    }
338
339    fn render_pad_line(
340        &self,
341        b: &BoxStyle,
342        width: usize,
343        border_ansi: &str,
344        border_reset: &str,
345    ) -> Vec<Segment> {
346        let inner = width.saturating_sub(2);
347        let left = format!("{border_ansi}{}{border_reset}", b.mid_vertical);
348        let right = format!("{border_ansi}{}{border_reset}", b.mid_right);
349        vec![
350            Segment::new(left),
351            Segment::new(" ".repeat(inner)),
352            Segment::new(right),
353            Segment::line(),
354        ]
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361    use crate::console::ConsoleOptions;
362
363    #[test]
364    fn test_panel_creation() {
365        let panel = Panel::new("Hello");
366        assert!(panel.title.is_none());
367    }
368
369    #[test]
370    fn test_panel_with_title() {
371        let panel = Panel::new("Content").title("My Title");
372        let opts = ConsoleOptions::default();
373        let result = panel.render(&opts);
374        let ansi = result.to_ansi();
375        assert!(ansi.contains("My Title"));
376    }
377}