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::measure::Measurement;
12use crate::protocol::Renderable;
13use crate::segment::Segment;
14use crate::style::Style;
15use crate::text::{Text, DEFAULT_TAB_SIZE};
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, console: &Console, width: usize) -> Text {
81        let Some(title) = self.title.as_ref().filter(|title| !title.is_empty()) 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        // Upstream uses Console.render_str, so titles retain markup, emoji,
101        // the console's highlighter and the `rule.text` theme style.
102        let parsed = console.build_text(title);
103        let mut title = parsed.blank_copy();
104        title.append(&parsed.plain().replace('\n', " "), None);
105        for span in parsed.spans() {
106            title.push_span(span.clone());
107        }
108        title.set_base_style("rule.text");
109        title.expand_tabs(DEFAULT_TAB_SIZE);
110        title.truncate(truncate_width, Some(Overflow::Ellipsis), false);
111
112        let mut text = match self.align {
113            HorizontalAlign::Center => {
114                // Title truncated (never padded) to leave room for the flanking spaces.
115                let title_len = title.cell_len();
116
117                let side_width = width.saturating_sub(title_len) / 2;
118                let left = self.fill(side_width.saturating_sub(1));
119                let right_length = width
120                    .saturating_sub(title_len)
121                    .saturating_sub(cell_len(&left))
122                    .saturating_sub(2);
123                let right = self.fill(right_length);
124
125                let mut text = Text::new("");
126                text.append(&format!("{left} "), Some(self.style.clone().into()));
127                text = text.append_text(&title);
128                text.append(&format!(" {right}"), Some(self.style.clone().into()));
129                text
130            }
131            HorizontalAlign::Left => {
132                let fill_len = width.saturating_sub(title.cell_len()).saturating_sub(1);
133                let mut text = Text::new("");
134                text = text.append_text(&title);
135                text.append(" ", None);
136                text.append(&self.fill(fill_len), Some(self.style.clone().into()));
137                text
138            }
139            HorizontalAlign::Right => {
140                // Upstream repeats the characters string once per remaining
141                // *cell*, so a multi-cell fill overshoots the width and the
142                // final crop below removes the title (#444).
143                let repeat = width.saturating_sub(title.cell_len()).saturating_sub(1);
144                let mut text = Text::new("");
145                text.append(
146                    &self.characters.repeat(repeat),
147                    Some(self.style.clone().into()),
148                );
149                text.append(" ", None);
150                text = text.append_text(&title);
151                text
152            }
153        };
154        // Upstream: `rule_text.plain = set_cell_size(rule_text.plain, width)`.
155        text.truncate(width, Some(Overflow::Crop), true);
156        text
157    }
158}
159
160impl Renderable for Rule {
161    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
162        let text = self.build_text(console, options.max_width);
163        text.render(console.theme(), console.base_style())
164    }
165
166    /// Port of `Rule.__rich_measure__`: a rule fits any width, so it asks for
167    /// a single cell and never widens a fitted container.
168    fn measure(&self, _console: &Console, _options: &ConsoleOptions) -> Measurement {
169        Measurement::new(1, 1)
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    fn console() -> Console {
178        Console::builder()
179            .force_terminal(true)
180            .color_system(Some(crate::color::ColorSystem::Truecolor))
181            .width(20)
182            .build()
183    }
184
185    #[test]
186    fn plain_rule_fills_width() {
187        let out = console().render_export(&Rule::line());
188        assert_eq!(out, format!("\x1b[92m{}\x1b[0m\n", "─".repeat(20)));
189    }
190
191    #[test]
192    fn titled_rule_centers() {
193        let out = console().render_export(&Rule::new("Hi"));
194        assert_eq!(out, "\x1b[92m──────── \x1b[0mHi\x1b[92m ────────\x1b[0m\n");
195    }
196
197    /// A title needs four cells beside it; with none left upstream falls back to
198    /// an untitled rule. We drew a line of spaces instead, so `--rule` in a very
199    /// narrow terminal produced no visible rule at all.
200    #[test]
201    fn a_title_that_cannot_fit_falls_back_to_a_plain_rule() {
202        for width in [1usize, 2, 3, 4] {
203            let console = Console::builder().width(width).color_system(None).build();
204            let out = console.render_to_string(&Rule::new("TITLE"));
205            assert_eq!(
206                out.trim_end_matches('\n'),
207                "\u{2500}".repeat(width),
208                "width {width} did not fall back to a plain rule"
209            );
210        }
211    }
212
213    /// Upstream truncates an over-long title with `overflow="ellipsis"`.
214    #[test]
215    fn an_over_long_title_is_ellipsised() {
216        let console = Console::builder().width(5).color_system(None).build();
217        let out = console.render_to_string(&Rule::new("TITLE"));
218        assert_eq!(out.trim_end_matches('\n'), "\u{2500} \u{2026} \u{2500}");
219    }
220
221    #[test]
222    fn right_aligned_title_is_dropped_by_a_multi_cell_fill() {
223        // Captured from real rich 15.0.0 (#444).
224        let console = Console::builder()
225            .force_terminal(true)
226            .color_system(Some(crate::color::ColorSystem::Truecolor))
227            .width(10)
228            .highlight(false)
229            .build();
230        let rule = Rule::new("x")
231            .characters("-~")
232            .align(HorizontalAlign::Right);
233        assert_eq!(console.render_export(&rule), "\x1b[92m-~-~-~-~-~\x1b[0m\n");
234    }
235}