1use crate::align::HorizontalAlign;
10use crate::cells::{cell_len, set_cell_size, truncate};
11use crate::console::{Console, ConsoleOptions};
12use crate::protocol::Renderable;
13use crate::segment::Segment;
14use crate::style::Style;
15use crate::text::Text;
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, width: usize) -> Text {
81 let Some(title) = &self.title 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 match self.align {
101 HorizontalAlign::Center => {
102 let title = truncate_ellipsis(title, truncate_width);
104 let title_len = cell_len(&title);
105
106 let side_width = width.saturating_sub(title_len) / 2;
107 let left = self.fill(side_width.saturating_sub(1));
108 let right_length = width
109 .saturating_sub(title_len)
110 .saturating_sub(cell_len(&left))
111 .saturating_sub(2);
112 let right = self.fill(right_length);
113
114 let mut text = Text::new("");
115 text.append(&format!("{left} "), Some(self.style.clone().into()));
116 text.append(&title, None);
117 text.append(&format!(" {right}"), Some(self.style.clone().into()));
118 text
119 }
120 HorizontalAlign::Left => {
121 let title = truncate_ellipsis(title, truncate_width);
122 let fill_len = width.saturating_sub(cell_len(&title)).saturating_sub(1);
123 let mut text = Text::new("");
124 text.append(&format!("{title} "), None);
125 text.append(&self.fill(fill_len), Some(self.style.clone().into()));
126 text
127 }
128 HorizontalAlign::Right => {
129 let title = truncate_ellipsis(title, truncate_width);
130 let fill_len = width.saturating_sub(cell_len(&title)).saturating_sub(1);
131 let mut text = Text::new("");
132 text.append(&self.fill(fill_len), Some(self.style.clone().into()));
133 text.append(&format!(" {title}"), None);
134 text
135 }
136 }
137 }
138}
139
140fn truncate_ellipsis(text: &str, width: usize) -> String {
144 if cell_len(text) <= width {
145 return text.to_string();
146 }
147 if width == 0 {
148 return String::new();
149 }
150 let mut out = truncate(text, width.saturating_sub(1));
151 out.push('\u{2026}');
152 out
153}
154
155impl Renderable for Rule {
156 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
157 let text = self.build_text(options.max_width);
158 text.render(console.theme(), console.base_style())
159 }
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165
166 fn console() -> Console {
167 Console::builder()
168 .force_terminal(true)
169 .color_system(Some(crate::color::ColorSystem::Truecolor))
170 .width(20)
171 .build()
172 }
173
174 #[test]
175 fn plain_rule_fills_width() {
176 let out = console().render_export(&Rule::line());
177 assert_eq!(out, format!("\x1b[92m{}\x1b[0m\n", "─".repeat(20)));
178 }
179
180 #[test]
181 fn titled_rule_centers() {
182 let out = console().render_export(&Rule::new("Hi"));
183 assert_eq!(out, "\x1b[92m──────── \x1b[0mHi\x1b[92m ────────\x1b[0m\n");
184 }
185
186 #[test]
190 fn a_title_that_cannot_fit_falls_back_to_a_plain_rule() {
191 for width in [1usize, 2, 3, 4] {
192 let console = Console::builder().width(width).no_color(true).build();
193 let out = console.render_to_string(&Rule::new("TITLE"));
194 assert_eq!(
195 out.trim_end_matches('\n'),
196 "\u{2500}".repeat(width),
197 "width {width} did not fall back to a plain rule"
198 );
199 }
200 }
201
202 #[test]
204 fn an_over_long_title_is_ellipsised() {
205 let console = Console::builder().width(5).no_color(true).build();
206 let out = console.render_to_string(&Rule::new("TITLE"));
207 assert_eq!(out.trim_end_matches('\n'), "\u{2500} \u{2026} \u{2500}");
208 }
209}