Skip to main content

fresh/primitives/
display_width.rs

1//! Display width calculation for Unicode text
2//!
3//! This module provides utilities for calculating the visual display width
4//! of characters and strings on a terminal. This is essential for proper
5//! cursor positioning, line wrapping, and UI layout with CJK characters,
6//! emoji, and other double-width or zero-width characters.
7
8use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
9
10/// Calculate the display width of a single character.
11///
12/// Returns 0 for control characters and zero-width characters,
13/// 2 for CJK/fullwidth characters and emoji,
14/// 1 for most other characters.
15#[inline]
16pub fn char_width(c: char) -> usize {
17    // unicode_width returns None for control characters
18    c.width().unwrap_or(0)
19}
20
21/// Calculate the display width of a string.
22///
23/// This is the sum of display widths of all characters in the string.
24/// Use this instead of `.chars().count()` when calculating visual layout.
25#[inline]
26pub fn str_width(s: &str) -> usize {
27    s.width()
28}
29
30/// Extension trait for convenient width calculation on string types.
31pub trait DisplayWidth {
32    /// Returns the display width (number of terminal columns) of this string.
33    fn display_width(&self) -> usize;
34}
35
36impl DisplayWidth for str {
37    #[inline]
38    fn display_width(&self) -> usize {
39        str_width(self)
40    }
41}
42
43impl DisplayWidth for String {
44    #[inline]
45    fn display_width(&self) -> usize {
46        str_width(self)
47    }
48}
49
50/// Calculate the visual column (display width) at a given byte offset within a string.
51///
52/// Returns the sum of display widths of all characters before the given byte offset.
53#[inline]
54pub fn visual_column_at_byte(s: &str, byte_offset: usize) -> usize {
55    s[..byte_offset.min(s.len())].chars().map(char_width).sum()
56}
57
58/// Convert a visual column to a byte offset within a string.
59///
60/// Returns the byte offset of the character that starts at or after the given visual column.
61/// If the visual column is beyond the string's width, returns the string's length.
62/// This ensures the result is always at a valid UTF-8 character boundary.
63#[inline]
64pub fn byte_offset_at_visual_column(s: &str, visual_col: usize) -> usize {
65    let mut current_col = 0;
66    for (byte_idx, ch) in s.char_indices() {
67        if current_col >= visual_col {
68            return byte_idx;
69        }
70        current_col += char_width(ch);
71    }
72    s.len()
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78
79    #[test]
80    fn test_ascii_width() {
81        assert_eq!(str_width("Hello"), 5);
82        assert_eq!(str_width(""), 0);
83        assert_eq!(str_width(" "), 1);
84    }
85
86    #[test]
87    fn test_cjk_width() {
88        // Chinese characters are 2 columns each
89        assert_eq!(str_width("你好"), 4);
90        assert_eq!(str_width("你好世界"), 8);
91
92        // Japanese
93        assert_eq!(str_width("月"), 2);
94        assert_eq!(str_width("日本"), 4);
95
96        // Korean
97        assert_eq!(str_width("한글"), 4);
98    }
99
100    #[test]
101    fn test_emoji_width() {
102        // Most emoji are 2 columns
103        assert_eq!(str_width("🚀"), 2);
104        assert_eq!(str_width("🎉"), 2);
105        assert_eq!(str_width("🚀🎉"), 4);
106    }
107
108    #[test]
109    fn test_mixed_width() {
110        // ASCII + CJK
111        assert_eq!(str_width("Hello你好"), 5 + 4);
112        assert_eq!(str_width("a你b"), 1 + 2 + 1);
113
114        // ASCII + emoji
115        assert_eq!(str_width("Hi🚀"), 2 + 2);
116    }
117
118    #[test]
119    fn test_char_width() {
120        assert_eq!(char_width('a'), 1);
121        assert_eq!(char_width('你'), 2);
122        assert_eq!(char_width('🚀'), 2);
123    }
124
125    #[test]
126    fn test_zero_width() {
127        // Control characters
128        assert_eq!(char_width('\0'), 0);
129        assert_eq!(char_width('\t'), 0); // Tab is control char, terminal handles it specially
130
131        // Zero-width space
132        assert_eq!(char_width('\u{200B}'), 0);
133    }
134
135    #[test]
136    fn test_display_width_trait() {
137        let s = "你好";
138        assert_eq!(s.display_width(), 4);
139
140        let string = String::from("Hello🚀");
141        assert_eq!(string.display_width(), 7);
142    }
143}