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}