Skip to main content

scope_cli/cli/tui/
fit.rs

1//! # Fit Output to the Pane
2//!
3//! Breaks output lines that are wider than the output pane. A broken line
4//! continues on the next row after the gutter of its first row (leading
5//! spaces and `│` bars), so text inside a section stays inside it. A rule
6//! (a line that ends in a long `─` run) is cut at the pane edge instead.
7//!
8//! Widths are measured per grapheme cluster, as ratatui draws them: `⚠️`
9//! (with its variation selector) is two columns, and an emoji ZWJ
10//! sequence is one cluster. A cluster is never split across rows. Tabs
11//! become spaces to the next tab stop first, because ratatui does not
12//! draw them. Styles of the spans are kept.
13
14use ratatui::style::Style;
15use ratatui::text::{Line, Span};
16use unicode_segmentation::UnicodeSegmentation;
17use unicode_width::UnicodeWidthStr;
18
19/// A `─` run at least this wide at the end of a line makes it a rule.
20const RULE_MIN_RUN: usize = 8;
21
22/// Tab stops are every this many columns.
23const TAB_STOP: usize = 4;
24
25/// One grapheme cluster with its style and display width.
26#[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
39/// Fits `lines` to `width` columns. Lines that fit are kept as they are
40/// (with tabs expanded).
41pub 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
67/// The grapheme clusters ratatui draws for `line`: clusters that hold a
68/// control char are skipped, as ratatui skips them.
69fn 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
79/// Replaces each tab with spaces up to the next tab stop.
80fn 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
116/// True when the line ends (before trailing spaces) in a `─` run of at
117/// least `RULE_MIN_RUN` columns.
118fn 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
129/// The leading cells of `cells` that fit in `width` columns.
130fn 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
142/// The gutter of a row: its leading spaces and `│` bars.
143fn 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
151/// Breaks `cells` into rows of at most `width` columns. Each row after the
152/// first starts with the gutter of the first, and drops the spaces that
153/// would follow it. A row breaks after its last space when it has one past
154/// the gutter's text, else at the edge.
155fn wrap<'a>(cells: &[Cell<'a>], width: usize) -> Vec<Vec<Cell<'a>>> {
156    let mut gutter = gutter(cells);
157    // A gutter that leaves no room for text is dropped.
158    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    // Index in `row` of the last space after text past the gutter.
167    let mut space: Option<usize> = None;
168    for &cell in cells {
169        // A row that holds text past the gutter breaks before a cluster
170        // that does not fit.
171        if cell.width > 0 && used + cell.width > width && row.len() > head {
172            // A space that does not fit is itself the break.
173            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            // A continuation row has no spaces between gutter and text.
191            if !rows.is_empty() && row.len() == head {
192                continue;
193            }
194            // A space is a break point only after text past the gutter, so
195            // no row is left empty.
196            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
207/// Joins runs of cells with one style into spans.
208fn 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        // Text inside a section box stays inside it when it wraps.
237        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        // Wide padding between a key and a long value must not leave a
252        // row that is only gutter and spaces.
253        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        // Only a `─` run that ends the line makes a rule; text after a run
270        // must wrap, not be cut away.
271        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        // Five CJK chars are ten columns: a row of nine holds four.
283        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        // `⚠️` is U+26A0 + U+FE0F and draws two columns. Summing chars
291        // (1 + 0) keeps it on a full row, where its second column clips.
292        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        // A family emoji is one two-column cluster; summing its chars gives
300        // six columns and splits it.
301        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        // "e" + combining acute is one column; it must not start a row.
310        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        // ratatui draws nothing for a tab, so the width math would be off.
318        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        // Expanded tabs count when the line is fitted.
323        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        // The red `✗ Failed in` footer stays red on every row it wraps to.
330        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        // A gutter of half the width or more would leave no room for text.
354        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}