Skip to main content

rich_rust/renderables/
panel.rs

1//! Panel - bordered box containing content.
2//!
3//! A Panel renders content inside a decorative border with optional
4//! title and subtitle.
5
6use crate::r#box::{ASCII, BoxChars, ROUNDED, SQUARE};
7use crate::cells;
8use crate::console::{Console, ConsoleOptions};
9use crate::markup;
10use crate::renderables::Renderable;
11use crate::segment::{Segment, adjust_line_length};
12use crate::style::Style;
13use crate::text::{JustifyMethod, OverflowMethod, Text};
14
15use super::padding::PaddingDimensions;
16
17/// A bordered panel containing content.
18#[derive(Debug, Clone)]
19pub struct Panel<'a> {
20    /// Content lines to render inside the panel.
21    content_lines: Vec<Vec<Segment<'a>>>,
22    /// Box drawing style.
23    box_style: &'static BoxChars,
24    /// Use ASCII-safe characters.
25    safe_box: Option<bool>,
26    /// Expand to fill available width.
27    expand: bool,
28    /// Style for the panel background.
29    style: Style,
30    /// Style for the border.
31    border_style: Style,
32    /// Fixed width (None = auto).
33    width: Option<usize>,
34    /// Fixed height (None = auto).
35    height: Option<usize>,
36    /// Padding inside the border.
37    padding: PaddingDimensions,
38    /// Optional title.
39    title: Option<Text>,
40    /// Title alignment.
41    title_align: JustifyMethod,
42    /// Optional subtitle (bottom).
43    subtitle: Option<Text>,
44    /// Subtitle alignment.
45    subtitle_align: JustifyMethod,
46}
47
48impl Default for Panel<'_> {
49    fn default() -> Self {
50        Self {
51            content_lines: Vec::new(),
52            box_style: &ROUNDED,
53            safe_box: None,
54            expand: true,
55            style: Style::new(),
56            border_style: Style::new(),
57            width: None,
58            height: None,
59            padding: PaddingDimensions::symmetric(0, 1),
60            title: None,
61            title_align: JustifyMethod::Center,
62            subtitle: None,
63            subtitle_align: JustifyMethod::Center,
64        }
65    }
66}
67
68impl<'a> Panel<'a> {
69    /// Create a new panel with content lines.
70    #[must_use]
71    pub fn new(content_lines: Vec<Vec<Segment<'a>>>) -> Self {
72        Self {
73            content_lines,
74            ..Self::default()
75        }
76    }
77
78    /// Create a panel from plain text content.
79    ///
80    /// This does **NOT** parse Rich markup. If you want markup styling,
81    /// parse into a [`Text`] first with [`crate::markup::render_or_plain`]
82    /// and use [`Panel::from_rich_text`].
83    #[must_use]
84    pub fn from_text(text: &'a str) -> Self {
85        let lines: Vec<Vec<Segment<'a>>> = text
86            .lines()
87            .map(|line| vec![Segment::new(line, None)])
88            .collect();
89        Self::new(lines)
90    }
91
92    /// Create a panel from a pre-styled Text object.
93    ///
94    /// Use this when you already have a `Text` with spans (for example from
95    /// [`crate::markup::render_or_plain`]).
96    #[must_use]
97    pub fn from_rich_text(text: &'a Text, width: usize) -> Self {
98        // Split into logical lines first, then render each line to segments.
99        let lines = text
100            .split_lines()
101            .into_iter()
102            .map(|line| {
103                line.render("")
104                    .into_iter()
105                    .map(super::super::segment::Segment::into_owned)
106                    .collect()
107            })
108            .collect();
109
110        Self {
111            content_lines: lines,
112            width: Some(width),
113            ..Self::default()
114        }
115    }
116
117    /// Set the box style.
118    #[must_use]
119    pub fn box_style(mut self, style: &'static BoxChars) -> Self {
120        self.box_style = style;
121        self
122    }
123
124    /// Use rounded box style.
125    #[must_use]
126    pub fn rounded(mut self) -> Self {
127        self.box_style = &ROUNDED;
128        self
129    }
130
131    /// Use square box style.
132    #[must_use]
133    pub fn square(mut self) -> Self {
134        self.box_style = &SQUARE;
135        self
136    }
137
138    /// Use ASCII-safe box style.
139    #[must_use]
140    pub fn ascii(mut self) -> Self {
141        self.box_style = &ASCII;
142        self.safe_box = Some(true);
143        self
144    }
145
146    /// Force ASCII-safe rendering.
147    #[must_use]
148    pub fn safe_box(mut self, safe: bool) -> Self {
149        self.safe_box = Some(safe);
150        self
151    }
152
153    /// Set whether to expand to fill width.
154    #[must_use]
155    pub fn expand(mut self, expand: bool) -> Self {
156        self.expand = expand;
157        self
158    }
159
160    /// Set the background style.
161    #[must_use]
162    pub fn style(mut self, style: Style) -> Self {
163        self.style = style;
164        self
165    }
166
167    /// Set the border style.
168    #[must_use]
169    pub fn border_style(mut self, style: Style) -> Self {
170        self.border_style = style;
171        self
172    }
173
174    /// Set fixed width.
175    #[must_use]
176    pub fn width(mut self, width: usize) -> Self {
177        self.width = Some(width);
178        self
179    }
180
181    /// Set fixed height.
182    #[must_use]
183    pub fn height(mut self, height: usize) -> Self {
184        self.height = Some(height);
185        self
186    }
187
188    /// Set padding.
189    #[must_use]
190    pub fn padding(mut self, padding: impl Into<PaddingDimensions>) -> Self {
191        self.padding = padding.into();
192        self
193    }
194
195    /// Set the title.
196    ///
197    /// Passing a `&str` uses `Text::new()` and does **NOT** parse markup.
198    /// For styled titles, pass a pre-styled `Text` (e.g. from
199    /// [`crate::markup::render_or_plain`]).
200    #[must_use]
201    pub fn title(mut self, title: impl Into<Text>) -> Self {
202        self.title = Some(title.into());
203        self
204    }
205
206    /// Set title alignment.
207    #[must_use]
208    pub fn title_align(mut self, align: JustifyMethod) -> Self {
209        self.title_align = align;
210        self
211    }
212
213    /// Set the title from a markup string.
214    ///
215    /// This parses Rich markup tags like `[bold cyan]Title[/]` into styled text.
216    ///
217    /// # Example
218    ///
219    /// ```rust
220    /// use rich_rust::renderables::Panel;
221    ///
222    /// let panel = Panel::from_text("Content")
223    ///     .title_from_markup("[bold cyan]Styled Title[/]");
224    /// ```
225    #[must_use]
226    pub fn title_from_markup(mut self, title: &str) -> Self {
227        self.title = Some(markup::render_or_plain(title));
228        self
229    }
230
231    /// Set the subtitle.
232    ///
233    /// Passing a `&str` uses `Text::new()` and does **NOT** parse markup.
234    /// For styled subtitles, pass a pre-styled `Text` (e.g. from
235    /// [`crate::markup::render_or_plain`]).
236    #[must_use]
237    pub fn subtitle(mut self, subtitle: impl Into<Text>) -> Self {
238        self.subtitle = Some(subtitle.into());
239        self
240    }
241
242    /// Set subtitle alignment.
243    #[must_use]
244    pub fn subtitle_align(mut self, align: JustifyMethod) -> Self {
245        self.subtitle_align = align;
246        self
247    }
248
249    /// Set the subtitle from a markup string.
250    ///
251    /// This parses Rich markup tags like `[dim]Subtitle[/]` into styled text.
252    ///
253    /// # Example
254    ///
255    /// ```rust
256    /// use rich_rust::renderables::Panel;
257    ///
258    /// let panel = Panel::from_text("Content")
259    ///     .subtitle_from_markup("[dim italic]Footer text[/]");
260    /// ```
261    #[must_use]
262    pub fn subtitle_from_markup(mut self, subtitle: &str) -> Self {
263        self.subtitle = Some(markup::render_or_plain(subtitle));
264        self
265    }
266
267    /// Get the effective box characters.
268    fn effective_box(&self) -> &'static BoxChars {
269        let safe = self.safe_box.unwrap_or(false);
270        if safe && !self.box_style.ascii {
271            &ASCII
272        } else {
273            self.box_style
274        }
275    }
276
277    /// Calculate content width from content lines.
278    fn content_width(&self) -> usize {
279        self.content_lines
280            .iter()
281            .map(|line: &Vec<Segment<'a>>| line.iter().map(|seg| cells::cell_len(&seg.text)).sum())
282            .max()
283            .unwrap_or(0)
284    }
285
286    /// Render the panel to segments.
287    #[must_use]
288    pub fn render(&self, max_width: usize) -> Vec<Segment<'a>> {
289        let box_chars = self.effective_box();
290
291        // Calculate panel width
292        let panel_width = if self.expand {
293            self.width.unwrap_or(max_width).min(max_width)
294        } else {
295            let content_w = self.content_width();
296            let min_width = content_w + 2 + self.padding.horizontal();
297            self.width.unwrap_or(min_width).min(max_width)
298        };
299
300        // Inner width (inside borders)
301        let inner_width = panel_width.saturating_sub(2);
302
303        let mut pad_left = self.padding.left;
304        let mut pad_right = self.padding.right;
305        let max_content_width = self.content_width();
306        if max_content_width <= inner_width {
307            let available_for_padding = inner_width.saturating_sub(max_content_width);
308            if pad_left + pad_right > available_for_padding {
309                let mut remaining = available_for_padding;
310                pad_left = pad_left.min(remaining);
311                remaining = remaining.saturating_sub(pad_left);
312                pad_right = pad_right.min(remaining);
313            }
314        }
315
316        // Content width (inside borders and padding)
317        let content_width = inner_width.saturating_sub(pad_left + pad_right);
318
319        let mut pad_top = self.padding.top;
320        let mut pad_bottom = self.padding.bottom;
321        let mut content_lines = self.content_lines.clone();
322
323        if let Some(height) = self.height {
324            let max_inner_lines = height.saturating_sub(2);
325            if content_lines.len() > max_inner_lines {
326                content_lines.truncate(max_inner_lines);
327                pad_top = 0;
328                pad_bottom = 0;
329            } else {
330                let remaining_after_content = max_inner_lines - content_lines.len();
331                if pad_top + pad_bottom > remaining_after_content {
332                    let mut remaining = remaining_after_content;
333                    pad_top = pad_top.min(remaining);
334                    remaining = remaining.saturating_sub(pad_top);
335                    pad_bottom = pad_bottom.min(remaining);
336                }
337
338                let max_content_lines = max_inner_lines.saturating_sub(pad_top + pad_bottom);
339                if content_lines.len() < max_content_lines {
340                    content_lines.extend(
341                        std::iter::repeat_with(Vec::new)
342                            .take(max_content_lines - content_lines.len()),
343                    );
344                }
345            }
346        }
347
348        let mut segments = Vec::new();
349
350        // Top border with optional title
351        segments.extend(self.render_top_border(box_chars, inner_width));
352        segments.push(Segment::line());
353
354        // Top padding
355        for _ in 0..pad_top {
356            segments.push(Segment::new(
357                box_chars.head[0].to_string(),
358                Some(self.border_style.clone()),
359            ));
360            segments.push(Segment::new(
361                " ".repeat(inner_width),
362                Some(self.style.clone()),
363            ));
364            segments.push(Segment::new(
365                box_chars.head[3].to_string(),
366                Some(self.border_style.clone()),
367            ));
368            segments.push(Segment::line());
369        }
370
371        // Content lines
372        let left_pad = " ".repeat(pad_left);
373        let right_pad = " ".repeat(pad_right);
374
375        for line in &content_lines {
376            // Left border
377            segments.push(Segment::new(
378                box_chars.head[0].to_string(),
379                Some(self.border_style.clone()),
380            ));
381
382            // Left padding
383            if pad_left > 0 {
384                segments.push(Segment::new(left_pad.clone(), Some(self.style.clone())));
385            }
386
387            // Content (truncate/pad to content width)
388            let mut content_segments: Vec<Segment<'a>> = line
389                .iter()
390                .cloned()
391                .map(|mut seg: Segment<'a>| {
392                    if !seg.is_control() {
393                        seg.style = Some(match seg.style.take() {
394                            Some(existing) => self.style.combine(&existing),
395                            None => self.style.clone(),
396                        });
397                    }
398                    seg
399                })
400                .collect();
401
402            content_segments = adjust_line_length(
403                content_segments,
404                content_width,
405                Some(self.style.clone()),
406                true,
407            );
408
409            segments.extend(content_segments);
410
411            // Right padding
412            if pad_right > 0 {
413                segments.push(Segment::new(right_pad.clone(), Some(self.style.clone())));
414            }
415
416            // Right border
417            segments.push(Segment::new(
418                box_chars.head[3].to_string(),
419                Some(self.border_style.clone()),
420            ));
421            segments.push(Segment::line());
422        }
423
424        // Bottom padding
425        for _ in 0..pad_bottom {
426            segments.push(Segment::new(
427                box_chars.head[0].to_string(),
428                Some(self.border_style.clone()),
429            ));
430            segments.push(Segment::new(
431                " ".repeat(inner_width),
432                Some(self.style.clone()),
433            ));
434            segments.push(Segment::new(
435                box_chars.head[3].to_string(),
436                Some(self.border_style.clone()),
437            ));
438            segments.push(Segment::line());
439        }
440
441        // Bottom border with optional subtitle
442        segments.extend(self.render_bottom_border(box_chars, inner_width));
443        segments.push(Segment::line());
444
445        segments
446    }
447
448    /// Render the top border with optional title.
449    fn render_top_border(&self, box_chars: &BoxChars, inner_width: usize) -> Vec<Segment<'a>> {
450        let border_style = Some(self.border_style.clone());
451        if let Some(title) = &self.title {
452            let mut segments = Vec::new();
453            let max_text_width = if inner_width >= 4 {
454                inner_width.saturating_sub(4)
455            } else {
456                inner_width.saturating_sub(2)
457            };
458            let title_text = if inner_width >= 2 {
459                if title.cell_len() > max_text_width {
460                    truncate_text_to_width(title, max_text_width)
461                } else {
462                    title.clone()
463                }
464            } else {
465                truncate_text_to_width(title, inner_width)
466            };
467
468            let title_width = title_text.cell_len();
469            if inner_width < 2 {
470                segments.push(Segment::new(
471                    box_chars.top[0].to_string(),
472                    border_style.clone(),
473                ));
474                segments.extend(
475                    title_text
476                        .render("")
477                        .into_iter()
478                        .map(super::super::segment::Segment::into_owned),
479                );
480                let remaining = inner_width.saturating_sub(title_width);
481                if remaining > 0 {
482                    segments.push(Segment::new(
483                        box_chars.top[1].to_string().repeat(remaining),
484                        border_style.clone(),
485                    ));
486                }
487                segments.push(Segment::new(
488                    box_chars.top[3].to_string(),
489                    border_style.clone(),
490                ));
491                return segments;
492            }
493
494            let title_total_width = title_width.saturating_add(2);
495            let available = inner_width.saturating_sub(title_total_width);
496            let (left_rule, right_rule) = if available == 0 {
497                (0, 0)
498            } else {
499                match self.title_align {
500                    JustifyMethod::Left | JustifyMethod::Default => {
501                        (1, available.saturating_sub(1))
502                    }
503                    JustifyMethod::Right => (available.saturating_sub(1), 1),
504                    JustifyMethod::Center | JustifyMethod::Full => {
505                        let left = available / 2;
506                        (left, available - left)
507                    }
508                }
509            };
510
511            let left_border = if left_rule > 0 {
512                format!("{}{}", box_chars.top[0], box_chars.top[1])
513            } else {
514                box_chars.top[0].to_string()
515            };
516            segments.push(Segment::new(left_border, border_style.clone()));
517            if left_rule > 1 {
518                segments.push(Segment::new(
519                    box_chars.top[1].to_string().repeat(left_rule - 1),
520                    border_style.clone(),
521                ));
522            }
523
524            // Keep title padding colored like the border to match Rich's ANSI output.
525            segments.push(Segment::new(" ", border_style.clone()));
526            segments.extend(
527                title_text
528                    .render("")
529                    .into_iter()
530                    .map(super::super::segment::Segment::into_owned),
531            );
532            segments.push(Segment::new(" ", border_style.clone()));
533
534            if right_rule > 1 {
535                segments.push(Segment::new(
536                    box_chars.top[1].to_string().repeat(right_rule - 1),
537                    border_style.clone(),
538                ));
539            }
540            let right_border = if right_rule > 0 {
541                format!("{}{}", box_chars.top[1], box_chars.top[3])
542            } else {
543                box_chars.top[3].to_string()
544            };
545            segments.push(Segment::new(right_border, border_style));
546            return segments;
547        }
548
549        vec![Segment::new(
550            format!(
551                "{}{}{}",
552                box_chars.top[0],
553                box_chars.top[1].to_string().repeat(inner_width),
554                box_chars.top[3]
555            ),
556            border_style,
557        )]
558    }
559
560    /// Render the bottom border with optional subtitle.
561    fn render_bottom_border(&self, box_chars: &BoxChars, inner_width: usize) -> Vec<Segment<'a>> {
562        let mut segments = Vec::new();
563
564        if let Some(subtitle) = &self.subtitle {
565            // Left corner
566            segments.push(Segment::new(
567                box_chars.bottom[0].to_string(),
568                Some(self.border_style.clone()),
569            ));
570            let max_text_width = if inner_width >= 4 {
571                inner_width.saturating_sub(4)
572            } else {
573                inner_width.saturating_sub(2)
574            };
575            let subtitle_text = if inner_width >= 2 {
576                if subtitle.cell_len() > max_text_width {
577                    truncate_text_to_width(subtitle, max_text_width)
578                } else {
579                    subtitle.clone()
580                }
581            } else {
582                truncate_text_to_width(subtitle, inner_width)
583            };
584
585            let subtitle_width = subtitle_text.cell_len();
586            if inner_width < 2 {
587                segments.extend(
588                    subtitle_text
589                        .render("")
590                        .into_iter()
591                        .map(super::super::segment::Segment::into_owned),
592                );
593                let remaining = inner_width.saturating_sub(subtitle_width);
594                if remaining > 0 {
595                    segments.push(Segment::new(
596                        box_chars.bottom[1].to_string().repeat(remaining),
597                        Some(self.border_style.clone()),
598                    ));
599                }
600            } else {
601                let subtitle_total_width = subtitle_width.saturating_add(2);
602                let available = inner_width.saturating_sub(subtitle_total_width);
603                let (left_rule, right_rule) = if available == 0 {
604                    (0, 0)
605                } else {
606                    match self.subtitle_align {
607                        JustifyMethod::Left | JustifyMethod::Default => {
608                            (1, available.saturating_sub(1))
609                        }
610                        JustifyMethod::Right => (available.saturating_sub(1), 1),
611                        JustifyMethod::Center | JustifyMethod::Full => {
612                            let left = available / 2;
613                            (left, available - left)
614                        }
615                    }
616                };
617
618                if left_rule > 0 {
619                    segments.push(Segment::new(
620                        box_chars.bottom[1].to_string().repeat(left_rule),
621                        Some(self.border_style.clone()),
622                    ));
623                }
624
625                segments.push(Segment::new(" ", Some(subtitle_text.style().clone())));
626                segments.extend(
627                    subtitle_text
628                        .render("")
629                        .into_iter()
630                        .map(super::super::segment::Segment::into_owned),
631                );
632                segments.push(Segment::new(" ", Some(subtitle_text.style().clone())));
633
634                if right_rule > 0 {
635                    segments.push(Segment::new(
636                        box_chars.bottom[1].to_string().repeat(right_rule),
637                        Some(self.border_style.clone()),
638                    ));
639                }
640            }
641        } else {
642            // No subtitle: render as one border segment to preserve ANSI continuity.
643            return vec![Segment::new(
644                format!(
645                    "{}{}{}",
646                    box_chars.bottom[0],
647                    box_chars.bottom[1].to_string().repeat(inner_width),
648                    box_chars.bottom[3]
649                ),
650                Some(self.border_style.clone()),
651            )];
652        }
653
654        // Right corner (subtitle case)
655        segments.push(Segment::new(
656            box_chars.bottom[3].to_string(),
657            Some(self.border_style.clone()),
658        ));
659
660        segments
661    }
662
663    /// Render to plain text.
664    #[must_use]
665    pub fn render_plain(&self, max_width: usize) -> String {
666        self.render(max_width)
667            .into_iter()
668            .map(|seg| seg.text.into_owned())
669            .collect()
670    }
671}
672
673impl Renderable for Panel<'_> {
674    fn render<'b>(&'b self, console: &Console, options: &ConsoleOptions) -> Vec<Segment<'b>> {
675        if self.safe_box.is_some() {
676            return self.render(options.max_width).into_iter().collect();
677        }
678
679        // Inherit the Console's safe_box setting unless explicitly overridden.
680        let effective = self.clone().safe_box(console.safe_box());
681        effective.render(options.max_width).into_iter().collect()
682    }
683}
684
685/// Truncate a Text object to a maximum cell width with ellipsis.
686fn truncate_text_to_width(text: &Text, max_width: usize) -> Text {
687    let mut truncated = text.clone();
688    truncated.truncate(max_width, OverflowMethod::Ellipsis, false);
689    truncated
690}
691
692/// Create a panel with content that fits (doesn't expand).
693#[must_use]
694pub fn fit_panel(text: &str) -> Panel<'_> {
695    Panel::from_text(text).expand(false)
696}
697
698#[cfg(test)]
699mod tests {
700    use super::*;
701    use crate::segment::split_lines;
702    use crate::style::Attributes;
703
704    #[test]
705    fn test_panel_from_text() {
706        let panel = Panel::from_text("Hello\nWorld");
707        assert_eq!(panel.content_lines.len(), 2);
708    }
709
710    #[test]
711    fn test_panel_render() {
712        let panel = Panel::from_text("Hello").width(20);
713        let segments = panel.render(80);
714        assert!(!segments.is_empty());
715
716        let text: String = segments.iter().map(|s| s.text.as_ref()).collect();
717        assert!(text.contains("Hello"));
718        // Should have rounded corners by default
719        assert!(text.contains('\u{256D}')); // ╭
720    }
721
722    #[test]
723    fn test_panel_with_title() {
724        let panel = Panel::from_text("Content").title("Title").width(30);
725        let text = panel.render_plain(80);
726        assert!(text.contains("Title"));
727        assert!(text.contains("Content"));
728    }
729
730    #[test]
731    fn test_panel_ascii() {
732        let panel = Panel::from_text("Hello").ascii().width(20);
733        let text = panel.render_plain(80);
734        assert!(text.contains('+')); // ASCII corners
735        assert!(text.contains('-')); // ASCII horizontal
736    }
737
738    #[test]
739    fn test_panel_square() {
740        let panel = Panel::from_text("Hello").square().width(20);
741        let text = panel.render_plain(80);
742        assert!(text.contains('\u{250C}')); // ┌
743    }
744
745    #[test]
746    fn test_panel_padding() {
747        let panel = Panel::from_text("Hi").padding((1, 2)).width(20);
748        let segments = panel.render(80);
749        // Count newlines to verify padding
750        let newlines = segments.iter().filter(|s| s.text == "\n").count();
751        // Should have: top border, 1 top pad, content, 1 bottom pad, bottom border
752        assert!(newlines >= 5);
753    }
754
755    #[test]
756    fn test_panel_subtitle() {
757        let panel = Panel::from_text("Content").subtitle("Footer").width(30);
758        let text = panel.render_plain(80);
759        assert!(text.contains("Footer"));
760    }
761
762    #[test]
763    fn test_panel_truncates_to_width() {
764        let panel = Panel::from_text("This is a very long line")
765            .width(10)
766            .padding(0);
767
768        let segments = panel.render(10);
769        let lines = split_lines(segments.into_iter());
770
771        for line in lines {
772            let width: usize = line.iter().map(Segment::cell_length).sum();
773            if width > 0 {
774                assert_eq!(width, 10);
775            }
776        }
777    }
778
779    #[test]
780    fn test_panel_height_limits_content_lines() {
781        let panel = Panel::from_text("A\nB\nC").height(4).padding(0).width(10);
782
783        let segments = panel.render(10);
784        let lines = split_lines(segments.into_iter());
785        let non_empty_lines = lines
786            .iter()
787            .filter(|line| line.iter().map(Segment::cell_length).sum::<usize>() > 0)
788            .count();
789
790        assert_eq!(non_empty_lines, 4);
791        let text: String = lines
792            .iter()
793            .map(|line| line.iter().map(|seg| seg.text.as_ref()).collect::<String>())
794            .collect();
795        assert!(!text.contains('C'));
796    }
797
798    #[test]
799    fn test_panel_height_pads_content_lines() {
800        let panel = Panel::from_text("A").height(5).padding(0).width(10);
801
802        let segments = panel.render(10);
803        let lines = split_lines(segments.into_iter());
804        let non_empty_lines = lines
805            .iter()
806            .filter(|line| line.iter().map(Segment::cell_length).sum::<usize>() > 0)
807            .count();
808
809        assert_eq!(non_empty_lines, 5);
810    }
811
812    #[test]
813    fn test_panel_height_prefers_content_over_padding() {
814        let panel = Panel::from_text("A").height(4).padding((2, 0)).width(10);
815
816        let segments = panel.render(10);
817        let lines = split_lines(segments.into_iter());
818        let non_empty_lines = lines
819            .iter()
820            .filter(|line| line.iter().map(Segment::cell_length).sum::<usize>() > 0)
821            .count();
822
823        assert_eq!(non_empty_lines, 4);
824        let text: String = lines
825            .iter()
826            .map(|line| line.iter().map(|seg| seg.text.as_ref()).collect::<String>())
827            .collect();
828        assert!(text.contains('A'));
829    }
830
831    #[test]
832    fn test_fit_panel() {
833        let panel = fit_panel("Short");
834        assert!(!panel.expand);
835    }
836
837    #[test]
838    fn test_truncate_text_to_width() {
839        let text = Text::new("Hello World");
840        let truncated = truncate_text_to_width(&text, 5);
841        assert_eq!(truncated.plain(), "He...");
842    }
843
844    #[test]
845    fn test_panel_title_preserves_spans() {
846        let mut title = Text::new("AB");
847        title.stylize(0, 1, Style::new().italic());
848
849        let panel = Panel::from_text("Content").title(title).width(20);
850        let segments = panel.render(20);
851        let title_segment = segments
852            .iter()
853            .find(|seg| seg.text.contains('A'))
854            .expect("expected title segment");
855        let style = title_segment
856            .style
857            .as_ref()
858            .expect("expected styled segment");
859        assert!(style.attributes.contains(Attributes::ITALIC));
860    }
861
862    #[test]
863    fn test_panel_title_from_markup() {
864        let panel = Panel::from_text("Content")
865            .title_from_markup("[bold]Styled Title[/]")
866            .width(30);
867        let segments = panel.render(30);
868
869        // Find the title segment and verify it has bold style
870        let title_segment = segments
871            .iter()
872            .find(|seg| seg.text.contains("Styled Title"))
873            .expect("expected title segment with styled text");
874        let style = title_segment
875            .style
876            .as_ref()
877            .expect("expected styled segment");
878        assert!(
879            style.attributes.contains(Attributes::BOLD),
880            "title should be bold"
881        );
882    }
883
884    #[test]
885    fn test_panel_subtitle_from_markup() {
886        let panel = Panel::from_text("Content")
887            .subtitle_from_markup("[italic]Footer[/]")
888            .width(30);
889        let segments = panel.render(30);
890
891        // Find the subtitle segment and verify it has italic style
892        let footer_segment = segments
893            .iter()
894            .find(|seg| seg.text.contains("Footer"))
895            .expect("expected subtitle segment with styled text");
896        let style = footer_segment
897            .style
898            .as_ref()
899            .expect("expected styled segment");
900        assert!(
901            style.attributes.contains(Attributes::ITALIC),
902            "subtitle should be italic"
903        );
904    }
905
906    #[test]
907    fn test_panel_title_from_markup_no_markup() {
908        // When there's no markup, text should still render correctly
909        let panel = Panel::from_text("Content")
910            .title_from_markup("Plain Title")
911            .width(30);
912        let text = panel.render_plain(30);
913        assert!(text.contains("Plain Title"));
914    }
915}