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