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 {
73 self.end = end.into();
74 self
75 }
76
77 pub fn characters(mut self, characters: impl Into<String>) -> Self {
79 self.characters = characters.into();
80 self
81 }
82
83 pub fn style(mut self, style: impl Into<StyleType>) -> Self {
86 self.style = style.into();
87 self
88 }
89
90 pub fn align(mut self, align: HorizontalAlign) -> Self {
92 self.align = align;
93 self
94 }
95
96 fn fill(characters: &str, width: usize) -> String {
98 if width == 0 {
99 return String::new();
100 }
101 let chars_len = cell_len(characters).max(1);
102 let repeat = width / chars_len + 1;
103 let repeated = characters.repeat(repeat);
104 set_cell_size(&repeated, width)
105 }
106
107 fn build_text(&self, console: &Console, options: &ConsoleOptions) -> (Text, bool) {
110 let width = options.max_width;
111 let characters = if options.ascii_only() && !self.characters.is_ascii() {
114 "-"
115 } else {
116 self.characters.as_str()
117 };
118 let rule_line = || Text::styled(Self::fill(&self.characters, width), self.style.clone());
119 let title = match (&self.title_text, &self.title) {
120 (Some(text), _) if !text.plain().is_empty() => {
121 let mut title = text.blank_copy();
122 title.append(&text.plain().replace('\n', " "), None);
123 for span in text.spans() {
124 title.push_span(span.clone());
125 }
126 title
127 }
128 (None, Some(title)) if !title.is_empty() => {
129 let parsed = console.build_text(title);
132 let mut title = parsed.blank_copy();
133 title.append(&parsed.plain().replace('\n', " "), None);
134 for span in parsed.spans() {
135 title.push_span(span.clone());
136 }
137 title.set_base_style("rule.text");
138 title
139 }
140 _ => return (rule_line(), false),
141 };
142 let mut title = title;
143
144 let required_space = if matches!(self.align, HorizontalAlign::Center) {
150 4
151 } else {
152 2
153 };
154 let truncate_width = width.saturating_sub(required_space);
155 if truncate_width == 0 {
156 return (rule_line(), false);
157 }
158 title.expand_tabs(DEFAULT_TAB_SIZE);
159 title.truncate(truncate_width, Some(Overflow::Ellipsis), false);
160
161 let mut text = match self.align {
162 HorizontalAlign::Center => {
163 let title_len = title.cell_len();
165
166 let side_width = width.saturating_sub(title_len) / 2;
167 let left = Self::fill(characters, side_width.saturating_sub(1));
168 let right_length = width
169 .saturating_sub(title_len)
170 .saturating_sub(cell_len(&left))
171 .saturating_sub(2);
172 let right = Self::fill(characters, right_length);
173
174 let mut text = Text::new("");
175 text.append(&format!("{left} "), Some(self.style.clone()));
176 text = text.append_text(&title);
177 text.append(&format!(" {right}"), Some(self.style.clone()));
178 text
179 }
180 HorizontalAlign::Left => {
181 let fill_len = width.saturating_sub(title.cell_len()).saturating_sub(1);
182 let mut text = Text::new("");
183 text = text.append_text(&title);
184 text.append(" ", None);
185 text.append(&Self::fill(characters, fill_len), Some(self.style.clone()));
186 text
187 }
188 HorizontalAlign::Right => {
189 let repeat = width.saturating_sub(title.cell_len()).saturating_sub(1);
193 let mut text = Text::new("");
194 text.append(&characters.repeat(repeat), Some(self.style.clone()));
195 text.append(" ", None);
196 text = text.append_text(&title);
197 text
198 }
199 };
200 text.truncate(width, Some(Overflow::Crop), true);
202 (text, true)
203 }
204}
205
206impl Renderable for Rule {
207 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
208 crate::protocol::report_region(
210 console,
211 || {
212 crate::protocol::RegionInfo::new(crate::protocol::RegionRole::Rule)
213 .label(self.title_plain(console))
214 },
215 || self.render_rule(console, options),
216 )
217 }
218
219 fn measure(&self, _console: &Console, _options: &ConsoleOptions) -> Measurement {
222 Measurement::new(1, 1)
223 }
224
225 fn print_end_line(&self, options: &ConsoleOptions) -> bool {
228 let titled = match (&self.title_text, &self.title) {
229 (Some(text), _) => !text.plain().is_empty(),
230 (None, Some(title)) => !title.is_empty(),
231 (None, None) => false,
232 };
233 let required_space = if self.align == HorizontalAlign::Center {
236 4
237 } else {
238 2
239 };
240 !titled || options.max_width <= required_space || self.end.ends_with('\n')
241 }
242}
243
244impl Rule {
257 pub fn get_title(&self) -> Option<crate::table::Cell> {
261 match (&self.title_text, &self.title) {
262 (Some(text), _) => Some(crate::table::Cell::Text(text.clone())),
263 (None, Some(title)) => Some(crate::table::Cell::Markup(title.clone())),
264 (None, None) => None,
265 }
266 }
267
268 pub fn get_align(&self) -> HorizontalAlign {
270 self.align
271 }
272}
273
274impl Rule {
275 fn title_plain(&self, console: &Console) -> String {
277 match (&self.title_text, &self.title) {
278 (Some(text), _) => text.plain().to_string(),
279 (None, Some(title)) => console.build_text(title).plain().to_string(),
280 (None, None) => String::new(),
281 }
282 }
283
284 fn render_rule(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
286 let (text, titled) = self.build_text(console, options);
287 let mut segments = text.render(console.theme(), console.base_style());
288 if titled {
289 let end = self.end.strip_suffix('\n').unwrap_or(&self.end);
290 if !end.is_empty() {
291 segments.push(Segment::new(end, None));
292 }
293 }
294 segments
295 }
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301
302 fn console() -> Console {
303 Console::builder()
304 .force_terminal(true)
305 .color_system(Some(crate::color::ColorSystem::Truecolor))
306 .width(20)
307 .build()
308 }
309
310 #[test]
311 fn plain_rule_fills_width() {
312 let out = console().render_export(&Rule::line());
313 assert_eq!(out, format!("\x1b[92m{}\x1b[0m\n", "─".repeat(20)));
314 }
315
316 #[test]
317 fn titled_rule_centers() {
318 let out = console().render_export(&Rule::new("Hi"));
319 assert_eq!(out, "\x1b[92m──────── \x1b[0mHi\x1b[92m ────────\x1b[0m\n");
320 }
321
322 #[test]
326 fn a_title_that_cannot_fit_falls_back_to_a_plain_rule() {
327 for width in [1usize, 2, 3, 4] {
328 let console = Console::builder().width(width).color_system(None).build();
329 let out = console.render_to_string(&Rule::new("TITLE"));
330 assert_eq!(
331 out.trim_end_matches('\n'),
332 "\u{2500}".repeat(width),
333 "width {width} did not fall back to a plain rule"
334 );
335 }
336 }
337
338 #[test]
340 fn an_over_long_title_is_ellipsised() {
341 let console = Console::builder().width(5).color_system(None).build();
342 let out = console.render_to_string(&Rule::new("TITLE"));
343 assert_eq!(out.trim_end_matches('\n'), "\u{2500} \u{2026} \u{2500}");
344 }
345
346 #[test]
347 fn right_aligned_title_is_dropped_by_a_multi_cell_fill() {
348 let console = Console::builder()
350 .force_terminal(true)
351 .color_system(Some(crate::color::ColorSystem::Truecolor))
352 .width(10)
353 .highlight(false)
354 .build();
355 let rule = Rule::new("x")
356 .characters("-~")
357 .align(HorizontalAlign::Right);
358 assert_eq!(console.render_export(&rule), "\x1b[92m-~-~-~-~-~\x1b[0m\n");
359 }
360}