1use ratatui::style::Style;
15use ratatui::text::{Line, Span};
16use unicode_segmentation::UnicodeSegmentation;
17use unicode_width::UnicodeWidthStr;
18
19const RULE_MIN_RUN: usize = 8;
21
22const TAB_STOP: usize = 4;
24
25#[derive(Clone, Copy)]
27struct Cell<'a> {
28 sym: &'a str,
29 style: Style,
30 width: usize,
31}
32
33impl Cell<'_> {
34 fn is(&self, sym: &str) -> bool {
35 self.sym == sym
36 }
37}
38
39pub fn fit(lines: Vec<Line<'static>>, width: usize) -> Vec<Line<'static>> {
42 if width == 0 {
43 return lines;
44 }
45 let mut out = Vec::with_capacity(lines.len());
46 for line in lines {
47 let line = expand_tabs(line);
48 if graphemes(&line).map(|g| g.width).sum::<usize>() <= width {
49 out.push(line);
50 continue;
51 }
52 let cells: Vec<Cell> = graphemes(&line).collect();
53 let rows = if is_rule(&cells) {
54 vec![cut(&cells, width)]
55 } else {
56 wrap(&cells, width)
57 };
58 out.extend(rows.into_iter().map(|row| Line {
59 spans: spans(&row),
60 style: line.style,
61 alignment: line.alignment,
62 }));
63 }
64 out
65}
66
67fn graphemes<'a>(line: &'a Line<'_>) -> impl Iterator<Item = Cell<'a>> {
70 line.spans.iter().flat_map(|span| {
71 span.styled_graphemes(Style::default()).map(|g| Cell {
72 sym: g.symbol,
73 style: g.style,
74 width: g.symbol.width(),
75 })
76 })
77}
78
79fn expand_tabs(line: Line<'static>) -> Line<'static> {
81 if !line.spans.iter().any(|s| s.content.contains('\t')) {
82 return line;
83 }
84 let Line {
85 style,
86 alignment,
87 spans,
88 } = line;
89 let mut col = 0;
90 let spans = spans
91 .into_iter()
92 .map(|span| {
93 let mut text = String::new();
94 for g in span.content.graphemes(true) {
95 if g == "\t" {
96 let n = TAB_STOP - col % TAB_STOP;
97 text.extend(std::iter::repeat_n(' ', n));
98 col += n;
99 } else {
100 text.push_str(g);
101 if !g.contains(char::is_control) {
102 col += g.width();
103 }
104 }
105 }
106 Span::styled(text, span.style)
107 })
108 .collect();
109 Line {
110 style,
111 alignment,
112 spans,
113 }
114}
115
116fn is_rule(cells: &[Cell]) -> bool {
119 let run: usize = cells
120 .iter()
121 .rev()
122 .skip_while(|c| c.is(" "))
123 .take_while(|c| c.is("─"))
124 .map(|c| c.width)
125 .sum();
126 run >= RULE_MIN_RUN
127}
128
129fn cut<'a>(cells: &[Cell<'a>], width: usize) -> Vec<Cell<'a>> {
131 let mut used = 0;
132 cells
133 .iter()
134 .take_while(|c| {
135 used += c.width;
136 used <= width
137 })
138 .copied()
139 .collect()
140}
141
142fn gutter<'a>(cells: &[Cell<'a>]) -> Vec<Cell<'a>> {
144 cells
145 .iter()
146 .take_while(|c| c.is(" ") || c.is("│"))
147 .copied()
148 .collect()
149}
150
151fn wrap<'a>(cells: &[Cell<'a>], width: usize) -> Vec<Vec<Cell<'a>>> {
156 let mut gutter = gutter(cells);
157 if gutter.iter().map(|c| c.width).sum::<usize>() >= width / 2 {
159 gutter.clear();
160 }
161 let head = gutter.len();
162
163 let mut rows: Vec<Vec<Cell>> = Vec::new();
164 let mut row: Vec<Cell> = Vec::new();
165 let mut used = 0;
166 let mut space: Option<usize> = None;
168 for &cell in cells {
169 if cell.width > 0 && used + cell.width > width && row.len() > head {
172 let rest = match space.filter(|_| !cell.is(" ")) {
174 Some(i) => {
175 let rest = row.split_off(i + 1);
176 row.truncate(i);
177 rest
178 }
179 None => Vec::new(),
180 };
181 while row.len() > head && row.last().is_some_and(|c| c.is(" ")) {
182 row.pop();
183 }
184 rows.push(std::mem::replace(&mut row, gutter.clone()));
185 row.extend(rest.into_iter().skip_while(|c| c.is(" ")));
186 used = row.iter().map(|c| c.width).sum();
187 space = None;
188 }
189 if cell.is(" ") {
190 if !rows.is_empty() && row.len() == head {
192 continue;
193 }
194 if row.iter().skip(head).any(|c| !c.is(" ")) {
197 space = Some(row.len());
198 }
199 }
200 row.push(cell);
201 used += cell.width;
202 }
203 rows.push(row);
204 rows
205}
206
207fn spans(row: &[Cell]) -> Vec<Span<'static>> {
209 let mut spans: Vec<Span<'static>> = Vec::new();
210 for cell in row {
211 match spans.last_mut() {
212 Some(last) if last.style == cell.style => last.content.to_mut().push_str(cell.sym),
213 _ => spans.push(Span::styled(cell.sym.to_string(), cell.style)),
214 }
215 }
216 spans
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222 use ratatui::style::{Color, Modifier};
223
224 fn plain(lines: &[Line<'_>]) -> Vec<String> {
225 lines.iter().map(|l| l.to_string()).collect()
226 }
227
228 #[test]
229 fn test_a_line_that_fits_is_kept_as_is() {
230 let line = Line::styled("short", Style::new().fg(Color::Red));
231 assert_eq!(fit(vec![line.clone()], 10), vec![line]);
232 }
233
234 #[test]
235 fn test_wrapped_rows_keep_the_gutter() {
236 let line = Line::raw("│ alpha beta gamma delta");
238 let rows = plain(&fit(vec![line], 13));
239 assert_eq!(rows, vec!["│ alpha beta", "│ gamma", "│ delta"]);
240 }
241
242 #[test]
243 fn test_a_long_word_breaks_at_the_edge() {
244 let line = Line::raw("│ /very/long/path/name");
245 let rows = plain(&fit(vec![line], 10));
246 assert_eq!(rows, vec!["│ /very/l", "│ ong/pat", "│ h/name"]);
247 }
248
249 #[test]
250 fn test_continuation_rows_drop_padding_after_the_gutter() {
251 let line = Line::raw(format!("│ key{}value", " ".repeat(20)));
254 let rows = plain(&fit(vec![line], 12));
255 assert_eq!(rows, vec!["│ key", "│ value"]);
256 }
257
258 #[test]
259 fn test_a_rule_is_cut_not_wrapped() {
260 let line = Line::raw(format!("└{}", "─".repeat(50)));
261 let rows = plain(&fit(vec![line], 40));
262 assert_eq!(rows, vec![format!("└{}", "─".repeat(39))]);
263 let header = Line::raw(format!("┌─ Venues {}", "─".repeat(40)));
264 assert_eq!(fit(vec![header], 20).len(), 1);
265 }
266
267 #[test]
268 fn test_text_with_dashes_inside_is_not_a_rule() {
269 let line = Line::raw(format!("a {} tail text", "─".repeat(10)));
272 let rows = plain(&fit(vec![line], 14));
273 assert_eq!(
274 rows.concat().replace(' ', ""),
275 format!("a{}tailtext", "─".repeat(10))
276 );
277 assert!(rows.len() > 1);
278 }
279
280 #[test]
281 fn test_wide_chars_are_not_split_and_rows_fit() {
282 let line = Line::raw("日本語の文");
284 let rows = plain(&fit(vec![line], 9));
285 assert_eq!(rows, vec!["日本語の", "文"]);
286 }
287
288 #[test]
289 fn test_emoji_presentation_at_the_edge_moves_to_the_next_row() {
290 let line = Line::raw("abcd\u{26a0}\u{fe0f}");
293 let rows = plain(&fit(vec![line], 5));
294 assert_eq!(rows, vec!["abcd", "\u{26a0}\u{fe0f}"]);
295 }
296
297 #[test]
298 fn test_a_zwj_emoji_is_one_cluster() {
299 let family = "\u{1f468}\u{200d}\u{1f469}\u{200d}\u{1f467}";
302 let line = Line::raw(family.repeat(3));
303 let rows = plain(&fit(vec![line], 4));
304 assert_eq!(rows, vec![family.repeat(2), family.to_string()]);
305 }
306
307 #[test]
308 fn test_zero_width_marks_stay_with_their_char() {
309 let line = Line::raw("abcde\u{301}f");
311 let rows = plain(&fit(vec![line], 5));
312 assert_eq!(rows, vec!["abcde\u{301}", "f"]);
313 }
314
315 #[test]
316 fn test_tabs_expand_to_the_next_tab_stop() {
317 let rows = plain(&fit(vec![Line::raw("ab\tcd\tx")], 40));
319 assert_eq!(rows, vec!["ab cd x"]);
320 let line = Line::from(vec![Span::raw("a"), Span::raw("\tb")]);
321 assert_eq!(plain(&fit(vec![line], 40)), vec!["a b"]);
322 let rows = plain(&fit(vec![Line::raw("x\t\t\ty")], 8));
324 assert_eq!(rows, vec!["x", "y"]);
325 }
326
327 #[test]
328 fn test_span_and_line_styles_survive_a_wrap() {
329 let red = Style::new().fg(Color::Red);
331 let footer = Line::styled("✗ Failed in 12.3s.", red);
332 let rows = fit(vec![footer], 10);
333 assert!(rows.len() > 1);
334 assert!(rows.iter().all(|r| r.style == red), "{rows:?}");
335
336 let bar = Style::new().fg(Color::Cyan);
337 let bold = Style::new().add_modifier(Modifier::BOLD);
338 let line = Line::from(vec![
339 Span::styled("│", bar),
340 Span::raw(" "),
341 Span::styled("bold words that wrap", bold),
342 ]);
343 let rows = fit(vec![line], 12);
344 assert_eq!(rows.len(), 3, "{rows:?}");
345 for row in &rows {
346 assert_eq!(row.spans[0].style, bar, "the gutter keeps its color");
347 assert_eq!(row.spans.last().unwrap().style, bold);
348 }
349 }
350
351 #[test]
352 fn test_a_deep_gutter_is_dropped() {
353 let line = Line::raw(format!("{}abc def", " ".repeat(6)));
355 let rows = plain(&fit(vec![line], 10));
356 assert_eq!(rows, vec![" abc", "def"]);
357 }
358
359 #[test]
360 fn test_zero_width_keeps_the_lines() {
361 let lines = vec![Line::raw("anything")];
362 assert_eq!(fit(lines.clone(), 0), lines);
363 }
364}