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
8// `char_width` / `str_width` are the single source of truth in `fresh-core`,
9// shared with the plugin runtime's `charWidth` / `stringWidth` APIs so plugins
10// measure width exactly the way the editor lays out cells. The editor-specific
11// byte/column helpers below build on them.
12pub use fresh_core::display_width::{char_width, str_width};
13
14/// Extension trait for convenient width calculation on string types.
15pub trait DisplayWidth {
16    /// Returns the display width (number of terminal columns) of this string.
17    fn display_width(&self) -> usize;
18}
19
20impl DisplayWidth for str {
21    #[inline]
22    fn display_width(&self) -> usize {
23        str_width(self)
24    }
25}
26
27impl DisplayWidth for String {
28    #[inline]
29    fn display_width(&self) -> usize {
30        str_width(self)
31    }
32}
33
34/// Calculate the visual column (display width) at a given byte offset within a string.
35///
36/// Returns the sum of display widths of all characters before the given byte offset.
37#[inline]
38pub fn visual_column_at_byte(s: &str, byte_offset: usize) -> usize {
39    s[..byte_offset.min(s.len())].chars().map(char_width).sum()
40}
41
42/// Convert a visual column to a byte offset within a string.
43///
44/// Returns the byte offset of the character that starts at or after the given visual column.
45/// If the visual column is beyond the string's width, returns the string's length.
46/// This ensures the result is always at a valid UTF-8 character boundary.
47#[inline]
48pub fn byte_offset_at_visual_column(s: &str, visual_col: usize) -> usize {
49    let mut current_col = 0;
50    for (byte_idx, ch) in s.char_indices() {
51        if current_col >= visual_col {
52            return byte_idx;
53        }
54        current_col += char_width(ch);
55    }
56    s.len()
57}
58
59#[cfg(test)]
60mod tests {
61    use super::*;
62
63    #[test]
64    fn test_ascii_width() {
65        assert_eq!(str_width("Hello"), 5);
66        assert_eq!(str_width(""), 0);
67        assert_eq!(str_width(" "), 1);
68    }
69
70    #[test]
71    fn test_cjk_width() {
72        // Chinese characters are 2 columns each
73        assert_eq!(str_width("你好"), 4);
74        assert_eq!(str_width("你好世界"), 8);
75
76        // Japanese
77        assert_eq!(str_width("月"), 2);
78        assert_eq!(str_width("日本"), 4);
79
80        // Korean
81        assert_eq!(str_width("한글"), 4);
82    }
83
84    #[test]
85    fn test_emoji_width() {
86        // Most emoji are 2 columns
87        assert_eq!(str_width("🚀"), 2);
88        assert_eq!(str_width("🎉"), 2);
89        assert_eq!(str_width("🚀🎉"), 4);
90    }
91
92    #[test]
93    fn test_mixed_width() {
94        // ASCII + CJK
95        assert_eq!(str_width("Hello你好"), 5 + 4);
96        assert_eq!(str_width("a你b"), 1 + 2 + 1);
97
98        // ASCII + emoji
99        assert_eq!(str_width("Hi🚀"), 2 + 2);
100    }
101
102    #[test]
103    fn test_char_width() {
104        assert_eq!(char_width('a'), 1);
105        assert_eq!(char_width('你'), 2);
106        assert_eq!(char_width('🚀'), 2);
107    }
108
109    #[test]
110    fn test_zero_width() {
111        // Control characters
112        assert_eq!(char_width('\0'), 0);
113        assert_eq!(char_width('\t'), 0); // Tab is control char, terminal handles it specially
114
115        // Zero-width space
116        assert_eq!(char_width('\u{200B}'), 0);
117    }
118
119    #[test]
120    fn test_display_width_trait() {
121        let s = "你好";
122        assert_eq!(s.display_width(), 4);
123
124        let string = String::from("Hello🚀");
125        assert_eq!(string.display_width(), 7);
126    }
127}