Skip to main content

ftui_widgets/
panel.rs

1#![forbid(unsafe_code)]
2
3//! Panel widget: border + optional title/subtitle + inner padding + child content.
4
5use crate::block::Alignment;
6use crate::borders::{BorderSet, BorderType, Borders};
7use crate::{Widget, apply_style, draw_text_span, set_style_area};
8use ftui_core::geometry::{Rect, Sides};
9use ftui_render::buffer::Buffer;
10use ftui_render::cell::Cell;
11use ftui_render::frame::Frame;
12use ftui_style::Style;
13use ftui_text::{display_width, grapheme_width};
14use unicode_segmentation::UnicodeSegmentation;
15
16/// A bordered container that renders a child widget inside an inner padded area.
17#[derive(Debug, Clone)]
18pub struct Panel<'a, W> {
19    child: W,
20    borders: Borders,
21    border_style: Style,
22    border_type: BorderType,
23    title: Option<&'a str>,
24    title_alignment: Alignment,
25    title_style: Style,
26    subtitle: Option<&'a str>,
27    subtitle_alignment: Alignment,
28    subtitle_style: Style,
29    style: Style,
30    padding: Sides,
31}
32
33impl<'a, W> Panel<'a, W> {
34    /// Create a new panel wrapping the given child widget.
35    pub fn new(child: W) -> Self {
36        Self {
37            child,
38            borders: Borders::ALL,
39            border_style: Style::default(),
40            border_type: BorderType::Square,
41            title: None,
42            title_alignment: Alignment::Left,
43            title_style: Style::default(),
44            subtitle: None,
45            subtitle_alignment: Alignment::Left,
46            subtitle_style: Style::default(),
47            style: Style::default(),
48            padding: Sides::default(),
49        }
50    }
51
52    /// Set which borders to draw.
53    #[must_use]
54    pub fn borders(mut self, borders: Borders) -> Self {
55        self.borders = borders;
56        self
57    }
58
59    /// Set the style applied to the border lines.
60    #[must_use]
61    pub fn border_style(mut self, style: Style) -> Self {
62        self.border_style = style;
63        self
64    }
65
66    /// Set the border line type (e.g. square, rounded, double).
67    #[must_use]
68    pub fn border_type(mut self, border_type: BorderType) -> Self {
69        self.border_type = border_type;
70        self
71    }
72
73    /// Set the title text displayed on the top border.
74    #[must_use]
75    pub fn title(mut self, title: &'a str) -> Self {
76        self.title = Some(title);
77        self
78    }
79
80    /// Set the horizontal alignment of the title.
81    #[must_use]
82    pub fn title_alignment(mut self, alignment: Alignment) -> Self {
83        self.title_alignment = alignment;
84        self
85    }
86
87    /// Set the style applied to the title text.
88    #[must_use]
89    pub fn title_style(mut self, style: Style) -> Self {
90        self.title_style = style;
91        self
92    }
93
94    /// Set the subtitle text displayed on the bottom border.
95    #[must_use]
96    pub fn subtitle(mut self, subtitle: &'a str) -> Self {
97        self.subtitle = Some(subtitle);
98        self
99    }
100
101    /// Set the horizontal alignment of the subtitle.
102    #[must_use]
103    pub fn subtitle_alignment(mut self, alignment: Alignment) -> Self {
104        self.subtitle_alignment = alignment;
105        self
106    }
107
108    /// Set the style applied to the subtitle text.
109    #[must_use]
110    pub fn subtitle_style(mut self, style: Style) -> Self {
111        self.subtitle_style = style;
112        self
113    }
114
115    /// Set the base style for the entire panel area.
116    #[must_use]
117    pub fn style(mut self, style: Style) -> Self {
118        self.style = style;
119        self
120    }
121
122    /// Set inner padding between the border and child content.
123    #[must_use]
124    pub fn padding(mut self, padding: impl Into<Sides>) -> Self {
125        self.padding = padding.into();
126        self
127    }
128
129    /// Compute the inner area inside the panel borders.
130    pub fn inner(&self, area: Rect) -> Rect {
131        let mut inner = area;
132
133        if self.borders.contains(Borders::LEFT) {
134            inner.x = inner.x.saturating_add(1);
135            inner.width = inner.width.saturating_sub(1);
136        }
137        if self.borders.contains(Borders::TOP) {
138            inner.y = inner.y.saturating_add(1);
139            inner.height = inner.height.saturating_sub(1);
140        }
141        if self.borders.contains(Borders::RIGHT) {
142            inner.width = inner.width.saturating_sub(1);
143        }
144        if self.borders.contains(Borders::BOTTOM) {
145            inner.height = inner.height.saturating_sub(1);
146        }
147
148        inner
149    }
150
151    fn border_cell(&self, c: char) -> Cell {
152        let mut cell = Cell::from_char(c);
153        apply_style(&mut cell, self.border_style);
154        cell
155    }
156
157    fn pick_border_set(&self, buf: &Buffer) -> BorderSet {
158        let deg = buf.degradation;
159        if !deg.use_unicode_borders() {
160            return BorderSet::ASCII;
161        }
162        self.border_type.to_border_set()
163    }
164
165    fn render_borders(&self, area: Rect, buf: &mut Buffer, set: BorderSet) {
166        if area.is_empty() {
167            return;
168        }
169
170        // Edges
171        if self.borders.contains(Borders::LEFT) {
172            for y in area.y..area.bottom() {
173                buf.set_fast(area.x, y, self.border_cell(set.vertical));
174            }
175        }
176        if self.borders.contains(Borders::RIGHT) {
177            let x = area.right() - 1;
178            for y in area.y..area.bottom() {
179                buf.set_fast(x, y, self.border_cell(set.vertical));
180            }
181        }
182        if self.borders.contains(Borders::TOP) {
183            for x in area.x..area.right() {
184                buf.set_fast(x, area.y, self.border_cell(set.horizontal));
185            }
186        }
187        if self.borders.contains(Borders::BOTTOM) {
188            let y = area.bottom().saturating_sub(1);
189            for x in area.x..area.right() {
190                buf.set_fast(x, y, self.border_cell(set.horizontal));
191            }
192        }
193
194        // Corners (drawn after edges)
195        if self.borders.contains(Borders::LEFT | Borders::TOP) {
196            buf.set_fast(area.x, area.y, self.border_cell(set.top_left));
197        }
198        if self.borders.contains(Borders::RIGHT | Borders::TOP) {
199            buf.set_fast(area.right() - 1, area.y, self.border_cell(set.top_right));
200        }
201        if self.borders.contains(Borders::LEFT | Borders::BOTTOM) {
202            buf.set_fast(area.x, area.bottom() - 1, self.border_cell(set.bottom_left));
203        }
204        if self.borders.contains(Borders::RIGHT | Borders::BOTTOM) {
205            buf.set_fast(
206                area.right() - 1,
207                area.bottom() - 1,
208                self.border_cell(set.bottom_right),
209            );
210        }
211    }
212
213    fn ellipsize<'s>(&self, s: &'s str, max_width: usize) -> std::borrow::Cow<'s, str> {
214        let total = display_width(s);
215        if total <= max_width {
216            return std::borrow::Cow::Borrowed(s);
217        }
218        if max_width == 0 {
219            return std::borrow::Cow::Borrowed("");
220        }
221
222        // Use a single-cell ellipsis.
223        if max_width == 1 {
224            return std::borrow::Cow::Borrowed("…");
225        }
226
227        let mut out = String::new();
228        let mut used = 0usize;
229        let target = max_width - 1;
230
231        for g in s.graphemes(true) {
232            let w = grapheme_width(g);
233            if w == 0 {
234                continue;
235            }
236            if used + w > target {
237                break;
238            }
239            out.push_str(g);
240            used += w;
241        }
242
243        out.push('…');
244        std::borrow::Cow::Owned(out)
245    }
246
247    fn render_top_text(
248        &self,
249        area: Rect,
250        frame: &mut Frame,
251        text: &str,
252        alignment: Alignment,
253        style: Style,
254    ) {
255        if area.width < 2 {
256            return;
257        }
258
259        let available_width = area.width.saturating_sub(2) as usize;
260        let text = self.ellipsize(text, available_width);
261        let text_width = display_width(text.as_ref()).min(available_width);
262
263        let x = match alignment {
264            Alignment::Left => area.x.saturating_add(1),
265            Alignment::Center => area
266                .x
267                .saturating_add(1)
268                .saturating_add(((available_width.saturating_sub(text_width)) / 2) as u16),
269            Alignment::Right => area
270                .right()
271                .saturating_sub(1)
272                .saturating_sub(text_width as u16),
273        };
274
275        let max_x = area.right().saturating_sub(1);
276        draw_text_span(frame, x, area.y, text.as_ref(), style, max_x);
277    }
278
279    fn render_bottom_text(
280        &self,
281        area: Rect,
282        frame: &mut Frame,
283        text: &str,
284        alignment: Alignment,
285        style: Style,
286    ) {
287        if area.height < 1 || area.width < 2 {
288            return;
289        }
290
291        let available_width = area.width.saturating_sub(2) as usize;
292        let text = self.ellipsize(text, available_width);
293        let text_width = display_width(text.as_ref()).min(available_width);
294
295        let x = match alignment {
296            Alignment::Left => area.x.saturating_add(1),
297            Alignment::Center => area
298                .x
299                .saturating_add(1)
300                .saturating_add(((available_width.saturating_sub(text_width)) / 2) as u16),
301            Alignment::Right => area
302                .right()
303                .saturating_sub(1)
304                .saturating_sub(text_width as u16),
305        };
306
307        let y = area.bottom().saturating_sub(1);
308        let max_x = area.right().saturating_sub(1);
309        draw_text_span(frame, x, y, text.as_ref(), style, max_x);
310    }
311}
312
313struct ScissorGuard<'a, 'pool> {
314    frame: &'a mut Frame<'pool>,
315}
316
317impl<'a, 'pool> ScissorGuard<'a, 'pool> {
318    fn new(frame: &'a mut Frame<'pool>, rect: Rect) -> Self {
319        frame.buffer.push_scissor(rect);
320        Self { frame }
321    }
322}
323
324impl Drop for ScissorGuard<'_, '_> {
325    fn drop(&mut self) {
326        self.frame.buffer.pop_scissor();
327    }
328}
329
330impl<W: Widget> Widget for Panel<'_, W> {
331    fn render(&self, area: Rect, frame: &mut Frame) {
332        #[cfg(feature = "tracing")]
333        let _span = tracing::debug_span!(
334            "widget_render",
335            widget = "Panel",
336            x = area.x,
337            y = area.y,
338            w = area.width,
339            h = area.height
340        )
341        .entered();
342
343        if area.is_empty() {
344            return;
345        }
346
347        let deg = frame.buffer.degradation;
348
349        // Skeleton+: skip everything, just clear area
350        if !deg.render_content() {
351            frame.buffer.fill(area, Cell::default());
352            return;
353        }
354
355        // Background/style
356        if deg.apply_styling() {
357            set_style_area(&mut frame.buffer, area, self.style);
358        }
359
360        // Decorative layer: borders + title/subtitle
361        if deg.render_decorative() {
362            let set = self.pick_border_set(&frame.buffer);
363            self.render_borders(area, &mut frame.buffer, set);
364
365            if self.borders.contains(Borders::TOP)
366                && let Some(title) = self.title
367            {
368                let title_style = if deg.apply_styling() {
369                    self.title_style.merge(&self.border_style)
370                } else {
371                    Style::default()
372                };
373                self.render_top_text(area, frame, title, self.title_alignment, title_style);
374            }
375
376            if self.borders.contains(Borders::BOTTOM)
377                && let Some(subtitle) = self.subtitle
378            {
379                let subtitle_style = if deg.apply_styling() {
380                    self.subtitle_style.merge(&self.border_style)
381                } else {
382                    Style::default()
383                };
384                self.render_bottom_text(
385                    area,
386                    frame,
387                    subtitle,
388                    self.subtitle_alignment,
389                    subtitle_style,
390                );
391            }
392        }
393
394        // Content
395        let mut content_area = self.inner(area);
396        content_area = content_area.inner(self.padding);
397        if content_area.is_empty() {
398            return;
399        }
400
401        let guard = ScissorGuard::new(frame, content_area);
402        self.child.render(content_area, guard.frame);
403    }
404
405    fn is_essential(&self) -> bool {
406        self.child.is_essential()
407    }
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413    use ftui_render::frame::Frame;
414    use ftui_render::grapheme_pool::GraphemePool;
415
416    fn panel_stub() -> Panel<'static, crate::block::Block<'static>> {
417        Panel::new(crate::block::Block::default())
418    }
419
420    fn cell_char(frame: &Frame, x: u16, y: u16) -> Option<char> {
421        frame.buffer.get(x, y).and_then(|c| c.content.as_char())
422    }
423
424    // --- ellipsize tests ---
425
426    #[test]
427    fn ellipsize_short_is_borrowed() {
428        let panel = panel_stub();
429        let out = panel.ellipsize("abc", 3);
430        assert!(matches!(out, std::borrow::Cow::Borrowed(_)));
431        assert_eq!(out, "abc");
432    }
433
434    #[test]
435    fn ellipsize_truncates_with_ellipsis() {
436        let panel = panel_stub();
437        let out = panel.ellipsize("abcdef", 4);
438        assert_eq!(out, "abc…");
439    }
440
441    #[test]
442    fn ellipsize_zero_width_returns_empty() {
443        let panel = panel_stub();
444        let out = panel.ellipsize("abc", 0);
445        assert_eq!(out, "");
446    }
447
448    #[test]
449    fn ellipsize_width_one_returns_ellipsis() {
450        let panel = panel_stub();
451        let out = panel.ellipsize("abc", 1);
452        assert_eq!(out, "…");
453    }
454
455    #[test]
456    fn ellipsize_exact_fit_is_borrowed() {
457        let panel = panel_stub();
458        let out = panel.ellipsize("hello", 5);
459        assert!(matches!(out, std::borrow::Cow::Borrowed(_)));
460        assert_eq!(out, "hello");
461    }
462
463    #[test]
464    fn ellipsize_one_over_truncates() {
465        let panel = panel_stub();
466        let out = panel.ellipsize("hello", 4);
467        assert_eq!(out, "hel…");
468    }
469
470    // --- inner() calculation tests ---
471
472    #[test]
473    fn inner_all_borders() {
474        let panel = panel_stub().borders(Borders::ALL);
475        let area = Rect::new(0, 0, 10, 10);
476        assert_eq!(panel.inner(area), Rect::new(1, 1, 8, 8));
477    }
478
479    #[test]
480    fn inner_no_borders() {
481        let panel = panel_stub().borders(Borders::NONE);
482        let area = Rect::new(0, 0, 10, 10);
483        assert_eq!(panel.inner(area), area);
484    }
485
486    #[test]
487    fn inner_top_and_left_only() {
488        let panel = panel_stub().borders(Borders::TOP | Borders::LEFT);
489        let area = Rect::new(0, 0, 10, 10);
490        assert_eq!(panel.inner(area), Rect::new(1, 1, 9, 9));
491    }
492
493    #[test]
494    fn inner_right_and_bottom_only() {
495        let panel = panel_stub().borders(Borders::RIGHT | Borders::BOTTOM);
496        let area = Rect::new(0, 0, 10, 10);
497        assert_eq!(panel.inner(area), Rect::new(0, 0, 9, 9));
498    }
499
500    #[test]
501    fn inner_with_offset_area() {
502        let panel = panel_stub().borders(Borders::ALL);
503        let area = Rect::new(5, 3, 10, 8);
504        assert_eq!(panel.inner(area), Rect::new(6, 4, 8, 6));
505    }
506
507    #[test]
508    fn inner_zero_size_saturates() {
509        let panel = panel_stub().borders(Borders::ALL);
510        let area = Rect::new(0, 0, 1, 1);
511        let inner = panel.inner(area);
512        assert_eq!(inner.width, 0);
513        assert_eq!(inner.height, 0);
514    }
515
516    // --- render border tests ---
517
518    #[test]
519    fn render_borders_square() {
520        let child = crate::block::Block::default();
521        let panel = Panel::new(child)
522            .borders(Borders::ALL)
523            .border_type(BorderType::Square);
524        let area = Rect::new(0, 0, 5, 3);
525        let mut pool = GraphemePool::new();
526        let mut frame = Frame::new(5, 3, &mut pool);
527
528        panel.render(area, &mut frame);
529
530        assert_eq!(cell_char(&frame, 0, 0), Some('┌'));
531        assert_eq!(cell_char(&frame, 4, 0), Some('┐'));
532        assert_eq!(cell_char(&frame, 0, 2), Some('└'));
533        assert_eq!(cell_char(&frame, 4, 2), Some('┘'));
534        assert_eq!(cell_char(&frame, 2, 0), Some('─'));
535        assert_eq!(cell_char(&frame, 0, 1), Some('│'));
536    }
537
538    #[test]
539    fn render_borders_rounded() {
540        let child = crate::block::Block::default();
541        let panel = Panel::new(child)
542            .borders(Borders::ALL)
543            .border_type(BorderType::Rounded);
544        let area = Rect::new(0, 0, 5, 3);
545        let mut pool = GraphemePool::new();
546        let mut frame = Frame::new(5, 3, &mut pool);
547
548        panel.render(area, &mut frame);
549
550        assert_eq!(cell_char(&frame, 0, 0), Some('╭'));
551        assert_eq!(cell_char(&frame, 4, 0), Some('╮'));
552        assert_eq!(cell_char(&frame, 0, 2), Some('╰'));
553        assert_eq!(cell_char(&frame, 4, 2), Some('╯'));
554    }
555
556    #[test]
557    fn render_empty_area_does_not_panic() {
558        let panel = panel_stub().borders(Borders::ALL);
559        let area = Rect::new(0, 0, 0, 0);
560        let mut pool = GraphemePool::new();
561        let mut frame = Frame::new(1, 1, &mut pool);
562        panel.render(area, &mut frame);
563    }
564
565    // --- title rendering tests ---
566
567    #[test]
568    fn render_title_left_aligned() {
569        let child = crate::block::Block::default();
570        let panel = Panel::new(child)
571            .borders(Borders::ALL)
572            .border_type(BorderType::Square)
573            .title("Hi")
574            .title_alignment(Alignment::Left);
575        let area = Rect::new(0, 0, 10, 3);
576        let mut pool = GraphemePool::new();
577        let mut frame = Frame::new(10, 3, &mut pool);
578
579        panel.render(area, &mut frame);
580
581        // Title starts at x=1 (after left border)
582        assert_eq!(cell_char(&frame, 1, 0), Some('H'));
583        assert_eq!(cell_char(&frame, 2, 0), Some('i'));
584    }
585
586    #[test]
587    fn render_title_right_aligned() {
588        let child = crate::block::Block::default();
589        let panel = Panel::new(child)
590            .borders(Borders::ALL)
591            .border_type(BorderType::Square)
592            .title("Hi")
593            .title_alignment(Alignment::Right);
594        let area = Rect::new(0, 0, 10, 3);
595        let mut pool = GraphemePool::new();
596        let mut frame = Frame::new(10, 3, &mut pool);
597
598        panel.render(area, &mut frame);
599
600        // "Hi" is 2 chars, right edge is 9, so title at 9-1-2=6..8
601        // right() = 10, sub 1 = 9, sub 2 = 7
602        assert_eq!(cell_char(&frame, 7, 0), Some('H'));
603        assert_eq!(cell_char(&frame, 8, 0), Some('i'));
604    }
605
606    #[test]
607    fn render_title_center_aligned() {
608        let child = crate::block::Block::default();
609        let panel = Panel::new(child)
610            .borders(Borders::ALL)
611            .border_type(BorderType::Square)
612            .title("AB")
613            .title_alignment(Alignment::Center);
614        let area = Rect::new(0, 0, 10, 3);
615        let mut pool = GraphemePool::new();
616        let mut frame = Frame::new(10, 3, &mut pool);
617
618        panel.render(area, &mut frame);
619
620        // available_width = 10-2 = 8, display_width = 2
621        // x = 0 + 1 + (8-2)/2 = 1 + 3 = 4
622        assert_eq!(cell_char(&frame, 4, 0), Some('A'));
623        assert_eq!(cell_char(&frame, 5, 0), Some('B'));
624    }
625
626    #[test]
627    fn render_title_no_top_border_skips_title() {
628        let child = crate::block::Block::default();
629        let panel = Panel::new(child)
630            .borders(Borders::LEFT | Borders::RIGHT | Borders::BOTTOM)
631            .title("Hi");
632        let area = Rect::new(0, 0, 10, 3);
633        let mut pool = GraphemePool::new();
634        let mut frame = Frame::new(10, 3, &mut pool);
635
636        panel.render(area, &mut frame);
637
638        // Title should NOT appear on row 0 since no top border
639        assert_ne!(cell_char(&frame, 1, 0), Some('H'));
640    }
641
642    #[test]
643    fn render_title_truncated_with_ellipsis() {
644        let child = crate::block::Block::default();
645        let panel = Panel::new(child)
646            .borders(Borders::ALL)
647            .border_type(BorderType::Square)
648            .title("LongTitle")
649            .title_alignment(Alignment::Left);
650        // Width 6: available = 6-2 = 4, "LongTitle" (9 chars) -> "Lon…"
651        let area = Rect::new(0, 0, 6, 3);
652        let mut pool = GraphemePool::new();
653        let mut frame = Frame::new(6, 3, &mut pool);
654
655        panel.render(area, &mut frame);
656
657        assert_eq!(cell_char(&frame, 1, 0), Some('L'));
658        assert_eq!(cell_char(&frame, 2, 0), Some('o'));
659        assert_eq!(cell_char(&frame, 3, 0), Some('n'));
660        assert_eq!(cell_char(&frame, 4, 0), Some('…'));
661    }
662
663    // --- subtitle rendering tests ---
664
665    #[test]
666    fn render_subtitle_left_aligned() {
667        let child = crate::block::Block::default();
668        let panel = Panel::new(child)
669            .borders(Borders::ALL)
670            .border_type(BorderType::Square)
671            .subtitle("Lo")
672            .subtitle_alignment(Alignment::Left);
673        let area = Rect::new(0, 0, 10, 3);
674        let mut pool = GraphemePool::new();
675        let mut frame = Frame::new(10, 3, &mut pool);
676
677        panel.render(area, &mut frame);
678
679        // Subtitle on bottom row (y=2), starting at x=1
680        assert_eq!(cell_char(&frame, 1, 2), Some('L'));
681        assert_eq!(cell_char(&frame, 2, 2), Some('o'));
682    }
683
684    #[test]
685    fn render_subtitle_no_bottom_border_skips() {
686        let child = crate::block::Block::default();
687        let panel = Panel::new(child)
688            .borders(Borders::TOP | Borders::LEFT | Borders::RIGHT)
689            .subtitle("Lo");
690        let area = Rect::new(0, 0, 10, 3);
691        let mut pool = GraphemePool::new();
692        let mut frame = Frame::new(10, 3, &mut pool);
693
694        panel.render(area, &mut frame);
695
696        // Subtitle should not appear since no bottom border
697        assert_ne!(cell_char(&frame, 1, 2), Some('L'));
698    }
699
700    // --- padding tests ---
701
702    #[test]
703    fn inner_with_padding_reduces_area() {
704        let panel = panel_stub().borders(Borders::ALL).padding(Sides::all(1));
705        let area = Rect::new(0, 0, 10, 10);
706        // inner from borders = (1,1,8,8), then padding of 1 on each side = (2,2,6,6)
707        let inner_from_borders = panel.inner(area);
708        let padded = inner_from_borders.inner(Sides::all(1));
709        assert_eq!(padded, Rect::new(2, 2, 6, 6));
710    }
711
712    // --- child rendering tests ---
713
714    /// A simple test widget that writes 'X' at (0,0) relative to its area.
715    struct MarkerWidget;
716
717    impl Widget for MarkerWidget {
718        fn render(&self, area: Rect, frame: &mut Frame) {
719            if !area.is_empty() {
720                let mut cell = Cell::from_char('X');
721                apply_style(&mut cell, Style::default());
722                frame.buffer.set(area.x, area.y, cell);
723            }
724        }
725    }
726
727    #[test]
728    fn child_rendered_inside_borders() {
729        let panel = Panel::new(MarkerWidget).borders(Borders::ALL);
730        let area = Rect::new(0, 0, 5, 5);
731        let mut pool = GraphemePool::new();
732        let mut frame = Frame::new(5, 5, &mut pool);
733
734        panel.render(area, &mut frame);
735
736        // Child area starts at (1,1) for ALL borders
737        assert_eq!(cell_char(&frame, 1, 1), Some('X'));
738    }
739
740    #[test]
741    fn child_rendered_with_padding_offset() {
742        let panel = Panel::new(MarkerWidget)
743            .borders(Borders::ALL)
744            .padding(Sides::new(1, 1, 0, 1));
745        let area = Rect::new(0, 0, 10, 10);
746        let mut pool = GraphemePool::new();
747        let mut frame = Frame::new(10, 10, &mut pool);
748
749        panel.render(area, &mut frame);
750
751        // borders inner = (1,1,8,8), padding top=1 left=1 -> child at (2,2)
752        assert_eq!(cell_char(&frame, 2, 2), Some('X'));
753    }
754
755    #[test]
756    fn child_not_rendered_when_padding_consumes_all_space() {
757        let panel = Panel::new(MarkerWidget)
758            .borders(Borders::ALL)
759            .padding(Sides::all(10));
760        let area = Rect::new(0, 0, 5, 5);
761        let mut pool = GraphemePool::new();
762        let mut frame = Frame::new(5, 5, &mut pool);
763
764        // Should not panic even though padding exceeds available space
765        panel.render(area, &mut frame);
766    }
767
768    // --- builder chain test ---
769
770    #[test]
771    fn builder_chain_compiles() {
772        let _panel = Panel::new(crate::block::Block::default())
773            .borders(Borders::ALL)
774            .border_type(BorderType::Double)
775            .border_style(Style::new().bold())
776            .title("Title")
777            .title_alignment(Alignment::Center)
778            .title_style(Style::new().italic())
779            .subtitle("Sub")
780            .subtitle_alignment(Alignment::Right)
781            .subtitle_style(Style::new())
782            .style(Style::new())
783            .padding(Sides::all(1));
784    }
785}