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