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 {
38        self.title = title.into();
39        self
40    }
41
42    /// Builder: set the characters.
43    pub fn characters(mut self, chars: impl Into<String>) -> Self {
44        self.characters = chars.into();
45        self
46    }
47
48    /// Builder: set the style.
49    pub fn style(mut self, style: Style) -> Self {
50        self.style = style;
51        self
52    }
53
54    /// Builder: set the alignment.
55    pub fn align(mut self, align: AlignMethod) -> Self {
56        self.align = align;
57        self
58    }
59}
60
61impl Renderable for Rule {
62    fn render(&self, options: &ConsoleOptions) -> RenderResult {
63        let width = options.max_width;
64        let chars = if options.ascii_only && !self.characters.is_ascii() {
65            "-"
66        } else {
67            self.characters.as_str()
68        };
69        let char_w = UnicodeWidthStr::width(chars);
70
71        if char_w == 0 {
72            return RenderResult::from_text("");
73        }
74
75        let style_ansi = self.style.to_ansi();
76        let style_reset = if style_ansi.is_empty() { "" } else { "\x1b[0m" };
77
78        if self.title.is_empty() {
79            // Simple rule line
80            let count = width / char_w;
81            let line = chars.repeat(count);
82            return RenderResult::from_segments(vec![
83                Segment::new(format!("{style_ansi}{line}{style_reset}")),
84                Segment::line(),
85            ]);
86        }
87
88        let title_w = UnicodeWidthStr::width(self.title.as_str());
89        let required_space = if matches!(self.align, AlignMethod::Center) {
90            4
91        } else {
92            2
93        };
94        let available = width.saturating_sub(required_space);
95
96        if available < 1 {
97            // Not enough space — just draw a plain rule
98            let count = width / char_w;
99            let line = chars.repeat(count);
100            return RenderResult::from_segments(vec![
101                Segment::new(format!("{style_ansi}{line}{style_reset}")),
102                Segment::line(),
103            ]);
104        }
105
106        let mut segments = Vec::new();
107
108        match self.align {
109            AlignMethod::Center => {
110                let side = (width.saturating_sub(title_w)) / 2;
111                let left_w = side.saturating_sub(1);
112                let right_w = width
113                    .saturating_sub(left_w)
114                    .saturating_sub(title_w)
115                    .saturating_sub(2);
116
117                let left = chars.repeat((left_w / char_w).max(1));
118                let right = chars.repeat((right_w / char_w).max(1));
119
120                segments.push(Segment::new(format!(
121                    "{style_ansi}{left} {}{} {right}{style_reset}",
122                    self.title, style_ansi
123                )));
124            }
125            AlignMethod::Left => {
126                let rem = width.saturating_sub(title_w + 1);
127                let right = chars.repeat((rem / char_w).max(1));
128                segments.push(Segment::new(format!(
129                    "{style_ansi}{} {right}{style_reset}",
130                    self.title
131                )));
132            }
133            AlignMethod::Right => {
134                let rem = width.saturating_sub(title_w + 1);
135                let left = chars.repeat((rem / char_w).max(1));
136                segments.push(Segment::new(format!(
137                    "{style_ansi}{left} {}{style_reset}",
138                    self.title
139                )));
140            }
141            AlignMethod::Full => {
142                let count = width / char_w;
143                let line = chars.repeat(count);
144                segments.push(Segment::new(format!("{style_ansi}{line}{style_reset}")));
145            }
146        }
147
148        segments.push(Segment::line());
149        RenderResult::from_segments(segments)
150    }
151}
152
153impl Default for Rule {
154    fn default() -> Self {
155        Self::new()
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use crate::console::ConsoleOptions;
163
164    #[test]
165    fn test_plain_rule() {
166        let rule = Rule::new();
167        let opts = ConsoleOptions {
168            max_width: 40,
169            ..Default::default()
170        };
171        let result = rule.render(&opts);
172        let ansi = result.to_ansi();
173        assert!(ansi.contains('─'));
174    }
175
176    #[test]
177    fn test_rule_with_title() {
178        let rule = Rule::new().title("Section");
179        let opts = ConsoleOptions {
180            max_width: 40,
181            ..Default::default()
182        };
183        let result = rule.render(&opts);
184        let ansi = result.to_ansi();
185        assert!(ansi.contains("Section"));
186    }
187}