Skip to main content

ctl_layout/
rule.rs

1//! A horizontal rule, with an optional title.
2
3use ctl_text::{Context, Line, Measurement, Overflow, Renderable, Text};
4
5use crate::glyphs::Glyphs;
6use crate::lines::{literal, rule_line};
7
8/// Cells a titled rule spends besides the title: two rule characters and a
9/// space before it, and a space after it.
10const TITLE_MARGIN: u16 = 4;
11
12/// A rule across the whole width, drawn with `─`, or `-` for
13/// [`BoxChars::Ascii`](ctl_text::BoxChars::Ascii).
14///
15/// A title sits at the left:
16///
17/// ```text
18/// ── Title ──────────────
19/// ```
20///
21/// A rule fills any width it is given. It measures 1 cell wide without a
22/// title. With one it measures the 4-cell margin plus the title.
23///
24/// The title counts as one cell at the narrowest, since a title that does not
25/// fit ends in `…`. At the widest it counts as its widest line. Only the first
26/// non-empty line of a multi-line title is drawn.
27#[derive(Clone, Debug, Default)]
28pub struct Rule {
29    title: Option<Text>,
30}
31
32impl Rule {
33    /// A rule with no title, drawn as one line of rule characters.
34    #[must_use]
35    pub fn new() -> Self {
36        Self::default()
37    }
38
39    /// A rule with `title` at its left, between two rule characters and the
40    /// rest of the line.
41    #[must_use]
42    pub fn titled(title: Text) -> Self {
43        Self { title: Some(title) }
44    }
45}
46
47impl Renderable for Rule {
48    fn measure(&self, context: &Context) -> Measurement {
49        let Some(title) = &self.title else {
50            return Measurement::exact(1);
51        };
52        let measured = title.measure_as(context, Overflow::Ellipsis);
53        Measurement::new(
54            measured.minimum().saturating_add(TITLE_MARGIN),
55            measured.maximum().saturating_add(TITLE_MARGIN),
56        )
57    }
58
59    fn render(&self, context: &Context) -> Vec<Line> {
60        let rule_char = Glyphs::for_chars(context.box_chars()).rule;
61        let width = context.width();
62        let titled = self.title.as_ref().and_then(|title| {
63            let room = width.saturating_sub(TITLE_MARGIN);
64            if room == 0 {
65                return None;
66            }
67            title
68                .render_as(&context.at_width(room), Overflow::Ellipsis)
69                .into_iter()
70                .find(|line| !line.is_empty())
71        });
72        let Some(title) = titled else {
73            return vec![rule_line(rule_char, width)];
74        };
75        let mut line = Line::new();
76        line.push(literal(&format!("{rule_char}{rule_char} ")));
77        line.extend(title.spans().iter().cloned());
78        let fill = width.saturating_sub(title.width().saturating_add(TITLE_MARGIN));
79        if fill > 0 {
80            let tail: String = std::iter::once(' ')
81                .chain(std::iter::repeat_n(rule_char, usize::from(fill)))
82                .collect();
83            line.push(literal(&tail));
84        }
85        vec![line]
86    }
87}