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