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 are console markup or a literal [`Text`], aligned left, center or
7//! right; `characters`, `style` (default the theme's `rule.line`) and `end`
8//! follow upstream.
9
10use crate::align::HorizontalAlign;
11use crate::cells::{cell_len, set_cell_size};
12use crate::console::{Console, ConsoleOptions, Overflow};
13use crate::measure::Measurement;
14use crate::protocol::Renderable;
15use crate::segment::Segment;
16use crate::style::StyleType;
17use crate::text::{Text, DEFAULT_TAB_SIZE};
18
19/// A horizontal rule, optionally titled. Mirrors `rich.rule.Rule`.
20pub struct Rule {
21    title: Option<String>,
22    /// A literal title (upstream `Rule(Text(…))`), in place of `title`.
23    title_text: Option<Text>,
24    characters: String,
25    style: StyleType,
26    end: String,
27    align: HorizontalAlign,
28}
29
30impl Default for Rule {
31    fn default() -> Self {
32        Rule {
33            title: None,
34            title_text: None,
35            characters: "─".to_string(),
36            // Upstream's default `style="rule.line"`, resolved per console.
37            style: StyleType::Name("rule.line".to_string()),
38            end: "\n".to_string(),
39            align: HorizontalAlign::Center,
40        }
41    }
42}
43
44impl Rule {
45    /// A plain, untitled rule.
46    pub fn line() -> Self {
47        Rule::default()
48    }
49
50    /// A rule with a centered title.
51    pub fn new(title: impl Into<String>) -> Self {
52        Rule {
53            title: Some(title.into()),
54            ..Rule::default()
55        }
56    }
57
58    /// A rule titled with a literal [`Text`] (upstream `Rule(Text(…))`): no
59    /// markup, and no `rule.text` style beneath it.
60    pub fn with_title_text(title: Text) -> Self {
61        Rule {
62            title_text: Some(title),
63            ..Rule::default()
64        }
65    }
66
67    /// What follows a *titled* rule (upstream `end`, default `"\n"`). As
68    /// upstream, an untitled rule ignores it. The port's renderables separate
69    /// lines rather than ending them, so one trailing newline of `end` is the
70    /// line end the printer adds; an `end` without one cannot suppress it.
71    pub fn end(mut self, end: impl Into<String>) -> Self {
72        self.end = end.into();
73        self
74    }
75
76    /// Override the fill character(s).
77    pub fn characters(mut self, characters: impl Into<String>) -> Self {
78        self.characters = characters.into();
79        self
80    }
81
82    /// Override the rule style: a [`Style`], or a theme name / definition
83    /// (default `"rule.line"`).
84    pub fn style(mut self, style: impl Into<StyleType>) -> Self {
85        self.style = style.into();
86        self
87    }
88
89    /// Set the title alignment (default center).
90    pub fn align(mut self, align: HorizontalAlign) -> Self {
91        self.align = align;
92        self
93    }
94
95    /// Repeat `characters` to at least `width` cells, then crop to exactly `width`.
96    fn fill(characters: &str, width: usize) -> String {
97        if width == 0 {
98            return String::new();
99        }
100        let chars_len = cell_len(characters).max(1);
101        let repeat = width / chars_len + 1;
102        let repeated = characters.repeat(repeat);
103        set_cell_size(&repeated, width)
104    }
105
106    /// The rule as a `Text`, and whether it is titled (only a titled rule
107    /// carries `end`).
108    fn build_text(&self, console: &Console, options: &ConsoleOptions) -> (Text, bool) {
109        let width = options.max_width;
110        // `"-" if options.ascii_only and not characters.isascii()`: only the
111        // titled layouts use the substitute; `_rule_line` keeps the original.
112        let characters = if options.ascii_only() && !self.characters.is_ascii() {
113            "-"
114        } else {
115            self.characters.as_str()
116        };
117        let rule_line = || Text::styled(Self::fill(&self.characters, width), self.style.clone());
118        let title = match (&self.title_text, &self.title) {
119            (Some(text), _) if !text.plain().is_empty() => {
120                let mut title = text.blank_copy();
121                title.append(&text.plain().replace('\n', " "), None);
122                for span in text.spans() {
123                    title.push_span(span.clone());
124                }
125                title
126            }
127            (None, Some(title)) if !title.is_empty() => {
128                // Upstream uses Console.render_str, so titles retain markup, emoji,
129                // the console's highlighter and the `rule.text` theme style.
130                let parsed = console.build_text(title);
131                let mut title = parsed.blank_copy();
132                title.append(&parsed.plain().replace('\n', " "), None);
133                for span in parsed.spans() {
134                    title.push_span(span.clone());
135                }
136                title.set_base_style("rule.text");
137                title
138            }
139            _ => return (rule_line(), false),
140        };
141        let mut title = title;
142
143        // Upstream: `required_space = 4 if align == "center" else 2`, and when
144        // no space is left for the title it falls back to an untitled rule.
145        // Without this a narrow rule drew nothing at all — at width 1 and 2 the
146        // whole line came out blank, so `--rule` in a narrow terminal silently
147        // produced no rule.
148        let required_space = if matches!(self.align, HorizontalAlign::Center) {
149            4
150        } else {
151            2
152        };
153        let truncate_width = width.saturating_sub(required_space);
154        if truncate_width == 0 {
155            return (rule_line(), false);
156        }
157        title.expand_tabs(DEFAULT_TAB_SIZE);
158        title.truncate(truncate_width, Some(Overflow::Ellipsis), false);
159
160        let mut text = match self.align {
161            HorizontalAlign::Center => {
162                // Title truncated (never padded) to leave room for the flanking spaces.
163                let title_len = title.cell_len();
164
165                let side_width = width.saturating_sub(title_len) / 2;
166                let left = Self::fill(characters, side_width.saturating_sub(1));
167                let right_length = width
168                    .saturating_sub(title_len)
169                    .saturating_sub(cell_len(&left))
170                    .saturating_sub(2);
171                let right = Self::fill(characters, right_length);
172
173                let mut text = Text::new("");
174                text.append(&format!("{left} "), Some(self.style.clone()));
175                text = text.append_text(&title);
176                text.append(&format!(" {right}"), Some(self.style.clone()));
177                text
178            }
179            HorizontalAlign::Left => {
180                let fill_len = width.saturating_sub(title.cell_len()).saturating_sub(1);
181                let mut text = Text::new("");
182                text = text.append_text(&title);
183                text.append(" ", None);
184                text.append(&Self::fill(characters, fill_len), Some(self.style.clone()));
185                text
186            }
187            HorizontalAlign::Right => {
188                // Upstream repeats the characters string once per remaining
189                // *cell*, so a multi-cell fill overshoots the width and the
190                // final crop below removes the title (#444).
191                let repeat = width.saturating_sub(title.cell_len()).saturating_sub(1);
192                let mut text = Text::new("");
193                text.append(&characters.repeat(repeat), Some(self.style.clone()));
194                text.append(" ", None);
195                text = text.append_text(&title);
196                text
197            }
198        };
199        // Upstream: `rule_text.plain = set_cell_size(rule_text.plain, width)`.
200        text.truncate(width, Some(Overflow::Crop), true);
201        (text, true)
202    }
203}
204
205impl Renderable for Rule {
206    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
207        // Not upstream: a semantic region, only when a sink is installed.
208        crate::protocol::report_region(
209            console,
210            || {
211                crate::protocol::RegionInfo::new(crate::protocol::RegionRole::Rule)
212                    .label(self.title_plain(console))
213            },
214            || self.render_rule(console, options),
215        )
216    }
217
218    /// Port of `Rule.__rich_measure__`: a rule fits any width, so it asks for
219    /// a single cell and never widens a fitted container.
220    fn measure(&self, _console: &Console, _options: &ConsoleOptions) -> Measurement {
221        Measurement::new(1, 1)
222    }
223}
224
225impl Rule {
226    /// The title as plain text, for a region's label. Not upstream.
227    fn title_plain(&self, console: &Console) -> String {
228        match (&self.title_text, &self.title) {
229            (Some(text), _) => text.plain().to_string(),
230            (None, Some(title)) => console.build_text(title).plain().to_string(),
231            (None, None) => String::new(),
232        }
233    }
234
235    /// Port of `Rule.__rich_console__`.
236    fn render_rule(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
237        let (text, titled) = self.build_text(console, options);
238        let mut segments = text.render(console.theme(), console.base_style());
239        if titled {
240            let end = self.end.strip_suffix('\n').unwrap_or(&self.end);
241            if !end.is_empty() {
242                segments.push(Segment::new(end, None));
243            }
244        }
245        segments
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    fn console() -> Console {
254        Console::builder()
255            .force_terminal(true)
256            .color_system(Some(crate::color::ColorSystem::Truecolor))
257            .width(20)
258            .build()
259    }
260
261    #[test]
262    fn plain_rule_fills_width() {
263        let out = console().render_export(&Rule::line());
264        assert_eq!(out, format!("\x1b[92m{}\x1b[0m\n", "─".repeat(20)));
265    }
266
267    #[test]
268    fn titled_rule_centers() {
269        let out = console().render_export(&Rule::new("Hi"));
270        assert_eq!(out, "\x1b[92m──────── \x1b[0mHi\x1b[92m ────────\x1b[0m\n");
271    }
272
273    /// A title needs four cells beside it; with none left upstream falls back to
274    /// an untitled rule. We drew a line of spaces instead, so `--rule` in a very
275    /// narrow terminal produced no visible rule at all.
276    #[test]
277    fn a_title_that_cannot_fit_falls_back_to_a_plain_rule() {
278        for width in [1usize, 2, 3, 4] {
279            let console = Console::builder().width(width).color_system(None).build();
280            let out = console.render_to_string(&Rule::new("TITLE"));
281            assert_eq!(
282                out.trim_end_matches('\n'),
283                "\u{2500}".repeat(width),
284                "width {width} did not fall back to a plain rule"
285            );
286        }
287    }
288
289    /// Upstream truncates an over-long title with `overflow="ellipsis"`.
290    #[test]
291    fn an_over_long_title_is_ellipsised() {
292        let console = Console::builder().width(5).color_system(None).build();
293        let out = console.render_to_string(&Rule::new("TITLE"));
294        assert_eq!(out.trim_end_matches('\n'), "\u{2500} \u{2026} \u{2500}");
295    }
296
297    #[test]
298    fn right_aligned_title_is_dropped_by_a_multi_cell_fill() {
299        // Captured from real rich 15.0.0 (#444).
300        let console = Console::builder()
301            .force_terminal(true)
302            .color_system(Some(crate::color::ColorSystem::Truecolor))
303            .width(10)
304            .highlight(false)
305            .build();
306        let rule = Rule::new("x")
307            .characters("-~")
308            .align(HorizontalAlign::Right);
309        assert_eq!(console.render_export(&rule), "\x1b[92m-~-~-~-~-~\x1b[0m\n");
310    }
311}