Skip to main content

ic_host_text/width/
mod.rs

1//! Pure clipping to a caller-selected terminal column allowance.
2use unicode_width::UnicodeWidthChar;
3
4use crate::escape::escaped_chars;
5
6/// Escape controls and fit one line within `columns`, adding `…` when clipped.
7///
8/// Zero columns return an empty string. Width is the sum of Unicode scalar
9/// widths, conservatively counting joined emoji separately. Clipping preserves
10/// UTF-8 boundaries, but does not promise grapheme-cluster boundaries. This is
11/// display text, not an identity-preserving encoding or an ANSI parser.
12/// Escaping stops once clipping is established; no complete escaped copy is built.
13/// Callers own input byte limits, terminal dimensions, output and redraw policy.
14#[must_use]
15pub fn fit_line(line: &str, columns: usize) -> String {
16    if columns == 0 {
17        return String::new();
18    }
19    let mut width = 0;
20    let mut text = String::new();
21    let mut clip_at = None;
22    for ch in escaped_chars(line) {
23        let next = ch.width().unwrap_or(0);
24        let remaining = columns - width;
25        if next > remaining {
26            text.truncate(clip_at.unwrap_or(text.len()));
27            text.push('…');
28            return text;
29        }
30        if next == remaining && clip_at.is_none() {
31            // Reserve the same prefix as clipping with one column for the ellipsis.
32            // Keep it only if a later character proves that clipping is necessary.
33            clip_at = Some(text.len());
34        }
35        text.push(ch);
36        width += next;
37    }
38    text
39}
40
41#[cfg(test)]
42mod tests {
43    use super::*;
44
45    #[test]
46    fn exact_width_and_small_allowances() {
47        for (text, columns, expected) in [
48            ("", 0, ""),
49            ("", usize::MAX, ""),
50            ("abc", 0, ""),
51            ("abc", 1, "…"),
52            ("abc", 2, "a…"),
53            ("abc", 3, "abc"),
54            ("abc", usize::MAX, "abc"),
55            ("界界", 3, "界…"),
56            ("e\u{301}x", 2, "e\u{301}x"),
57            ("a\u{301}", 1, "a\u{301}"),
58            ("a\u{301}x", 1, "…"),
59            ("\u{301}界", 1, "\u{301}…"),
60            ("界\u{301}", 2, "界\u{301}"),
61            ("界\u{301}x", 2, "…"),
62            ("a\u{200d}b", 2, "a\u{200d}b"),
63            ("a\u{200d}bc", 2, "a\u{200d}…"),
64            ("\u{202e}", 8, "\\u{202e}"),
65            ("\u{202e}", 7, "\\u{202…"),
66        ] {
67            assert_eq!(fit_line(text, columns), expected);
68        }
69    }
70
71    #[test]
72    fn controls_are_visible_before_width_admission() {
73        assert_eq!(fit_line("a\nb", 4), "a\\nb");
74        assert_eq!(fit_line("a\nb", 3), "a\\…");
75        assert_eq!(fit_line("\x1b[31m", 40), "\\u{1b}[31m");
76    }
77
78    #[test]
79    fn unicode_output_obeys_each_selected_allowance() {
80        for input in ["界界界", "e\u{301}e\u{301}xx", "👩‍💻 ready", "\n\t\u{202e}"] {
81            for columns in 0..32 {
82                let fitted = fit_line(input, columns);
83                assert!(
84                    fitted
85                        .chars()
86                        .map(|ch| ch.width().unwrap_or(0))
87                        .sum::<usize>()
88                        <= columns
89                );
90                assert!(!fitted.chars().any(char::is_control));
91            }
92        }
93    }
94}