Skip to main content

uncurses/ansi/
truncate.rs

1//! Width-aware truncation and cutting for ANSI-decorated strings.
2//!
3//! ## Category
4//!
5//! This module shortens strings by terminal display columns while preserving
6//! escape sequences such as SGR resets and OSC hyperlinks. Escape bytes do not
7//! count toward width.
8//!
9//! ## Width conventions
10//!
11//! Width is computed by [`crate::ansi::text::tokenize`] using [`WidthMode`].
12//! Visible text is truncated only on token boundaries; escape sequences before,
13//! inside, or after the retained text are copied so terminal state remains
14//! attached to the result.
15//!
16//! ## Mode interaction
17//!
18//! Truncation does not emulate terminal modes. Mode-dependent sequence semantics
19//! are preserved as bytes but not interpreted.
20//!
21//! Sequence boundaries and widths come from [`crate::ansi::text`];
22//! which byte ends a control string, and when a byte in `0x80..=0x9F`
23//! is a C1 control rather than part of a character, are documented there.
24
25use super::text::{Token, WidthMode, string_width, tokenize};
26
27#[inline]
28fn bs(b: &[u8]) -> &str {
29    // The tokenizer never splits a character: text tokens are whole grapheme
30    // clusters, and every sequence scanner steps a whole UTF-8 character at a
31    // time. Nothing enforced that, and when a scanner did split one - 0x9C is
32    // 8-bit ST and also a continuation byte, so an OSC title containing a
33    // check mark ended mid-character - the ill-formed bytes arrived here and
34    // this was undefined behaviour. Checked where checking is free.
35    debug_assert!(
36        std::str::from_utf8(b).is_ok(),
37        "token split a UTF-8 character: {b:?}"
38    );
39    // SAFETY: `b` is a token slice of `&str` input, taken on character
40    // boundaries, as asserted above.
41    unsafe { std::str::from_utf8_unchecked(b) }
42}
43
44/// Truncate `s` to at most `length` display columns, appending `tail` if truncation occurs.
45///
46/// ANSI escape sequences are preserved verbatim and do not count toward the width budget. When `length == 0`, this function returns an empty string.
47pub fn truncate(s: &str, length: usize, tail: &str) -> String {
48    truncate_mode(s, length, tail, WidthMode::default(), false)
49}
50
51/// Width-mode variant of [`truncate`].
52///
53/// `mode` and `eaw_wide` control grapheme width calculation. If the input already fits, it is returned unchanged; otherwise the visible prefix is shortened enough to fit `tail`, and trailing escape sequences are still copied.
54pub fn truncate_mode(
55    s: &str,
56    length: usize,
57    tail: &str,
58    mode: WidthMode,
59    eaw_wide: bool,
60) -> String {
61    if length == 0 {
62        return String::new();
63    }
64    if string_width(s.as_bytes(), mode, eaw_wide) <= length {
65        return s.to_string();
66    }
67    let tail_w = string_width(tail.as_bytes(), mode, eaw_wide);
68    let budget = length.saturating_sub(tail_w);
69
70    let mut out = String::new();
71    let mut used = 0usize;
72    let mut tail_inserted = false;
73    for tok in tokenize(s.as_bytes(), mode, eaw_wide) {
74        match tok {
75            Token::Escape(esc) => out.push_str(bs(esc)),
76            Token::Control(b) => {
77                if !tail_inserted {
78                    out.push(b as char);
79                }
80            }
81            Token::Text { text, width } => {
82                let w = width as usize;
83                if used + w > budget {
84                    if !tail_inserted {
85                        out.push_str(tail);
86                        tail_inserted = true;
87                    }
88                    // Continue scanning to capture trailing escapes (e.g. SGR reset).
89                    continue;
90                }
91                out.push_str(bs(text));
92                used += w;
93            }
94        }
95    }
96    if !tail_inserted {
97        out.push_str(tail);
98    }
99    out
100}
101
102/// Truncate `s` from the left until at most `length` display columns remain.
103///
104/// If truncation occurs, `prefix` is prepended after any leading escape sequences needed to preserve active terminal state.
105pub fn truncate_left(s: &str, length: usize, prefix: &str) -> String {
106    truncate_left_mode(s, length, prefix, WidthMode::default(), false)
107}
108
109/// Width-mode variant of [`truncate_left`].
110///
111/// `mode` and `eaw_wide` control grapheme width calculation. Escape sequences before the cut are retained before `prefix` so styling can carry into the visible suffix.
112pub fn truncate_left_mode(
113    s: &str,
114    length: usize,
115    prefix: &str,
116    mode: WidthMode,
117    eaw_wide: bool,
118) -> String {
119    let total = string_width(s.as_bytes(), mode, eaw_wide);
120    if total <= length {
121        return s.to_string();
122    }
123    let prefix_w = string_width(prefix.as_bytes(), mode, eaw_wide);
124    let drop = total.saturating_sub(length.saturating_sub(prefix_w));
125
126    let mut head_escapes = String::new();
127    let mut out = String::new();
128    let mut dropped = 0usize;
129    let mut dropping = true;
130    for tok in tokenize(s.as_bytes(), mode, eaw_wide) {
131        match tok {
132            Token::Escape(esc) => {
133                if dropping {
134                    head_escapes.push_str(bs(esc));
135                } else {
136                    out.push_str(bs(esc));
137                }
138            }
139            Token::Control(b) => {
140                if !dropping {
141                    out.push(b as char);
142                }
143            }
144            Token::Text { text, width } => {
145                let w = width as usize;
146                if dropping {
147                    dropped += w;
148                    if dropped >= drop {
149                        dropping = false;
150                    }
151                } else {
152                    out.push_str(bs(text));
153                }
154            }
155        }
156    }
157    let mut result = String::new();
158    // Preserve escapes encountered before the cut so style carries over.
159    result.push_str(&head_escapes);
160    result.push_str(prefix);
161    result.push_str(&out);
162    result
163}
164
165/// Remove `left` display columns from the start and `right` display columns from the end of `s`.
166///
167/// ANSI escape sequences are preserved and do not count toward either cut amount.
168pub fn cut(s: &str, left: usize, right: usize) -> String {
169    cut_mode(s, left, right, WidthMode::default(), false)
170}
171
172/// Width-mode variant of [`cut`].
173///
174/// `mode` and `eaw_wide` control grapheme width calculation. If the requested cuts leave no visible columns, the result is empty; otherwise trailing escape sequences are retained.
175pub fn cut_mode(s: &str, left: usize, right: usize, mode: WidthMode, eaw_wide: bool) -> String {
176    if left == 0 && right == 0 {
177        return s.to_string();
178    }
179    let total = string_width(s.as_bytes(), mode, eaw_wide);
180    if left >= total {
181        return String::new();
182    }
183    let keep_end = total.saturating_sub(right);
184    if keep_end <= left {
185        return String::new();
186    }
187    let target = keep_end - left;
188
189    let mut out = String::new();
190    let mut col = 0usize;
191    let mut emitted = 0usize;
192    for tok in tokenize(s.as_bytes(), mode, eaw_wide) {
193        match tok {
194            Token::Escape(esc) => out.push_str(bs(esc)),
195            Token::Control(b) => {
196                if col >= left && emitted < target {
197                    out.push(b as char);
198                }
199            }
200            Token::Text { text, width } => {
201                let w = width as usize;
202                if col >= left && emitted + w <= target {
203                    out.push_str(bs(text));
204                    emitted += w;
205                }
206                col += w;
207                if emitted >= target {
208                    // Continue scanning so trailing escapes still attach.
209                }
210            }
211        }
212    }
213    out
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[test]
221    fn truncate_basic() {
222        assert_eq!(truncate("hello world", 5, ""), "hello");
223    }
224
225    #[test]
226    fn truncate_with_tail() {
227        assert_eq!(truncate("hello world", 8, "..."), "hello...");
228    }
229
230    #[test]
231    fn truncate_no_op() {
232        assert_eq!(truncate("hi", 5, "..."), "hi");
233    }
234
235    #[test]
236    fn truncate_preserves_ansi() {
237        let s = "\x1b[31mhello world\x1b[m";
238        let got = truncate(s, 5, "");
239        assert_eq!(got, "\x1b[31mhello\x1b[m");
240    }
241
242    #[test]
243    fn truncate_wide_chars() {
244        assert_eq!(truncate("中文测试", 4, ""), "中文");
245        // Width-3 budget with 2-wide chars: only first fits.
246        assert_eq!(truncate("中文", 3, ""), "中");
247    }
248
249    #[test]
250    fn truncate_zero_length() {
251        assert_eq!(truncate("hello", 0, ""), "");
252        assert_eq!(truncate("hello", 0, "..."), "");
253    }
254
255    #[test]
256    fn truncate_left_basic() {
257        assert_eq!(truncate_left("hello world", 5, ""), "world");
258    }
259
260    #[test]
261    fn truncate_left_with_prefix() {
262        assert_eq!(truncate_left("hello world", 8, "..."), "...world");
263    }
264
265    #[test]
266    fn cut_basic() {
267        assert_eq!(cut("hello world", 2, 2), "llo wor");
268    }
269
270    #[test]
271    fn cut_zero_zero_is_noop() {
272        assert_eq!(cut("hello", 0, 0), "hello");
273    }
274
275    #[test]
276    fn cut_too_wide() {
277        assert_eq!(cut("hi", 10, 0), "");
278    }
279
280    #[test]
281    fn truncate_preserves_osc_link() {
282        let s = "\x1b]8;;https://example.com\x1b\\link text\x1b]8;;\x1b\\";
283        let got = truncate(s, 4, "");
284        assert_eq!(got, "\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\");
285    }
286}