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