fast_rich/
rule.rs

1//! Horizontal rules for visual separation.
2//!
3//! A `Rule` draws a horizontal line across the terminal, optionally with
4//! a centered title.
5
6use crate::console::RenderContext;
7use crate::renderable::{Renderable, Segment};
8use crate::style::Style;
9use crate::text::Span;
10
11/// A horizontal rule/line.
12#[derive(Debug, Clone)]
13pub struct Rule {
14    /// Optional title in the center
15    title: Option<String>,
16    /// Character to use for the line
17    character: char,
18    /// Style for the rule
19    style: Style,
20    /// Style for the title
21    title_style: Style,
22    /// Alignment of the title
23    align: RuleAlign,
24}
25
26/// Alignment for rule title.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
28pub enum RuleAlign {
29    /// Left-aligned title
30    Left,
31    /// Center-aligned title (default)
32    #[default]
33    Center,
34    /// Right-aligned title
35    Right,
36}
37
38impl Rule {
39    /// Create a new rule with optional title.
40    pub fn new(title: &str) -> Self {
41        Rule {
42            title: if title.is_empty() {
43                None
44            } else {
45                Some(title.to_string())
46            },
47            character: '─',
48            style: Style::new(),
49            title_style: Style::new(),
50            align: RuleAlign::Center,
51        }
52    }
53
54    /// Create a rule without a title.
55    pub fn line() -> Self {
56        Rule {
57            title: None,
58            character: '─',
59            style: Style::new(),
60            title_style: Style::new(),
61            align: RuleAlign::Center,
62        }
63    }
64
65    /// Set the character used for the line.
66    pub fn character(mut self, c: char) -> Self {
67        self.character = c;
68        self
69    }
70
71    /// Set the style for the rule line.
72    pub fn style(mut self, style: Style) -> Self {
73        self.style = style;
74        self
75    }
76
77    /// Set the style for the title.
78    pub fn title_style(mut self, style: Style) -> Self {
79        self.title_style = style;
80        self
81    }
82
83    /// Set the title alignment.
84    pub fn align(mut self, align: RuleAlign) -> Self {
85        self.align = align;
86        self
87    }
88}
89
90impl Default for Rule {
91    fn default() -> Self {
92        Rule::line()
93    }
94}
95
96impl Renderable for Rule {
97    fn render(&self, context: &RenderContext) -> Vec<Segment> {
98        let width = context.width;
99
100        match &self.title {
101            None => {
102                // Simple line
103                let line = self.character.to_string().repeat(width);
104                vec![Segment::new(vec![Span::styled(line, self.style)])]
105            }
106            Some(title) => {
107                // Parse markup in the title
108                let title_text = crate::markup::parse(title);
109                let title_spans = title_text.spans;
110
111                // Calculate width of parsed title
112                let title_content: String = title_spans.iter().map(|s| s.text.as_ref()).collect();
113                let title_with_spacing = format!(" {} ", title_content);
114                let title_width =
115                    unicode_width::UnicodeWidthStr::width(title_with_spacing.as_str());
116
117                if title_width >= width {
118                    // Title is too long, just show title with markup
119                    return vec![Segment::new(title_spans)];
120                }
121
122                let remaining = width - title_width;
123
124                let (left_len, right_len) = match self.align {
125                    RuleAlign::Left => (4.min(remaining), remaining.saturating_sub(4)),
126                    RuleAlign::Center => {
127                        let left = remaining / 2;
128                        (left, remaining - left)
129                    }
130                    RuleAlign::Right => (remaining.saturating_sub(4), 4.min(remaining)),
131                };
132
133                let left_line = self.character.to_string().repeat(left_len);
134                let right_line = self.character.to_string().repeat(right_len);
135
136                let mut spans = Vec::new();
137                spans.push(Span::styled(left_line, self.style));
138                spans.push(Span::raw(" "));
139                spans.extend(title_spans);
140                spans.push(Span::raw(" "));
141                spans.push(Span::styled(right_line, self.style));
142
143                vec![Segment::new(spans)]
144            }
145        }
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    #[test]
154    fn test_rule_simple() {
155        let rule = Rule::line();
156        let context = RenderContext { width: 10, height: None };
157        let segments = rule.render(&context);
158        assert_eq!(segments.len(), 1);
159        assert_eq!(segments[0].plain_text(), "──────────");
160    }
161
162    #[test]
163    fn test_rule_with_title() {
164        let rule = Rule::new("Title");
165        let context = RenderContext { width: 20, height: None };
166        let segments = rule.render(&context);
167        let text = segments[0].plain_text();
168        assert!(text.contains("Title"));
169        assert!(text.starts_with("─"));
170        assert!(text.ends_with("─"));
171    }
172
173    #[test]
174    fn test_rule_custom_char() {
175        let rule = Rule::line().character('=');
176        let context = RenderContext { width: 5, height: None };
177        let segments = rule.render(&context);
178        assert_eq!(segments[0].plain_text(), "=====");
179    }
180}