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, 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            return;
148        }
149
150        let y = area.y;
151        let width = area.width;
152
153        match self.title {
154            None => {
155                // No title: fill the entire width with rule characters.
156                self.fill_rule_char(&mut frame.buffer, y, area.x, area.right());
157            }
158            Some("") => self.fill_rule_char(&mut frame.buffer, y, area.x, area.right()),
159            Some(title) => {
160                let title_width = display_width(title) as u16;
161
162                // Need at least 1 char of padding on each side of the title,
163                // plus the title itself. If the area is too narrow, just draw
164                // the rule without a title.
165                let min_width_for_title = title_width.saturating_add(2);
166                if width < min_width_for_title || width < 3 {
167                    // Too narrow for title + padding; fall back to plain rule.
168                    // If title fits exactly, truncate and show just the rule.
169                    if title_width > width {
170                        // Title doesn't even fit; just draw the rule line.
171                        self.fill_rule_char(&mut frame.buffer, y, area.x, area.right());
172                    } else {
173                        // Title fits but no room for rule chars; show truncated title.
174                        let ts = self.title_style.unwrap_or(self.style);
175                        draw_text_span(frame, area.x, y, title, ts, area.right());
176                        // Fill remaining with rule
177                        let after = area.x.saturating_add(title_width);
178                        self.fill_rule_char(&mut frame.buffer, y, after, area.right());
179                    }
180                    return;
181                }
182
183                // Truncate title if it won't fit with padding.
184                let max_title_width = width.saturating_sub(2);
185                let display_width = title_width.min(max_title_width);
186
187                // Calculate where the title block starts (including 1-char pad on each side).
188                let title_block_width = display_width + 2; // pad + title + pad
189                let title_block_x = match self.title_alignment {
190                    Alignment::Left => area.x,
191                    Alignment::Center => area
192                        .x
193                        .saturating_add((width.saturating_sub(title_block_width)) / 2),
194                    Alignment::Right => area.right().saturating_sub(title_block_width),
195                };
196
197                // Draw left rule section.
198                self.fill_rule_char(&mut frame.buffer, y, area.x, title_block_x);
199
200                // Draw left padding space.
201                let pad_x = title_block_x;
202                if let Some(cell) = frame.buffer.get_mut(pad_x, y) {
203                    *cell = Cell::from_char(' ');
204                    apply_style(cell, self.style);
205                }
206
207                // Draw title text.
208                let ts = self.title_style.unwrap_or(self.style);
209                let title_x = pad_x.saturating_add(1);
210                let title_end = title_x.saturating_add(display_width);
211                draw_text_span(frame, title_x, y, title, ts, title_end);
212
213                // Draw right padding space.
214                let right_pad_x = title_end;
215                if right_pad_x < area.right()
216                    && let Some(cell) = frame.buffer.get_mut(right_pad_x, y)
217                {
218                    *cell = Cell::from_char(' ');
219                    apply_style(cell, self.style);
220                }
221
222                // Draw right rule section.
223                let right_rule_start = right_pad_x.saturating_add(1);
224                self.fill_rule_char(&mut frame.buffer, y, right_rule_start, area.right());
225            }
226        }
227    }
228}
229
230impl MeasurableWidget for Rule<'_> {
231    fn measure(&self, _available: Size) -> SizeConstraints {
232        // Rule is always exactly 1 cell tall
233        // Minimum width is 1 (single rule char), preferred depends on title
234        let min_width = 1u16;
235
236        let preferred_width = if let Some(title) = self.title {
237            // Title + padding (1 space on each side) + at least 2 rule chars
238            let title_width = display_width(title) as u16;
239            title_width.saturating_add(4) // title + 2 spaces + 2 rule chars minimum
240        } else {
241            1 // Just a single rule char is fine
242        };
243
244        SizeConstraints {
245            min: Size::new(min_width, 1),
246            preferred: Size::new(preferred_width, 1),
247            max: Some(Size::new(u16::MAX, 1)), // Fixed height of 1
248        }
249    }
250
251    fn has_intrinsic_size(&self) -> bool {
252        // Rule always has intrinsic height of 1
253        true
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    use ftui_render::grapheme_pool::GraphemePool;
261
262    /// Helper: extract row content as chars from a buffer.
263    fn row_chars(buf: &Buffer, y: u16, width: u16) -> Vec<char> {
264        (0..width)
265            .map(|x| {
266                buf.get(x, y)
267                    .and_then(|c| c.content.as_char())
268                    .unwrap_or(' ')
269            })
270            .collect()
271    }
272
273    /// Helper: row content as a String (trimming trailing spaces).
274    fn row_string(buf: &Buffer, y: u16, width: u16) -> String {
275        let chars: String = row_chars(buf, y, width).into_iter().collect();
276        chars.trim_end().to_string()
277    }
278
279    // --- No-title tests ---
280
281    #[test]
282    fn no_title_fills_width() {
283        let rule = Rule::new();
284        let area = Rect::new(0, 0, 10, 1);
285        let mut pool = GraphemePool::new();
286        let mut frame = Frame::new(10, 1, &mut pool);
287        rule.render(area, &mut frame);
288
289        let row = row_chars(&frame.buffer, 0, 10);
290        assert!(
291            row.iter().all(|&c| c == '─'),
292            "Expected all ─, got: {row:?}"
293        );
294    }
295
296    #[test]
297    fn no_title_heavy_border() {
298        let rule = Rule::new().border_type(BorderType::Heavy);
299        let area = Rect::new(0, 0, 5, 1);
300        let mut pool = GraphemePool::new();
301        let mut frame = Frame::new(5, 1, &mut pool);
302        rule.render(area, &mut frame);
303
304        let row = row_chars(&frame.buffer, 0, 5);
305        assert!(
306            row.iter().all(|&c| c == '━'),
307            "Expected all ━, got: {row:?}"
308        );
309    }
310
311    #[test]
312    fn no_title_double_border() {
313        let rule = Rule::new().border_type(BorderType::Double);
314        let area = Rect::new(0, 0, 5, 1);
315        let mut pool = GraphemePool::new();
316        let mut frame = Frame::new(5, 1, &mut pool);
317        rule.render(area, &mut frame);
318
319        let row = row_chars(&frame.buffer, 0, 5);
320        assert!(
321            row.iter().all(|&c| c == '═'),
322            "Expected all ═, got: {row:?}"
323        );
324    }
325
326    #[test]
327    fn no_title_ascii_border() {
328        let rule = Rule::new().border_type(BorderType::Ascii);
329        let area = Rect::new(0, 0, 5, 1);
330        let mut pool = GraphemePool::new();
331        let mut frame = Frame::new(5, 1, &mut pool);
332        rule.render(area, &mut frame);
333
334        let row = row_chars(&frame.buffer, 0, 5);
335        assert!(
336            row.iter().all(|&c| c == '-'),
337            "Expected all -, got: {row:?}"
338        );
339    }
340
341    // --- Titled tests ---
342
343    #[test]
344    fn title_center_default() {
345        let rule = Rule::new().title("Hi");
346        let area = Rect::new(0, 0, 20, 1);
347        let mut pool = GraphemePool::new();
348        let mut frame = Frame::new(20, 1, &mut pool);
349        rule.render(area, &mut frame);
350
351        let s = row_string(&frame.buffer, 0, 20);
352        assert!(
353            s.contains(" Hi "),
354            "Expected centered title with spaces, got: '{s}'"
355        );
356        assert!(s.contains('─'), "Expected rule chars, got: '{s}'");
357    }
358
359    #[test]
360    fn title_left_aligned() {
361        let rule = Rule::new().title("Hi").title_alignment(Alignment::Left);
362        let area = Rect::new(0, 0, 20, 1);
363        let mut pool = GraphemePool::new();
364        let mut frame = Frame::new(20, 1, &mut pool);
365        rule.render(area, &mut frame);
366
367        let s = row_string(&frame.buffer, 0, 20);
368        assert!(
369            s.starts_with(" Hi "),
370            "Left-aligned should start with ' Hi ', got: '{s}'"
371        );
372    }
373
374    #[test]
375    fn title_right_aligned() {
376        let rule = Rule::new().title("Hi").title_alignment(Alignment::Right);
377        let area = Rect::new(0, 0, 20, 1);
378        let mut pool = GraphemePool::new();
379        let mut frame = Frame::new(20, 1, &mut pool);
380        rule.render(area, &mut frame);
381
382        let s = row_string(&frame.buffer, 0, 20);
383        assert!(
384            s.ends_with(" Hi"),
385            "Right-aligned should end with ' Hi', got: '{s}'"
386        );
387    }
388
389    #[test]
390    fn title_truncated_at_narrow_width() {
391        // Title "Hello" is 5 chars, needs 7 with padding. Width is 7 exactly.
392        let rule = Rule::new().title("Hello");
393        let area = Rect::new(0, 0, 7, 1);
394        let mut pool = GraphemePool::new();
395        let mut frame = Frame::new(7, 1, &mut pool);
396        rule.render(area, &mut frame);
397
398        let s = row_string(&frame.buffer, 0, 7);
399        assert!(s.contains("Hello"), "Title should be present, got: '{s}'");
400    }
401
402    #[test]
403    fn title_too_wide_falls_back_to_rule() {
404        // Title "VeryLongTitle" is 13 chars, area is 5 wide. Can't fit.
405        let rule = Rule::new().title("VeryLongTitle");
406        let area = Rect::new(0, 0, 5, 1);
407        let mut pool = GraphemePool::new();
408        let mut frame = Frame::new(5, 1, &mut pool);
409        rule.render(area, &mut frame);
410
411        let row = row_chars(&frame.buffer, 0, 5);
412        // Should fall back to plain rule since title doesn't fit
413        assert!(
414            row.iter().all(|&c| c == '─'),
415            "Expected fallback to rule, got: {row:?}"
416        );
417    }
418
419    #[test]
420    fn empty_title_same_as_no_title() {
421        let rule = Rule::new().title("");
422        let area = Rect::new(0, 0, 10, 1);
423        let mut pool = GraphemePool::new();
424        let mut frame = Frame::new(10, 1, &mut pool);
425        rule.render(area, &mut frame);
426
427        let row = row_chars(&frame.buffer, 0, 10);
428        assert!(
429            row.iter().all(|&c| c == '─'),
430            "Empty title should be plain rule, got: {row:?}"
431        );
432    }
433
434    // --- Edge cases ---
435
436    #[test]
437    fn zero_width_no_panic() {
438        let rule = Rule::new().title("Test");
439        let area = Rect::new(0, 0, 0, 0);
440        let mut pool = GraphemePool::new();
441        let mut frame = Frame::new(1, 1, &mut pool);
442        rule.render(area, &mut frame);
443        // Should not panic
444    }
445
446    #[test]
447    fn width_one_no_title() {
448        let rule = Rule::new();
449        let area = Rect::new(0, 0, 1, 1);
450        let mut pool = GraphemePool::new();
451        let mut frame = Frame::new(1, 1, &mut pool);
452        rule.render(area, &mut frame);
453
454        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('─'));
455    }
456
457    #[test]
458    fn width_two_with_title() {
459        // Width 2, title "X" (1 char). min_width_for_title = 3. Falls back.
460        let rule = Rule::new().title("X");
461        let area = Rect::new(0, 0, 2, 1);
462        let mut pool = GraphemePool::new();
463        let mut frame = Frame::new(2, 1, &mut pool);
464        rule.render(area, &mut frame);
465
466        // Title "X" fits in 2 but no room for padding; should show "X" + rule or just rule
467        let s = row_string(&frame.buffer, 0, 2);
468        assert!(!s.is_empty(), "Should render something, got empty");
469    }
470
471    #[test]
472    fn offset_area() {
473        // Rule rendered at a non-zero origin.
474        let rule = Rule::new();
475        let area = Rect::new(5, 3, 10, 1);
476        let mut pool = GraphemePool::new();
477        let mut frame = Frame::new(20, 5, &mut pool);
478        rule.render(area, &mut frame);
479
480        // Cells before the area should be untouched (space/default)
481        assert_ne!(frame.buffer.get(4, 3).unwrap().content.as_char(), Some('─'));
482        // Cells in the area should be rule chars
483        assert_eq!(frame.buffer.get(5, 3).unwrap().content.as_char(), Some('─'));
484        assert_eq!(
485            frame.buffer.get(14, 3).unwrap().content.as_char(),
486            Some('─')
487        );
488        // Cell after the area should be untouched
489        assert_ne!(
490            frame.buffer.get(15, 3).unwrap().content.as_char(),
491            Some('─')
492        );
493    }
494
495    #[test]
496    fn style_applied_to_rule_chars() {
497        use ftui_render::cell::PackedRgba;
498
499        let fg = PackedRgba::rgb(255, 0, 0);
500        let rule = Rule::new().style(Style::new().fg(fg));
501        let area = Rect::new(0, 0, 5, 1);
502        let mut pool = GraphemePool::new();
503        let mut frame = Frame::new(5, 1, &mut pool);
504        rule.render(area, &mut frame);
505
506        for x in 0..5 {
507            assert_eq!(frame.buffer.get(x, 0).unwrap().fg, fg);
508        }
509    }
510
511    #[test]
512    fn title_style_distinct_from_rule_style() {
513        use ftui_render::cell::PackedRgba;
514
515        let rule_fg = PackedRgba::rgb(255, 0, 0);
516        let title_fg = PackedRgba::rgb(0, 255, 0);
517        let rule = Rule::new()
518            .title("AB")
519            .title_alignment(Alignment::Center)
520            .style(Style::new().fg(rule_fg))
521            .title_style(Style::new().fg(title_fg));
522        let area = Rect::new(0, 0, 20, 1);
523        let mut pool = GraphemePool::new();
524        let mut frame = Frame::new(20, 1, &mut pool);
525        rule.render(area, &mut frame);
526
527        // Find the title characters and check their fg
528        let mut found_title = false;
529        for x in 0..20u16 {
530            if let Some(cell) = frame.buffer.get(x, 0)
531                && cell.content.as_char() == Some('A')
532            {
533                assert_eq!(cell.fg, title_fg, "Title char should have title_fg");
534                found_title = true;
535            }
536        }
537        assert!(found_title, "Should have found title character 'A'");
538
539        // Check that rule chars have rule_fg
540        let first = frame.buffer.get(0, 0).unwrap();
541        assert_eq!(first.content.as_char(), Some('─'));
542        assert_eq!(first.fg, rule_fg, "Rule char should have rule_fg");
543    }
544
545    // --- Unicode title ---
546
547    #[test]
548    fn unicode_title() {
549        // Japanese characters (each 2 cells wide)
550        let rule = Rule::new().title("日本");
551        let area = Rect::new(0, 0, 20, 1);
552        let mut pool = GraphemePool::new();
553        let mut frame = Frame::new(20, 1, &mut pool);
554        rule.render(area, &mut frame);
555
556        let s = row_string(&frame.buffer, 0, 20);
557        assert!(s.contains('─'), "Should contain rule chars, got: '{s}'");
558        // The unicode title should be rendered somewhere in the middle.
559        // Wide characters are stored as grapheme IDs, so we check for
560        // non-empty cells with width > 1 (indicating a wide character).
561        let mut found_wide = false;
562        for x in 0..20u16 {
563            if let Some(cell) = frame.buffer.get(x, 0)
564                && !cell.is_empty()
565                && cell.content.width() > 1
566            {
567                found_wide = true;
568                break;
569            }
570        }
571        assert!(found_wide, "Should have rendered unicode title (wide char)");
572    }
573
574    // --- Degradation tests ---
575
576    #[test]
577    fn degradation_essential_only_skips_entirely() {
578        use ftui_render::budget::DegradationLevel;
579
580        let rule = Rule::new();
581        let area = Rect::new(0, 0, 10, 1);
582        let mut pool = GraphemePool::new();
583        let mut frame = Frame::new(10, 1, &mut pool);
584        frame.buffer.degradation = DegradationLevel::EssentialOnly;
585        rule.render(area, &mut frame);
586
587        // Rule is decorative, skipped at EssentialOnly
588        for x in 0..10u16 {
589            assert!(
590                frame.buffer.get(x, 0).unwrap().is_empty(),
591                "cell at x={x} should be empty at EssentialOnly"
592            );
593        }
594    }
595
596    #[test]
597    fn degradation_skeleton_skips_entirely() {
598        use ftui_render::budget::DegradationLevel;
599
600        let rule = Rule::new();
601        let area = Rect::new(0, 0, 10, 1);
602        let mut pool = GraphemePool::new();
603        let mut frame = Frame::new(10, 1, &mut pool);
604        frame.buffer.degradation = DegradationLevel::Skeleton;
605        rule.render(area, &mut frame);
606
607        for x in 0..10u16 {
608            assert!(
609                frame.buffer.get(x, 0).unwrap().is_empty(),
610                "cell at x={x} should be empty at Skeleton"
611            );
612        }
613    }
614
615    #[test]
616    fn degradation_simple_borders_uses_ascii() {
617        use ftui_render::budget::DegradationLevel;
618
619        let rule = Rule::new().border_type(BorderType::Square);
620        let area = Rect::new(0, 0, 10, 1);
621        let mut pool = GraphemePool::new();
622        let mut frame = Frame::new(10, 1, &mut pool);
623        frame.buffer.degradation = DegradationLevel::SimpleBorders;
624        rule.render(area, &mut frame);
625
626        // Should use ASCII '-' instead of Unicode '─'
627        let row = row_chars(&frame.buffer, 0, 10);
628        assert!(
629            row.iter().all(|&c| c == '-'),
630            "Expected all -, got: {row:?}"
631        );
632    }
633
634    #[test]
635    fn degradation_full_uses_unicode() {
636        use ftui_render::budget::DegradationLevel;
637
638        let rule = Rule::new().border_type(BorderType::Square);
639        let area = Rect::new(0, 0, 10, 1);
640        let mut pool = GraphemePool::new();
641        let mut frame = Frame::new(10, 1, &mut pool);
642        frame.buffer.degradation = DegradationLevel::Full;
643        rule.render(area, &mut frame);
644
645        let row = row_chars(&frame.buffer, 0, 10);
646        assert!(
647            row.iter().all(|&c| c == '─'),
648            "Expected all ─, got: {row:?}"
649        );
650    }
651
652    // --- MeasurableWidget tests ---
653
654    use crate::MeasurableWidget;
655    use ftui_core::geometry::Size;
656
657    #[test]
658    fn measure_no_title() {
659        let rule = Rule::new();
660        let constraints = rule.measure(Size::MAX);
661
662        // Min is 1x1, preferred is 1x1, max height is 1
663        assert_eq!(constraints.min, Size::new(1, 1));
664        assert_eq!(constraints.preferred, Size::new(1, 1));
665        assert_eq!(constraints.max, Some(Size::new(u16::MAX, 1)));
666    }
667
668    #[test]
669    fn measure_with_title() {
670        let rule = Rule::new().title("Test");
671        let constraints = rule.measure(Size::MAX);
672
673        // "Test" is 4 chars, plus 2 spaces padding, plus 2 rule chars = 8
674        assert_eq!(constraints.min, Size::new(1, 1));
675        assert_eq!(constraints.preferred, Size::new(8, 1));
676        assert_eq!(constraints.max.unwrap().height, 1);
677    }
678
679    #[test]
680    fn measure_with_long_title() {
681        let rule = Rule::new().title("Very Long Title");
682        let constraints = rule.measure(Size::MAX);
683
684        // "Very Long Title" is 15 chars, + 4 = 19
685        assert_eq!(constraints.preferred, Size::new(19, 1));
686    }
687
688    #[test]
689    fn measure_fixed_height() {
690        let rule = Rule::new().title("Hi");
691        let constraints = rule.measure(Size::MAX);
692
693        // Height is always exactly 1
694        assert_eq!(constraints.min.height, 1);
695        assert_eq!(constraints.preferred.height, 1);
696        assert_eq!(constraints.max.unwrap().height, 1);
697    }
698
699    #[test]
700    fn rule_has_intrinsic_size() {
701        let rule = Rule::new();
702        assert!(rule.has_intrinsic_size());
703    }
704
705    #[test]
706    fn rule_measure_is_pure() {
707        let rule = Rule::new().title("Hello");
708        let a = rule.measure(Size::new(100, 50));
709        let b = rule.measure(Size::new(100, 50));
710        assert_eq!(a, b);
711    }
712}