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//! Titles support console markup and left, center or right alignment.
7
8use crate::align::HorizontalAlign;
9use crate::cells::{cell_len, set_cell_size};
10use crate::console::{Console, ConsoleOptions, Overflow};
11use crate::protocol::Renderable;
12use crate::segment::Segment;
13use crate::style::Style;
14use crate::text::{Text, DEFAULT_TAB_SIZE};
15
16/// A horizontal rule, optionally titled. Mirrors `rich.rule.Rule`.
17pub struct Rule {
18    title: Option<String>,
19    characters: String,
20    style: Style,
21    align: HorizontalAlign,
22}
23
24impl Default for Rule {
25    fn default() -> Self {
26        Rule {
27            title: None,
28            characters: "─".to_string(),
29            // Upstream's `rule.line` default style.
30            style: Style::parse("bright_green").expect("valid built-in style"),
31            align: HorizontalAlign::Center,
32        }
33    }
34}
35
36impl Rule {
37    /// A plain, untitled rule.
38    pub fn line() -> Self {
39        Rule::default()
40    }
41
42    /// A rule with a centered title.
43    pub fn new(title: impl Into<String>) -> Self {
44        Rule {
45            title: Some(title.into()),
46            ..Rule::default()
47        }
48    }
49
50    /// Override the fill character(s).
51    pub fn characters(mut self, characters: impl Into<String>) -> Self {
52        self.characters = characters.into();
53        self
54    }
55
56    /// Override the rule style.
57    pub fn style(mut self, style: Style) -> Self {
58        self.style = style;
59        self
60    }
61
62    /// Set the title alignment (default center).
63    pub fn align(mut self, align: HorizontalAlign) -> Self {
64        self.align = align;
65        self
66    }
67
68    /// Repeat `characters` to at least `width` cells, then crop to exactly `width`.
69    fn fill(&self, width: usize) -> String {
70        if width == 0 {
71            return String::new();
72        }
73        let chars_len = cell_len(&self.characters).max(1);
74        let repeat = width / chars_len + 1;
75        let repeated = self.characters.repeat(repeat);
76        set_cell_size(&repeated, width)
77    }
78
79    fn build_text(&self, console: &Console, width: usize) -> Text {
80        let Some(title) = self.title.as_ref().filter(|title| !title.is_empty()) else {
81            return Text::styled(self.fill(width), self.style.clone());
82        };
83
84        // Upstream: `required_space = 4 if align == "center" else 2`, and when
85        // no space is left for the title it falls back to an untitled rule.
86        // Without this a narrow rule drew nothing at all — at width 1 and 2 the
87        // whole line came out blank, so `--rule` in a narrow terminal silently
88        // produced no rule.
89        let required_space = if matches!(self.align, HorizontalAlign::Center) {
90            4
91        } else {
92            2
93        };
94        let truncate_width = width.saturating_sub(required_space);
95        if truncate_width == 0 {
96            return Text::styled(self.fill(width), self.style.clone());
97        }
98
99        // Upstream uses Console.render_str, so titles retain markup, emoji,
100        // the console's highlighter and the `rule.text` theme style.
101        let parsed = console.build_text(title);
102        let mut title = parsed.blank_copy();
103        title.append(&parsed.plain().replace('\n', " "), None);
104        for span in parsed.spans() {
105            title.push_span(span.clone());
106        }
107        title.set_base_style("rule.text");
108        title.expand_tabs(DEFAULT_TAB_SIZE);
109        title.truncate(truncate_width, Some(Overflow::Ellipsis), false);
110
111        match self.align {
112            HorizontalAlign::Center => {
113                // Title truncated (never padded) to leave room for the flanking spaces.
114                let title_len = title.cell_len();
115
116                let side_width = width.saturating_sub(title_len) / 2;
117                let left = self.fill(side_width.saturating_sub(1));
118                let right_length = width
119                    .saturating_sub(title_len)
120                    .saturating_sub(cell_len(&left))
121                    .saturating_sub(2);
122                let right = self.fill(right_length);
123
124                let mut text = Text::new("");
125                text.append(&format!("{left} "), Some(self.style.clone().into()));
126                text = text.append_text(&title);
127                text.append(&format!(" {right}"), Some(self.style.clone().into()));
128                text
129            }
130            HorizontalAlign::Left => {
131                let fill_len = width.saturating_sub(title.cell_len()).saturating_sub(1);
132                let mut text = Text::new("");
133                text = text.append_text(&title);
134                text.append(" ", None);
135                text.append(&self.fill(fill_len), Some(self.style.clone().into()));
136                text
137            }
138            HorizontalAlign::Right => {
139                let fill_len = width.saturating_sub(title.cell_len()).saturating_sub(1);
140                let mut text = Text::new("");
141                text.append(&self.fill(fill_len), Some(self.style.clone().into()));
142                text.append(" ", None);
143                text = text.append_text(&title);
144                text
145            }
146        }
147    }
148}
149
150impl Renderable for Rule {
151    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
152        let text = self.build_text(console, options.max_width);
153        text.render(console.theme(), console.base_style())
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    fn console() -> Console {
162        Console::builder()
163            .force_terminal(true)
164            .color_system(Some(crate::color::ColorSystem::Truecolor))
165            .width(20)
166            .build()
167    }
168
169    #[test]
170    fn plain_rule_fills_width() {
171        let out = console().render_export(&Rule::line());
172        assert_eq!(out, format!("\x1b[92m{}\x1b[0m\n", "─".repeat(20)));
173    }
174
175    #[test]
176    fn titled_rule_centers() {
177        let out = console().render_export(&Rule::new("Hi"));
178        assert_eq!(out, "\x1b[92m──────── \x1b[0mHi\x1b[92m ────────\x1b[0m\n");
179    }
180
181    /// A title needs four cells beside it; with none left upstream falls back to
182    /// an untitled rule. We drew a line of spaces instead, so `--rule` in a very
183    /// narrow terminal produced no visible rule at all.
184    #[test]
185    fn a_title_that_cannot_fit_falls_back_to_a_plain_rule() {
186        for width in [1usize, 2, 3, 4] {
187            let console = Console::builder().width(width).no_color(true).build();
188            let out = console.render_to_string(&Rule::new("TITLE"));
189            assert_eq!(
190                out.trim_end_matches('\n'),
191                "\u{2500}".repeat(width),
192                "width {width} did not fall back to a plain rule"
193            );
194        }
195    }
196
197    /// Upstream truncates an over-long title with `overflow="ellipsis"`.
198    #[test]
199    fn an_over_long_title_is_ellipsised() {
200        let console = Console::builder().width(5).no_color(true).build();
201        let out = console.render_to_string(&Rule::new("TITLE"));
202        assert_eq!(out.trim_end_matches('\n'), "\u{2500} \u{2026} \u{2500}");
203    }
204}