1use crate::align::HorizontalAlign;
9use crate::cells::{cell_len, set_cell_size};
10use crate::console::{Console, ConsoleOptions, Overflow};
11use crate::protocol::Renderable;
12use crate::segment::Segment;
13use crate::style::Style;
14use crate::text::{Text, DEFAULT_TAB_SIZE};
15
16pub struct Rule {
18 title: Option<String>,
19 characters: String,
20 style: Style,
21 align: HorizontalAlign,
22}
23
24impl Default for Rule {
25 fn default() -> Self {
26 Rule {
27 title: None,
28 characters: "─".to_string(),
29 style: Style::parse("bright_green").expect("valid built-in style"),
31 align: HorizontalAlign::Center,
32 }
33 }
34}
35
36impl Rule {
37 pub fn line() -> Self {
39 Rule::default()
40 }
41
42 pub fn new(title: impl Into<String>) -> Self {
44 Rule {
45 title: Some(title.into()),
46 ..Rule::default()
47 }
48 }
49
50 pub fn characters(mut self, characters: impl Into<String>) -> Self {
52 self.characters = characters.into();
53 self
54 }
55
56 pub fn style(mut self, style: Style) -> Self {
58 self.style = style;
59 self
60 }
61
62 pub fn align(mut self, align: HorizontalAlign) -> Self {
64 self.align = align;
65 self
66 }
67
68 fn fill(&self, width: usize) -> String {
70 if width == 0 {
71 return String::new();
72 }
73 let chars_len = cell_len(&self.characters).max(1);
74 let repeat = width / chars_len + 1;
75 let repeated = self.characters.repeat(repeat);
76 set_cell_size(&repeated, width)
77 }
78
79 fn build_text(&self, console: &Console, width: usize) -> Text {
80 let Some(title) = self.title.as_ref().filter(|title| !title.is_empty()) else {
81 return Text::styled(self.fill(width), self.style.clone());
82 };
83
84 let required_space = if matches!(self.align, HorizontalAlign::Center) {
90 4
91 } else {
92 2
93 };
94 let truncate_width = width.saturating_sub(required_space);
95 if truncate_width == 0 {
96 return Text::styled(self.fill(width), self.style.clone());
97 }
98
99 let parsed = console.build_text(title);
102 let mut title = parsed.blank_copy();
103 title.append(&parsed.plain().replace('\n', " "), None);
104 for span in parsed.spans() {
105 title.push_span(span.clone());
106 }
107 title.set_base_style("rule.text");
108 title.expand_tabs(DEFAULT_TAB_SIZE);
109 title.truncate(truncate_width, Some(Overflow::Ellipsis), false);
110
111 match self.align {
112 HorizontalAlign::Center => {
113 let title_len = title.cell_len();
115
116 let side_width = width.saturating_sub(title_len) / 2;
117 let left = self.fill(side_width.saturating_sub(1));
118 let right_length = width
119 .saturating_sub(title_len)
120 .saturating_sub(cell_len(&left))
121 .saturating_sub(2);
122 let right = self.fill(right_length);
123
124 let mut text = Text::new("");
125 text.append(&format!("{left} "), Some(self.style.clone().into()));
126 text = text.append_text(&title);
127 text.append(&format!(" {right}"), Some(self.style.clone().into()));
128 text
129 }
130 HorizontalAlign::Left => {
131 let fill_len = width.saturating_sub(title.cell_len()).saturating_sub(1);
132 let mut text = Text::new("");
133 text = text.append_text(&title);
134 text.append(" ", None);
135 text.append(&self.fill(fill_len), Some(self.style.clone().into()));
136 text
137 }
138 HorizontalAlign::Right => {
139 let fill_len = width.saturating_sub(title.cell_len()).saturating_sub(1);
140 let mut text = Text::new("");
141 text.append(&self.fill(fill_len), Some(self.style.clone().into()));
142 text.append(" ", None);
143 text = text.append_text(&title);
144 text
145 }
146 }
147 }
148}
149
150impl Renderable for Rule {
151 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
152 let text = self.build_text(console, options.max_width);
153 text.render(console.theme(), console.base_style())
154 }
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160
161 fn console() -> Console {
162 Console::builder()
163 .force_terminal(true)
164 .color_system(Some(crate::color::ColorSystem::Truecolor))
165 .width(20)
166 .build()
167 }
168
169 #[test]
170 fn plain_rule_fills_width() {
171 let out = console().render_export(&Rule::line());
172 assert_eq!(out, format!("\x1b[92m{}\x1b[0m\n", "─".repeat(20)));
173 }
174
175 #[test]
176 fn titled_rule_centers() {
177 let out = console().render_export(&Rule::new("Hi"));
178 assert_eq!(out, "\x1b[92m──────── \x1b[0mHi\x1b[92m ────────\x1b[0m\n");
179 }
180
181 #[test]
185 fn a_title_that_cannot_fit_falls_back_to_a_plain_rule() {
186 for width in [1usize, 2, 3, 4] {
187 let console = Console::builder().width(width).no_color(true).build();
188 let out = console.render_to_string(&Rule::new("TITLE"));
189 assert_eq!(
190 out.trim_end_matches('\n'),
191 "\u{2500}".repeat(width),
192 "width {width} did not fall back to a plain rule"
193 );
194 }
195 }
196
197 #[test]
199 fn an_over_long_title_is_ellipsised() {
200 let console = Console::builder().width(5).no_color(true).build();
201 let out = console.render_to_string(&Rule::new("TITLE"));
202 assert_eq!(out.trim_end_matches('\n'), "\u{2500} \u{2026} \u{2500}");
203 }
204}