Skip to main content

yoctui_utils/
text.rs

1/// ECMA-48 CSI final bytes include punctuation, not only letters.
2pub fn is_csi_final_byte(byte: u8) -> bool {
3    (0x40..=0x7e).contains(&byte)
4}
5
6/// Strip terminal escape sequences while preserving ordinary text and whitespace.
7/// Unterminated control strings are discarded through the end of the input.
8pub fn strip_ansi(input: &str) -> String {
9    let mut out = String::with_capacity(input.len());
10    let mut chars = input.chars().peekable();
11    while let Some(character) = chars.next() {
12        if character != '\x1b' {
13            out.push(character);
14            continue;
15        }
16        match chars.next() {
17            Some('[') => {
18                for next in chars.by_ref() {
19                    if next.is_ascii() && is_csi_final_byte(next as u8) {
20                        break;
21                    }
22                }
23            }
24            Some(kind @ (']' | 'P' | 'X' | '^' | '_')) => {
25                while let Some(next) = chars.next() {
26                    if kind == ']' && next == '\x07' {
27                        break;
28                    }
29                    if next == '\x1b' && chars.peek() == Some(&'\\') {
30                        chars.next();
31                        break;
32                    }
33                }
34            }
35            Some(' '..='/') => {
36                for next in chars.by_ref() {
37                    if ('0'..='~').contains(&next) {
38                        break;
39                    }
40                }
41            }
42            _ => {}
43        }
44    }
45    out
46}
47
48/// Truncate to a byte budget without splitting a UTF-8 character.
49pub fn truncate_utf8(value: &mut String, limit: usize) {
50    if value.len() <= limit {
51        return;
52    }
53    let mut end = limit;
54    while !value.is_char_boundary(end) {
55        end -= 1;
56    }
57    value.truncate(end);
58}
59
60#[cfg(test)]
61#[path = "tests/text/mod.rs"]
62mod tests;
63
64/// Append bounded UTF-8 text without splitting characters.
65pub fn push_bounded(output: &mut String, value: &str, maximum_bytes: usize) -> bool {
66    let available = maximum_bytes.saturating_sub(output.len());
67    if value.len() <= available {
68        output.push_str(value);
69        return true;
70    }
71    let mut keep = available.min(value.len());
72    while keep > 0 && !value.is_char_boundary(keep) {
73        keep -= 1;
74    }
75    output.push_str(&value[..keep]);
76    false
77}
78
79/// Append bounded UTF-8 text without splitting characters.
80pub fn append_truncation_marker(output: &mut String, marker: &str, maximum_bytes: usize) {
81    if marker.len() > maximum_bytes {
82        return;
83    }
84    if output.len().saturating_add(marker.len()) > maximum_bytes {
85        let mut keep = maximum_bytes - marker.len();
86        while keep > 0 && !output.is_char_boundary(keep) {
87            keep -= 1;
88        }
89        output.truncate(keep);
90    }
91    output.push_str(marker);
92}
93
94/// Return the longest prefix within the byte budget.
95pub fn utf8_prefix(value: &str, limit: usize) -> &str {
96    let mut end = limit.min(value.len());
97    while !value.is_char_boundary(end) {
98        end -= 1;
99    }
100    &value[..end]
101}
102
103#[cfg(test)]
104#[path = "tests/text_bounded/mod.rs"]
105mod bounded_tests;