Skip to main content

indigo_kernel/
display_width.rs

1//! Width of text, as displayed in terminal emulators.
2//!
3//! <https://www.unicode.org/reports/tr11/>
4
5use ropey::{Rope, RopeSlice};
6use std::cmp::max;
7use unicode_segmentation::UnicodeSegmentation as _;
8use unicode_width::UnicodeWidthStr;
9
10pub trait DisplayWidth {
11    fn display_width(&self) -> usize;
12}
13
14impl DisplayWidth for char {
15    fn display_width(&self) -> usize {
16        str_width(self.encode_utf8(&mut [0; 4]))
17    }
18}
19
20impl DisplayWidth for &str {
21    fn display_width(&self) -> usize {
22        str_width(self)
23    }
24}
25
26impl DisplayWidth for RopeSlice<'_> {
27    fn display_width(&self) -> usize {
28        // Nearly every slice is one line inside one chunk, which needs no iterator or copy.
29        let (chunk, _) = self.chunk(0);
30        if chunk.len() == self.len() {
31            return str_width(chunk);
32        }
33        str_width(&String::from(*self))
34    }
35}
36
37impl DisplayWidth for Rope {
38    fn display_width(&self) -> usize {
39        self.slice(..).display_width()
40    }
41}
42
43const TAB_WIDTH: usize = 8;
44
45fn str_width(str: &str) -> usize {
46    let bytes = str.as_bytes();
47    if bytes.is_ascii() {
48        return ascii_width(bytes);
49    }
50    // Two adjacent ASCII bytes always lie in different grapheme clusters, CRLF aside, and every
51    // rule that looks back past a byte is reset by an ASCII byte. So cutting the string only
52    // between such pairs hands the segmenter exactly the clusters it would find in the whole.
53    let joined = |&a: &u8, &b: &u8| !(a.is_ascii() && b.is_ascii()) || (a == b'\r' && b == b'\n');
54    let mut width = 0;
55    let mut start = 0;
56    for run in bytes.chunk_by(joined) {
57        let end = start + run.len();
58        width += if run.is_ascii() {
59            ascii_width(run)
60        } else {
61            str[start..end].graphemes(true).map(grapheme_width).sum()
62        };
63        start = end;
64    }
65    width
66}
67
68/// Width of ASCII text, where every byte is its own grapheme except a CRLF pair.
69fn ascii_width(bytes: &[u8]) -> usize {
70    let tabs = bytes
71        .iter()
72        .map(|&byte| usize::from(byte == b'\t'))
73        .sum::<usize>();
74    let crlfs = bytes.windows(2).filter(|pair| pair == b"\r\n").count();
75    bytes.len() + (TAB_WIDTH - 1) * tabs - crlfs
76}
77
78fn grapheme_width(grapheme: &str) -> usize {
79    if let Some('\t') = grapheme.chars().next() {
80        return TAB_WIDTH;
81    }
82    max(1, UnicodeWidthStr::width(grapheme))
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88    use hegel::{TestCase, generators as gs};
89    use std::str;
90
91    fn oracle(str: &str) -> usize {
92        str.graphemes(true).map(grapheme_width).sum()
93    }
94
95    fn text_gen() -> gs::TextGenerator {
96        /// Exercise every grapheme rule that could interact with ASCII.
97        const ALPHABET: &str = "ab1 \t\r\n\x00\x7f\u{0301}\u{200D}\u{FE0F}\u{20E3}\u{0600}\u{0602}πŸ‡―πŸ‡΅πŸ‘©β€πŸ’‹\u{1F3FB}δΈ­Γ©\u{1100}\u{1161}\u{0915}\u{094D}\u{0937}";
98        gs::text().alphabet(ALPHABET).max_size(64)
99    }
100
101    #[hegel::test(test_cases = 20_000)]
102    fn str_width_matches_oracle(tc: TestCase) {
103        let text: String = tc.draw(text_gen());
104        assert_eq!(text.as_str().display_width(), oracle(&text));
105        for char in text.chars() {
106            assert_eq!(char.display_width(), oracle(char.encode_utf8(&mut [0; 4])));
107        }
108    }
109
110    #[hegel::test(test_cases = 2_000)]
111    fn rope_slice_width_matches_oracle(tc: TestCase) {
112        // Repeat the text enough to span several chunks, then measure random sub-slices so that
113        // chunk boundaries land at arbitrary points inside the text.
114        let text: String = tc.draw(text_gen().min_size(1));
115        let repeats = tc.draw(
116            gs::integers::<usize>()
117                .min_value(1)
118                .max_value(16 * 1024 / text.len()),
119        );
120        let string = text.repeat(repeats);
121        let rope = Rope::from_str(&string);
122        let start = tc.draw(gs::integers::<usize>().max_value(string.len()));
123        let end = tc.draw(
124            gs::integers::<usize>()
125                .min_value(start)
126                .max_value(string.len()),
127        );
128        let start = str::floor_char_boundary(&string, start);
129        let end = str::floor_char_boundary(&string, end);
130        assert_eq!(
131            rope.slice(start..end).display_width(),
132            oracle(&string[start..end])
133        );
134    }
135}