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}