Skip to main content

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