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