Skip to main content

ratatui_interact/utils/
display.rs

1//! Display utilities for TUI rendering
2//!
3//! Functions for cleaning, truncating, and formatting strings for terminal display.
4//!
5//! # Example
6//!
7//! ```rust
8//! use ratatui_interact::utils::display::{truncate_to_width, format_size, clean_for_display};
9//!
10//! // Truncate long text
11//! let truncated = truncate_to_width("Hello, this is a very long string", 15);
12//! assert_eq!(truncated, "Hello, this ...");
13//!
14//! // Format file sizes
15//! assert_eq!(format_size(1024), "1.0 KB");
16//! assert_eq!(format_size(1048576), "1.0 MB");
17//!
18//! // Clean text for display
19//! let clean = clean_for_display("Line with \rcarriage return");
20//! assert_eq!(clean, "carriage return");
21//! ```
22
23use std::sync::LazyLock;
24
25use regex::Regex;
26use unicode_width::UnicodeWidthStr;
27
28/// Regex to match ANSI escape sequences (colors, cursor movement, etc.)
29static ANSI_REGEX: LazyLock<Regex> = LazyLock::new(|| {
30    // Matches:
31    // - CSI sequences: \x1b[ ... (params) ... (final byte)
32    // - OSC sequences: \x1b] ... \x07 or \x1b]...\x1b\\
33    // - Simple escapes: \x1b followed by single char
34    Regex::new(r"\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b\][^\x1b]*\x1b\\|\x1b.").unwrap()
35});
36
37/// Clean a string for TUI display.
38///
39/// This function:
40/// 1. Handles carriage returns (keeps only text after last `\r`, like a terminal)
41/// 2. Strips ANSI escape sequences
42/// 3. Removes other control characters (except space)
43///
44/// # Example
45///
46/// ```rust
47/// use ratatui_interact::utils::display::clean_for_display;
48///
49/// let clean = clean_for_display("Progress: 50%\rProgress: 100%");
50/// assert_eq!(clean, "Progress: 100%");
51///
52/// let no_ansi = clean_for_display("\x1b[31mRed\x1b[0m text");
53/// assert_eq!(no_ansi, "Red text");
54/// ```
55pub fn clean_for_display(s: &str) -> String {
56    // Handle carriage returns - terminals overwrite from beginning of line
57    // So we only want the text after the last \r (if any)
58    let after_cr = if let Some(last_cr_pos) = s.rfind('\r') {
59        &s[last_cr_pos + 1..]
60    } else {
61        s
62    };
63
64    // Strip ANSI escape sequences
65    let no_ansi = ANSI_REGEX.replace_all(after_cr, "");
66
67    // Remove other control characters (except space) that could mess up display
68    no_ansi
69        .chars()
70        .filter(|c| !c.is_control() || *c == ' ')
71        .collect()
72}
73
74/// Strip only ANSI escape sequences from a string, preserving other content.
75///
76/// Unlike `clean_for_display`, this does not handle carriage returns
77/// or strip other control characters.
78///
79/// # Example
80///
81/// ```rust
82/// use ratatui_interact::utils::display::strip_ansi;
83///
84/// let plain = strip_ansi("\x1b[1;32mBold green\x1b[0m");
85/// assert_eq!(plain, "Bold green");
86/// ```
87pub fn strip_ansi(s: &str) -> String {
88    ANSI_REGEX.replace_all(s, "").to_string()
89}
90
91/// Truncate a string to fit within a maximum display width.
92///
93/// Returns the truncated string with "..." suffix if truncation occurred.
94/// Uses Unicode display width for proper handling of wide characters.
95/// Control characters and ANSI sequences are stripped before width calculation.
96///
97/// # Example
98///
99/// ```rust
100/// use ratatui_interact::utils::display::truncate_to_width;
101///
102/// assert_eq!(truncate_to_width("Hello", 10), "Hello");
103/// assert_eq!(truncate_to_width("Hello World!", 8), "Hello...");
104/// ```
105pub fn truncate_to_width(s: &str, max_width: usize) -> String {
106    // Clean the string first - handles \r, ANSI codes, and control chars
107    let clean = clean_for_display(s);
108    let width = clean.width();
109
110    if width <= max_width {
111        return clean;
112    }
113
114    // Need to truncate - find where to cut
115    let target_width = max_width.saturating_sub(3); // Reserve space for "..."
116    let mut current_width = 0;
117    let mut end_idx = 0;
118
119    for (idx, ch) in clean.char_indices() {
120        let ch_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
121        if current_width + ch_width > target_width {
122            break;
123        }
124        current_width += ch_width;
125        end_idx = idx + ch.len_utf8();
126    }
127
128    format!("{}...", &clean[..end_idx])
129}
130
131/// Pad a string to a specific display width with spaces.
132///
133/// If the string is already wider than target, it's returned as-is.
134/// Uses Unicode display width for proper handling of wide characters.
135///
136/// Note: Assumes input has already been stripped of ANSI codes if needed.
137///
138/// # Example
139///
140/// ```rust
141/// use ratatui_interact::utils::display::pad_to_width;
142///
143/// assert_eq!(pad_to_width("Hi", 5), "Hi   ");
144/// assert_eq!(pad_to_width("Hello", 3), "Hello"); // Already wider
145/// ```
146pub fn pad_to_width(s: &str, target_width: usize) -> String {
147    let width = s.width();
148    if width >= target_width {
149        return s.to_string();
150    }
151
152    let padding = target_width - width;
153    format!("{}{}", s, " ".repeat(padding))
154}
155
156/// Format a byte count as a human-readable file size.
157///
158/// # Example
159///
160/// ```rust
161/// use ratatui_interact::utils::display::format_size;
162///
163/// assert_eq!(format_size(512), "512 B");
164/// assert_eq!(format_size(1024), "1.0 KB");
165/// assert_eq!(format_size(1536), "1.5 KB");
166/// assert_eq!(format_size(1048576), "1.0 MB");
167/// assert_eq!(format_size(1073741824), "1.0 GB");
168/// ```
169pub fn format_size(bytes: u64) -> String {
170    const KB: u64 = 1024;
171    const MB: u64 = 1024 * 1024;
172    const GB: u64 = 1024 * 1024 * 1024;
173
174    if bytes >= GB {
175        format!("{:.1} GB", bytes as f64 / GB as f64)
176    } else if bytes >= MB {
177        format!("{:.1} MB", bytes as f64 / MB as f64)
178    } else if bytes >= KB {
179        format!("{:.1} KB", bytes as f64 / KB as f64)
180    } else {
181        format!("{} B", bytes)
182    }
183}
184
185/// Calculate the display width of a string.
186///
187/// This is a convenience wrapper around `unicode_width::UnicodeWidthStr::width()`.
188///
189/// # Example
190///
191/// ```rust
192/// use ratatui_interact::utils::display::display_width;
193///
194/// assert_eq!(display_width("Hello"), 5);
195/// assert_eq!(display_width("你好"), 4); // CJK characters are 2 cells wide
196/// ```
197pub fn display_width(s: &str) -> usize {
198    s.width()
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn test_clean_for_display_carriage_return() {
207        assert_eq!(clean_for_display("abc\rdef"), "def");
208        assert_eq!(clean_for_display("no cr here"), "no cr here");
209    }
210
211    #[test]
212    fn test_clean_for_display_ansi() {
213        assert_eq!(clean_for_display("\x1b[31mred\x1b[0m"), "red");
214        assert_eq!(
215            clean_for_display("\x1b[1;32mbold green\x1b[0m"),
216            "bold green"
217        );
218    }
219
220    #[test]
221    fn test_strip_ansi() {
222        assert_eq!(strip_ansi("\x1b[31mred\x1b[0m text"), "red text");
223    }
224
225    #[test]
226    fn test_truncate_to_width() {
227        assert_eq!(truncate_to_width("short", 10), "short");
228        assert_eq!(truncate_to_width("this is a long string", 10), "this is...");
229    }
230
231    #[test]
232    fn test_pad_to_width() {
233        assert_eq!(pad_to_width("hi", 5), "hi   ");
234        assert_eq!(pad_to_width("hello", 3), "hello");
235    }
236
237    #[test]
238    fn test_format_size() {
239        assert_eq!(format_size(0), "0 B");
240        assert_eq!(format_size(1023), "1023 B");
241        assert_eq!(format_size(1024), "1.0 KB");
242        assert_eq!(format_size(1048576), "1.0 MB");
243        assert_eq!(format_size(1073741824), "1.0 GB");
244    }
245}