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::terminal_text;
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/// Callers own input byte limits, terminal dimensions, output and redraw policy.
13#[must_use]
14pub fn fit_line(line: &str, columns: usize) -> String {
15    if columns == 0 {
16        return String::new();
17    }
18    let line = terminal_text(line);
19    let total: usize = line.chars().map(|ch| ch.width().unwrap_or(0)).sum();
20    if total <= columns {
21        return line.into_owned();
22    }
23    let mut width = 0;
24    let mut text = String::new();
25    for ch in line.chars() {
26        let next = width + ch.width().unwrap_or(0);
27        if next >= columns {
28            break;
29        }
30        text.push(ch);
31        width = next;
32    }
33    text.push('…');
34    text
35}
36
37#[cfg(test)]
38mod tests {
39    use super::*;
40
41    #[test]
42    fn exact_width_and_small_allowances() {
43        for (text, columns, expected) in [
44            ("", 0, ""),
45            ("", usize::MAX, ""),
46            ("abc", 0, ""),
47            ("abc", 1, "…"),
48            ("abc", 2, "a…"),
49            ("abc", 3, "abc"),
50            ("abc", usize::MAX, "abc"),
51            ("界界", 3, "界…"),
52            ("e\u{301}x", 2, "e\u{301}x"),
53        ] {
54            assert_eq!(fit_line(text, columns), expected);
55        }
56    }
57
58    #[test]
59    fn controls_are_visible_before_width_admission() {
60        assert_eq!(fit_line("a\nb", 4), "a\\nb");
61        assert_eq!(fit_line("a\nb", 3), "a\\…");
62        assert_eq!(fit_line("\x1b[31m", 40), "\\u{1b}[31m");
63    }
64
65    #[test]
66    fn unicode_output_obeys_each_selected_allowance() {
67        for input in ["界界界", "e\u{301}e\u{301}xx", "👩‍💻 ready", "\n\t\u{202e}"] {
68            for columns in 0..32 {
69                let fitted = fit_line(input, columns);
70                assert!(
71                    fitted
72                        .chars()
73                        .map(|ch| ch.width().unwrap_or(0))
74                        .sum::<usize>()
75                        <= columns
76                );
77                assert!(!fitted.chars().any(char::is_control));
78            }
79        }
80    }
81}