Skip to main content

ftui_widgets/
rule.rs

1#![forbid(unsafe_code)]
2
3//! Horizontal rule (divider) widget.
4//!
5//! Draws a horizontal line across the available width, optionally with a
6//! title that can be aligned left, center, or right.
7
8use crate::block::Alignment;
9use crate::borders::BorderType;
10use crate::measurable::{MeasurableWidget, SizeConstraints};
11use crate::{Widget, apply_style, clear_text_row, draw_text_span};
12use ftui_core::geometry::{Rect, Size};
13use ftui_render::buffer::Buffer;
14use ftui_render::cell::Cell;
15use ftui_render::frame::Frame;
16use ftui_style::Style;
17use ftui_text::display_width;
18
19/// A horizontal rule / divider.
20///
21/// Renders a single-row horizontal line using a border character, optionally
22/// with a title inset at the given alignment.
23///
24/// # Examples
25///
26/// ```ignore
27/// use ftui_widgets::rule::Rule;
28/// use ftui_widgets::block::Alignment;
29///
30/// // Simple divider
31/// let rule = Rule::new();
32///
33/// // Titled divider, centered
34/// let rule = Rule::new()
35///     .title("Section")
36///     .title_alignment(Alignment::Center);
37/// ```
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct Rule<'a> {
40    /// Optional title text.
41    title: Option<&'a str>,
42    /// Title alignment.
43    title_alignment: Alignment,
44    /// Style for the rule line characters.
45    style: Style,
46    /// Style for the title text (if different from rule style).
47    title_style: Option<Style>,
48    /// Border type determining the line character.
49    border_type: BorderType,
50}
51
52impl<'a> Default for Rule<'a> {
53    fn default() -> Self {
54        Self {
55            title: None,
56            title_alignment: Alignment::Center,
57            style: Style::default(),
58            title_style: None,
59            border_type: BorderType::Square,
60        }
61    }
62}
63
64impl<'a> Rule<'a> {
65    /// Create a new rule with default settings (square horizontal line, no title).
66    #[must_use]
67    pub fn new() -> Self {
68        Self::default()
69    }
70
71    /// Set the title text.
72    #[must_use]
73    pub fn title(mut self, title: &'a str) -> Self {
74        self.title = Some(title);
75        self
76    }
77
78    /// Set the title alignment.
79    #[must_use]
80    pub fn title_alignment(mut self, alignment: Alignment) -> Self {
81        self.title_alignment = alignment;
82        self
83    }
84
85    /// Set the style for the rule line.
86    #[must_use]
87    pub fn style(mut self, style: Style) -> Self {
88        self.style = style;
89        self
90    }
91
92    /// Set a separate style for the title text.
93    ///
94    /// If not set, the rule's main style is used for the title.
95    #[must_use]
96    pub fn title_style(mut self, style: Style) -> Self {
97        self.title_style = Some(style);
98        self
99    }
100
101    /// Set the border type (determines the line character).
102    #[must_use]
103    pub fn border_type(mut self, border_type: BorderType) -> Self {
104        self.border_type = border_type;
105        self
106    }
107
108    /// Fill a range of cells with the rule character.
109    fn fill_rule_char(&self, buf: &mut Buffer, y: u16, start: u16, end: u16) {
110        let ch = if buf.degradation.use_unicode_borders() {
111            self.border_type.to_border_set().horizontal
112        } else {
113            '-' // ASCII fallback
114        };
115        let style = if buf.degradation.apply_styling() {
116            self.style
117        } else {
118            Style::default()
119        };
120        for x in start..end {
121            let mut cell = Cell::from_char(ch);
122            apply_style(&mut cell, style);
123            buf.set_fast(x, y, cell);
124        }
125    }
126}
127
128impl Widget for Rule<'_> {
129    fn render(&self, area: Rect, frame: &mut Frame) {
130        #[cfg(feature = "tracing")]
131        let _span = tracing::debug_span!(
132            "widget_render",
133            widget = "Rule",
134            x = area.x,
135            y = area.y,
136            w = area.width,
137            h = area.height
138        )
139        .entered();
140
141        if area.is_empty() {
142            return;
143        }
144
145        // Rule is decorative — skip at EssentialOnly+
146        if !frame.buffer.degradation.render_decorative() {
147            clear_text_row(
148                frame,
149                Rect::new(area.x, area.y, area.width, 1),
150                Style::default(),
151            );
152            return;
153        }
154
155        let deg = frame.buffer.degradation;
156        let y = area.y;
157        let width = area.width;
158        let rule_style = if deg.apply_styling() {
159            self.style
160        } else {
161            Style::default()
162        };
163        let title_style = if deg.apply_styling() {
164            self.title_style.unwrap_or(self.style)
165        } else {
166            Style::default()
167        };
168
169        match self.title {
170            None => {
171                // No title: fill the entire width with rule characters.
172                self.fill_rule_char(&mut frame.buffer, y, area.x, area.right());
173            }
174            Some("") => self.fill_rule_char(&mut frame.buffer, y, area.x, area.right()),
175            Some(title) => {
176                let title_width = display_width(title) as u16;
177
178                // Need at least 1 char of padding on each side of the title,
179                // plus the title itself. If the area is too narrow, just draw
180                // the rule without a title.
181                let min_width_for_title = title_width.saturating_add(2);
182                if width < min_width_for_title || width < 3 {
183                    // Too narrow for title + padding; fall back to plain rule.
184                    // If title fits exactly, truncate and show just the rule.
185                    if title_width > width {
186                        // Title doesn't even fit; just draw the rule line.
187                        self.fill_rule_char(&mut frame.buffer, y, area.x, area.right());
188                    } else {
189                        // Title fits but no room for rule chars; show truncated title.
190                        draw_text_span(frame, area.x, y, title, title_style, area.right());
191                        // Fill remaining with rule
192                        let after = area.x.saturating_add(title_width);
193                        self.fill_rule_char(&mut frame.buffer, y, after, area.right());
194                    }
195                    return;
196                }
197
198                // Truncate title if it won't fit with padding.
199                let max_title_width = width.saturating_sub(2);
200                let display_width = title_width.min(max_title_width);
201
202                // Calculate where the title block starts (including 1-char pad on each side).
203                let title_block_width = display_width + 2; // pad + title + pad
204                let title_block_x = match self.title_alignment {
205                    Alignment::Left => area.x,
206                    Alignment::Center => area
207                        .x
208                        .saturating_add((width.saturating_sub(title_block_width)) / 2),
209                    Alignment::Right => area.right().saturating_sub(title_block_width),
210                };
211
212                // Draw left rule section.
213                self.fill_rule_char(&mut frame.buffer, y, area.x, title_block_x);
214
215                // Draw left padding space.
216                let pad_x = title_block_x;
217                let mut cell_pad_l = Cell::from_char(' ');
218                crate::apply_style(&mut cell_pad_l, rule_style);
219                frame.buffer.set_fast(pad_x, y, cell_pad_l);
220
221                // Draw title text.
222                let title_x = pad_x.saturating_add(1);
223                let title_end = title_x.saturating_add(display_width);
224                draw_text_span(frame, title_x, y, title, title_style, title_end);
225
226                // Draw right padding space.
227                let right_pad_x = title_end;
228                if right_pad_x < area.right() {
229                    let mut cell_pad_r = Cell::from_char(' ');
230                    crate::apply_style(&mut cell_pad_r, rule_style);
231                    frame.buffer.set_fast(right_pad_x, y, cell_pad_r);
232                }
233
234                // Draw right rule section.
235                let right_rule_start = right_pad_x.saturating_add(1);
236                self.fill_rule_char(&mut frame.buffer, y, right_rule_start, area.right());
237            }
238        }
239    }
240}
241
242impl MeasurableWidget for Rule<'_> {
243    fn measure(&self, _available: Size) -> SizeConstraints {
244        // Rule is always exactly 1 cell tall
245        // Minimum width is 1 (single rule char), preferred depends on title
246        let min_width = 1u16;
247
248        let preferred_width = if let Some(title) = self.title {
249            // Title + padding (1 space on each side) + at least 2 rule chars
250            let title_width = display_width(title) as u16;
251            title_width.saturating_add(4) // title + 2 spaces + 2 rule chars minimum
252        } else {
253            1 // Just a single rule char is fine
254        };
255
256        SizeConstraints {
257            min: Size::new(min_width, 1),
258            preferred: Size::new(preferred_width, 1),
259            max: Some(Size::new(u16::MAX, 1)), // Fixed height of 1
260        }
261    }
262
263    fn has_intrinsic_size(&self) -> bool {
264        // Rule always has intrinsic height of 1
265        true
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272    use ftui_render::cell::PackedRgba;
273    use ftui_render::grapheme_pool::GraphemePool;
274
275    /// Helper: extract row content as chars from a buffer.
276    fn row_chars(buf: &Buffer, y: u16, width: u16) -> Vec<char> {
277        (0..width)
278            .map(|x| {
279                buf.get(x, y)
280                    .and_then(|c| c.content.as_char())
281                    .unwrap_or(' ')
282            })
283            .collect()
284    }
285
286    /// Helper: row content as a String (trimming trailing spaces).
287    fn row_string(buf: &Buffer, y: u16, width: u16) -> String {
288        let chars: String = row_chars(buf, y, width).into_iter().collect();
289        chars.trim_end().to_string()
290    }
291
292    // --- No-title tests ---
293
294    #[test]
295    fn no_title_fills_width() {
296        let rule = Rule::new();
297        let area = Rect::new(0, 0, 10, 1);
298        let mut pool = GraphemePool::new();
299        let mut frame = Frame::new(10, 1, &mut pool);
300        rule.render(area, &mut frame);
301
302        let row = row_chars(&frame.buffer, 0, 10);
303        assert!(
304            row.iter().all(|&c| c == '─'),
305            "Expected all ─, got: {row:?}"
306        );
307    }
308
309    #[test]
310    fn no_title_heavy_border() {
311        let rule = Rule::new().border_type(BorderType::Heavy);
312        let area = Rect::new(0, 0, 5, 1);
313        let mut pool = GraphemePool::new();
314        let mut frame = Frame::new(5, 1, &mut pool);
315        rule.render(area, &mut frame);
316
317        let row = row_chars(&frame.buffer, 0, 5);
318        assert!(
319            row.iter().all(|&c| c == '━'),
320            "Expected all ━, got: {row:?}"
321        );
322    }
323
324    #[test]
325    fn no_title_double_border() {
326        let rule = Rule::new().border_type(BorderType::Double);
327        let area = Rect::new(0, 0, 5, 1);
328        let mut pool = GraphemePool::new();
329        let mut frame = Frame::new(5, 1, &mut pool);
330        rule.render(area, &mut frame);
331
332        let row = row_chars(&frame.buffer, 0, 5);
333        assert!(
334            row.iter().all(|&c| c == '═'),
335            "Expected all ═, got: {row:?}"
336        );
337    }
338
339    #[test]
340    fn no_title_ascii_border() {
341        let rule = Rule::new().border_type(BorderType::Ascii);
342        let area = Rect::new(0, 0, 5, 1);
343        let mut pool = GraphemePool::new();
344        let mut frame = Frame::new(5, 1, &mut pool);
345        rule.render(area, &mut frame);
346
347        let row = row_chars(&frame.buffer, 0, 5);
348        assert!(
349            row.iter().all(|&c| c == '-'),
350            "Expected all -, got: {row:?}"
351        );
352    }
353
354    // --- Titled tests ---
355
356    #[test]
357    fn title_center_default() {
358        let rule = Rule::new().title("Hi");
359        let area = Rect::new(0, 0, 20, 1);
360        let mut pool = GraphemePool::new();
361        let mut frame = Frame::new(20, 1, &mut pool);
362        rule.render(area, &mut frame);
363
364        let s = row_string(&frame.buffer, 0, 20);
365        assert!(
366            s.contains(" Hi "),
367            "Expected centered title with spaces, got: '{s}'"
368        );
369        assert!(s.contains('─'), "Expected rule chars, got: '{s}'");
370    }
371
372    #[test]
373    fn title_left_aligned() {
374        let rule = Rule::new().title("Hi").title_alignment(Alignment::Left);
375        let area = Rect::new(0, 0, 20, 1);
376        let mut pool = GraphemePool::new();
377        let mut frame = Frame::new(20, 1, &mut pool);
378        rule.render(area, &mut frame);
379
380        let s = row_string(&frame.buffer, 0, 20);
381        assert!(
382            s.starts_with(" Hi "),
383            "Left-aligned should start with ' Hi ', got: '{s}'"
384        );
385    }
386
387    #[test]
388    fn title_right_aligned() {
389        let rule = Rule::new().title("Hi").title_alignment(Alignment::Right);
390        let area = Rect::new(0, 0, 20, 1);
391        let mut pool = GraphemePool::new();
392        let mut frame = Frame::new(20, 1, &mut pool);
393        rule.render(area, &mut frame);
394
395        let s = row_string(&frame.buffer, 0, 20);
396        assert!(
397            s.ends_with(" Hi"),
398            "Right-aligned should end with ' Hi', got: '{s}'"
399        );
400    }
401
402    #[test]
403    fn title_truncated_at_narrow_width() {
404        // Title "Hello" is 5 chars, needs 7 with padding. Width is 7 exactly.
405        let rule = Rule::new().title("Hello");
406        let area = Rect::new(0, 0, 7, 1);
407        let mut pool = GraphemePool::new();
408        let mut frame = Frame::new(7, 1, &mut pool);
409        rule.render(area, &mut frame);
410
411        let s = row_string(&frame.buffer, 0, 7);
412        assert!(s.contains("Hello"), "Title should be present, got: '{s}'");
413    }
414
415    #[test]
416    fn title_too_wide_falls_back_to_rule() {
417        // Title "VeryLongTitle" is 13 chars, area is 5 wide. Can't fit.
418        let rule = Rule::new().title("VeryLongTitle");
419        let area = Rect::new(0, 0, 5, 1);
420        let mut pool = GraphemePool::new();
421        let mut frame = Frame::new(5, 1, &mut pool);
422        rule.render(area, &mut frame);
423
424        let row = row_chars(&frame.buffer, 0, 5);
425        // Should fall back to plain rule since title doesn't fit
426        assert!(
427            row.iter().all(|&c| c == '─'),
428            "Expected fallback to rule, got: {row:?}"
429        );
430    }
431
432    #[test]
433    fn empty_title_same_as_no_title() {
434        let rule = Rule::new().title("");
435        let area = Rect::new(0, 0, 10, 1);
436        let mut pool = GraphemePool::new();
437        let mut frame = Frame::new(10, 1, &mut pool);
438        rule.render(area, &mut frame);
439
440        let row = row_chars(&frame.buffer, 0, 10);
441        assert!(
442            row.iter().all(|&c| c == '─'),
443            "Empty title should be plain rule, got: {row:?}"
444        );
445    }
446
447    // --- Edge cases ---
448
449    #[test]
450    fn zero_width_no_panic() {
451        let rule = Rule::new().title("Test");
452        let area = Rect::new(0, 0, 0, 0);
453        let mut pool = GraphemePool::new();
454        let mut frame = Frame::new(1, 1, &mut pool);
455        rule.render(area, &mut frame);
456        // Should not panic
457    }
458
459    #[test]
460    fn width_one_no_title() {
461        let rule = Rule::new();
462        let area = Rect::new(0, 0, 1, 1);
463        let mut pool = GraphemePool::new();
464        let mut frame = Frame::new(1, 1, &mut pool);
465        rule.render(area, &mut frame);
466
467        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('─'));
468    }
469
470    #[test]
471    fn width_two_with_title() {
472        // Width 2, title "X" (1 char). min_width_for_title = 3. Falls back.
473        let rule = Rule::new().title("X");
474        let area = Rect::new(0, 0, 2, 1);
475        let mut pool = GraphemePool::new();
476        let mut frame = Frame::new(2, 1, &mut pool);
477        rule.render(area, &mut frame);
478
479        // Title "X" fits in 2 but no room for padding; should show "X" + rule or just rule
480        let s = row_string(&frame.buffer, 0, 2);
481        assert!(!s.is_empty(), "Should render something, got empty");
482    }
483
484    #[test]
485    fn offset_area() {
486        // Rule rendered at a non-zero origin.
487        let rule = Rule::new();
488        let area = Rect::new(5, 3, 10, 1);
489        let mut pool = GraphemePool::new();
490        let mut frame = Frame::new(20, 5, &mut pool);
491        rule.render(area, &mut frame);
492
493        // Cells before the area should be untouched (space/default)
494        assert_ne!(frame.buffer.get(4, 3).unwrap().content.as_char(), Some('─'));
495        // Cells in the area should be rule chars
496        assert_eq!(frame.buffer.get(5, 3).unwrap().content.as_char(), Some('─'));
497        assert_eq!(
498            frame.buffer.get(14, 3).unwrap().content.as_char(),
499            Some('─')
500        );
501        // Cell after the area should be untouched
502        assert_ne!(
503            frame.buffer.get(15, 3).unwrap().content.as_char(),
504            Some('─')
505        );
506    }
507
508    #[test]
509    fn style_applied_to_rule_chars() {
510        use ftui_render::cell::PackedRgba;
511
512        let fg = PackedRgba::rgb(255, 0, 0);
513        let rule = Rule::new().style(Style::new().fg(fg));
514        let area = Rect::new(0, 0, 5, 1);
515        let mut pool = GraphemePool::new();
516        let mut frame = Frame::new(5, 1, &mut pool);
517        rule.render(area, &mut frame);
518
519        for x in 0..5 {
520            assert_eq!(frame.buffer.get(x, 0).unwrap().fg, fg);
521        }
522    }
523
524    #[test]
525    fn title_style_distinct_from_rule_style() {
526        use ftui_render::cell::PackedRgba;
527
528        let rule_fg = PackedRgba::rgb(255, 0, 0);
529        let title_fg = PackedRgba::rgb(0, 255, 0);
530        let rule = Rule::new()
531            .title("AB")
532            .title_alignment(Alignment::Center)
533            .style(Style::new().fg(rule_fg))
534            .title_style(Style::new().fg(title_fg));
535        let area = Rect::new(0, 0, 20, 1);
536        let mut pool = GraphemePool::new();
537        let mut frame = Frame::new(20, 1, &mut pool);
538        rule.render(area, &mut frame);
539
540        // Find the title characters and check their fg
541        let mut found_title = false;
542        for x in 0..20u16 {
543            if let Some(cell) = frame.buffer.get(x, 0)
544                && cell.content.as_char() == Some('A')
545            {
546                assert_eq!(cell.fg, title_fg, "Title char should have title_fg");
547                found_title = true;
548            }
549        }
550        assert!(found_title, "Should have found title character 'A'");
551
552        // Check that rule chars have rule_fg
553        let first = frame.buffer.get(0, 0).unwrap();
554        assert_eq!(first.content.as_char(), Some('─'));
555        assert_eq!(first.fg, rule_fg, "Rule char should have rule_fg");
556    }
557
558    // --- Unicode title ---
559
560    #[test]
561    fn unicode_title() {
562        // Japanese characters (each 2 cells wide)
563        let rule = Rule::new().title("日本");
564        let area = Rect::new(0, 0, 20, 1);
565        let mut pool = GraphemePool::new();
566        let mut frame = Frame::new(20, 1, &mut pool);
567        rule.render(area, &mut frame);
568
569        let s = row_string(&frame.buffer, 0, 20);
570        assert!(s.contains('─'), "Should contain rule chars, got: '{s}'");
571        // The unicode title should be rendered somewhere in the middle.
572        // Wide characters are stored as grapheme IDs, so we check for
573        // non-empty cells with width > 1 (indicating a wide character).
574        let mut found_wide = false;
575        for x in 0..20u16 {
576            if let Some(cell) = frame.buffer.get(x, 0)
577                && !cell.is_empty()
578                && cell.content.width() > 1
579            {
580                found_wide = true;
581                break;
582            }
583        }
584        assert!(found_wide, "Should have rendered unicode title (wide char)");
585    }
586
587    // --- Degradation tests ---
588
589    #[test]
590    fn degradation_essential_only_skips_entirely() {
591        use ftui_render::budget::DegradationLevel;
592
593        let rule = Rule::new();
594        let area = Rect::new(0, 0, 10, 1);
595        let mut pool = GraphemePool::new();
596        let mut frame = Frame::new(10, 1, &mut pool);
597        rule.render(area, &mut frame);
598        frame.buffer.degradation = DegradationLevel::EssentialOnly;
599        rule.render(area, &mut frame);
600
601        for x in 0..10u16 {
602            assert_eq!(frame.buffer.get(x, 0).unwrap().content.as_char(), Some(' '));
603        }
604    }
605
606    #[test]
607    fn degradation_skeleton_skips_entirely() {
608        use ftui_render::budget::DegradationLevel;
609
610        let rule = Rule::new();
611        let area = Rect::new(0, 0, 10, 1);
612        let mut pool = GraphemePool::new();
613        let mut frame = Frame::new(10, 1, &mut pool);
614        rule.render(area, &mut frame);
615        frame.buffer.degradation = DegradationLevel::Skeleton;
616        rule.render(area, &mut frame);
617
618        for x in 0..10u16 {
619            assert_eq!(frame.buffer.get(x, 0).unwrap().content.as_char(), Some(' '));
620        }
621    }
622
623    #[test]
624    fn degradation_simple_borders_uses_ascii() {
625        use ftui_render::budget::DegradationLevel;
626
627        let rule = Rule::new().border_type(BorderType::Square);
628        let area = Rect::new(0, 0, 10, 1);
629        let mut pool = GraphemePool::new();
630        let mut frame = Frame::new(10, 1, &mut pool);
631        frame.buffer.degradation = DegradationLevel::SimpleBorders;
632        rule.render(area, &mut frame);
633
634        // Should use ASCII '-' instead of Unicode '─'
635        let row = row_chars(&frame.buffer, 0, 10);
636        assert!(
637            row.iter().all(|&c| c == '-'),
638            "Expected all -, got: {row:?}"
639        );
640    }
641
642    #[test]
643    fn degradation_full_uses_unicode() {
644        use ftui_render::budget::DegradationLevel;
645
646        let rule = Rule::new().border_type(BorderType::Square);
647        let area = Rect::new(0, 0, 10, 1);
648        let mut pool = GraphemePool::new();
649        let mut frame = Frame::new(10, 1, &mut pool);
650        frame.buffer.degradation = DegradationLevel::Full;
651        rule.render(area, &mut frame);
652
653        let row = row_chars(&frame.buffer, 0, 10);
654        assert!(
655            row.iter().all(|&c| c == '─'),
656            "Expected all ─, got: {row:?}"
657        );
658    }
659
660    #[test]
661    fn degradation_no_styling_drops_title_and_padding_styles() {
662        use ftui_render::budget::DegradationLevel;
663
664        let rule_fg = PackedRgba::rgb(255, 0, 0);
665        let title_fg = PackedRgba::rgb(0, 255, 0);
666        let rule = Rule::new()
667            .title("Hi")
668            .style(Style::new().fg(rule_fg).bg(PackedRgba::rgb(1, 2, 3)))
669            .title_style(Style::new().fg(title_fg).bg(PackedRgba::rgb(4, 5, 6)));
670        let area = Rect::new(0, 0, 10, 1);
671        let mut pool = GraphemePool::new();
672        let mut frame = Frame::new(10, 1, &mut pool);
673        frame.buffer.degradation = DegradationLevel::NoStyling;
674        rule.render(area, &mut frame);
675
676        let row = row_chars(&frame.buffer, 0, 10);
677        let title_x = row
678            .iter()
679            .position(|&c| c == 'H')
680            .expect("title should render");
681        let title_cell = frame.buffer.get(title_x as u16, 0).unwrap();
682        let left_pad = frame.buffer.get(title_x as u16 - 1, 0).unwrap();
683
684        assert_ne!(title_cell.fg, title_fg);
685        assert_ne!(left_pad.fg, rule_fg);
686        assert_ne!(left_pad.bg, PackedRgba::rgb(1, 2, 3));
687    }
688
689    #[test]
690    fn degradation_no_styling_narrow_title_branch_drops_styles() {
691        use ftui_render::budget::DegradationLevel;
692
693        let title_fg = PackedRgba::rgb(0, 255, 0);
694        let rule = Rule::new()
695            .title("X")
696            .style(Style::new().fg(PackedRgba::rgb(255, 0, 0)))
697            .title_style(Style::new().fg(title_fg).bg(PackedRgba::rgb(4, 5, 6)));
698        let area = Rect::new(0, 0, 2, 1);
699        let mut pool = GraphemePool::new();
700        let mut frame = Frame::new(2, 1, &mut pool);
701        frame.buffer.degradation = DegradationLevel::NoStyling;
702        rule.render(area, &mut frame);
703
704        let title_cell = frame.buffer.get(0, 0).unwrap();
705        assert_eq!(title_cell.content.as_char(), Some('X'));
706        assert_ne!(title_cell.fg, title_fg);
707        assert_ne!(title_cell.bg, PackedRgba::rgb(4, 5, 6));
708    }
709
710    // --- MeasurableWidget tests ---
711
712    use crate::MeasurableWidget;
713    use ftui_core::geometry::Size;
714
715    #[test]
716    fn measure_no_title() {
717        let rule = Rule::new();
718        let constraints = rule.measure(Size::MAX);
719
720        // Min is 1x1, preferred is 1x1, max height is 1
721        assert_eq!(constraints.min, Size::new(1, 1));
722        assert_eq!(constraints.preferred, Size::new(1, 1));
723        assert_eq!(constraints.max, Some(Size::new(u16::MAX, 1)));
724    }
725
726    #[test]
727    fn measure_with_title() {
728        let rule = Rule::new().title("Test");
729        let constraints = rule.measure(Size::MAX);
730
731        // "Test" is 4 chars, plus 2 spaces padding, plus 2 rule chars = 8
732        assert_eq!(constraints.min, Size::new(1, 1));
733        assert_eq!(constraints.preferred, Size::new(8, 1));
734        assert_eq!(constraints.max.unwrap().height, 1);
735    }
736
737    #[test]
738    fn measure_with_long_title() {
739        let rule = Rule::new().title("Very Long Title");
740        let constraints = rule.measure(Size::MAX);
741
742        // "Very Long Title" is 15 chars, + 4 = 19
743        assert_eq!(constraints.preferred, Size::new(19, 1));
744    }
745
746    #[test]
747    fn measure_fixed_height() {
748        let rule = Rule::new().title("Hi");
749        let constraints = rule.measure(Size::MAX);
750
751        // Height is always exactly 1
752        assert_eq!(constraints.min.height, 1);
753        assert_eq!(constraints.preferred.height, 1);
754        assert_eq!(constraints.max.unwrap().height, 1);
755    }
756
757    #[test]
758    fn rule_has_intrinsic_size() {
759        let rule = Rule::new();
760        assert!(rule.has_intrinsic_size());
761    }
762
763    #[test]
764    fn rule_measure_is_pure() {
765        let rule = Rule::new().title("Hello");
766        let a = rule.measure(Size::new(100, 50));
767        let b = rule.measure(Size::new(100, 50));
768        assert_eq!(a, b);
769    }
770}