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 has_edge = box_style.has_visible_edges();
140        // Only reserve space for borders if the box actually draws them.
141        let edge_width: usize = if has_edge { 2 } else { 0 };
142        let inner_max_width = options.max_width.saturating_sub(edge_width + padding.1 + padding.3);
143
144        // Render the content
145        let inner_options = options
146            .update_width(inner_max_width.max(1));
147        let content = self.renderable.render(&inner_options);
148
149        // Calculate content width and height
150        let content_width: usize = content
151            .lines
152            .iter()
153            .map(|line| {
154                line.iter()
155                    .map(|s| s.cell_length())
156                    .sum::<usize>()
157            })
158            .max()
159            .unwrap_or(0);
160
161        let panel_width = if self.expand {
162            options.max_width
163        } else {
164            (content_width + edge_width + padding.1 + padding.3).min(options.max_width).max(3)
165        };
166
167        // Build the panel
168        let mut lines: Vec<Vec<Segment>> = Vec::new();
169        let border = &box_style;
170        let border_ansi = self.border_style.to_ansi();
171        let border_reset = if border_ansi.is_empty() { "" } else { "\x1b[0m" };
172
173        // Helper: create a border segment
174        let bs = |ch: char| -> Segment {
175            let text = format!("{border_ansi}{ch}{border_reset}");
176            Segment::new(text)
177        };
178
179        // -- Edge-less mode: render title/subtitle as plain text, skip borders --
180        if !has_edge {
181            // Title as plain text
182            if let Some(ref title) = self.title {
183                let aligned = self.title_align.align_text(title, panel_width);
184                lines.push(vec![Segment::new(&aligned), Segment::line()]);
185            }
186            // Top padding
187            for _ in 0..padding.0 {
188                lines.push(vec![Segment::new(" ".repeat(panel_width)), Segment::line()]);
189            }
190            // Content
191            for content_line in &content.lines {
192                let mut line: Vec<Segment> = Vec::new();
193                if padding.3 > 0 {
194                    line.push(Segment::new(" ".repeat(padding.3)));
195                }
196                let available = panel_width.saturating_sub(padding.1 + padding.3);
197                let seg_width: usize = content_line.iter().map(|s| s.cell_length()).sum();
198                line.extend(content_line.iter().take(seg_width.min(available)).cloned());
199                let fill = available.saturating_sub(seg_width);
200                if fill > 0 {
201                    line.push(Segment::new(" ".repeat(fill)));
202                }
203                if padding.1 > 0 {
204                    line.push(Segment::new(" ".repeat(padding.1)));
205                }
206                line.push(Segment::line());
207                lines.push(line);
208            }
209            // Bottom padding
210            for _ in 0..padding.2 {
211                lines.push(vec![Segment::new(" ".repeat(panel_width)), Segment::line()]);
212            }
213            // Subtitle as plain text
214            if let Some(ref subtitle) = self.subtitle {
215                let aligned = self.subtitle_align.align_text(subtitle, panel_width);
216                lines.push(vec![Segment::new(&aligned), Segment::line()]);
217            }
218            return RenderResult { lines, items: Vec::new() };
219        }
220
221        // -- Bordered mode (original path) --
222        // Top border (with optional title)
223        let top_line = self.render_top_border(
224            &box_style, panel_width, &border_ansi, &border_reset,
225        );
226        lines.push(top_line);
227
228        // Pad top
229        for _ in 0..padding.0 {
230            let pad_line = self.render_pad_line(&box_style, panel_width, &border_ansi, &border_reset);
231            lines.push(pad_line);
232        }
233
234        // Content lines
235        for content_line in &content.lines {
236            let mut line: Vec<Segment> = Vec::new();
237            // Left border
238            line.push(bs(border.mid_left));
239            // Left padding
240            if padding.3 > 0 {
241                line.push(Segment::new(" ".repeat(padding.3)));
242            }
243
244            // Content (possibly truncated to fit)
245            let available = panel_width.saturating_sub(2 + padding.1 + padding.3);
246            let seg_width: usize = content_line.iter().map(|s| s.cell_length()).sum();
247            line.extend(content_line.iter().take(seg_width.min(available)).cloned());
248
249            // Fill remaining space
250            let fill = available.saturating_sub(seg_width);
251            if fill > 0 {
252                line.push(Segment::new(" ".repeat(fill)));
253            }
254
255            // Right padding
256            if padding.1 > 0 {
257                line.push(Segment::new(" ".repeat(padding.1)));
258            }
259            // Right border
260            line.push(bs(border.mid_right));
261            line.push(Segment::line());
262            lines.push(line);
263        }
264
265        // Pad bottom
266        for _ in 0..padding.2 {
267            let pad_line = self.render_pad_line(&box_style, panel_width, &border_ansi, &border_reset);
268            lines.push(pad_line);
269        }
270
271        // Bottom border (with optional subtitle)
272        let bottom_line = self.render_bottom_border(
273            &box_style, panel_width, &border_ansi, &border_reset,
274        );
275        lines.push(bottom_line);
276
277        RenderResult { lines, items: Vec::new() }
278    }
279}
280
281impl Panel {
282    fn render_top_border(
283        &self,
284        b: &BoxStyle,
285        width: usize,
286        border_ansi: &str,
287        border_reset: &str,
288    ) -> Vec<Segment> {
289        let mut line = Vec::new();
290        let inner = width.saturating_sub(2);
291
292        if let Some(ref title) = self.title {
293            let title_w = unicode_width::UnicodeWidthStr::width(title.as_str());
294            if title_w + 2 <= inner {
295                let rem = inner - title_w - 2;
296                let (left_w, right_w) = match self.title_align {
297                    AlignMethod::Left => (1, rem - 1),
298                    AlignMethod::Right => (rem - 1, 1),
299                    AlignMethod::Center => {
300                        let l = rem / 2;
301                        (l, rem - l)
302                    }
303                    AlignMethod::Full => (1, rem - 1),
304                };
305
306                // Batch repeated horizontal chars under a single ANSI wrap
307                let bl = format!("{border_ansi}{}{border_reset}", b.top_left);
308                let br = format!("{border_ansi}{}{border_reset}", b.top_right);
309                let bt_left = format!("{border_ansi}{}{border_reset}", b.top.to_string().repeat(left_w));
310                let bt_right = format!("{border_ansi}{}{border_reset}", b.top.to_string().repeat(right_w));
311
312                line.push(Segment::new(bl));
313                line.push(Segment::new(bt_left));
314                line.push(Segment::new(format!(" {title} ")));
315                line.push(Segment::new(bt_right));
316                line.push(Segment::new(br));
317                line.push(Segment::line());
318                return line;
319            }
320        }
321
322        // No title, or title too long
323        let bl = format!("{border_ansi}{}{border_reset}", b.top_left);
324        let br = format!("{border_ansi}{}{border_reset}", b.top_right);
325        let bt = format!("{border_ansi}{}{border_reset}", b.top.to_string().repeat(inner));
326
327        line.push(Segment::new(bl));
328        line.push(Segment::new(bt));
329        line.push(Segment::new(br));
330        line.push(Segment::line());
331        line
332    }
333
334    fn render_bottom_border(
335        &self,
336        b: &BoxStyle,
337        width: usize,
338        border_ansi: &str,
339        border_reset: &str,
340    ) -> Vec<Segment> {
341        let mut line = Vec::new();
342        let inner = width.saturating_sub(2);
343
344        if let Some(ref subtitle) = self.subtitle {
345            let sub_w = unicode_width::UnicodeWidthStr::width(subtitle.as_str());
346            if sub_w + 2 <= inner {
347                let rem = inner - sub_w - 2;
348                let (left_w, right_w) = match self.subtitle_align {
349                    AlignMethod::Left => (1, rem - 1),
350                    AlignMethod::Right => (rem - 1, 1),
351                    AlignMethod::Center => {
352                        let l = rem / 2;
353                        (l, rem - l)
354                    }
355                    AlignMethod::Full => (1, rem - 1),
356                };
357
358                let bl = format!("{border_ansi}{}{border_reset}", b.bottom_left);
359                let br = format!("{border_ansi}{}{border_reset}", b.bottom_right);
360                let bb_left = format!("{border_ansi}{}{border_reset}", b.bottom.to_string().repeat(left_w));
361                let bb_right = format!("{border_ansi}{}{border_reset}", b.bottom.to_string().repeat(right_w));
362
363                line.push(Segment::new(bl));
364                line.push(Segment::new(bb_left));
365                line.push(Segment::new(format!(" {subtitle} ")));
366                line.push(Segment::new(bb_right));
367                line.push(Segment::new(br));
368                line.push(Segment::line());
369                return line;
370            }
371        }
372
373        let bl = format!("{border_ansi}{}{border_reset}", b.bottom_left);
374        let br = format!("{border_ansi}{}{border_reset}", b.bottom_right);
375        let bb = format!("{border_ansi}{}{border_reset}", b.bottom.to_string().repeat(inner));
376
377        line.push(Segment::new(bl));
378        line.push(Segment::new(bb));
379        line.push(Segment::new(br));
380        line.push(Segment::line());
381        line
382    }
383
384    fn render_pad_line(
385        &self,
386        b: &BoxStyle,
387        width: usize,
388        border_ansi: &str,
389        border_reset: &str,
390    ) -> Vec<Segment> {
391        let inner = width.saturating_sub(2);
392        let left = format!("{border_ansi}{}{border_reset}", b.mid_left);
393        let right = format!("{border_ansi}{}{border_reset}", b.mid_right);
394        vec![
395            Segment::new(left),
396            Segment::new(" ".repeat(inner)),
397            Segment::new(right),
398            Segment::line(),
399        ]
400    }
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406    use crate::console::ConsoleOptions;
407
408    #[test]
409    fn test_panel_creation() {
410        let panel = Panel::new("Hello");
411        assert!(panel.title.is_none());
412    }
413
414    #[test]
415    fn test_panel_with_title() {
416        let panel = Panel::new("Content").title("My Title");
417        let opts = ConsoleOptions::default();
418        let result = panel.render(&opts);
419        let ansi = result.to_ansi();
420        assert!(ansi.contains("My Title"));
421    }
422}