Skip to main content

rich/
rule.rs

1//! Horizontal rules.
2//!
3//! Port of upstream `rich/rule.py`. A [`Rule`] draws a horizontal line across
4//! the available width, optionally with a centered title.
5//!
6//! Slice scope: center alignment (the default). `left`/`right` title alignment
7//! is deferred with the rest of `rule.py`.
8
9use crate::align::HorizontalAlign;
10use crate::cells::{cell_len, set_cell_size, truncate};
11use crate::console::{Console, ConsoleOptions};
12use crate::protocol::Renderable;
13use crate::segment::Segment;
14use crate::style::Style;
15use crate::text::Text;
16
17/// A horizontal rule, optionally titled. Mirrors `rich.rule.Rule`.
18pub struct Rule {
19    title: Option<String>,
20    characters: String,
21    style: Style,
22    align: HorizontalAlign,
23}
24
25impl Default for Rule {
26    fn default() -> Self {
27        Rule {
28            title: None,
29            characters: "─".to_string(),
30            // Upstream's `rule.line` default style.
31            style: Style::parse("bright_green").expect("valid built-in style"),
32            align: HorizontalAlign::Center,
33        }
34    }
35}
36
37impl Rule {
38    /// A plain, untitled rule.
39    pub fn line() -> Self {
40        Rule::default()
41    }
42
43    /// A rule with a centered title.
44    pub fn new(title: impl Into<String>) -> Self {
45        Rule {
46            title: Some(title.into()),
47            ..Rule::default()
48        }
49    }
50
51    /// Override the fill character(s).
52    pub fn characters(mut self, characters: impl Into<String>) -> Self {
53        self.characters = characters.into();
54        self
55    }
56
57    /// Override the rule style.
58    pub fn style(mut self, style: Style) -> Self {
59        self.style = style;
60        self
61    }
62
63    /// Set the title alignment (default center).
64    pub fn align(mut self, align: HorizontalAlign) -> Self {
65        self.align = align;
66        self
67    }
68
69    /// Repeat `characters` to at least `width` cells, then crop to exactly `width`.
70    fn fill(&self, width: usize) -> String {
71        if width == 0 {
72            return String::new();
73        }
74        let chars_len = cell_len(&self.characters).max(1);
75        let repeat = width / chars_len + 1;
76        let repeated = self.characters.repeat(repeat);
77        set_cell_size(&repeated, width)
78    }
79
80    fn build_text(&self, width: usize) -> Text {
81        let Some(title) = &self.title else {
82            return Text::styled(self.fill(width), self.style.clone());
83        };
84
85        match self.align {
86            HorizontalAlign::Center => {
87                // Title truncated (never padded) to leave room for the flanking spaces.
88                let title = truncate(title, width.saturating_sub(4));
89                let title_len = cell_len(&title);
90
91                let side_width = width.saturating_sub(title_len) / 2;
92                let left = self.fill(side_width.saturating_sub(1));
93                let right_length = width
94                    .saturating_sub(title_len)
95                    .saturating_sub(cell_len(&left))
96                    .saturating_sub(2);
97                let right = self.fill(right_length);
98
99                let mut text = Text::new("");
100                text.append(&format!("{left} "), Some(self.style.clone().into()));
101                text.append(&title, None);
102                text.append(&format!(" {right}"), Some(self.style.clone().into()));
103                text
104            }
105            HorizontalAlign::Left => {
106                let title = truncate(title, width.saturating_sub(2));
107                let fill_len = width.saturating_sub(cell_len(&title)).saturating_sub(1);
108                let mut text = Text::new("");
109                text.append(&format!("{title} "), None);
110                text.append(&self.fill(fill_len), Some(self.style.clone().into()));
111                text
112            }
113            HorizontalAlign::Right => {
114                let title = truncate(title, width.saturating_sub(2));
115                let fill_len = width.saturating_sub(cell_len(&title)).saturating_sub(1);
116                let mut text = Text::new("");
117                text.append(&self.fill(fill_len), Some(self.style.clone().into()));
118                text.append(&format!(" {title}"), None);
119                text
120            }
121        }
122    }
123}
124
125impl Renderable for Rule {
126    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
127        let text = self.build_text(options.max_width);
128        text.render(console.theme(), console.base_style())
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    fn console() -> Console {
137        Console::builder()
138            .force_terminal(true)
139            .color_system(Some(crate::color::ColorSystem::Truecolor))
140            .width(20)
141            .build()
142    }
143
144    #[test]
145    fn plain_rule_fills_width() {
146        let out = console().render_export(&Rule::line());
147        assert_eq!(out, format!("\x1b[92m{}\x1b[0m\n", "─".repeat(20)));
148    }
149
150    #[test]
151    fn titled_rule_centers() {
152        let out = console().render_export(&Rule::new("Hi"));
153        assert_eq!(out, "\x1b[92m──────── \x1b[0mHi\x1b[92m ────────\x1b[0m\n");
154    }
155}