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