fast_rich/
panel.rs

1//! Panels for displaying content in a box with optional title.
2//!
3//! A `Panel` draws a box around content with customizable borders,
4//! title, and padding.
5
6use crate::console::RenderContext;
7use crate::renderable::{Renderable, Segment};
8use crate::style::Style;
9use crate::text::{Span, Text};
10
11use crate::box_drawing::{self, Box};
12
13/// Border style for panels.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum BorderStyle {
16    /// Standard box drawing characters
17    #[default]
18    Rounded,
19    /// Square corners
20    Square,
21    /// Heavy/bold borders
22    Heavy,
23    /// Double-line borders
24    Double,
25    /// ASCII-only borders
26    Ascii,
27    /// ASCII double-head
28    AsciiDoubleHead,
29    /// Minimal borders (dashes)
30    Minimal,
31    /// Minimal heavy head
32    MinimalHeavyHead,
33    /// Minimal double head
34    MinimalDoubleHead,
35    /// Horizontals only
36    Horizontals,
37    /// Square double head
38    SquareDoubleHead,
39    /// Heavy edge
40    HeavyEdge,
41    /// Heavy head
42    HeavyHead,
43    /// Double edge
44    DoubleEdge,
45    /// No visible border (but space is reserved)
46    Hidden,
47}
48
49impl BorderStyle {
50    /// Get the box characters for this style.
51    pub fn to_box(&self) -> Box {
52        match self {
53            BorderStyle::Rounded => box_drawing::ROUNDED,
54            BorderStyle::Square => box_drawing::SQUARE,
55            BorderStyle::Heavy => box_drawing::HEAVY,
56            BorderStyle::Double => box_drawing::DOUBLE,
57            BorderStyle::Ascii => box_drawing::ASCII,
58            BorderStyle::AsciiDoubleHead => box_drawing::ASCII_DOUBLE_HEAD,
59            BorderStyle::Minimal => box_drawing::MINIMAL,
60            BorderStyle::MinimalHeavyHead => box_drawing::MINIMAL_HEAVY_HEAD,
61            BorderStyle::MinimalDoubleHead => box_drawing::MINIMAL_DOUBLE_HEAD,
62            BorderStyle::Horizontals => box_drawing::HORIZONTALS,
63            BorderStyle::SquareDoubleHead => box_drawing::SQUARE_DOUBLE_HEAD,
64            BorderStyle::HeavyEdge => box_drawing::HEAVY_EDGE,
65            BorderStyle::HeavyHead => box_drawing::HEAVY_HEAD,
66            BorderStyle::DoubleEdge => box_drawing::DOUBLE_EDGE,
67            BorderStyle::Hidden => Box {
68                top: box_drawing::Line::new(' ', ' ', ' ', ' '),
69                head: box_drawing::Line::new(' ', ' ', ' ', ' '),
70                mid: box_drawing::Line::new(' ', ' ', ' ', ' '),
71                bottom: box_drawing::Line::new(' ', ' ', ' ', ' '),
72                header: box_drawing::Line::new(' ', ' ', ' ', ' '),
73                cell: box_drawing::Line::new(' ', ' ', ' ', ' '),
74            },
75        }
76    }
77}
78
79/// A panel that wraps content in a box.
80#[derive(Debug, Clone)]
81pub struct Panel {
82    /// The content to display
83    content: Text,
84    /// Optional title at the top
85    title: Option<String>,
86    /// Optional subtitle at the bottom
87    subtitle: Option<String>,
88    /// Border style
89    border_style: BorderStyle,
90    /// Style for the border
91    style: Style,
92    /// Style for the title
93    title_style: Style,
94    /// Horizontal padding inside the box
95    padding_x: usize,
96    /// Vertical padding inside the box
97    padding_y: usize,
98    /// Expand to full width
99    expand: bool,
100}
101
102impl Panel {
103    /// Create a new panel with content.
104    pub fn new<T: Into<Text>>(content: T) -> Self {
105        Panel {
106            content: content.into(),
107            title: None,
108            subtitle: None,
109            border_style: BorderStyle::Rounded,
110            style: Style::new(),
111            title_style: Style::new(),
112            padding_x: 1,
113            padding_y: 0,
114            expand: true,
115        }
116    }
117
118    /// Set the title.
119    pub fn title(mut self, title: &str) -> Self {
120        self.title = Some(title.to_string());
121        self
122    }
123
124    /// Set the subtitle.
125    pub fn subtitle(mut self, subtitle: &str) -> Self {
126        self.subtitle = Some(subtitle.to_string());
127        self
128    }
129
130    /// Set the border style.
131    pub fn border_style(mut self, style: BorderStyle) -> Self {
132        self.border_style = style;
133        self
134    }
135
136    /// Set the border color/style.
137    pub fn style(mut self, style: Style) -> Self {
138        self.style = style;
139        self
140    }
141
142    /// Set the title style.
143    pub fn title_style(mut self, style: Style) -> Self {
144        self.title_style = style;
145        self
146    }
147
148    /// Set horizontal padding.
149    pub fn padding_x(mut self, padding: usize) -> Self {
150        self.padding_x = padding;
151        self
152    }
153
154    /// Set vertical padding.
155    pub fn padding_y(mut self, padding: usize) -> Self {
156        self.padding_y = padding;
157        self
158    }
159
160    /// Set both horizontal and vertical padding.
161    pub fn padding(self, x: usize, y: usize) -> Self {
162        self.padding_x(x).padding_y(y)
163    }
164
165    /// Set whether the panel expands to full width.
166    pub fn expand(mut self, expand: bool) -> Self {
167        self.expand = expand;
168        self
169    }
170
171    fn render_top_border(&self, width: usize, box_chars: &Box) -> Segment {
172        let inner_width = width.saturating_sub(2);
173        let chars = box_chars.top;
174
175        match &self.title {
176            None => {
177                let line = chars.mid.to_string().repeat(inner_width);
178                Segment::line(vec![
179                    Span::styled(chars.left.to_string(), self.style),
180                    Span::styled(line, self.style),
181                    Span::styled(chars.right.to_string(), self.style),
182                ])
183            }
184            Some(title) => {
185                let title_with_space = format!(" {} ", title);
186                let title_width = unicode_width::UnicodeWidthStr::width(title_with_space.as_str());
187
188                if title_width >= inner_width {
189                    let line = chars.mid.to_string().repeat(inner_width);
190                    return Segment::line(vec![
191                        Span::styled(chars.left.to_string(), self.style),
192                        Span::styled(line, self.style),
193                        Span::styled(chars.right.to_string(), self.style),
194                    ]);
195                }
196
197                let remaining = inner_width - title_width;
198                let left_len = 2.min(remaining);
199                let right_len = remaining - left_len;
200
201                Segment::line(vec![
202                    Span::styled(chars.left.to_string(), self.style),
203                    Span::styled(chars.mid.to_string().repeat(left_len), self.style),
204                    Span::styled(title_with_space, self.title_style),
205                    Span::styled(chars.mid.to_string().repeat(right_len), self.style),
206                    Span::styled(chars.right.to_string(), self.style),
207                ])
208            }
209        }
210    }
211
212    fn render_bottom_border(&self, width: usize, box_chars: &Box) -> Segment {
213        let inner_width = width.saturating_sub(2);
214        let chars = box_chars.bottom;
215
216        match &self.subtitle {
217            None => {
218                let line = chars.mid.to_string().repeat(inner_width);
219                Segment::line(vec![
220                    Span::styled(chars.left.to_string(), self.style),
221                    Span::styled(line, self.style),
222                    Span::styled(chars.right.to_string(), self.style),
223                ])
224            }
225            Some(subtitle) => {
226                let sub_with_space = format!(" {} ", subtitle);
227                let sub_width = unicode_width::UnicodeWidthStr::width(sub_with_space.as_str());
228
229                if sub_width >= inner_width {
230                    let line = chars.mid.to_string().repeat(inner_width);
231                    return Segment::line(vec![
232                        Span::styled(chars.left.to_string(), self.style),
233                        Span::styled(line, self.style),
234                        Span::styled(chars.right.to_string(), self.style),
235                    ]);
236                }
237
238                let remaining = inner_width - sub_width;
239                let right_len = 2.min(remaining);
240                let left_len = remaining - right_len;
241
242                Segment::line(vec![
243                    Span::styled(chars.left.to_string(), self.style),
244                    Span::styled(chars.mid.to_string().repeat(left_len), self.style),
245                    Span::styled(sub_with_space, self.title_style),
246                    Span::styled(chars.mid.to_string().repeat(right_len), self.style),
247                    Span::styled(chars.right.to_string(), self.style),
248                ])
249            }
250        }
251    }
252
253    fn render_content_line(&self, spans: Vec<Span>, width: usize, box_chars: &Box) -> Segment {
254        let inner_width = width.saturating_sub(2 + self.padding_x * 2);
255        let content_width: usize = spans.iter().map(|s| s.width()).sum();
256        let padding_right = inner_width.saturating_sub(content_width);
257        let chars = box_chars.cell;
258
259        let mut line_spans = Vec::new();
260        line_spans.push(Span::styled(chars.left.to_string(), self.style));
261        line_spans.push(Span::styled(" ".repeat(self.padding_x), self.style));
262        line_spans.extend(spans);
263        line_spans.push(Span::styled(
264            " ".repeat(padding_right + self.padding_x),
265            self.style,
266        ));
267        line_spans.push(Span::styled(chars.right.to_string(), self.style));
268
269        Segment::line(line_spans)
270    }
271
272    fn render_empty_line(&self, width: usize, box_chars: &Box) -> Segment {
273        let inner_width = width.saturating_sub(2);
274        let chars = box_chars.cell;
275        Segment::line(vec![
276            Span::styled(chars.left.to_string(), self.style),
277            Span::styled(" ".repeat(inner_width), self.style),
278            Span::styled(chars.right.to_string(), self.style),
279        ])
280    }
281}
282
283impl<T: Into<Text>> From<T> for Panel {
284    fn from(content: T) -> Self {
285        Panel::new(content)
286    }
287}
288
289impl Renderable for Panel {
290    fn render(&self, context: &RenderContext) -> Vec<Segment> {
291        let box_chars = self.border_style.to_box();
292        let width = if self.expand {
293            context.width
294        } else {
295            let content_width = self.content.width();
296            let min_width = content_width + 2 + self.padding_x * 2;
297            min_width.min(context.width)
298        };
299
300        let inner_width = width.saturating_sub(2 + self.padding_x * 2);
301        let content_lines = self.content.wrap(inner_width);
302
303        let mut segments = Vec::new();
304
305        // Top border
306        segments.push(self.render_top_border(width, &box_chars));
307
308        // Top padding
309        for _ in 0..self.padding_y {
310            segments.push(self.render_empty_line(width, &box_chars));
311        }
312
313        // Content lines
314        for line_spans in content_lines {
315            segments.push(self.render_content_line(line_spans, width, &box_chars));
316        }
317
318        // Bottom padding
319        for _ in 0..self.padding_y {
320            segments.push(self.render_empty_line(width, &box_chars));
321        }
322
323        // Bottom border
324        segments.push(self.render_bottom_border(width, &box_chars));
325
326        segments
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    #[test]
335    fn test_panel_simple() {
336        let panel = Panel::new("Hello");
337        let context = RenderContext {
338            width: 20,
339            height: None,
340        };
341        let segments = panel.render(&context);
342
343        // Should have top border, content, bottom border
344        assert!(segments.len() >= 3);
345
346        // Check top border starts with corner
347        let top = segments[0].plain_text();
348        assert!(top.starts_with('╭'));
349        assert!(top.ends_with('╮'));
350    }
351
352    #[test]
353    fn test_panel_with_title() {
354        let panel = Panel::new("Content").title("Title");
355        let context = RenderContext {
356            width: 30,
357            height: None,
358        };
359        let segments = panel.render(&context);
360
361        let top = segments[0].plain_text();
362        assert!(top.contains("Title"));
363    }
364
365    #[test]
366    fn test_panel_border_styles() {
367        let panel = Panel::new("Test").border_style(BorderStyle::Double);
368        let context = RenderContext {
369            width: 20,
370            height: None,
371        };
372        let segments = panel.render(&context);
373
374        let top = segments[0].plain_text();
375        assert!(top.starts_with('╔'));
376    }
377}