1use 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
19pub struct Rule {
21 title: Option<String>,
22 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 style: StyleType::Name("rule.line".to_string()),
38 end: "\n".to_string(),
39 align: HorizontalAlign::Center,
40 }
41 }
42}
43
44impl Rule {
45 pub fn line() -> Self {
47 Rule::default()
48 }
49
50 pub fn new(title: impl Into<String>) -> Self {
52 Rule {
53 title: Some(title.into()),
54 ..Rule::default()
55 }
56 }
57
58 pub fn with_title_text(title: Text) -> Self {
61 Rule {
62 title_text: Some(title),
63 ..Rule::default()
64 }
65 }
66
67 pub fn end(mut self, end: impl Into<String>) -> Self {
72 self.end = end.into();
73 self
74 }
75
76 pub fn characters(mut self, characters: impl Into<String>) -> Self {
78 self.characters = characters.into();
79 self
80 }
81
82 pub fn style(mut self, style: impl Into<StyleType>) -> Self {
85 self.style = style.into();
86 self
87 }
88
89 pub fn align(mut self, align: HorizontalAlign) -> Self {
91 self.align = align;
92 self
93 }
94
95 fn fill(characters: &str, width: usize) -> String {
97 if width == 0 {
98 return String::new();
99 }
100 let chars_len = cell_len(characters).max(1);
101 let repeat = width / chars_len + 1;
102 let repeated = characters.repeat(repeat);
103 set_cell_size(&repeated, width)
104 }
105
106 fn build_text(&self, console: &Console, options: &ConsoleOptions) -> (Text, bool) {
109 let width = options.max_width;
110 let characters = if options.ascii_only() && !self.characters.is_ascii() {
113 "-"
114 } else {
115 self.characters.as_str()
116 };
117 let rule_line = || Text::styled(Self::fill(&self.characters, width), self.style.clone());
118 let title = match (&self.title_text, &self.title) {
119 (Some(text), _) if !text.plain().is_empty() => {
120 let mut title = text.blank_copy();
121 title.append(&text.plain().replace('\n', " "), None);
122 for span in text.spans() {
123 title.push_span(span.clone());
124 }
125 title
126 }
127 (None, Some(title)) if !title.is_empty() => {
128 let parsed = console.build_text(title);
131 let mut title = parsed.blank_copy();
132 title.append(&parsed.plain().replace('\n', " "), None);
133 for span in parsed.spans() {
134 title.push_span(span.clone());
135 }
136 title.set_base_style("rule.text");
137 title
138 }
139 _ => return (rule_line(), false),
140 };
141 let mut title = title;
142
143 let required_space = if matches!(self.align, HorizontalAlign::Center) {
149 4
150 } else {
151 2
152 };
153 let truncate_width = width.saturating_sub(required_space);
154 if truncate_width == 0 {
155 return (rule_line(), false);
156 }
157 title.expand_tabs(DEFAULT_TAB_SIZE);
158 title.truncate(truncate_width, Some(Overflow::Ellipsis), false);
159
160 let mut text = match self.align {
161 HorizontalAlign::Center => {
162 let title_len = title.cell_len();
164
165 let side_width = width.saturating_sub(title_len) / 2;
166 let left = Self::fill(characters, side_width.saturating_sub(1));
167 let right_length = width
168 .saturating_sub(title_len)
169 .saturating_sub(cell_len(&left))
170 .saturating_sub(2);
171 let right = Self::fill(characters, right_length);
172
173 let mut text = Text::new("");
174 text.append(&format!("{left} "), Some(self.style.clone()));
175 text = text.append_text(&title);
176 text.append(&format!(" {right}"), Some(self.style.clone()));
177 text
178 }
179 HorizontalAlign::Left => {
180 let fill_len = width.saturating_sub(title.cell_len()).saturating_sub(1);
181 let mut text = Text::new("");
182 text = text.append_text(&title);
183 text.append(" ", None);
184 text.append(&Self::fill(characters, fill_len), Some(self.style.clone()));
185 text
186 }
187 HorizontalAlign::Right => {
188 let repeat = width.saturating_sub(title.cell_len()).saturating_sub(1);
192 let mut text = Text::new("");
193 text.append(&characters.repeat(repeat), Some(self.style.clone()));
194 text.append(" ", None);
195 text = text.append_text(&title);
196 text
197 }
198 };
199 text.truncate(width, Some(Overflow::Crop), true);
201 (text, true)
202 }
203}
204
205impl Renderable for Rule {
206 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
207 crate::protocol::report_region(
209 console,
210 || {
211 crate::protocol::RegionInfo::new(crate::protocol::RegionRole::Rule)
212 .label(self.title_plain(console))
213 },
214 || self.render_rule(console, options),
215 )
216 }
217
218 fn measure(&self, _console: &Console, _options: &ConsoleOptions) -> Measurement {
221 Measurement::new(1, 1)
222 }
223}
224
225impl Rule {
226 fn title_plain(&self, console: &Console) -> String {
228 match (&self.title_text, &self.title) {
229 (Some(text), _) => text.plain().to_string(),
230 (None, Some(title)) => console.build_text(title).plain().to_string(),
231 (None, None) => String::new(),
232 }
233 }
234
235 fn render_rule(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
237 let (text, titled) = self.build_text(console, options);
238 let mut segments = text.render(console.theme(), console.base_style());
239 if titled {
240 let end = self.end.strip_suffix('\n').unwrap_or(&self.end);
241 if !end.is_empty() {
242 segments.push(Segment::new(end, None));
243 }
244 }
245 segments
246 }
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252
253 fn console() -> Console {
254 Console::builder()
255 .force_terminal(true)
256 .color_system(Some(crate::color::ColorSystem::Truecolor))
257 .width(20)
258 .build()
259 }
260
261 #[test]
262 fn plain_rule_fills_width() {
263 let out = console().render_export(&Rule::line());
264 assert_eq!(out, format!("\x1b[92m{}\x1b[0m\n", "─".repeat(20)));
265 }
266
267 #[test]
268 fn titled_rule_centers() {
269 let out = console().render_export(&Rule::new("Hi"));
270 assert_eq!(out, "\x1b[92m──────── \x1b[0mHi\x1b[92m ────────\x1b[0m\n");
271 }
272
273 #[test]
277 fn a_title_that_cannot_fit_falls_back_to_a_plain_rule() {
278 for width in [1usize, 2, 3, 4] {
279 let console = Console::builder().width(width).color_system(None).build();
280 let out = console.render_to_string(&Rule::new("TITLE"));
281 assert_eq!(
282 out.trim_end_matches('\n'),
283 "\u{2500}".repeat(width),
284 "width {width} did not fall back to a plain rule"
285 );
286 }
287 }
288
289 #[test]
291 fn an_over_long_title_is_ellipsised() {
292 let console = Console::builder().width(5).color_system(None).build();
293 let out = console.render_to_string(&Rule::new("TITLE"));
294 assert_eq!(out.trim_end_matches('\n'), "\u{2500} \u{2026} \u{2500}");
295 }
296
297 #[test]
298 fn right_aligned_title_is_dropped_by_a_multi_cell_fill() {
299 let console = Console::builder()
301 .force_terminal(true)
302 .color_system(Some(crate::color::ColorSystem::Truecolor))
303 .width(10)
304 .highlight(false)
305 .build();
306 let rule = Rule::new("x")
307 .characters("-~")
308 .align(HorizontalAlign::Right);
309 assert_eq!(console.render_export(&rule), "\x1b[92m-~-~-~-~-~\x1b[0m\n");
310 }
311}