tuxtui_core/
util.rs

1//! Utility functions and helpers.
2
3use unicode_width::UnicodeWidthStr;
4
5/// Calculate the display width of a string, respecting grapheme clusters.
6///
7/// # Example
8///
9/// ```
10/// use tuxtui_core::util::string_width;
11///
12/// assert_eq!(string_width("Hello"), 5);
13/// assert_eq!(string_width("你好"), 4); // CJK characters are 2 cells wide
14/// ```
15#[must_use]
16pub fn string_width(s: &str) -> usize {
17    s.width()
18}
19
20/// Truncate a string to fit within a given width, adding an ellipsis if needed.
21///
22/// # Example
23///
24/// ```
25/// use tuxtui_core::util::truncate_string;
26///
27/// let result = truncate_string("Hello, world!", 8);
28/// assert_eq!(result, "Hello...");
29/// ```
30#[must_use]
31pub fn truncate_string(s: &str, max_width: usize) -> alloc::string::String {
32    let width = s.width();
33    if width <= max_width {
34        return s.to_string();
35    }
36
37    if max_width < 3 {
38        return alloc::string::String::from("...");
39    }
40
41    let mut result = alloc::string::String::new();
42    let mut current_width = 0;
43    let target_width = max_width - 3; // Reserve space for "..."
44
45    for grapheme in unicode_segmentation::UnicodeSegmentation::graphemes(s, true) {
46        let grapheme_width = grapheme.width();
47        if current_width + grapheme_width > target_width {
48            break;
49        }
50        result.push_str(grapheme);
51        current_width += grapheme_width;
52    }
53
54    result.push_str("...");
55    result
56}
57
58/// Wrap text to fit within a given width.
59///
60/// Returns a vector of lines.
61#[must_use]
62pub fn wrap_text(text: &str, width: usize) -> alloc::vec::Vec<alloc::string::String> {
63    let mut lines = alloc::vec::Vec::new();
64    let mut current_line = alloc::string::String::new();
65    let mut current_width = 0;
66
67    for word in text.split_whitespace() {
68        let word_width = word.width();
69
70        if current_width + word_width + 1 > width && !current_line.is_empty() {
71            lines.push(current_line);
72            current_line = alloc::string::String::new();
73            current_width = 0;
74        }
75
76        if !current_line.is_empty() {
77            current_line.push(' ');
78            current_width += 1;
79        }
80
81        current_line.push_str(word);
82        current_width += word_width;
83    }
84
85    if !current_line.is_empty() {
86        lines.push(current_line);
87    }
88
89    if lines.is_empty() {
90        lines.push(alloc::string::String::new());
91    }
92
93    lines
94}
95
96/// Detect if the terminal likely supports truecolor (24-bit RGB).
97///
98/// This checks common environment variables but is not foolproof.
99#[must_use]
100pub fn supports_truecolor() -> bool {
101    #[cfg(feature = "std")]
102    {
103        if let Ok(colorterm) = std::env::var("COLORTERM") {
104            if colorterm == "truecolor" || colorterm == "24bit" {
105                return true;
106            }
107        }
108
109        if let Ok(term) = std::env::var("TERM") {
110            if term.contains("256color") || term.contains("24bit") {
111                return true;
112            }
113        }
114    }
115
116    false
117}
118
119/// Detect the approximate color support level of the terminal.
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub enum ColorSupport {
122    /// No color support
123    None,
124    /// 16 colors
125    Ansi16,
126    /// 256 colors
127    Ansi256,
128    /// 24-bit RGB (truecolor)
129    TrueColor,
130}
131
132/// Detect the color support level of the terminal.
133#[must_use]
134pub fn detect_color_support() -> ColorSupport {
135    #[cfg(feature = "std")]
136    {
137        if supports_truecolor() {
138            return ColorSupport::TrueColor;
139        }
140
141        if let Ok(term) = std::env::var("TERM") {
142            if term.contains("256") {
143                return ColorSupport::Ansi256;
144            }
145            if term != "dumb" && !term.is_empty() {
146                return ColorSupport::Ansi16;
147            }
148        }
149    }
150
151    ColorSupport::Ansi16
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn test_string_width() {
160        assert_eq!(string_width("Hello"), 5);
161        assert_eq!(string_width(""), 0);
162    }
163
164    #[test]
165    fn test_truncate_string() {
166        assert_eq!(truncate_string("Hello, world!", 8), "Hello...");
167        assert_eq!(truncate_string("Hi", 10), "Hi");
168    }
169
170    #[test]
171    fn test_wrap_text() {
172        let lines = wrap_text("Hello world this is a test", 10);
173        assert!(lines.len() > 1);
174        assert!(lines[0].width() <= 10);
175    }
176
177    #[test]
178    fn test_color_support() {
179        let support = detect_color_support();
180        assert!(matches!(
181            support,
182            ColorSupport::None
183                | ColorSupport::Ansi16
184                | ColorSupport::Ansi256
185                | ColorSupport::TrueColor
186        ));
187    }
188}