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        // Upstream: `required_space = 4 if align == "center" else 2`, and when
86        // no space is left for the title it falls back to an untitled rule.
87        // Without this a narrow rule drew nothing at all — at width 1 and 2 the
88        // whole line came out blank, so `--rule` in a narrow terminal silently
89        // produced no rule.
90        let required_space = if matches!(self.align, HorizontalAlign::Center) {
91            4
92        } else {
93            2
94        };
95        let truncate_width = width.saturating_sub(required_space);
96        if truncate_width == 0 {
97            return Text::styled(self.fill(width), self.style.clone());
98        }
99
100        match self.align {
101            HorizontalAlign::Center => {
102                // Title truncated (never padded) to leave room for the flanking spaces.
103                let title = truncate_ellipsis(title, truncate_width);
104                let title_len = cell_len(&title);
105
106                let side_width = width.saturating_sub(title_len) / 2;
107                let left = self.fill(side_width.saturating_sub(1));
108                let right_length = width
109                    .saturating_sub(title_len)
110                    .saturating_sub(cell_len(&left))
111                    .saturating_sub(2);
112                let right = self.fill(right_length);
113
114                let mut text = Text::new("");
115                text.append(&format!("{left} "), Some(self.style.clone().into()));
116                text.append(&title, None);
117                text.append(&format!(" {right}"), Some(self.style.clone().into()));
118                text
119            }
120            HorizontalAlign::Left => {
121                let title = truncate_ellipsis(title, truncate_width);
122                let fill_len = width.saturating_sub(cell_len(&title)).saturating_sub(1);
123                let mut text = Text::new("");
124                text.append(&format!("{title} "), None);
125                text.append(&self.fill(fill_len), Some(self.style.clone().into()));
126                text
127            }
128            HorizontalAlign::Right => {
129                let title = truncate_ellipsis(title, truncate_width);
130                let fill_len = width.saturating_sub(cell_len(&title)).saturating_sub(1);
131                let mut text = Text::new("");
132                text.append(&self.fill(fill_len), Some(self.style.clone().into()));
133                text.append(&format!(" {title}"), None);
134                text
135            }
136        }
137    }
138}
139
140/// Truncate to `width` cells with upstream's `overflow="ellipsis"`: an over-long
141/// title loses its tail to a single `…` rather than being cut mid-word, which is
142/// what `Text.truncate(..., overflow="ellipsis")` does before a rule is drawn.
143fn truncate_ellipsis(text: &str, width: usize) -> String {
144    if cell_len(text) <= width {
145        return text.to_string();
146    }
147    if width == 0 {
148        return String::new();
149    }
150    let mut out = truncate(text, width.saturating_sub(1));
151    out.push('\u{2026}');
152    out
153}
154
155impl Renderable for Rule {
156    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
157        let text = self.build_text(options.max_width);
158        text.render(console.theme(), console.base_style())
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    fn console() -> Console {
167        Console::builder()
168            .force_terminal(true)
169            .color_system(Some(crate::color::ColorSystem::Truecolor))
170            .width(20)
171            .build()
172    }
173
174    #[test]
175    fn plain_rule_fills_width() {
176        let out = console().render_export(&Rule::line());
177        assert_eq!(out, format!("\x1b[92m{}\x1b[0m\n", "─".repeat(20)));
178    }
179
180    #[test]
181    fn titled_rule_centers() {
182        let out = console().render_export(&Rule::new("Hi"));
183        assert_eq!(out, "\x1b[92m──────── \x1b[0mHi\x1b[92m ────────\x1b[0m\n");
184    }
185
186    /// A title needs four cells beside it; with none left upstream falls back to
187    /// an untitled rule. We drew a line of spaces instead, so `--rule` in a very
188    /// narrow terminal produced no visible rule at all.
189    #[test]
190    fn a_title_that_cannot_fit_falls_back_to_a_plain_rule() {
191        for width in [1usize, 2, 3, 4] {
192            let console = Console::builder().width(width).no_color(true).build();
193            let out = console.render_to_string(&Rule::new("TITLE"));
194            assert_eq!(
195                out.trim_end_matches('\n'),
196                "\u{2500}".repeat(width),
197                "width {width} did not fall back to a plain rule"
198            );
199        }
200    }
201
202    /// Upstream truncates an over-long title with `overflow="ellipsis"`.
203    #[test]
204    fn an_over_long_title_is_ellipsised() {
205        let console = Console::builder().width(5).no_color(true).build();
206        let out = console.render_to_string(&Rule::new("TITLE"));
207        assert_eq!(out.trim_end_matches('\n'), "\u{2500} \u{2026} \u{2500}");
208    }
209}