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