Skip to main content

sparcli/output/
rule.rs

1//! Horizontal rules (separators) with an optional embedded title.
2
3use crate::core::border::BorderType;
4use crate::core::geometry::{Align, Edges};
5use crate::core::render::{Renderable, Rendered};
6use crate::core::style::Style;
7use crate::core::text::{Line, Span, Text};
8use crate::core::theme::theme;
9use crate::output::compose::pad;
10
11/// A horizontal divider line, optionally labelled with a title.
12///
13/// # Examples
14///
15/// ```
16/// use sparcli::{Renderable, Rule};
17///
18/// let out = Rule::with_title("Settings").render(30);
19/// assert!(out.plain().contains("Settings"));
20/// ```
21pub struct Rule {
22    title: Option<Text>,
23    border: BorderType,
24    style: Style,
25    align: Align,
26    width: Option<u16>,
27    margin: Edges,
28    pad: u16,
29}
30
31impl Default for Rule {
32    fn default() -> Self {
33        let theme = theme();
34        Self {
35            title: None,
36            border: theme.border,
37            style: theme.secondary,
38            align: Align::Center,
39            width: None,
40            margin: Edges::default(),
41            pad: 1,
42        }
43    }
44}
45
46impl Rule {
47    /// Creates a plain rule with no title.
48    pub fn new() -> Self {
49        Self::default()
50    }
51
52    /// Creates a rule with an embedded title.
53    pub fn with_title(title: impl Into<Text>) -> Self {
54        Self {
55            title: Some(title.into()),
56            ..Self::default()
57        }
58    }
59
60    /// Sets the line style (border type).
61    #[must_use]
62    pub fn border(mut self, border: BorderType) -> Self {
63        self.border = border;
64        self
65    }
66
67    /// Sets the line/glyph style.
68    #[must_use]
69    pub fn style(mut self, style: Style) -> Self {
70        self.style = style;
71        self
72    }
73
74    /// Sets the title alignment.
75    #[must_use]
76    pub fn align(mut self, align: Align) -> Self {
77        self.align = align;
78        self
79    }
80
81    /// Sets a fixed width in columns.
82    #[must_use]
83    pub fn width(mut self, width: u16) -> Self {
84        self.width = Some(width);
85        self
86    }
87
88    /// Sets the outer margin.
89    #[must_use]
90    pub fn margin(mut self, margin: Edges) -> Self {
91        self.margin = margin;
92        self
93    }
94}
95
96impl Renderable for Rule {
97    fn render(&self, max_width: u16) -> Rendered {
98        let total = self.width.unwrap_or(max_width) as usize;
99        let inner = total.saturating_sub(self.margin.horizontal() as usize);
100        let glyph = self.border.chars().horizontal;
101        let line = match &self.title {
102            None => fill_line(glyph, inner, self.style),
103            Some(title) => self.titled_line(title, glyph, inner),
104        };
105        pad(&Rendered::new(vec![line]), self.margin)
106    }
107}
108
109impl Rule {
110    /// Builds a rule line with a centered/aligned title.
111    fn titled_line(&self, title: &Text, glyph: char, width: usize) -> Line {
112        let title_line = title.lines.first().cloned().unwrap_or_default();
113        let pad = self.pad as usize;
114        let title_w = title_line.width() + 2 * pad;
115        if title_w >= width {
116            return title_line;
117        }
118        let remaining = width - title_w;
119        let (left, right) = match self.align {
120            Align::Left => (1.min(remaining), remaining.saturating_sub(1)),
121            Align::Right => (remaining.saturating_sub(1), 1.min(remaining)),
122            Align::Center => (remaining / 2, remaining - remaining / 2),
123        };
124        let mut spans = vec![glyph_span(glyph, left, self.style)];
125        spans.push(Span::raw(" ".repeat(pad)));
126        spans.extend(title_line.spans);
127        spans.push(Span::raw(" ".repeat(pad)));
128        spans.push(glyph_span(glyph, right, self.style));
129        Line::new(spans)
130    }
131}
132
133/// Builds a full line of `width` glyphs.
134fn fill_line(glyph: char, width: usize, style: Style) -> Line {
135    Line::new(vec![glyph_span(glyph, width, style)])
136}
137
138/// Builds a span of `count` repeated glyphs.
139fn glyph_span(glyph: char, count: usize, style: Style) -> Span {
140    Span::styled(glyph.to_string().repeat(count), style)
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn plain_rule_fills_the_width() {
149        let rendered = Rule::new().border(BorderType::Single).render(10);
150        assert_eq!(rendered.lines[0].plain(), "──────────");
151    }
152
153    #[test]
154    fn titled_rule_contains_title() {
155        let rendered = Rule::with_title("Section")
156            .border(BorderType::Single)
157            .render(20);
158        assert!(rendered.lines[0].plain().contains("Section"));
159        assert_eq!(rendered.lines[0].width(), 20);
160    }
161}