syncable_cli/analyzer/display/
utils.rs

1//! String manipulation and visual width calculation utilities
2
3/// Calculate visual width of a string, handling ANSI color codes
4pub fn visual_width(s: &str) -> usize {
5    let mut width = 0;
6    let mut chars = s.chars().peekable();
7    
8    while let Some(ch) = chars.next() {
9        if ch == '\x1b' {
10            // Skip ANSI escape sequence
11            if chars.peek() == Some(&'[') {
12                chars.next(); // consume '['
13                while let Some(c) = chars.next() {
14                    if c.is_ascii_alphabetic() {
15                        break; // End of escape sequence
16                    }
17                }
18            }
19        } else {
20            // Simple width calculation for common cases
21            // Most characters are width 1, some are width 0 or 2
22            width += char_width(ch);
23        }
24    }
25    
26    width
27}
28
29/// Simple character width calculation without external dependencies
30pub fn char_width(ch: char) -> usize {
31    match ch {
32        // Control characters have width 0
33        '\u{0000}'..='\u{001F}' | '\u{007F}' => 0,
34        // Combining marks have width 0
35        '\u{0300}'..='\u{036F}' => 0,
36        // Emoji and symbols (width 2)
37        '\u{2600}'..='\u{26FF}' |    // Miscellaneous Symbols
38        '\u{2700}'..='\u{27BF}' |    // Dingbats
39        '\u{1F000}'..='\u{1F02F}' |  // Mahjong Tiles
40        '\u{1F030}'..='\u{1F09F}' |  // Domino Tiles
41        '\u{1F0A0}'..='\u{1F0FF}' |  // Playing Cards
42        '\u{1F100}'..='\u{1F1FF}' |  // Enclosed Alphanumeric Supplement
43        '\u{1F200}'..='\u{1F2FF}' |  // Enclosed Ideographic Supplement
44        '\u{1F300}'..='\u{1F5FF}' |  // Miscellaneous Symbols and Pictographs
45        '\u{1F600}'..='\u{1F64F}' |  // Emoticons
46        '\u{1F650}'..='\u{1F67F}' |  // Ornamental Dingbats
47        '\u{1F680}'..='\u{1F6FF}' |  // Transport and Map Symbols
48        '\u{1F700}'..='\u{1F77F}' |  // Alchemical Symbols
49        '\u{1F780}'..='\u{1F7FF}' |  // Geometric Shapes Extended
50        '\u{1F800}'..='\u{1F8FF}' |  // Supplemental Arrows-C
51        '\u{1F900}'..='\u{1F9FF}' |  // Supplemental Symbols and Pictographs
52        // Full-width characters (common CJK ranges)
53        '\u{1100}'..='\u{115F}' |  // Hangul Jamo
54        '\u{2E80}'..='\u{2EFF}' |  // CJK Radicals
55        '\u{2F00}'..='\u{2FDF}' |  // Kangxi Radicals
56        '\u{2FF0}'..='\u{2FFF}' |  // Ideographic Description
57        '\u{3000}'..='\u{303E}' |  // CJK Symbols and Punctuation
58        '\u{3041}'..='\u{3096}' |  // Hiragana
59        '\u{30A1}'..='\u{30FA}' |  // Katakana
60        '\u{3105}'..='\u{312D}' |  // Bopomofo
61        '\u{3131}'..='\u{318E}' |  // Hangul Compatibility Jamo
62        '\u{3190}'..='\u{31BA}' |  // Kanbun
63        '\u{31C0}'..='\u{31E3}' |  // CJK Strokes
64        '\u{31F0}'..='\u{31FF}' |  // Katakana Phonetic Extensions
65        '\u{3200}'..='\u{32FF}' |  // Enclosed CJK Letters and Months
66        '\u{3300}'..='\u{33FF}' |  // CJK Compatibility
67        '\u{3400}'..='\u{4DBF}' |  // CJK Extension A
68        '\u{4E00}'..='\u{9FFF}' |  // CJK Unified Ideographs
69        '\u{A000}'..='\u{A48C}' |  // Yi Syllables
70        '\u{A490}'..='\u{A4C6}' |  // Yi Radicals
71        '\u{AC00}'..='\u{D7AF}' |  // Hangul Syllables
72        '\u{F900}'..='\u{FAFF}' |  // CJK Compatibility Ideographs
73        '\u{FE10}'..='\u{FE19}' |  // Vertical Forms
74        '\u{FE30}'..='\u{FE6F}' |  // CJK Compatibility Forms
75        '\u{FF00}'..='\u{FF60}' |  // Fullwidth Forms
76        '\u{FFE0}'..='\u{FFE6}' => 2,
77        // Most other printable characters have width 1
78        _ => 1,
79    }
80}
81
82/// Truncate string to specified visual width, preserving color codes
83pub fn truncate_to_width(s: &str, max_width: usize) -> String {
84    let current_visual_width = visual_width(s);
85    if current_visual_width <= max_width {
86        return s.to_string();
87    }
88    
89    // For strings with ANSI codes, we need to be more careful
90    if s.contains('\x1b') {
91        // Simple approach: strip ANSI codes, truncate, then re-apply if needed
92        let stripped = strip_ansi_codes(s);
93        if visual_width(&stripped) <= max_width {
94            return s.to_string();
95        }
96        
97        // Truncate the stripped version
98        let mut result = String::new();
99        let mut width = 0;
100        for ch in stripped.chars() {
101            let ch_width = char_width(ch);
102            if width + ch_width > max_width.saturating_sub(3) {
103                result.push_str("...");
104                break;
105            }
106            result.push(ch);
107            width += ch_width;
108        }
109        return result;
110    }
111    
112    // No ANSI codes - simple truncation
113    let mut result = String::new();
114    let mut width = 0;
115    
116    for ch in s.chars() {
117        let ch_width = char_width(ch);
118        if width + ch_width > max_width.saturating_sub(3) {
119            result.push_str("...");
120            break;
121        }
122        result.push(ch);
123        width += ch_width;
124    }
125    
126    result
127}
128
129/// Strip ANSI escape codes from a string
130pub fn strip_ansi_codes(s: &str) -> String {
131    let mut result = String::new();
132    let mut chars = s.chars().peekable();
133    
134    while let Some(ch) = chars.next() {
135        if ch == '\x1b' {
136            // Skip ANSI escape sequence
137            if chars.peek() == Some(&'[') {
138                chars.next(); // consume '['
139                while let Some(c) = chars.next() {
140                    if c.is_ascii_alphabetic() {
141                        break; // End of escape sequence
142                    }
143                }
144            }
145        } else {
146            result.push(ch);
147        }
148    }
149    
150    result
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    
157    #[test]
158    fn test_visual_width_basic() {
159        assert_eq!(visual_width("hello"), 5);
160        assert_eq!(visual_width(""), 0);
161        assert_eq!(visual_width("123"), 3);
162    }
163    
164    #[test]
165    fn test_visual_width_with_ansi() {
166        assert_eq!(visual_width("\x1b[31mhello\x1b[0m"), 5);
167        assert_eq!(visual_width("\x1b[1;32mtest\x1b[0m"), 4);
168    }
169    
170    #[test]
171    fn test_truncate_to_width() {
172        assert_eq!(truncate_to_width("hello world", 5), "he...");
173        assert_eq!(truncate_to_width("hello", 10), "hello");
174        assert_eq!(truncate_to_width("hello world", 8), "hello...");
175    }
176    
177    #[test]
178    fn test_strip_ansi_codes() {
179        assert_eq!(strip_ansi_codes("\x1b[31mhello\x1b[0m"), "hello");
180        assert_eq!(strip_ansi_codes("plain text"), "plain text");
181        assert_eq!(strip_ansi_codes("\x1b[1;32mgreen\x1b[0m text"), "green text");
182    }
183}