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, style: Style) -> Cell {
152        let mut cell = Cell::from_char(c);
153        apply_style(&mut cell, 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, style: Style) {
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, style));
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, style));
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, style));
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, style));
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, style));
197        }
198        if self.borders.contains(Borders::RIGHT | Borders::TOP) {
199            buf.set_fast(
200                area.right() - 1,
201                area.y,
202                self.border_cell(set.top_right, style),
203            );
204        }
205        if self.borders.contains(Borders::LEFT | Borders::BOTTOM) {
206            buf.set_fast(
207                area.x,
208                area.bottom() - 1,
209                self.border_cell(set.bottom_left, style),
210            );
211        }
212        if self.borders.contains(Borders::RIGHT | Borders::BOTTOM) {
213            buf.set_fast(
214                area.right() - 1,
215                area.bottom() - 1,
216                self.border_cell(set.bottom_right, style),
217            );
218        }
219    }
220
221    fn ellipsize<'s>(&self, s: &'s str, max_width: usize) -> std::borrow::Cow<'s, str> {
222        let total = display_width(s);
223        if total <= max_width {
224            return std::borrow::Cow::Borrowed(s);
225        }
226        if max_width == 0 {
227            return std::borrow::Cow::Borrowed("");
228        }
229
230        // Use a single-cell ellipsis.
231        if max_width == 1 {
232            return std::borrow::Cow::Borrowed("…");
233        }
234
235        let mut out = String::new();
236        let mut used = 0usize;
237        let target = max_width - 1;
238
239        for g in s.graphemes(true) {
240            let w = grapheme_width(g);
241            if w == 0 {
242                continue;
243            }
244            if used + w > target {
245                break;
246            }
247            out.push_str(g);
248            used += w;
249        }
250
251        out.push('…');
252        std::borrow::Cow::Owned(out)
253    }
254
255    fn render_top_text(
256        &self,
257        area: Rect,
258        frame: &mut Frame,
259        text: &str,
260        alignment: Alignment,
261        style: Style,
262        clear_existing_style: bool,
263    ) {
264        if area.width < 2 {
265            return;
266        }
267
268        let available_width = area.width.saturating_sub(2) as usize;
269        let text = self.ellipsize(text, available_width);
270        let text_width = display_width(text.as_ref()).min(available_width);
271
272        let x = match alignment {
273            Alignment::Left => area.x.saturating_add(1),
274            Alignment::Center => area
275                .x
276                .saturating_add(1)
277                .saturating_add(((available_width.saturating_sub(text_width)) / 2) as u16),
278            Alignment::Right => area
279                .right()
280                .saturating_sub(1)
281                .saturating_sub(text_width as u16),
282        };
283
284        let max_x = area.right().saturating_sub(1);
285        if clear_existing_style && text_width > 0 {
286            frame
287                .buffer
288                .fill(Rect::new(x, area.y, text_width as u16, 1), Cell::default());
289        }
290        draw_text_span(frame, x, area.y, text.as_ref(), style, max_x);
291    }
292
293    fn render_bottom_text(
294        &self,
295        area: Rect,
296        frame: &mut Frame,
297        text: &str,
298        alignment: Alignment,
299        style: Style,
300        clear_existing_style: bool,
301    ) {
302        if area.height < 1 || area.width < 2 {
303            return;
304        }
305
306        let available_width = area.width.saturating_sub(2) as usize;
307        let text = self.ellipsize(text, available_width);
308        let text_width = display_width(text.as_ref()).min(available_width);
309
310        let x = match alignment {
311            Alignment::Left => area.x.saturating_add(1),
312            Alignment::Center => area
313                .x
314                .saturating_add(1)
315                .saturating_add(((available_width.saturating_sub(text_width)) / 2) as u16),
316            Alignment::Right => area
317                .right()
318                .saturating_sub(1)
319                .saturating_sub(text_width as u16),
320        };
321
322        let y = area.bottom().saturating_sub(1);
323        let max_x = area.right().saturating_sub(1);
324        if clear_existing_style && text_width > 0 {
325            frame
326                .buffer
327                .fill(Rect::new(x, y, text_width as u16, 1), Cell::default());
328        }
329        draw_text_span(frame, x, y, text.as_ref(), style, max_x);
330    }
331}
332
333struct ScissorGuard<'a, 'pool> {
334    frame: &'a mut Frame<'pool>,
335}
336
337impl<'a, 'pool> ScissorGuard<'a, 'pool> {
338    fn new(frame: &'a mut Frame<'pool>, rect: Rect) -> Self {
339        frame.buffer.push_scissor(rect);
340        Self { frame }
341    }
342}
343
344impl Drop for ScissorGuard<'_, '_> {
345    fn drop(&mut self) {
346        self.frame.buffer.pop_scissor();
347    }
348}
349
350impl<W: Widget> Widget for Panel<'_, W> {
351    fn render(&self, area: Rect, frame: &mut Frame) {
352        #[cfg(feature = "tracing")]
353        let _span = tracing::debug_span!(
354            "widget_render",
355            widget = "Panel",
356            x = area.x,
357            y = area.y,
358            w = area.width,
359            h = area.height
360        )
361        .entered();
362
363        if area.is_empty() {
364            return;
365        }
366
367        let deg = frame.buffer.degradation;
368        let clear_existing_text_style = !deg.apply_styling();
369        let border_style = if deg.apply_styling() {
370            self.border_style
371        } else {
372            Style::default()
373        };
374
375        // Skeleton+: clear decorative output, but still give essential child
376        // content a chance to render into the panel body.
377        if !deg.render_content() {
378            frame.buffer.fill(area, Cell::default());
379        } else if deg.apply_styling() {
380            // Background/style
381            set_style_area(&mut frame.buffer, area, self.style);
382        }
383
384        // Decorative layer: borders + title/subtitle
385        if deg.render_decorative() {
386            let set = self.pick_border_set(&frame.buffer);
387            self.render_borders(area, &mut frame.buffer, set, border_style);
388
389            if self.borders.contains(Borders::TOP)
390                && let Some(title) = self.title
391            {
392                let title_style = if deg.apply_styling() {
393                    self.title_style.merge(&self.border_style)
394                } else {
395                    Style::default()
396                };
397                self.render_top_text(
398                    area,
399                    frame,
400                    title,
401                    self.title_alignment,
402                    title_style,
403                    clear_existing_text_style,
404                );
405            }
406
407            if self.borders.contains(Borders::BOTTOM)
408                && let Some(subtitle) = self.subtitle
409            {
410                let subtitle_style = if deg.apply_styling() {
411                    self.subtitle_style.merge(&self.border_style)
412                } else {
413                    Style::default()
414                };
415                self.render_bottom_text(
416                    area,
417                    frame,
418                    subtitle,
419                    self.subtitle_alignment,
420                    subtitle_style,
421                    clear_existing_text_style,
422                );
423            }
424        }
425
426        // Content
427        let content_bounds = if deg.render_decorative() {
428            self.inner(area)
429        } else {
430            area
431        };
432        let mut content_area = content_bounds;
433        content_area = content_area.inner(self.padding);
434        if content_area.is_empty() {
435            return;
436        }
437
438        let guard = ScissorGuard::new(frame, content_area);
439        self.child.render(content_area, guard.frame);
440    }
441
442    fn is_essential(&self) -> bool {
443        self.child.is_essential()
444    }
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450    use ftui_render::frame::Frame;
451    use ftui_render::grapheme_pool::GraphemePool;
452
453    fn panel_stub() -> Panel<'static, crate::block::Block<'static>> {
454        Panel::new(crate::block::Block::default())
455    }
456
457    fn cell_char(frame: &Frame, x: u16, y: u16) -> Option<char> {
458        frame.buffer.get(x, y).and_then(|c| c.content.as_char())
459    }
460
461    // --- ellipsize tests ---
462
463    #[test]
464    fn ellipsize_short_is_borrowed() {
465        let panel = panel_stub();
466        let out = panel.ellipsize("abc", 3);
467        assert!(matches!(out, std::borrow::Cow::Borrowed(_)));
468        assert_eq!(out, "abc");
469    }
470
471    #[test]
472    fn ellipsize_truncates_with_ellipsis() {
473        let panel = panel_stub();
474        let out = panel.ellipsize("abcdef", 4);
475        assert_eq!(out, "abc…");
476    }
477
478    #[test]
479    fn ellipsize_zero_width_returns_empty() {
480        let panel = panel_stub();
481        let out = panel.ellipsize("abc", 0);
482        assert_eq!(out, "");
483    }
484
485    #[test]
486    fn ellipsize_width_one_returns_ellipsis() {
487        let panel = panel_stub();
488        let out = panel.ellipsize("abc", 1);
489        assert_eq!(out, "…");
490    }
491
492    #[test]
493    fn ellipsize_exact_fit_is_borrowed() {
494        let panel = panel_stub();
495        let out = panel.ellipsize("hello", 5);
496        assert!(matches!(out, std::borrow::Cow::Borrowed(_)));
497        assert_eq!(out, "hello");
498    }
499
500    #[test]
501    fn ellipsize_one_over_truncates() {
502        let panel = panel_stub();
503        let out = panel.ellipsize("hello", 4);
504        assert_eq!(out, "hel…");
505    }
506
507    // --- inner() calculation tests ---
508
509    #[test]
510    fn inner_all_borders() {
511        let panel = panel_stub().borders(Borders::ALL);
512        let area = Rect::new(0, 0, 10, 10);
513        assert_eq!(panel.inner(area), Rect::new(1, 1, 8, 8));
514    }
515
516    #[test]
517    fn inner_no_borders() {
518        let panel = panel_stub().borders(Borders::NONE);
519        let area = Rect::new(0, 0, 10, 10);
520        assert_eq!(panel.inner(area), area);
521    }
522
523    #[test]
524    fn inner_top_and_left_only() {
525        let panel = panel_stub().borders(Borders::TOP | Borders::LEFT);
526        let area = Rect::new(0, 0, 10, 10);
527        assert_eq!(panel.inner(area), Rect::new(1, 1, 9, 9));
528    }
529
530    #[test]
531    fn inner_right_and_bottom_only() {
532        let panel = panel_stub().borders(Borders::RIGHT | Borders::BOTTOM);
533        let area = Rect::new(0, 0, 10, 10);
534        assert_eq!(panel.inner(area), Rect::new(0, 0, 9, 9));
535    }
536
537    #[test]
538    fn inner_with_offset_area() {
539        let panel = panel_stub().borders(Borders::ALL);
540        let area = Rect::new(5, 3, 10, 8);
541        assert_eq!(panel.inner(area), Rect::new(6, 4, 8, 6));
542    }
543
544    #[test]
545    fn inner_zero_size_saturates() {
546        let panel = panel_stub().borders(Borders::ALL);
547        let area = Rect::new(0, 0, 1, 1);
548        let inner = panel.inner(area);
549        assert_eq!(inner.width, 0);
550        assert_eq!(inner.height, 0);
551    }
552
553    // --- render border tests ---
554
555    #[test]
556    fn render_borders_square() {
557        let child = crate::block::Block::default();
558        let panel = Panel::new(child)
559            .borders(Borders::ALL)
560            .border_type(BorderType::Square);
561        let area = Rect::new(0, 0, 5, 3);
562        let mut pool = GraphemePool::new();
563        let mut frame = Frame::new(5, 3, &mut pool);
564
565        panel.render(area, &mut frame);
566
567        assert_eq!(cell_char(&frame, 0, 0), Some('┌'));
568        assert_eq!(cell_char(&frame, 4, 0), Some('┐'));
569        assert_eq!(cell_char(&frame, 0, 2), Some('└'));
570        assert_eq!(cell_char(&frame, 4, 2), Some('┘'));
571        assert_eq!(cell_char(&frame, 2, 0), Some('─'));
572        assert_eq!(cell_char(&frame, 0, 1), Some('│'));
573    }
574
575    #[test]
576    fn render_borders_rounded() {
577        let child = crate::block::Block::default();
578        let panel = Panel::new(child)
579            .borders(Borders::ALL)
580            .border_type(BorderType::Rounded);
581        let area = Rect::new(0, 0, 5, 3);
582        let mut pool = GraphemePool::new();
583        let mut frame = Frame::new(5, 3, &mut pool);
584
585        panel.render(area, &mut frame);
586
587        assert_eq!(cell_char(&frame, 0, 0), Some('╭'));
588        assert_eq!(cell_char(&frame, 4, 0), Some('╮'));
589        assert_eq!(cell_char(&frame, 0, 2), Some('╰'));
590        assert_eq!(cell_char(&frame, 4, 2), Some('╯'));
591    }
592
593    #[test]
594    fn render_empty_area_does_not_panic() {
595        let panel = panel_stub().borders(Borders::ALL);
596        let area = Rect::new(0, 0, 0, 0);
597        let mut pool = GraphemePool::new();
598        let mut frame = Frame::new(1, 1, &mut pool);
599        panel.render(area, &mut frame);
600    }
601
602    // --- title rendering tests ---
603
604    #[test]
605    fn render_title_left_aligned() {
606        let child = crate::block::Block::default();
607        let panel = Panel::new(child)
608            .borders(Borders::ALL)
609            .border_type(BorderType::Square)
610            .title("Hi")
611            .title_alignment(Alignment::Left);
612        let area = Rect::new(0, 0, 10, 3);
613        let mut pool = GraphemePool::new();
614        let mut frame = Frame::new(10, 3, &mut pool);
615
616        panel.render(area, &mut frame);
617
618        // Title starts at x=1 (after left border)
619        assert_eq!(cell_char(&frame, 1, 0), Some('H'));
620        assert_eq!(cell_char(&frame, 2, 0), Some('i'));
621    }
622
623    #[test]
624    fn render_title_right_aligned() {
625        let child = crate::block::Block::default();
626        let panel = Panel::new(child)
627            .borders(Borders::ALL)
628            .border_type(BorderType::Square)
629            .title("Hi")
630            .title_alignment(Alignment::Right);
631        let area = Rect::new(0, 0, 10, 3);
632        let mut pool = GraphemePool::new();
633        let mut frame = Frame::new(10, 3, &mut pool);
634
635        panel.render(area, &mut frame);
636
637        // "Hi" is 2 chars, right edge is 9, so title at 9-1-2=6..8
638        // right() = 10, sub 1 = 9, sub 2 = 7
639        assert_eq!(cell_char(&frame, 7, 0), Some('H'));
640        assert_eq!(cell_char(&frame, 8, 0), Some('i'));
641    }
642
643    #[test]
644    fn render_title_center_aligned() {
645        let child = crate::block::Block::default();
646        let panel = Panel::new(child)
647            .borders(Borders::ALL)
648            .border_type(BorderType::Square)
649            .title("AB")
650            .title_alignment(Alignment::Center);
651        let area = Rect::new(0, 0, 10, 3);
652        let mut pool = GraphemePool::new();
653        let mut frame = Frame::new(10, 3, &mut pool);
654
655        panel.render(area, &mut frame);
656
657        // available_width = 10-2 = 8, display_width = 2
658        // x = 0 + 1 + (8-2)/2 = 1 + 3 = 4
659        assert_eq!(cell_char(&frame, 4, 0), Some('A'));
660        assert_eq!(cell_char(&frame, 5, 0), Some('B'));
661    }
662
663    #[test]
664    fn render_title_no_top_border_skips_title() {
665        let child = crate::block::Block::default();
666        let panel = Panel::new(child)
667            .borders(Borders::LEFT | Borders::RIGHT | Borders::BOTTOM)
668            .title("Hi");
669        let area = Rect::new(0, 0, 10, 3);
670        let mut pool = GraphemePool::new();
671        let mut frame = Frame::new(10, 3, &mut pool);
672
673        panel.render(area, &mut frame);
674
675        // Title should NOT appear on row 0 since no top border
676        assert_ne!(cell_char(&frame, 1, 0), Some('H'));
677    }
678
679    #[test]
680    fn render_title_truncated_with_ellipsis() {
681        let child = crate::block::Block::default();
682        let panel = Panel::new(child)
683            .borders(Borders::ALL)
684            .border_type(BorderType::Square)
685            .title("LongTitle")
686            .title_alignment(Alignment::Left);
687        // Width 6: available = 6-2 = 4, "LongTitle" (9 chars) -> "Lon…"
688        let area = Rect::new(0, 0, 6, 3);
689        let mut pool = GraphemePool::new();
690        let mut frame = Frame::new(6, 3, &mut pool);
691
692        panel.render(area, &mut frame);
693
694        assert_eq!(cell_char(&frame, 1, 0), Some('L'));
695        assert_eq!(cell_char(&frame, 2, 0), Some('o'));
696        assert_eq!(cell_char(&frame, 3, 0), Some('n'));
697        assert_eq!(cell_char(&frame, 4, 0), Some('…'));
698    }
699
700    // --- subtitle rendering tests ---
701
702    #[test]
703    fn render_subtitle_left_aligned() {
704        let child = crate::block::Block::default();
705        let panel = Panel::new(child)
706            .borders(Borders::ALL)
707            .border_type(BorderType::Square)
708            .subtitle("Lo")
709            .subtitle_alignment(Alignment::Left);
710        let area = Rect::new(0, 0, 10, 3);
711        let mut pool = GraphemePool::new();
712        let mut frame = Frame::new(10, 3, &mut pool);
713
714        panel.render(area, &mut frame);
715
716        // Subtitle on bottom row (y=2), starting at x=1
717        assert_eq!(cell_char(&frame, 1, 2), Some('L'));
718        assert_eq!(cell_char(&frame, 2, 2), Some('o'));
719    }
720
721    #[test]
722    fn render_subtitle_no_bottom_border_skips() {
723        let child = crate::block::Block::default();
724        let panel = Panel::new(child)
725            .borders(Borders::TOP | Borders::LEFT | Borders::RIGHT)
726            .subtitle("Lo");
727        let area = Rect::new(0, 0, 10, 3);
728        let mut pool = GraphemePool::new();
729        let mut frame = Frame::new(10, 3, &mut pool);
730
731        panel.render(area, &mut frame);
732
733        // Subtitle should not appear since no bottom border
734        assert_ne!(cell_char(&frame, 1, 2), Some('L'));
735    }
736
737    // --- padding tests ---
738
739    #[test]
740    fn inner_with_padding_reduces_area() {
741        let panel = panel_stub().borders(Borders::ALL).padding(Sides::all(1));
742        let area = Rect::new(0, 0, 10, 10);
743        // inner from borders = (1,1,8,8), then padding of 1 on each side = (2,2,6,6)
744        let inner_from_borders = panel.inner(area);
745        let padded = inner_from_borders.inner(Sides::all(1));
746        assert_eq!(padded, Rect::new(2, 2, 6, 6));
747    }
748
749    // --- child rendering tests ---
750
751    /// A simple test widget that writes 'X' at (0,0) relative to its area.
752    struct MarkerWidget;
753
754    impl Widget for MarkerWidget {
755        fn render(&self, area: Rect, frame: &mut Frame) {
756            if !area.is_empty() {
757                let mut cell = Cell::from_char('X');
758                apply_style(&mut cell, Style::default());
759                frame.buffer.set(area.x, area.y, cell);
760            }
761        }
762    }
763
764    #[test]
765    fn child_rendered_inside_borders() {
766        let panel = Panel::new(MarkerWidget).borders(Borders::ALL);
767        let area = Rect::new(0, 0, 5, 5);
768        let mut pool = GraphemePool::new();
769        let mut frame = Frame::new(5, 5, &mut pool);
770
771        panel.render(area, &mut frame);
772
773        // Child area starts at (1,1) for ALL borders
774        assert_eq!(cell_char(&frame, 1, 1), Some('X'));
775    }
776
777    #[test]
778    fn child_rendered_with_padding_offset() {
779        let panel = Panel::new(MarkerWidget)
780            .borders(Borders::ALL)
781            .padding(Sides::new(1, 1, 0, 1));
782        let area = Rect::new(0, 0, 10, 10);
783        let mut pool = GraphemePool::new();
784        let mut frame = Frame::new(10, 10, &mut pool);
785
786        panel.render(area, &mut frame);
787
788        // borders inner = (1,1,8,8), padding top=1 left=1 -> child at (2,2)
789        assert_eq!(cell_char(&frame, 2, 2), Some('X'));
790    }
791
792    #[test]
793    fn child_not_rendered_when_padding_consumes_all_space() {
794        let panel = Panel::new(MarkerWidget)
795            .borders(Borders::ALL)
796            .padding(Sides::all(10));
797        let area = Rect::new(0, 0, 5, 5);
798        let mut pool = GraphemePool::new();
799        let mut frame = Frame::new(5, 5, &mut pool);
800
801        // Should not panic even though padding exceeds available space
802        panel.render(area, &mut frame);
803    }
804
805    // --- builder chain test ---
806
807    #[test]
808    fn builder_chain_compiles() {
809        let _panel = Panel::new(crate::block::Block::default())
810            .borders(Borders::ALL)
811            .border_type(BorderType::Double)
812            .border_style(Style::new().bold())
813            .title("Title")
814            .title_alignment(Alignment::Center)
815            .title_style(Style::new().italic())
816            .subtitle("Sub")
817            .subtitle_alignment(Alignment::Right)
818            .subtitle_style(Style::new())
819            .style(Style::new())
820            .padding(Sides::all(1));
821    }
822
823    #[test]
824    fn degradation_no_styling_drops_border_style() {
825        use ftui_render::budget::DegradationLevel;
826        use ftui_render::cell::PackedRgba;
827
828        let panel = Panel::new(crate::block::Block::default())
829            .borders(Borders::ALL)
830            .border_style(Style::new().fg(PackedRgba::rgb(255, 0, 0)).bold());
831        let area = Rect::new(0, 0, 5, 3);
832        let mut pool = GraphemePool::new();
833        let mut frame = Frame::new(5, 3, &mut pool);
834        frame.buffer.degradation = DegradationLevel::NoStyling;
835        panel.render(area, &mut frame);
836
837        let border = frame.buffer.get(0, 0).unwrap();
838        let default_cell = Cell::from_char(border.content.as_char().unwrap());
839        assert_eq!(border.fg, default_cell.fg);
840        assert_eq!(border.bg, default_cell.bg);
841        assert_eq!(border.attrs, default_cell.attrs);
842    }
843
844    #[test]
845    fn degradation_no_styling_title_does_not_inherit_border_style() {
846        use ftui_render::budget::DegradationLevel;
847        use ftui_render::cell::PackedRgba;
848
849        let panel = Panel::new(crate::block::Block::default())
850            .borders(Borders::ALL)
851            .border_style(Style::new().fg(PackedRgba::rgb(255, 0, 0)).bold())
852            .title("Hi");
853        let area = Rect::new(0, 0, 6, 3);
854        let mut pool = GraphemePool::new();
855        let mut frame = Frame::new(6, 3, &mut pool);
856        frame.buffer.degradation = DegradationLevel::NoStyling;
857        panel.render(area, &mut frame);
858
859        let title = frame.buffer.get(1, 0).unwrap();
860        let default_cell = Cell::from_char('H');
861        assert_eq!(title.content.as_char(), Some('H'));
862        assert_eq!(title.fg, default_cell.fg);
863        assert_eq!(title.bg, default_cell.bg);
864        assert_eq!(title.attrs, default_cell.attrs);
865    }
866
867    #[test]
868    fn degradation_no_styling_subtitle_does_not_inherit_border_style() {
869        use ftui_render::budget::DegradationLevel;
870        use ftui_render::cell::PackedRgba;
871
872        let panel = Panel::new(crate::block::Block::default())
873            .borders(Borders::ALL)
874            .border_style(Style::new().fg(PackedRgba::rgb(255, 0, 0)).bold())
875            .subtitle("Lo");
876        let area = Rect::new(0, 0, 6, 3);
877        let mut pool = GraphemePool::new();
878        let mut frame = Frame::new(6, 3, &mut pool);
879        frame.buffer.degradation = DegradationLevel::NoStyling;
880        panel.render(area, &mut frame);
881
882        let subtitle = frame.buffer.get(1, 2).unwrap();
883        let default_cell = Cell::from_char('L');
884        assert_eq!(subtitle.content.as_char(), Some('L'));
885        assert_eq!(subtitle.fg, default_cell.fg);
886        assert_eq!(subtitle.bg, default_cell.bg);
887        assert_eq!(subtitle.attrs, default_cell.attrs);
888    }
889
890    #[test]
891    fn skeleton_still_renders_essential_child() {
892        use ftui_render::budget::DegradationLevel;
893
894        struct EssentialMarker;
895
896        impl Widget for EssentialMarker {
897            fn render(&self, area: Rect, frame: &mut Frame) {
898                frame.buffer.set(area.x, area.y, Cell::from_char('E'));
899            }
900
901            fn is_essential(&self) -> bool {
902                true
903            }
904        }
905
906        let panel = Panel::new(EssentialMarker).borders(Borders::ALL);
907        let area = Rect::new(0, 0, 5, 3);
908        let mut pool = GraphemePool::new();
909        let mut frame = Frame::new(5, 3, &mut pool);
910        frame.buffer.degradation = DegradationLevel::Skeleton;
911
912        panel.render(area, &mut frame);
913
914        assert_eq!(cell_char(&frame, 0, 0), Some('E'));
915        assert!(frame.buffer.get(4, 0).unwrap().is_empty());
916    }
917
918    #[test]
919    fn essential_only_does_not_reserve_hidden_border_space() {
920        use ftui_render::budget::DegradationLevel;
921
922        let panel = Panel::new(MarkerWidget).borders(Borders::ALL);
923        let area = Rect::new(0, 0, 5, 3);
924        let mut pool = GraphemePool::new();
925        let mut frame = Frame::new(5, 3, &mut pool);
926        frame.buffer.degradation = DegradationLevel::EssentialOnly;
927
928        panel.render(area, &mut frame);
929
930        assert_eq!(cell_char(&frame, 0, 0), Some('X'));
931        assert!(frame.buffer.get(1, 0).unwrap().is_empty());
932    }
933}