text_fx/
presentation.rs

1/// Returns the display width of a string, accounting for Unicode double-width and combining characters.
2///
3/// This function sums the display width of each character in the string, using [`char_display_width`].
4///
5/// # Examples
6///
7/// ```
8/// use text_fx::presentation::display_width;
9///
10/// assert_eq!(display_width("hello"), 5); // ASCII
11/// assert_eq!(display_width("你好"), 4);   // CJK
12/// assert_eq!(display_width("á"), 1);    // 'a' + combining accent
13/// ```
14#[inline]
15pub fn display_width(s: &str) -> usize {
16    s.chars().map(char_display_width).sum()
17}
18
19/// Returns the display width of a single Unicode character.
20///
21/// - Control and combining characters have width 0.
22/// - CJK and wide symbols have width 2.
23/// - Most other characters have width 1.
24///
25/// # Examples
26///
27/// ```
28/// use text_fx::presentation::char_display_width;
29///
30/// assert_eq!(char_display_width('a'), 1);
31/// assert_eq!(char_display_width('你'), 2);
32/// assert_eq!(char_display_width('\u{0301}'), 0); // combining acute accent
33/// ```
34pub fn char_display_width(c: char) -> usize {
35    use std::cmp::Ordering::*;
36
37    match c {
38        '\u{0000}' => 0,                           // NULL
39        '\u{0001}'..='\u{001F}' | '\u{007F}' => 0, // Control characters
40
41        '\u{0300}'..='\u{036F}'
42        | '\u{1AB0}'..='\u{1AFF}'
43        | '\u{1DC0}'..='\u{1DFF}'
44        | '\u{20D0}'..='\u{20FF}'
45        | '\u{FE20}'..='\u{FE2F}' => 0, // Combining characters
46
47        _ => match unicode_width_hint(c) {
48            Less => 1,
49            Equal | Greater => 2,
50        },
51    }
52}
53
54/// Heuristically determines if a character is double-width (CJK, emoji, etc).
55///
56/// Returns `Greater` for double-width, `Less` for single-width.
57fn unicode_width_hint(c: char) -> std::cmp::Ordering {
58    match c as u32 {
59        0x1100..=0x115F
60        | 0x2329
61        | 0x232A
62        | 0x2E80..=0xA4CF
63        | 0xAC00..=0xD7A3
64        | 0xF900..=0xFAFF
65        | 0xFE10..=0xFE19
66        | 0xFE30..=0xFE6F
67        | 0xFF00..=0xFF60
68        | 0xFFE0..=0xFFE6
69        | 0x1F300..=0x1F64F
70        | 0x1F900..=0x1F9FF => std::cmp::Ordering::Greater,
71        _ => std::cmp::Ordering::Less,
72    }
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78
79    #[test]
80    fn test_display_width() {
81        for (s, expected) in [
82            ("hello", 5), // ASCII → width 5
83            ("你好", 4),  // CJK → width 4
84            ("á", 1),     // 'a' + combining accent → width 1
85        ] {
86            assert_eq!(display_width(s), expected);
87        }
88    }
89}