Skip to main content

ftui_widgets/
block.rs

1#![forbid(unsafe_code)]
2
3use crate::Widget;
4use crate::borders::{BorderSet, BorderType, Borders};
5use crate::measurable::{MeasurableWidget, SizeConstraints};
6use crate::{apply_style, draw_text_span, set_style_area};
7use ftui_core::geometry::{Rect, Sides, Size};
8use ftui_render::buffer::Buffer;
9use ftui_render::cell::Cell;
10use ftui_render::frame::Frame;
11use ftui_style::Style;
12use ftui_text::{grapheme_width, graphemes};
13
14/// A widget that draws a block with optional borders, title, and padding.
15#[derive(Debug, Clone, PartialEq, Eq, Default)]
16pub struct Block<'a> {
17    borders: Borders,
18    border_style: Style,
19    border_type: BorderType,
20    title: Option<&'a str>,
21    title_alignment: Alignment,
22    style: Style,
23    padding: Sides,
24}
25
26/// Text alignment.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
28pub enum Alignment {
29    #[default]
30    /// Align text to the left.
31    Left,
32    /// Center text horizontally.
33    Center,
34    /// Align text to the right.
35    Right,
36}
37
38impl<'a> Block<'a> {
39    /// Create a new block with default settings.
40    #[must_use]
41    pub fn new() -> Self {
42        Self::default()
43    }
44
45    /// Create a block with all borders enabled and default padding.
46    #[must_use]
47    pub fn bordered() -> Self {
48        Self::default().borders(Borders::ALL).padding(Sides::all(1))
49    }
50
51    /// Set which borders to render.
52    #[must_use]
53    pub fn borders(mut self, borders: Borders) -> Self {
54        self.borders = borders;
55        self
56    }
57
58    /// Set the style applied to border characters.
59    #[must_use]
60    pub fn border_style(mut self, style: Style) -> Self {
61        self.border_style = style;
62        self
63    }
64
65    /// Set the border character set (e.g. square, rounded, double).
66    #[must_use]
67    pub fn border_type(mut self, border_type: BorderType) -> Self {
68        self.border_type = border_type;
69        self
70    }
71
72    /// Set the padding inside the borders.
73    #[must_use]
74    pub fn padding(mut self, padding: impl Into<Sides>) -> Self {
75        self.padding = padding.into();
76        self
77    }
78
79    /// Get the border set for this block.
80    pub(crate) fn border_set(&self) -> BorderSet {
81        self.border_type.to_border_set()
82    }
83
84    /// Set the block title displayed on the top border.
85    #[must_use]
86    pub fn title(mut self, title: &'a str) -> Self {
87        self.title = Some(title);
88        self
89    }
90
91    /// Return the title text, if any.
92    #[must_use]
93    pub fn title_text(&self) -> Option<&str> {
94        self.title
95    }
96
97    /// Set the horizontal alignment of the title.
98    #[must_use]
99    pub fn title_alignment(mut self, alignment: Alignment) -> Self {
100        self.title_alignment = alignment;
101        self
102    }
103
104    /// Set the background style for the entire block area.
105    #[must_use]
106    pub fn style(mut self, style: Style) -> Self {
107        self.style = style;
108        self
109    }
110
111    /// Compute the inner area inside the block's borders and padding.
112    #[must_use]
113    pub fn inner(&self, area: Rect) -> Rect {
114        let mut inner = area;
115
116        if self.borders.contains(Borders::LEFT) {
117            inner.x = inner.x.saturating_add(1);
118            inner.width = inner.width.saturating_sub(1);
119        }
120        if self.borders.contains(Borders::TOP) {
121            inner.y = inner.y.saturating_add(1);
122            inner.height = inner.height.saturating_sub(1);
123        }
124        if self.borders.contains(Borders::RIGHT) {
125            inner.width = inner.width.saturating_sub(1);
126        }
127        if self.borders.contains(Borders::BOTTOM) {
128            inner.height = inner.height.saturating_sub(1);
129        }
130
131        inner.inner(self.padding)
132    }
133
134    /// Calculate the chrome (border + padding) size consumed by this block.
135    ///
136    /// Returns `(horizontal_chrome, vertical_chrome)` representing the
137    /// total width and height consumed by borders and padding.
138    #[must_use]
139    pub fn chrome_size(&self) -> (u16, u16) {
140        let border_h = self.borders.contains(Borders::LEFT) as u16
141            + self.borders.contains(Borders::RIGHT) as u16;
142        let border_v = self.borders.contains(Borders::TOP) as u16
143            + self.borders.contains(Borders::BOTTOM) as u16;
144
145        let padding_h = self.padding.left + self.padding.right;
146        let padding_v = self.padding.top + self.padding.bottom;
147
148        (
149            border_h.saturating_add(padding_h),
150            border_v.saturating_add(padding_v),
151        )
152    }
153
154    /// Create a styled border cell.
155    fn border_cell(&self, c: char, style: Style) -> Cell {
156        let mut cell = Cell::from_char(c);
157        apply_style(&mut cell, style);
158        cell
159    }
160
161    fn render_borders(&self, area: Rect, buf: &mut Buffer, style: Style) {
162        if area.is_empty() {
163            return;
164        }
165
166        let set = self.border_set();
167
168        // Edges
169        if self.borders.contains(Borders::LEFT) {
170            for y in area.y..area.bottom() {
171                buf.set_fast(area.x, y, self.border_cell(set.vertical, style));
172            }
173        }
174        if self.borders.contains(Borders::RIGHT) {
175            let x = area.right() - 1;
176            for y in area.y..area.bottom() {
177                buf.set_fast(x, y, self.border_cell(set.vertical, style));
178            }
179        }
180        if self.borders.contains(Borders::TOP) {
181            for x in area.x..area.right() {
182                buf.set_fast(x, area.y, self.border_cell(set.horizontal, style));
183            }
184        }
185        if self.borders.contains(Borders::BOTTOM) {
186            let y = area.bottom() - 1;
187            for x in area.x..area.right() {
188                buf.set_fast(x, y, self.border_cell(set.horizontal, style));
189            }
190        }
191
192        // Corners (drawn after edges to overwrite edge characters at corners)
193        if self.borders.contains(Borders::LEFT | Borders::TOP) {
194            buf.set_fast(area.x, area.y, self.border_cell(set.top_left, style));
195        }
196        if self.borders.contains(Borders::RIGHT | Borders::TOP) {
197            buf.set_fast(
198                area.right() - 1,
199                area.y,
200                self.border_cell(set.top_right, style),
201            );
202        }
203        if self.borders.contains(Borders::LEFT | Borders::BOTTOM) {
204            buf.set_fast(
205                area.x,
206                area.bottom() - 1,
207                self.border_cell(set.bottom_left, style),
208            );
209        }
210        if self.borders.contains(Borders::RIGHT | Borders::BOTTOM) {
211            buf.set_fast(
212                area.right() - 1,
213                area.bottom() - 1,
214                self.border_cell(set.bottom_right, style),
215            );
216        }
217    }
218
219    /// Render borders using ASCII characters regardless of configured border_type.
220    fn render_borders_ascii(&self, area: Rect, buf: &mut Buffer, style: Style) {
221        if area.is_empty() {
222            return;
223        }
224
225        let set = crate::borders::BorderSet::ASCII;
226
227        if self.borders.contains(Borders::LEFT) {
228            for y in area.y..area.bottom() {
229                buf.set_fast(area.x, y, self.border_cell(set.vertical, style));
230            }
231        }
232        if self.borders.contains(Borders::RIGHT) {
233            let x = area.right() - 1;
234            for y in area.y..area.bottom() {
235                buf.set_fast(x, y, self.border_cell(set.vertical, style));
236            }
237        }
238        if self.borders.contains(Borders::TOP) {
239            for x in area.x..area.right() {
240                buf.set_fast(x, area.y, self.border_cell(set.horizontal, style));
241            }
242        }
243        if self.borders.contains(Borders::BOTTOM) {
244            let y = area.bottom() - 1;
245            for x in area.x..area.right() {
246                buf.set_fast(x, y, self.border_cell(set.horizontal, style));
247            }
248        }
249
250        if self.borders.contains(Borders::LEFT | Borders::TOP) {
251            buf.set_fast(area.x, area.y, self.border_cell(set.top_left, style));
252        }
253        if self.borders.contains(Borders::RIGHT | Borders::TOP) {
254            buf.set_fast(
255                area.right() - 1,
256                area.y,
257                self.border_cell(set.top_right, style),
258            );
259        }
260        if self.borders.contains(Borders::LEFT | Borders::BOTTOM) {
261            buf.set_fast(
262                area.x,
263                area.bottom() - 1,
264                self.border_cell(set.bottom_left, style),
265            );
266        }
267        if self.borders.contains(Borders::RIGHT | Borders::BOTTOM) {
268            buf.set_fast(
269                area.right() - 1,
270                area.bottom() - 1,
271                self.border_cell(set.bottom_right, style),
272            );
273        }
274    }
275
276    fn render_title(&self, area: Rect, frame: &mut Frame) {
277        if let Some(title) = self.title {
278            if !self.borders.contains(Borders::TOP) || area.width < 3 {
279                return;
280            }
281
282            let available_width = area.width.saturating_sub(2) as usize;
283            if available_width == 0 {
284                return;
285            }
286
287            let display_width = fitted_text_width(title, available_width);
288            if display_width == 0 {
289                return;
290            }
291
292            let x = match self.title_alignment {
293                Alignment::Left => area.x.saturating_add(1),
294                Alignment::Center => area
295                    .x
296                    .saturating_add(1)
297                    .saturating_add(((available_width.saturating_sub(display_width)) / 2) as u16),
298                Alignment::Right => area
299                    .right()
300                    .saturating_sub(1)
301                    .saturating_sub(display_width as u16),
302            };
303
304            let max_x = area.right().saturating_sub(1);
305            draw_text_span(frame, x, area.y, title, self.border_style, max_x);
306        }
307    }
308}
309
310impl Widget for Block<'_> {
311    fn render(&self, area: Rect, frame: &mut Frame) {
312        #[cfg(feature = "tracing")]
313        let _span = tracing::debug_span!(
314            "widget_render",
315            widget = "Block",
316            x = area.x,
317            y = area.y,
318            w = area.width,
319            h = area.height
320        )
321        .entered();
322
323        if area.is_empty() {
324            return;
325        }
326
327        let deg = frame.degradation;
328        let border_style = if deg.apply_styling() {
329            self.border_style
330        } else {
331            Style::default()
332        };
333
334        // Skeleton+: skip everything, just clear area
335        if !deg.render_content() {
336            frame.buffer.fill(area, Cell::default());
337            return;
338        }
339
340        // EssentialOnly: block chrome is purely decorative, so clear the owned
341        // area instead of leaving stale borders/title content behind.
342        if !deg.render_decorative() {
343            frame.buffer.fill(area, Cell::default());
344            if deg.apply_styling() {
345                set_style_area(&mut frame.buffer, area, self.style);
346            }
347            return;
348        }
349
350        // Apply background/style
351        if deg.apply_styling() {
352            set_style_area(&mut frame.buffer, area, self.style);
353        }
354
355        // Render borders (with possible ASCII downgrade)
356        if deg.use_unicode_borders() {
357            self.render_borders(area, &mut frame.buffer, border_style);
358        } else {
359            // Force ASCII borders regardless of configured border_type
360            self.render_borders_ascii(area, &mut frame.buffer, border_style);
361        }
362
363        // Render title (skip at NoStyling to save time)
364        if deg.apply_styling() {
365            self.render_title(area, frame);
366        } else if deg.render_decorative() {
367            // Still show title but without styling
368            // Pass frame to reuse draw_text_span
369            if let Some(title) = self.title
370                && self.borders.contains(Borders::TOP)
371                && area.width >= 3
372            {
373                let available_width = area.width.saturating_sub(2) as usize;
374                if available_width > 0 {
375                    let display_width = fitted_text_width(title, available_width);
376                    if display_width == 0 {
377                        return;
378                    }
379                    let x = match self.title_alignment {
380                        Alignment::Left => area.x.saturating_add(1),
381                        Alignment::Center => area.x.saturating_add(1).saturating_add(
382                            ((available_width.saturating_sub(display_width)) / 2) as u16,
383                        ),
384                        Alignment::Right => area
385                            .right()
386                            .saturating_sub(1)
387                            .saturating_sub(display_width as u16),
388                    };
389                    let max_x = area.right().saturating_sub(1);
390                    // draw_text_span() preserves existing cell styles. Clear the
391                    // title span first so NoStyling truly drops border styling.
392                    frame.buffer.fill(
393                        Rect::new(x, area.y, display_width as u16, 1),
394                        Cell::default(),
395                    );
396                    draw_text_span(frame, x, area.y, title, Style::default(), max_x);
397                }
398            }
399        }
400    }
401}
402
403impl MeasurableWidget for Block<'_> {
404    fn measure(&self, _available: Size) -> SizeConstraints {
405        let (chrome_width, chrome_height) = self.chrome_size();
406        let chrome = Size::new(chrome_width, chrome_height);
407
408        // Block's intrinsic size is just its chrome (borders).
409        // The minimum is the chrome size - less than this and borders overlap.
410        // Preferred is also the chrome size - any inner content adds to this.
411        // Maximum is unbounded - block can fill available space.
412        SizeConstraints::at_least(chrome, chrome)
413    }
414
415    fn has_intrinsic_size(&self) -> bool {
416        // Block has intrinsic size only if it has borders
417        self.borders != Borders::empty()
418    }
419}
420
421fn fitted_text_width(text: &str, max_width: usize) -> usize {
422    let mut width = 0usize;
423    for grapheme in graphemes(text) {
424        let w = grapheme_width(grapheme);
425        if w == 0 {
426            continue;
427        }
428        if width.saturating_add(w) > max_width {
429            break;
430        }
431        width += w;
432    }
433    width
434}
435
436// ============================================================================
437// Accessibility
438// ============================================================================
439
440impl ftui_a11y::Accessible for Block<'_> {
441    fn accessibility_nodes(&self, area: Rect) -> Vec<ftui_a11y::node::A11yNodeInfo> {
442        use ftui_a11y::node::{A11yNodeInfo, A11yRole};
443
444        let id = crate::a11y_node_id(area);
445        let mut node = A11yNodeInfo::new(id, A11yRole::Group, area);
446        if let Some(title) = self.title_text() {
447            node = node.with_name(title);
448        }
449        vec![node]
450    }
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456    use ftui_render::cell::PackedRgba;
457    use ftui_render::grapheme_pool::GraphemePool;
458
459    #[test]
460    fn inner_with_all_borders() {
461        let block = Block::new().borders(Borders::ALL);
462        let area = Rect::new(0, 0, 10, 10);
463        let inner = block.inner(area);
464        assert_eq!(inner, Rect::new(1, 1, 8, 8));
465    }
466
467    #[test]
468    fn inner_with_no_borders() {
469        let block = Block::new();
470        let area = Rect::new(0, 0, 10, 10);
471        let inner = block.inner(area);
472        assert_eq!(inner, area);
473    }
474
475    #[test]
476    fn inner_with_partial_borders() {
477        let block = Block::new().borders(Borders::TOP | Borders::LEFT);
478        let area = Rect::new(0, 0, 10, 10);
479        let inner = block.inner(area);
480        assert_eq!(inner, Rect::new(1, 1, 9, 9));
481    }
482
483    #[test]
484    fn render_empty_area() {
485        let block = Block::new().borders(Borders::ALL);
486        let area = Rect::new(0, 0, 0, 0);
487        let mut pool = GraphemePool::new();
488        let mut frame = Frame::new(1, 1, &mut pool);
489        block.render(area, &mut frame);
490    }
491
492    #[test]
493    fn render_block_with_square_borders() {
494        let block = Block::new()
495            .borders(Borders::ALL)
496            .border_type(BorderType::Square);
497        let area = Rect::new(0, 0, 5, 3);
498        let mut pool = GraphemePool::new();
499        let mut frame = Frame::new(5, 3, &mut pool);
500        block.render(area, &mut frame);
501
502        let buf = &frame.buffer;
503        assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('┌'));
504        assert_eq!(buf.get(4, 0).unwrap().content.as_char(), Some('┐'));
505        assert_eq!(buf.get(0, 2).unwrap().content.as_char(), Some('└'));
506        assert_eq!(buf.get(4, 2).unwrap().content.as_char(), Some('┘'));
507        assert_eq!(buf.get(2, 0).unwrap().content.as_char(), Some('─'));
508        assert_eq!(buf.get(0, 1).unwrap().content.as_char(), Some('│'));
509    }
510
511    #[test]
512    fn render_block_with_title() {
513        let block = Block::new()
514            .borders(Borders::ALL)
515            .border_type(BorderType::Square)
516            .title("Hi");
517        let area = Rect::new(0, 0, 10, 3);
518        let mut pool = GraphemePool::new();
519        let mut frame = Frame::new(10, 3, &mut pool);
520        block.render(area, &mut frame);
521
522        let buf = &frame.buffer;
523        assert_eq!(buf.get(1, 0).unwrap().content.as_char(), Some('H'));
524        assert_eq!(buf.get(2, 0).unwrap().content.as_char(), Some('i'));
525    }
526
527    #[test]
528    fn render_title_overrides_on_multiple_calls() {
529        let block = Block::new()
530            .borders(Borders::ALL)
531            .border_type(BorderType::Square)
532            .title("First")
533            .title("Second");
534        let area = Rect::new(0, 0, 12, 3);
535        let mut pool = GraphemePool::new();
536        let mut frame = Frame::new(12, 3, &mut pool);
537        block.render(area, &mut frame);
538
539        let buf = &frame.buffer;
540        assert_eq!(buf.get(1, 0).unwrap().content.as_char(), Some('S'));
541    }
542
543    #[test]
544    fn render_block_with_background() {
545        let block = Block::new().style(Style::new().bg(PackedRgba::rgb(10, 20, 30)));
546        let area = Rect::new(0, 0, 3, 2);
547        let mut pool = GraphemePool::new();
548        let mut frame = Frame::new(3, 2, &mut pool);
549        block.render(area, &mut frame);
550
551        let buf = &frame.buffer;
552        assert_eq!(buf.get(0, 0).unwrap().bg, PackedRgba::rgb(10, 20, 30));
553        assert_eq!(buf.get(2, 1).unwrap().bg, PackedRgba::rgb(10, 20, 30));
554    }
555
556    #[test]
557    fn inner_with_only_bottom() {
558        let block = Block::new().borders(Borders::BOTTOM);
559        let area = Rect::new(0, 0, 10, 10);
560        let inner = block.inner(area);
561        assert_eq!(inner, Rect::new(0, 0, 10, 9));
562    }
563
564    #[test]
565    fn inner_with_only_right() {
566        let block = Block::new().borders(Borders::RIGHT);
567        let area = Rect::new(0, 0, 10, 10);
568        let inner = block.inner(area);
569        assert_eq!(inner, Rect::new(0, 0, 9, 10));
570    }
571
572    #[test]
573    fn inner_saturates_on_tiny_area() {
574        let block = Block::new().borders(Borders::ALL);
575        let area = Rect::new(0, 0, 1, 1);
576        let inner = block.inner(area);
577        // 1x1 with all borders: x+1=1, w-2=0, y+1=1, h-2=0
578        assert_eq!(inner.width, 0);
579    }
580
581    #[test]
582    fn bordered_constructor() {
583        let block = Block::bordered();
584        assert_eq!(block.borders, Borders::ALL);
585    }
586
587    #[test]
588    fn default_has_no_borders() {
589        let block = Block::new();
590        assert_eq!(block.borders, Borders::empty());
591        assert!(block.title.is_none());
592    }
593
594    #[test]
595    fn render_rounded_borders() {
596        let block = Block::new()
597            .borders(Borders::ALL)
598            .border_type(BorderType::Rounded);
599        let area = Rect::new(0, 0, 5, 3);
600        let mut pool = GraphemePool::new();
601        let mut frame = Frame::new(5, 3, &mut pool);
602        block.render(area, &mut frame);
603
604        let buf = &frame.buffer;
605        assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('╭'));
606        assert_eq!(buf.get(4, 0).unwrap().content.as_char(), Some('╮'));
607        assert_eq!(buf.get(0, 2).unwrap().content.as_char(), Some('╰'));
608        assert_eq!(buf.get(4, 2).unwrap().content.as_char(), Some('╯'));
609    }
610
611    #[test]
612    fn render_double_borders() {
613        let block = Block::new()
614            .borders(Borders::ALL)
615            .border_type(BorderType::Double);
616        let area = Rect::new(0, 0, 5, 3);
617        let mut pool = GraphemePool::new();
618        let mut frame = Frame::new(5, 3, &mut pool);
619        block.render(area, &mut frame);
620
621        let buf = &frame.buffer;
622        assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('╔'));
623        assert_eq!(buf.get(4, 0).unwrap().content.as_char(), Some('╗'));
624    }
625
626    #[test]
627    fn render_partial_borders_corners_only_when_edges_enabled() {
628        let block = Block::new()
629            .borders(Borders::TOP | Borders::LEFT | Borders::BOTTOM)
630            .border_type(BorderType::Square);
631        let area = Rect::new(0, 0, 4, 3);
632        let mut pool = GraphemePool::new();
633        let mut frame = Frame::new(4, 3, &mut pool);
634        block.render(area, &mut frame);
635
636        let buf = &frame.buffer;
637        assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('┌'));
638        assert_eq!(buf.get(0, 2).unwrap().content.as_char(), Some('└'));
639        assert_eq!(buf.get(3, 0).unwrap().content.as_char(), Some('─'));
640        assert_eq!(buf.get(3, 2).unwrap().content.as_char(), Some('─'));
641        assert!(
642            buf.get(3, 1).unwrap().is_empty()
643                || buf.get(3, 1).unwrap().content.as_char() == Some(' ')
644        );
645    }
646
647    #[test]
648    fn render_vertical_only_borders_use_vertical_glyphs() {
649        let block = Block::new()
650            .borders(Borders::LEFT | Borders::RIGHT)
651            .border_type(BorderType::Double);
652        let area = Rect::new(0, 0, 4, 3);
653        let mut pool = GraphemePool::new();
654        let mut frame = Frame::new(4, 3, &mut pool);
655        block.render(area, &mut frame);
656
657        let buf = &frame.buffer;
658        assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('║'));
659        assert_eq!(buf.get(3, 0).unwrap().content.as_char(), Some('║'));
660        assert!(
661            buf.get(1, 0).unwrap().is_empty()
662                || buf.get(1, 0).unwrap().content.as_char() == Some(' ')
663        );
664    }
665
666    #[test]
667    fn render_missing_left_keeps_horizontal_corner_logic() {
668        let block = Block::new()
669            .borders(Borders::TOP | Borders::RIGHT | Borders::BOTTOM)
670            .border_type(BorderType::Square);
671        let area = Rect::new(0, 0, 4, 3);
672        let mut pool = GraphemePool::new();
673        let mut frame = Frame::new(4, 3, &mut pool);
674        block.render(area, &mut frame);
675
676        let buf = &frame.buffer;
677        assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('─'));
678        assert_eq!(buf.get(3, 0).unwrap().content.as_char(), Some('┐'));
679        assert_eq!(buf.get(0, 2).unwrap().content.as_char(), Some('─'));
680        assert_eq!(buf.get(3, 2).unwrap().content.as_char(), Some('┘'));
681        assert_eq!(buf.get(3, 1).unwrap().content.as_char(), Some('│'));
682    }
683
684    #[test]
685    fn render_title_left_aligned() {
686        let block = Block::new()
687            .borders(Borders::ALL)
688            .title("Test")
689            .title_alignment(Alignment::Left);
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        block.render(area, &mut frame);
694
695        let buf = &frame.buffer;
696        assert_eq!(buf.get(1, 0).unwrap().content.as_char(), Some('T'));
697        assert_eq!(buf.get(2, 0).unwrap().content.as_char(), Some('e'));
698    }
699
700    #[test]
701    fn render_title_center_aligned() {
702        let block = Block::new()
703            .borders(Borders::ALL)
704            .title("Hi")
705            .title_alignment(Alignment::Center);
706        let area = Rect::new(0, 0, 10, 3);
707        let mut pool = GraphemePool::new();
708        let mut frame = Frame::new(10, 3, &mut pool);
709        block.render(area, &mut frame);
710
711        // Title "Hi" (2 chars) in 8 available (10-2 borders), centered at offset 3
712        let buf = &frame.buffer;
713        assert_eq!(buf.get(4, 0).unwrap().content.as_char(), Some('H'));
714        assert_eq!(buf.get(5, 0).unwrap().content.as_char(), Some('i'));
715    }
716
717    #[test]
718    fn render_title_center_aligned_with_wide_grapheme() {
719        let block = Block::new()
720            .borders(Borders::ALL)
721            .title("界")
722            .title_alignment(Alignment::Center);
723        let area = Rect::new(0, 0, 8, 3);
724        let mut pool = GraphemePool::new();
725        let mut frame = Frame::new(8, 3, &mut pool);
726        block.render(area, &mut frame);
727
728        // Available width = 6, title width = 2 => center offset 2 => x = 3
729        let buf = &frame.buffer;
730        let cell = buf.get(3, 0).unwrap();
731        assert!(
732            cell.content.as_char() == Some('界') || cell.content.is_grapheme(),
733            "expected title grapheme at x=3"
734        );
735        assert!(buf.get(4, 0).unwrap().is_continuation());
736    }
737
738    #[test]
739    fn render_title_right_aligned() {
740        let block = Block::new()
741            .borders(Borders::ALL)
742            .title("Hi")
743            .title_alignment(Alignment::Right);
744        let area = Rect::new(0, 0, 10, 3);
745        let mut pool = GraphemePool::new();
746        let mut frame = Frame::new(10, 3, &mut pool);
747        block.render(area, &mut frame);
748
749        let buf = &frame.buffer;
750        // "Hi" right-aligned: right()-1 - 2 = col 7
751        assert_eq!(buf.get(7, 0).unwrap().content.as_char(), Some('H'));
752        assert_eq!(buf.get(8, 0).unwrap().content.as_char(), Some('i'));
753    }
754
755    #[test]
756    fn render_title_right_aligned_truncated_wide_grapheme_uses_fitted_width() {
757        let block = Block::new()
758            .borders(Borders::ALL)
759            .title("界界")
760            .title_alignment(Alignment::Right);
761        let area = Rect::new(0, 0, 5, 3);
762        let mut pool = GraphemePool::new();
763        let mut frame = Frame::new(5, 3, &mut pool);
764        block.render(area, &mut frame);
765
766        let buf = &frame.buffer;
767        let cell = buf.get(2, 0).unwrap();
768        assert!(
769            cell.content.as_char() == Some('界') || cell.content.is_grapheme(),
770            "expected fitted wide title to be right aligned"
771        );
772        assert!(buf.get(3, 0).unwrap().is_continuation());
773        assert_eq!(buf.get(1, 0).unwrap().content.as_char(), Some('─'));
774    }
775
776    #[test]
777    fn render_multi_title_alignment_uses_last_title_and_alignment() {
778        let block = Block::new()
779            .borders(Borders::ALL)
780            .title("Left")
781            .title_alignment(Alignment::Left)
782            .title("Right")
783            .title_alignment(Alignment::Right);
784        let area = Rect::new(0, 0, 12, 3);
785        let mut pool = GraphemePool::new();
786        let mut frame = Frame::new(12, 3, &mut pool);
787        block.render(area, &mut frame);
788
789        let buf = &frame.buffer;
790        assert_eq!(buf.get(6, 0).unwrap().content.as_char(), Some('R'));
791        assert_ne!(buf.get(1, 0).unwrap().content.as_char(), Some('L'));
792    }
793
794    #[test]
795    fn title_not_rendered_without_top_border() {
796        let block = Block::new()
797            .borders(Borders::LEFT | Borders::RIGHT | Borders::BOTTOM)
798            .title("Hi");
799        let area = Rect::new(0, 0, 10, 3);
800        let mut pool = GraphemePool::new();
801        let mut frame = Frame::new(10, 3, &mut pool);
802        block.render(area, &mut frame);
803
804        let buf = &frame.buffer;
805        // No title should appear on row 0
806        assert_ne!(buf.get(1, 0).unwrap().content.as_char(), Some('H'));
807    }
808
809    #[test]
810    fn border_style_applied() {
811        let block = Block::new()
812            .borders(Borders::ALL)
813            .border_style(Style::new().fg(PackedRgba::rgb(255, 0, 0)));
814        let area = Rect::new(0, 0, 5, 3);
815        let mut pool = GraphemePool::new();
816        let mut frame = Frame::new(5, 3, &mut pool);
817        block.render(area, &mut frame);
818
819        let buf = &frame.buffer;
820        assert_eq!(buf.get(0, 0).unwrap().fg, PackedRgba::rgb(255, 0, 0));
821    }
822
823    #[test]
824    fn only_horizontal_borders() {
825        let block = Block::new()
826            .borders(Borders::TOP | Borders::BOTTOM)
827            .border_type(BorderType::Square);
828        let area = Rect::new(0, 0, 5, 3);
829        let mut pool = GraphemePool::new();
830        let mut frame = Frame::new(5, 3, &mut pool);
831        block.render(area, &mut frame);
832
833        let buf = &frame.buffer;
834        // Top and bottom should have horizontal lines
835        assert_eq!(buf.get(2, 0).unwrap().content.as_char(), Some('─'));
836        assert_eq!(buf.get(2, 2).unwrap().content.as_char(), Some('─'));
837        // Left edge should be empty (no vertical border)
838        assert!(
839            buf.get(0, 1).unwrap().is_empty()
840                || buf.get(0, 1).unwrap().content.as_char() == Some(' ')
841        );
842    }
843
844    #[test]
845    fn degradation_simple_borders_forces_ascii() {
846        use ftui_render::budget::DegradationLevel;
847
848        let block = Block::new()
849            .borders(Borders::ALL)
850            .border_type(BorderType::Rounded);
851        let area = Rect::new(0, 0, 5, 3);
852        let mut pool = GraphemePool::new();
853        let mut frame = Frame::new(5, 3, &mut pool);
854        frame.set_degradation(DegradationLevel::SimpleBorders);
855        block.render(area, &mut frame);
856
857        let buf = &frame.buffer;
858        assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('+'));
859        assert_eq!(buf.get(4, 0).unwrap().content.as_char(), Some('+'));
860        assert_eq!(buf.get(2, 0).unwrap().content.as_char(), Some('-'));
861        assert_eq!(buf.get(0, 1).unwrap().content.as_char(), Some('|'));
862    }
863
864    #[test]
865    fn degradation_simple_borders_partial_edges_use_ascii_corners() {
866        use ftui_render::budget::DegradationLevel;
867
868        let block = Block::new()
869            .borders(Borders::TOP | Borders::RIGHT | Borders::BOTTOM)
870            .border_type(BorderType::Double);
871        let area = Rect::new(0, 0, 4, 3);
872        let mut pool = GraphemePool::new();
873        let mut frame = Frame::new(4, 3, &mut pool);
874        frame.set_degradation(DegradationLevel::SimpleBorders);
875        block.render(area, &mut frame);
876
877        let buf = &frame.buffer;
878        assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('-'));
879        assert_eq!(buf.get(3, 0).unwrap().content.as_char(), Some('+'));
880        assert_eq!(buf.get(0, 2).unwrap().content.as_char(), Some('-'));
881        assert_eq!(buf.get(3, 2).unwrap().content.as_char(), Some('+'));
882        assert_eq!(buf.get(3, 1).unwrap().content.as_char(), Some('|'));
883    }
884
885    #[test]
886    fn degradation_no_styling_renders_title_without_styles() {
887        use ftui_render::budget::DegradationLevel;
888
889        let block = Block::new()
890            .borders(Borders::ALL)
891            .border_style(Style::new().fg(PackedRgba::rgb(200, 0, 0)))
892            .title("Hi");
893        let area = Rect::new(0, 0, 6, 3);
894        let mut pool = GraphemePool::new();
895        let mut frame = Frame::new(6, 3, &mut pool);
896        frame.set_degradation(DegradationLevel::NoStyling);
897        block.render(area, &mut frame);
898
899        let buf = &frame.buffer;
900        let default_fg = Cell::default().fg;
901        assert_eq!(buf.get(1, 0).unwrap().content.as_char(), Some('H'));
902        assert_eq!(buf.get(1, 0).unwrap().fg, default_fg);
903    }
904
905    #[test]
906    fn degradation_no_styling_keeps_border_when_title_does_not_fit() {
907        use ftui_render::budget::DegradationLevel;
908
909        let titled = Block::new()
910            .borders(Borders::ALL)
911            .border_type(BorderType::Square)
912            .title("界");
913        let plain = Block::new()
914            .borders(Borders::ALL)
915            .border_type(BorderType::Square);
916        let area = Rect::new(0, 0, 3, 3);
917
918        let mut titled_pool = GraphemePool::new();
919        let mut titled_frame = Frame::new(3, 3, &mut titled_pool);
920        titled_frame.set_degradation(DegradationLevel::NoStyling);
921        titled.render(area, &mut titled_frame);
922
923        let mut plain_pool = GraphemePool::new();
924        let mut plain_frame = Frame::new(3, 3, &mut plain_pool);
925        plain_frame.set_degradation(DegradationLevel::NoStyling);
926        plain.render(area, &mut plain_frame);
927
928        assert_eq!(titled_frame.buffer.get(1, 0), plain_frame.buffer.get(1, 0));
929    }
930
931    #[test]
932    fn degradation_no_styling_drops_border_style_everywhere() {
933        use ftui_render::budget::DegradationLevel;
934
935        let block = Block::new()
936            .borders(Borders::ALL)
937            .border_style(Style::new().fg(PackedRgba::rgb(200, 0, 0)).bold());
938        let area = Rect::new(0, 0, 5, 3);
939        let mut pool = GraphemePool::new();
940        let mut frame = Frame::new(5, 3, &mut pool);
941        frame.set_degradation(DegradationLevel::NoStyling);
942        block.render(area, &mut frame);
943
944        let border = frame.buffer.get(0, 0).unwrap();
945        let default_cell = Cell::from_char(border.content.as_char().unwrap());
946        assert_eq!(border.fg, default_cell.fg);
947        assert_eq!(border.bg, default_cell.bg);
948        assert_eq!(border.attrs, default_cell.attrs);
949    }
950
951    #[test]
952    fn degradation_essential_only_clears_stale_borders_and_title() {
953        use ftui_render::budget::DegradationLevel;
954
955        let block = Block::bordered()
956            .border_type(BorderType::Square)
957            .title("Hi");
958        let area = Rect::new(0, 0, 6, 3);
959        let mut pool = GraphemePool::new();
960        let mut frame = Frame::new(6, 3, &mut pool);
961        block.render(area, &mut frame);
962
963        frame.set_degradation(DegradationLevel::EssentialOnly);
964        block.render(area, &mut frame);
965
966        let buf = &frame.buffer;
967        for y in 0..area.height {
968            for x in 0..area.width {
969                assert!(
970                    buf.get(x, y).unwrap().is_empty(),
971                    "expected cleared cell at ({x}, {y}), got {:?}",
972                    buf.get(x, y).unwrap()
973                );
974            }
975        }
976    }
977
978    #[test]
979    fn degradation_skeleton_clears_area() {
980        use ftui_render::budget::DegradationLevel;
981
982        let block = Block::bordered();
983        let area = Rect::new(0, 0, 3, 2);
984        let mut pool = GraphemePool::new();
985        let mut frame = Frame::new(3, 2, &mut pool);
986        frame.buffer.fill(area, Cell::from_char('X'));
987        frame.set_degradation(DegradationLevel::Skeleton);
988        block.render(area, &mut frame);
989
990        let buf = &frame.buffer;
991        assert!(buf.get(0, 0).unwrap().is_empty());
992    }
993
994    #[test]
995    fn block_equality() {
996        let a = Block::new().borders(Borders::ALL).title("Test");
997        let b = Block::new().borders(Borders::ALL).title("Test");
998        assert_eq!(a, b);
999    }
1000
1001    #[test]
1002    fn render_1x1_no_panic() {
1003        let block = Block::bordered();
1004        let area = Rect::new(0, 0, 1, 1);
1005        let mut pool = GraphemePool::new();
1006        let mut frame = Frame::new(1, 1, &mut pool);
1007        block.render(area, &mut frame);
1008    }
1009
1010    #[test]
1011    fn render_2x2_with_borders() {
1012        let block = Block::bordered().border_type(BorderType::Square);
1013        let area = Rect::new(0, 0, 2, 2);
1014        let mut pool = GraphemePool::new();
1015        let mut frame = Frame::new(2, 2, &mut pool);
1016        block.render(area, &mut frame);
1017
1018        let buf = &frame.buffer;
1019        assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('┌'));
1020        assert_eq!(buf.get(1, 0).unwrap().content.as_char(), Some('┐'));
1021        assert_eq!(buf.get(0, 1).unwrap().content.as_char(), Some('└'));
1022        assert_eq!(buf.get(1, 1).unwrap().content.as_char(), Some('┘'));
1023    }
1024
1025    #[test]
1026    fn title_too_narrow() {
1027        // Width 3 with all borders = 1 char available for title
1028        let block = Block::bordered().title("LongTitle");
1029        let area = Rect::new(0, 0, 4, 3);
1030        let mut pool = GraphemePool::new();
1031        let mut frame = Frame::new(4, 3, &mut pool);
1032        block.render(area, &mut frame);
1033        // Should not panic, title gets truncated
1034    }
1035
1036    #[test]
1037    fn alignment_default_is_left() {
1038        assert_eq!(Alignment::default(), Alignment::Left);
1039    }
1040
1041    // --- MeasurableWidget tests ---
1042
1043    use crate::MeasurableWidget;
1044    use ftui_core::geometry::Size;
1045
1046    #[test]
1047    fn chrome_size_no_borders() {
1048        let block = Block::new();
1049        assert_eq!(block.chrome_size(), (0, 0));
1050    }
1051
1052    #[test]
1053    fn chrome_size_all_borders() {
1054        let block = Block::bordered();
1055        // Block::bordered() includes 1 cell padding on each side.
1056        // Chrome = borders (2) + padding (2) = 4 on each axis.
1057        assert_eq!(block.chrome_size(), (4, 4));
1058    }
1059
1060    #[test]
1061    fn chrome_size_partial_borders() {
1062        let block = Block::new().borders(Borders::TOP | Borders::LEFT);
1063        assert_eq!(block.chrome_size(), (1, 1));
1064    }
1065
1066    #[test]
1067    fn chrome_size_horizontal_only() {
1068        let block = Block::new().borders(Borders::LEFT | Borders::RIGHT);
1069        assert_eq!(block.chrome_size(), (2, 0));
1070    }
1071
1072    #[test]
1073    fn chrome_size_vertical_only() {
1074        let block = Block::new().borders(Borders::TOP | Borders::BOTTOM);
1075        assert_eq!(block.chrome_size(), (0, 2));
1076    }
1077
1078    #[test]
1079    fn measure_no_borders() {
1080        let block = Block::new();
1081        let constraints = block.measure(Size::MAX);
1082        assert_eq!(constraints.min, Size::ZERO);
1083        assert_eq!(constraints.preferred, Size::ZERO);
1084    }
1085
1086    #[test]
1087    fn measure_all_borders() {
1088        let block = Block::bordered();
1089        let constraints = block.measure(Size::MAX);
1090        assert_eq!(constraints.min, Size::new(4, 4));
1091        assert_eq!(constraints.preferred, Size::new(4, 4));
1092        assert_eq!(constraints.max, None); // Unbounded
1093    }
1094
1095    #[test]
1096    fn measure_partial_borders() {
1097        let block = Block::new().borders(Borders::TOP | Borders::RIGHT);
1098        let constraints = block.measure(Size::MAX);
1099        assert_eq!(constraints.min, Size::new(1, 1));
1100        assert_eq!(constraints.preferred, Size::new(1, 1));
1101    }
1102
1103    #[test]
1104    fn has_intrinsic_size_with_borders() {
1105        let block = Block::bordered();
1106        assert!(block.has_intrinsic_size());
1107    }
1108
1109    #[test]
1110    fn has_no_intrinsic_size_without_borders() {
1111        let block = Block::new();
1112        assert!(!block.has_intrinsic_size());
1113    }
1114
1115    #[test]
1116    fn measure_is_pure() {
1117        let block = Block::bordered();
1118        let a = block.measure(Size::new(100, 50));
1119        let b = block.measure(Size::new(100, 50));
1120        assert_eq!(a, b);
1121    }
1122}