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; a printed rule whose `end` has none leaves
71    /// the line open, as upstream's does.
72    pub fn end(mut self, end: impl Into<String>) -> Self {
73        self.end = end.into();
74        self
75    }
76
77    /// Override the fill character(s).
78    pub fn characters(mut self, characters: impl Into<String>) -> Self {
79        self.characters = characters.into();
80        self
81    }
82
83    /// Override the rule style: a [`Style`], or a theme name / definition
84    /// (default `"rule.line"`).
85    pub fn style(mut self, style: impl Into<StyleType>) -> Self {
86        self.style = style.into();
87        self
88    }
89
90    /// Set the title alignment (default center).
91    pub fn align(mut self, align: HorizontalAlign) -> Self {
92        self.align = align;
93        self
94    }
95
96    /// Repeat `characters` to at least `width` cells, then crop to exactly `width`.
97    fn fill(characters: &str, width: usize) -> String {
98        if width == 0 {
99            return String::new();
100        }
101        let chars_len = cell_len(characters).max(1);
102        let repeat = width / chars_len + 1;
103        let repeated = characters.repeat(repeat);
104        set_cell_size(&repeated, width)
105    }
106
107    /// The rule as a `Text`, and whether it is titled (only a titled rule
108    /// carries `end`).
109    fn build_text(&self, console: &Console, options: &ConsoleOptions) -> (Text, bool) {
110        let width = options.max_width;
111        // `"-" if options.ascii_only and not characters.isascii()`: only the
112        // titled layouts use the substitute; `_rule_line` keeps the original.
113        let characters = if options.ascii_only() && !self.characters.is_ascii() {
114            "-"
115        } else {
116            self.characters.as_str()
117        };
118        let rule_line = || Text::styled(Self::fill(&self.characters, width), self.style.clone());
119        let title = match (&self.title_text, &self.title) {
120            (Some(text), _) if !text.plain().is_empty() => {
121                let mut title = text.blank_copy();
122                title.append(&text.plain().replace('\n', " "), None);
123                for span in text.spans() {
124                    title.push_span(span.clone());
125                }
126                title
127            }
128            (None, Some(title)) if !title.is_empty() => {
129                // Upstream uses Console.render_str, so titles retain markup, emoji,
130                // the console's highlighter and the `rule.text` theme style.
131                let parsed = console.build_text(title);
132                let mut title = parsed.blank_copy();
133                title.append(&parsed.plain().replace('\n', " "), None);
134                for span in parsed.spans() {
135                    title.push_span(span.clone());
136                }
137                title.set_base_style("rule.text");
138                title
139            }
140            _ => return (rule_line(), false),
141        };
142        let mut title = title;
143
144        // Upstream: `required_space = 4 if align == "center" else 2`, and when
145        // no space is left for the title it falls back to an untitled rule.
146        // Without this a narrow rule drew nothing at all — at width 1 and 2 the
147        // whole line came out blank, so `--rule` in a narrow terminal silently
148        // produced no rule.
149        let required_space = if matches!(self.align, HorizontalAlign::Center) {
150            4
151        } else {
152            2
153        };
154        let truncate_width = width.saturating_sub(required_space);
155        if truncate_width == 0 {
156            return (rule_line(), false);
157        }
158        title.expand_tabs(DEFAULT_TAB_SIZE);
159        title.truncate(truncate_width, Some(Overflow::Ellipsis), false);
160
161        let mut text = match self.align {
162            HorizontalAlign::Center => {
163                // Title truncated (never padded) to leave room for the flanking spaces.
164                let title_len = title.cell_len();
165
166                let side_width = width.saturating_sub(title_len) / 2;
167                let left = Self::fill(characters, side_width.saturating_sub(1));
168                let right_length = width
169                    .saturating_sub(title_len)
170                    .saturating_sub(cell_len(&left))
171                    .saturating_sub(2);
172                let right = Self::fill(characters, right_length);
173
174                let mut text = Text::new("");
175                text.append(&format!("{left} "), Some(self.style.clone()));
176                text = text.append_text(&title);
177                text.append(&format!(" {right}"), Some(self.style.clone()));
178                text
179            }
180            HorizontalAlign::Left => {
181                let fill_len = width.saturating_sub(title.cell_len()).saturating_sub(1);
182                let mut text = Text::new("");
183                text = text.append_text(&title);
184                text.append(" ", None);
185                text.append(&Self::fill(characters, fill_len), Some(self.style.clone()));
186                text
187            }
188            HorizontalAlign::Right => {
189                // Upstream repeats the characters string once per remaining
190                // *cell*, so a multi-cell fill overshoots the width and the
191                // final crop below removes the title (#444).
192                let repeat = width.saturating_sub(title.cell_len()).saturating_sub(1);
193                let mut text = Text::new("");
194                text.append(&characters.repeat(repeat), Some(self.style.clone()));
195                text.append(" ", None);
196                text = text.append_text(&title);
197                text
198            }
199        };
200        // Upstream: `rule_text.plain = set_cell_size(rule_text.plain, width)`.
201        text.truncate(width, Some(Overflow::Crop), true);
202        (text, true)
203    }
204}
205
206impl Renderable for Rule {
207    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
208        // Not upstream: a semantic region, only when a sink is installed.
209        crate::protocol::report_region(
210            console,
211            || {
212                crate::protocol::RegionInfo::new(crate::protocol::RegionRole::Rule)
213                    .label(self.title_plain(console))
214            },
215            || self.render_rule(console, options),
216        )
217    }
218
219    /// Port of `Rule.__rich_measure__`: a rule fits any width, so it asks for
220    /// a single cell and never widens a fitted container.
221    fn measure(&self, _console: &Console, _options: &ConsoleOptions) -> Measurement {
222        Measurement::new(1, 1)
223    }
224
225    /// A titled rule ends with its `end`, which upstream yields as is: an
226    /// `end` without a trailing newline leaves the line open.
227    fn print_end_line(&self, options: &ConsoleOptions) -> bool {
228        let titled = match (&self.title_text, &self.title) {
229            (Some(text), _) => !text.plain().is_empty(),
230            (None, Some(title)) => !title.is_empty(),
231            (None, None) => false,
232        };
233        // `truncate_width = max(0, width - required_space)`: no room for the
234        // title draws the plain line.
235        let required_space = if self.align == HorizontalAlign::Center {
236            4
237        } else {
238            2
239        };
240        !titled || options.max_width <= required_space || self.end.ends_with('\n')
241    }
242}
243
244/// Read access to a rule's title and alignment: upstream's public `Rule`
245/// fields. Not upstream as methods; they change nothing.
246///
247/// ```
248/// use rich::{HorizontalAlign, Rule};
249/// use rich::table::Cell;
250///
251/// let rule = Rule::new("Next").align(HorizontalAlign::Left);
252/// assert!(matches!(rule.get_title(), Some(Cell::Markup(t)) if t == "Next"));
253/// assert_eq!(rule.get_align(), HorizontalAlign::Left);
254/// assert!(Rule::line().get_title().is_none());
255/// ```
256impl Rule {
257    /// The title (upstream `Rule.title`): a [`Cell::Text`](crate::table::Cell::Text)
258    /// for [`with_title_text`](Self::with_title_text), else a
259    /// [`Cell::Markup`](crate::table::Cell::Markup); `None` for a plain line.
260    pub fn get_title(&self) -> Option<crate::table::Cell> {
261        match (&self.title_text, &self.title) {
262            (Some(text), _) => Some(crate::table::Cell::Text(text.clone())),
263            (None, Some(title)) => Some(crate::table::Cell::Markup(title.clone())),
264            (None, None) => None,
265        }
266    }
267
268    /// The title's alignment (upstream `Rule.align`).
269    pub fn get_align(&self) -> HorizontalAlign {
270        self.align
271    }
272}
273
274impl Rule {
275    /// The title as plain text, for a region's label. Not upstream.
276    fn title_plain(&self, console: &Console) -> String {
277        match (&self.title_text, &self.title) {
278            (Some(text), _) => text.plain().to_string(),
279            (None, Some(title)) => console.build_text(title).plain().to_string(),
280            (None, None) => String::new(),
281        }
282    }
283
284    /// Port of `Rule.__rich_console__`.
285    fn render_rule(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
286        let (text, titled) = self.build_text(console, options);
287        let mut segments = text.render(console.theme(), console.base_style());
288        if titled {
289            let end = self.end.strip_suffix('\n').unwrap_or(&self.end);
290            if !end.is_empty() {
291                segments.push(Segment::new(end, None));
292            }
293        }
294        segments
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    fn console() -> Console {
303        Console::builder()
304            .force_terminal(true)
305            .color_system(Some(crate::color::ColorSystem::Truecolor))
306            .width(20)
307            .build()
308    }
309
310    #[test]
311    fn plain_rule_fills_width() {
312        let out = console().render_export(&Rule::line());
313        assert_eq!(out, format!("\x1b[92m{}\x1b[0m\n", "─".repeat(20)));
314    }
315
316    #[test]
317    fn titled_rule_centers() {
318        let out = console().render_export(&Rule::new("Hi"));
319        assert_eq!(out, "\x1b[92m──────── \x1b[0mHi\x1b[92m ────────\x1b[0m\n");
320    }
321
322    /// A title needs four cells beside it; with none left upstream falls back to
323    /// an untitled rule. We drew a line of spaces instead, so `--rule` in a very
324    /// narrow terminal produced no visible rule at all.
325    #[test]
326    fn a_title_that_cannot_fit_falls_back_to_a_plain_rule() {
327        for width in [1usize, 2, 3, 4] {
328            let console = Console::builder().width(width).color_system(None).build();
329            let out = console.render_to_string(&Rule::new("TITLE"));
330            assert_eq!(
331                out.trim_end_matches('\n'),
332                "\u{2500}".repeat(width),
333                "width {width} did not fall back to a plain rule"
334            );
335        }
336    }
337
338    /// Upstream truncates an over-long title with `overflow="ellipsis"`.
339    #[test]
340    fn an_over_long_title_is_ellipsised() {
341        let console = Console::builder().width(5).color_system(None).build();
342        let out = console.render_to_string(&Rule::new("TITLE"));
343        assert_eq!(out.trim_end_matches('\n'), "\u{2500} \u{2026} \u{2500}");
344    }
345
346    #[test]
347    fn right_aligned_title_is_dropped_by_a_multi_cell_fill() {
348        // Captured from real rich 15.0.0 (#444).
349        let console = Console::builder()
350            .force_terminal(true)
351            .color_system(Some(crate::color::ColorSystem::Truecolor))
352            .width(10)
353            .highlight(false)
354            .build();
355        let rule = Rule::new("x")
356            .characters("-~")
357            .align(HorizontalAlign::Right);
358        assert_eq!(console.render_export(&rule), "\x1b[92m-~-~-~-~-~\x1b[0m\n");
359    }
360}