Skip to main content

unicode_lang/
width.rs

1//! Monospace display width — how many terminal columns a scalar occupies.
2//!
3//! The contract follows the long-established `wcwidth` convention, refined by
4//! the Unicode [East Asian Width][uax11] property:
5//!
6//! - **0 columns** — the null character, the C0/C1 control blocks, nonspacing
7//!   and enclosing combining marks, format characters, and the conjoining
8//!   Hangul jamo. These add nothing to the visible advance of the text.
9//! - **2 columns** — East Asian *Wide* and *Fullwidth* characters: CJK
10//!   ideographs, fullwidth forms, most emoji-range ideographic symbols.
11//! - **1 column** — everything else, including East Asian *Ambiguous*, which
12//!   is treated as narrow because its width depends on locale context this
13//!   crate deliberately does not carry.
14//!
15//! Width is a rendering estimate for fixed-pitch output (terminals, column
16//! alignment, truncation). It is not grapheme segmentation: a base letter and
17//! its combining marks sum correctly (the marks contribute 0), but the crate
18//! does not merge them into a single cluster.
19//!
20//! [uax11]: https://www.unicode.org/reports/tr11/
21
22use crate::lookup::in_ranges;
23use crate::tables;
24
25/// Returns the number of monospace columns `c` occupies: `0`, `1`, or `2`.
26///
27/// See the module-level documentation for the exact contract.
28///
29/// # Examples
30///
31/// ```
32/// use unicode_lang::char_width;
33///
34/// assert_eq!(char_width('A'), 1);
35/// assert_eq!(char_width('世'), 2);          // CJK ideograph, wide
36/// assert_eq!(char_width('\u{0301}'), 0);    // COMBINING ACUTE ACCENT
37/// assert_eq!(char_width('\n'), 0);          // control characters take no column
38/// assert_eq!(char_width('\u{200B}'), 0);    // ZERO WIDTH SPACE
39/// ```
40#[inline]
41#[must_use]
42pub fn char_width(c: char) -> usize {
43    let cp = c as u32;
44    // The C0 (< 0x20, includes NUL) and C1 (0x7F..=0x9F) control blocks have no
45    // printable advance. They are not in the zero-width table (that is combining
46    // and format characters), so they are handled explicitly.
47    if cp < 0x20 || (0x7F..0xA0).contains(&cp) {
48        return 0;
49    }
50    if in_ranges(cp, tables::ZERO_WIDTH) {
51        return 0;
52    }
53    if in_ranges(cp, tables::WIDE) {
54        return 2;
55    }
56    1
57}
58
59/// Returns the total monospace width of `s`: the sum of [`char_width`] over its
60/// scalar values.
61///
62/// # Examples
63///
64/// ```
65/// use unicode_lang::str_width;
66///
67/// assert_eq!(str_width("hello"), 5);
68/// assert_eq!(str_width("日本語"), 6);      // three wide ideographs
69/// assert_eq!(str_width("café"), 4);        // precomposed é is one column
70///
71/// // A base letter plus a combining mark still advances by one column.
72/// assert_eq!(str_width("e\u{0301}"), 1);   // e + COMBINING ACUTE ACCENT
73/// ```
74#[inline]
75#[must_use]
76pub fn str_width(s: &str) -> usize {
77    s.chars().map(char_width).sum()
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    #[test]
85    fn test_ascii_printable_is_one() {
86        for c in '\u{20}'..='\u{7E}' {
87            assert_eq!(char_width(c), 1, "{c:?}");
88        }
89    }
90
91    #[test]
92    fn test_controls_are_zero() {
93        assert_eq!(char_width('\0'), 0);
94        assert_eq!(char_width('\t'), 0);
95        assert_eq!(char_width('\n'), 0);
96        assert_eq!(char_width('\u{1B}'), 0); // ESC
97        assert_eq!(char_width('\u{7F}'), 0); // DEL
98        assert_eq!(char_width('\u{85}'), 0); // NEL, C1
99    }
100
101    #[test]
102    fn test_wide_cjk_is_two() {
103        assert_eq!(char_width('世'), 2);
104        assert_eq!(char_width('界'), 2);
105        assert_eq!(char_width('\u{FF21}'), 2); // FULLWIDTH LATIN CAPITAL A
106    }
107
108    #[test]
109    fn test_combining_marks_are_zero() {
110        assert_eq!(char_width('\u{0301}'), 0);
111        assert_eq!(char_width('\u{0300}'), 0);
112        assert_eq!(char_width('\u{200B}'), 0); // ZERO WIDTH SPACE
113    }
114
115    #[test]
116    fn test_soft_hyphen_is_one() {
117        // SOFT HYPHEN is category Cf but occupies a column when displayed.
118        assert_eq!(char_width('\u{00AD}'), 1);
119    }
120
121    #[test]
122    fn test_str_width_sums() {
123        assert_eq!(str_width(""), 0);
124        assert_eq!(str_width("abc"), 3);
125        assert_eq!(str_width("a世b"), 4);
126        assert_eq!(str_width("e\u{0301}"), 1);
127    }
128}