Skip to main content

inkling/
width.rs

1//! How many terminal columns a glyph occupies.
2//!
3//! Every renderer measures cells through this one function, so a hidden cell
4//! reserves exactly as many columns as the glyph will need once it appears. That
5//! is what keeps a row from shifting sideways as the reveal crosses a wide glyph.
6
7/// Display columns a glyph occupies: `0` for zero-width and combining marks, `2`
8/// for wide glyphs (CJK and many emoji), `1` otherwise.
9///
10/// With the `unicode` feature, on by default, this is the real East Asian width
11/// from [`unicode-width`](https://crates.io/crates/unicode-width). Without it
12/// every glyph counts as one column, which is exact for the ASCII the crate is
13/// named for and keeps the core free of dependencies.
14#[inline]
15pub fn glyph_cols(c: char) -> u16 {
16    #[cfg(feature = "unicode")]
17    {
18        unicode_width::UnicodeWidthChar::width(c).unwrap_or(0) as u16
19    }
20    #[cfg(not(feature = "unicode"))]
21    {
22        let _ = c;
23        1
24    }
25}
26
27/// Total display columns of a string.
28pub fn str_cols(s: &str) -> u16 {
29    s.chars().map(glyph_cols).fold(0u16, u16::saturating_add)
30}
31
32/// Truncate `s` to at most `max` display columns, dropping whole glyphs so a wide
33/// glyph is never split across the edge.
34pub fn truncate_to_cols(s: &str, max: u16) -> String {
35    let mut out = String::new();
36    let mut used = 0u16;
37    for c in s.chars() {
38        let w = glyph_cols(c);
39        if used + w > max {
40            break;
41        }
42        out.push(c);
43        used += w;
44    }
45    out
46}
47
48/// Replace control characters with spaces.
49///
50/// Captions arrive from anywhere: the CLI reads them off a pipe, so `make 2>&1 |
51/// inkling` puts another program's output on the caption line. A control
52/// character costs no display columns but can move the cursor or repaint the
53/// screen, which would let that output steer the terminal it was only meant to
54/// label. Neutralize them at the door rather than at every write.
55pub fn sanitize(s: &str) -> String {
56    s.chars()
57        .map(|c| if c.is_control() { ' ' } else { c })
58        .collect()
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64
65    #[test]
66    fn truncate_respects_display_width() {
67        assert_eq!(truncate_to_cols("abc", 2), "ab");
68        assert_eq!(truncate_to_cols("abc", 0), "");
69    }
70
71    #[test]
72    fn sanitize_neutralizes_escape_sequences() {
73        // A caption that would otherwise clear the screen and move the cursor.
74        assert_eq!(sanitize("done\x1b[2J\x1b[H"), "done [2J [H");
75        assert_eq!(sanitize("plain caption"), "plain caption");
76        // One glyph in, one glyph out: a control character becomes a space rather
77        // than vanishing, so anything measured alongside it keeps its alignment.
78        let caption = "a\x1b[31mb";
79        assert_eq!(sanitize(caption).chars().count(), caption.chars().count());
80        assert!(!sanitize(caption).chars().any(char::is_control));
81    }
82
83    #[cfg(feature = "unicode")]
84    #[test]
85    fn wide_glyphs_count_two() {
86        assert_eq!(glyph_cols('a'), 1);
87        assert_eq!(glyph_cols('世'), 2);
88        assert_eq!(str_cols("a世"), 3);
89        assert_eq!(truncate_to_cols("a世", 3), "a世"); // 1 + 2 == 3 fits
90        assert_eq!(truncate_to_cols("世界", 3), "世"); // 2 + 2 > 3, drop the second
91    }
92}