git_iris/studio/
utils.rs

1//! Utility functions for Iris Studio
2//!
3//! Common utilities used across the TUI, including string truncation.
4
5use unicode_width::UnicodeWidthStr;
6
7// ═══════════════════════════════════════════════════════════════════════════════
8// Tab Expansion
9// ═══════════════════════════════════════════════════════════════════════════════
10
11/// Expand tab characters to spaces and strip control characters.
12///
13/// Tabs are expanded to the next multiple of `tab_width` columns.
14/// Control characters (except tab) are stripped to prevent TUI corruption.
15/// This is essential for TUI rendering where tabs and control codes
16/// would otherwise cause misalignment or visual glitches.
17pub fn expand_tabs(s: &str, tab_width: usize) -> String {
18    let mut result = String::with_capacity(s.len());
19    let mut column = 0;
20
21    for ch in s.chars() {
22        if ch == '\t' {
23            // Calculate spaces needed to reach next tab stop
24            let spaces = tab_width - (column % tab_width);
25            result.push_str(&" ".repeat(spaces));
26            column += spaces;
27        } else if !ch.is_control() {
28            // Non-control character: add to result
29            result.push(ch);
30            column += ch.to_string().width();
31        }
32        // Control characters (except tab) are silently stripped
33    }
34
35    result
36}
37
38// ═══════════════════════════════════════════════════════════════════════════════
39// String Truncation Utilities
40// ═══════════════════════════════════════════════════════════════════════════════
41
42/// Truncate a string to a maximum character count, adding "..." if truncated.
43///
44/// This is useful for simple text truncation where unicode display width
45/// isn't critical (e.g., log previews, notifications).
46///
47/// # Example
48/// ```ignore
49/// let result = truncate_chars("Hello, World!", 8);
50/// assert_eq!(result, "Hello...");
51/// ```
52pub fn truncate_chars(s: &str, max_chars: usize) -> String {
53    if max_chars == 0 {
54        return String::new();
55    }
56
57    let char_count = s.chars().count();
58    if char_count <= max_chars {
59        s.to_string()
60    } else if max_chars <= 3 {
61        s.chars().take(max_chars).collect()
62    } else {
63        format!("{}...", s.chars().take(max_chars - 3).collect::<String>())
64    }
65}
66
67/// Truncate a string to a maximum display width, adding "…" if truncated.
68///
69/// This accounts for unicode character display widths (e.g., CJK characters
70/// take 2 columns, emoji may take 2, etc.). Essential for TUI rendering.
71///
72/// # Example
73/// ```ignore
74/// let result = truncate_width("Hello, World!", 8);
75/// assert_eq!(result, "Hello,…");
76/// ```
77pub fn truncate_width(s: &str, max_width: usize) -> String {
78    if max_width == 0 {
79        return String::new();
80    }
81
82    let s_width = s.width();
83    if s_width <= max_width {
84        return s.to_string();
85    }
86
87    if max_width <= 1 {
88        return ".".to_string();
89    }
90
91    // Reserve space for ellipsis (width = 1 for "…")
92    let target_width = max_width - 1;
93
94    let mut result = String::new();
95    let mut current_width = 0;
96
97    for ch in s.chars() {
98        let ch_width = ch.to_string().width();
99        if current_width + ch_width > target_width {
100            break;
101        }
102        result.push(ch);
103        current_width += ch_width;
104    }
105
106    result.push('…');
107    result
108}
109
110// ═══════════════════════════════════════════════════════════════════════════════
111// Tests
112// ═══════════════════════════════════════════════════════════════════════════════
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    #[test]
119    fn test_truncate_chars_no_truncation() {
120        assert_eq!(truncate_chars("hello", 10), "hello");
121        assert_eq!(truncate_chars("hello", 5), "hello");
122    }
123
124    #[test]
125    fn test_truncate_chars_with_truncation() {
126        assert_eq!(truncate_chars("hello world", 8), "hello...");
127        assert_eq!(truncate_chars("hello world", 6), "hel...");
128    }
129
130    #[test]
131    fn test_truncate_chars_edge_cases() {
132        assert_eq!(truncate_chars("hello", 0), "");
133        assert_eq!(truncate_chars("hello", 3), "hel");
134        assert_eq!(truncate_chars("hello", 2), "he");
135    }
136
137    #[test]
138    fn test_truncate_width_no_truncation() {
139        assert_eq!(truncate_width("hello", 10), "hello");
140        assert_eq!(truncate_width("hello", 5), "hello");
141    }
142
143    #[test]
144    fn test_truncate_width_with_truncation() {
145        assert_eq!(truncate_width("hello world", 8), "hello w…");
146        assert_eq!(truncate_width("hello world", 6), "hello…");
147    }
148
149    #[test]
150    fn test_truncate_width_edge_cases() {
151        assert_eq!(truncate_width("hello", 0), "");
152        assert_eq!(truncate_width("hello", 1), ".");
153        assert_eq!(truncate_width("hello", 2), "h…");
154    }
155
156    #[test]
157    fn test_truncate_width_unicode() {
158        // CJK characters are typically 2 columns wide
159        let cjk = "你好世界"; // 8 columns wide (4 chars x 2)
160        assert_eq!(cjk.width(), 8);
161
162        let result = truncate_width(cjk, 6);
163        assert!(result.width() <= 6);
164    }
165}