Skip to main content

rusty_rich/
rule.rs

1//! Rule — horizontal rule / divider. Equivalent to Rich's `rule.py`.
2
3use crate::align::AlignMethod;
4use crate::console::{ConsoleOptions, RenderResult, Renderable};
5use crate::segment::Segment;
6use crate::style::Style;
7use unicode_width::UnicodeWidthStr;
8
9/// A horizontal rule (divider) with an optional title.
10#[derive(Debug, Clone)]
11pub struct Rule {
12    /// Optional title text.
13    pub title: String,
14    /// Character(s) used for the line.
15    pub characters: String,
16    /// Style for the rule line.
17    pub style: Style,
18    /// Text appended after the rule.
19    pub end: String,
20    /// Alignment of the title.
21    pub align: AlignMethod,
22}
23
24impl Rule {
25    /// Create a new Rule.
26    pub fn new() -> Self {
27        Self {
28            title: String::new(),
29            characters: "─".to_string(),
30            style: Style::new(),
31            end: "\n".to_string(),
32            align: AlignMethod::Center,
33        }
34    }
35
36    /// Builder: set the title.
37    pub fn title(mut self, title: impl Into<String>) -> Self { self.title = title.into(); self }
38
39    /// Builder: set the characters.
40    pub fn characters(mut self, chars: impl Into<String>) -> Self { self.characters = chars.into(); self }
41
42    /// Builder: set the style.
43    pub fn style(mut self, style: Style) -> Self { self.style = style; self }
44
45    /// Builder: set the alignment.
46    pub fn align(mut self, align: AlignMethod) -> Self { self.align = align; self }
47}
48
49impl Renderable for Rule {
50    fn render(&self, options: &ConsoleOptions) -> RenderResult {
51        let width = options.max_width;
52        let chars = if options.ascii_only && !self.characters.is_ascii() {
53            "-"
54        } else {
55            self.characters.as_str()
56        };
57        let char_w = UnicodeWidthStr::width(chars);
58
59        if char_w == 0 {
60            return RenderResult::from_text("");
61        }
62
63        let style_ansi = self.style.to_ansi();
64        let style_reset = if style_ansi.is_empty() { "" } else { "\x1b[0m" };
65
66        if self.title.is_empty() {
67            // Simple rule line
68            let count = width / char_w;
69            let line = chars.repeat(count);
70            return RenderResult::from_segments(vec![
71                Segment::new(format!("{style_ansi}{line}{style_reset}")),
72                Segment::line(),
73            ]);
74        }
75
76        let title_w = UnicodeWidthStr::width(self.title.as_str());
77        let required_space = if matches!(self.align, AlignMethod::Center) { 4 } else { 2 };
78        let available = width.saturating_sub(required_space);
79
80        if available < 1 {
81            // Not enough space — just draw a plain rule
82            let count = width / char_w;
83            let line = chars.repeat(count);
84            return RenderResult::from_segments(vec![
85                Segment::new(format!("{style_ansi}{line}{style_reset}")),
86                Segment::line(),
87            ]);
88        }
89
90        let mut segments = Vec::new();
91
92        match self.align {
93            AlignMethod::Center => {
94                let side = (width.saturating_sub(title_w)) / 2;
95                let left_w = side.saturating_sub(1);
96                let right_w = width.saturating_sub(left_w).saturating_sub(title_w).saturating_sub(2);
97
98                let left = chars.repeat((left_w / char_w).max(1));
99                let right = chars.repeat((right_w / char_w).max(1));
100
101                segments.push(Segment::new(format!(
102                    "{style_ansi}{left} {}{} {right}{style_reset}",
103                    self.title, style_ansi
104                )));
105            }
106            AlignMethod::Left => {
107                let rem = width.saturating_sub(title_w + 1);
108                let right = chars.repeat((rem / char_w).max(1));
109                segments.push(Segment::new(format!(
110                    "{style_ansi}{} {right}{style_reset}",
111                    self.title
112                )));
113            }
114            AlignMethod::Right => {
115                let rem = width.saturating_sub(title_w + 1);
116                let left = chars.repeat((rem / char_w).max(1));
117                segments.push(Segment::new(format!(
118                    "{style_ansi}{left} {}{style_reset}",
119                    self.title
120                )));
121            }
122            AlignMethod::Full => {
123                let count = width / char_w;
124                let line = chars.repeat(count);
125                segments.push(Segment::new(format!("{style_ansi}{line}{style_reset}")));
126            }
127        }
128
129        segments.push(Segment::line());
130        RenderResult::from_segments(segments)
131    }
132}
133
134impl Default for Rule {
135    fn default() -> Self {
136        Self::new()
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use crate::console::ConsoleOptions;
144
145    #[test]
146    fn test_plain_rule() {
147        let rule = Rule::new();
148        let opts = ConsoleOptions { max_width: 40, ..Default::default() };
149        let result = rule.render(&opts);
150        let ansi = result.to_ansi();
151        assert!(ansi.contains('─'));
152    }
153
154    #[test]
155    fn test_rule_with_title() {
156        let rule = Rule::new().title("Section");
157        let opts = ConsoleOptions { max_width: 40, ..Default::default() };
158        let result = rule.render(&opts);
159        let ansi = result.to_ansi();
160        assert!(ansi.contains("Section"));
161    }
162}