Skip to main content

opentui_rust/unicode/
width.rs

1//! Display width calculation for terminal rendering.
2
3use std::collections::HashMap;
4use std::sync::atomic::{AtomicU8, Ordering};
5use std::sync::{OnceLock, RwLock};
6use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
7
8/// Width calculation method for ambiguous-width characters.
9#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
10pub enum WidthMethod {
11    /// POSIX-like wcwidth: ambiguous width = 1.
12    #[default]
13    WcWidth,
14    /// Unicode East Asian Width: ambiguous width = 2.
15    Unicode,
16}
17
18const WIDTH_METHOD_WCWIDTH: u8 = 0;
19const WIDTH_METHOD_UNICODE: u8 = 1;
20
21static WIDTH_METHOD: AtomicU8 = AtomicU8::new(WIDTH_METHOD_WCWIDTH);
22
23static WIDTH_OVERRIDES: OnceLock<RwLock<HashMap<char, usize>>> = OnceLock::new();
24static WIDTH_OVERRIDES_ENABLED: std::sync::atomic::AtomicBool =
25    std::sync::atomic::AtomicBool::new(false);
26
27fn width_overrides() -> &'static RwLock<HashMap<char, usize>> {
28    WIDTH_OVERRIDES.get_or_init(|| RwLock::new(HashMap::new()))
29}
30
31/// Override the display width for a specific character.
32pub fn set_width_override(ch: char, width: usize) {
33    {
34        let mut map = width_overrides()
35            .write()
36            .expect("width override lock poisoned");
37        map.insert(ch, width);
38    }
39    WIDTH_OVERRIDES_ENABLED.store(true, Ordering::Release);
40}
41
42/// Get a width override for `ch`, if one exists.
43#[must_use]
44pub fn get_width_override(ch: char) -> Option<usize> {
45    if !WIDTH_OVERRIDES_ENABLED.load(Ordering::Acquire) {
46        return None;
47    }
48
49    let map = WIDTH_OVERRIDES
50        .get()?
51        .read()
52        .expect("width override lock poisoned");
53    map.get(&ch).copied()
54}
55
56/// Clear all configured width overrides.
57pub fn clear_width_overrides() {
58    if let Some(map) = WIDTH_OVERRIDES.get() {
59        map.write().expect("width override lock poisoned").clear();
60    }
61    WIDTH_OVERRIDES_ENABLED.store(false, Ordering::Release);
62}
63
64/// Set the global width method used by `display_width` helpers.
65pub fn set_width_method(method: WidthMethod) {
66    let value = match method {
67        WidthMethod::WcWidth => WIDTH_METHOD_WCWIDTH,
68        WidthMethod::Unicode => WIDTH_METHOD_UNICODE,
69    };
70    WIDTH_METHOD.store(value, Ordering::Relaxed);
71}
72
73/// Get the global width method.
74#[must_use]
75pub fn width_method() -> WidthMethod {
76    match WIDTH_METHOD.load(Ordering::Relaxed) {
77        WIDTH_METHOD_UNICODE => WidthMethod::Unicode,
78        _ => WidthMethod::WcWidth,
79    }
80}
81
82/// Get the display width of a string in terminal columns (global method).
83#[must_use]
84pub fn display_width(s: &str) -> usize {
85    display_width_with_method(s, width_method())
86}
87
88/// Get the display width of a character in terminal columns (global method).
89///
90/// This includes a fast path for ASCII printable characters (0x20-0x7E)
91/// which are always width 1 and are the most common case.
92#[inline]
93#[must_use]
94pub fn display_width_char(c: char) -> usize {
95    // Fast path: ASCII printable characters are always width 1
96    // This covers the vast majority of terminal content
97    if c.is_ascii() && (' '..='~').contains(&c) {
98        return 1;
99    }
100    // Control characters (below space) have width 0
101    if c < ' ' {
102        return 0;
103    }
104    display_width_char_with_method(c, width_method())
105}
106
107/// Get the display width of a string in terminal columns using a specific method.
108#[must_use]
109pub fn display_width_with_method(s: &str, method: WidthMethod) -> usize {
110    if WIDTH_OVERRIDES_ENABLED.load(Ordering::Acquire) {
111        return s
112            .chars()
113            .map(|ch| display_width_char_with_method(ch, method))
114            .sum();
115    }
116
117    match method {
118        WidthMethod::WcWidth => UnicodeWidthStr::width(s),
119        WidthMethod::Unicode => UnicodeWidthStr::width_cjk(s),
120    }
121}
122
123/// Get the display width of a character in terminal columns using a specific method.
124#[must_use]
125pub fn display_width_char_with_method(c: char, method: WidthMethod) -> usize {
126    if let Some(width) = get_width_override(c) {
127        return width;
128    }
129
130    match method {
131        WidthMethod::WcWidth => UnicodeWidthChar::width(c).unwrap_or(0),
132        WidthMethod::Unicode => UnicodeWidthChar::width_cjk(c).unwrap_or(0),
133    }
134}
135
136/// Check if a character is a zero-width character (global method).
137#[must_use]
138pub fn is_zero_width(c: char) -> bool {
139    display_width_char(c) == 0
140}
141
142/// Check if a character is wide (takes 2 columns, global method).
143#[must_use]
144pub fn is_wide(c: char) -> bool {
145    display_width_char(c) == 2
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    struct ClearOverridesOnDrop;
153
154    impl Drop for ClearOverridesOnDrop {
155        fn drop(&mut self) {
156            clear_width_overrides();
157        }
158    }
159
160    #[test]
161    fn test_ascii_width() {
162        assert_eq!(display_width("hello"), 5);
163        assert_eq!(display_width_char('a'), 1);
164    }
165
166    #[test]
167    fn test_cjk_width() {
168        assert_eq!(display_width("漢字"), 4);
169        assert_eq!(display_width_char('æ¼¢'), 2);
170        assert!(is_wide('æ¼¢'));
171    }
172
173    #[test]
174    fn test_emoji_width() {
175        // Simple emoji
176        assert_eq!(display_width("😀"), 2);
177    }
178
179    #[test]
180    fn test_zero_width() {
181        // Combining characters are zero width
182        assert!(is_zero_width('\u{0301}')); // combining acute
183    }
184
185    #[test]
186    fn test_width_methods() {
187        // Ambiguous width character: Circled digit one (U+2460)
188        // In WcWidth mode: 1, in CJK/Unicode mode: 2
189        let ch = 'â‘ ';
190        assert_eq!(display_width_char_with_method(ch, WidthMethod::WcWidth), 1);
191        assert_eq!(display_width_char_with_method(ch, WidthMethod::Unicode), 2);
192    }
193
194    #[test]
195    fn test_width_overrides_set_get_clear() {
196        let _guard = ClearOverridesOnDrop;
197
198        assert_eq!(get_width_override('🦀'), None);
199
200        set_width_override('🦀', 1);
201        assert_eq!(get_width_override('🦀'), Some(1));
202
203        clear_width_overrides();
204        assert_eq!(get_width_override('🦀'), None);
205    }
206
207    #[test]
208    fn test_width_calculation_uses_override() {
209        let _guard = ClearOverridesOnDrop;
210
211        // Default emoji width (unicode-width) is expected to be 2 columns.
212        assert_eq!(display_width_char('🦀'), 2);
213
214        set_width_override('🦀', 1);
215        assert_eq!(display_width_char('🦀'), 1);
216        assert_eq!(display_width("A🦀B"), 3);
217    }
218}