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
21use super::text::{Token, WidthMode, string_width, tokenize};
22
23#[inline]
24fn bs(b: &[u8]) -> &str {
25    // SAFETY: token slices from `&str` input fall on valid UTF-8 boundaries.
26    unsafe { std::str::from_utf8_unchecked(b) }
27}
28
29/// Truncate `s` to at most `length` display columns, appending `tail` if truncation occurs.
30///
31/// ANSI escape sequences are preserved verbatim and do not count toward the width budget. When `length == 0`, this function returns an empty string.
32pub fn truncate(s: &str, length: usize, tail: &str) -> String {
33    truncate_mode(s, length, tail, WidthMode::default(), false)
34}
35
36/// Width-mode variant of [`truncate`].
37///
38/// `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.
39pub fn truncate_mode(
40    s: &str,
41    length: usize,
42    tail: &str,
43    mode: WidthMode,
44    eaw_wide: bool,
45) -> String {
46    if length == 0 {
47        return String::new();
48    }
49    if string_width(s.as_bytes(), mode, eaw_wide) <= length {
50        return s.to_string();
51    }
52    let tail_w = string_width(tail.as_bytes(), mode, eaw_wide);
53    let budget = length.saturating_sub(tail_w);
54
55    let mut out = String::new();
56    let mut used = 0usize;
57    let mut tail_inserted = false;
58    for tok in tokenize(s.as_bytes(), mode, eaw_wide) {
59        match tok {
60            Token::Escape(esc) => out.push_str(bs(esc)),
61            Token::Control(b) => {
62                if !tail_inserted {
63                    out.push(b as char);
64                }
65            }
66            Token::Text { text, width } => {
67                let w = width as usize;
68                if used + w > budget {
69                    if !tail_inserted {
70                        out.push_str(tail);
71                        tail_inserted = true;
72                    }
73                    // Continue scanning to capture trailing escapes (e.g. SGR reset).
74                    continue;
75                }
76                out.push_str(bs(text));
77                used += w;
78            }
79        }
80    }
81    if !tail_inserted {
82        out.push_str(tail);
83    }
84    out
85}
86
87/// Truncate `s` from the left until at most `length` display columns remain.
88///
89/// If truncation occurs, `prefix` is prepended after any leading escape sequences needed to preserve active terminal state.
90pub fn truncate_left(s: &str, length: usize, prefix: &str) -> String {
91    truncate_left_mode(s, length, prefix, WidthMode::default(), false)
92}
93
94/// Width-mode variant of [`truncate_left`].
95///
96/// `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.
97pub fn truncate_left_mode(
98    s: &str,
99    length: usize,
100    prefix: &str,
101    mode: WidthMode,
102    eaw_wide: bool,
103) -> String {
104    let total = string_width(s.as_bytes(), mode, eaw_wide);
105    if total <= length {
106        return s.to_string();
107    }
108    let prefix_w = string_width(prefix.as_bytes(), mode, eaw_wide);
109    let drop = total.saturating_sub(length.saturating_sub(prefix_w));
110
111    let mut head_escapes = String::new();
112    let mut out = String::new();
113    let mut dropped = 0usize;
114    let mut dropping = true;
115    for tok in tokenize(s.as_bytes(), mode, eaw_wide) {
116        match tok {
117            Token::Escape(esc) => {
118                if dropping {
119                    head_escapes.push_str(bs(esc));
120                } else {
121                    out.push_str(bs(esc));
122                }
123            }
124            Token::Control(b) => {
125                if !dropping {
126                    out.push(b as char);
127                }
128            }
129            Token::Text { text, width } => {
130                let w = width as usize;
131                if dropping {
132                    dropped += w;
133                    if dropped >= drop {
134                        dropping = false;
135                    }
136                } else {
137                    out.push_str(bs(text));
138                }
139            }
140        }
141    }
142    let mut result = String::new();
143    // Preserve escapes encountered before the cut so style carries over.
144    result.push_str(&head_escapes);
145    result.push_str(prefix);
146    result.push_str(&out);
147    result
148}
149
150/// Remove `left` display columns from the start and `right` display columns from the end of `s`.
151///
152/// ANSI escape sequences are preserved and do not count toward either cut amount.
153pub fn cut(s: &str, left: usize, right: usize) -> String {
154    cut_mode(s, left, right, WidthMode::default(), false)
155}
156
157/// Width-mode variant of [`cut`].
158///
159/// `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.
160pub fn cut_mode(s: &str, left: usize, right: usize, mode: WidthMode, eaw_wide: bool) -> String {
161    if left == 0 && right == 0 {
162        return s.to_string();
163    }
164    let total = string_width(s.as_bytes(), mode, eaw_wide);
165    if left >= total {
166        return String::new();
167    }
168    let keep_end = total.saturating_sub(right);
169    if keep_end <= left {
170        return String::new();
171    }
172    let target = keep_end - left;
173
174    let mut out = String::new();
175    let mut col = 0usize;
176    let mut emitted = 0usize;
177    for tok in tokenize(s.as_bytes(), mode, eaw_wide) {
178        match tok {
179            Token::Escape(esc) => out.push_str(bs(esc)),
180            Token::Control(b) => {
181                if col >= left && emitted < target {
182                    out.push(b as char);
183                }
184            }
185            Token::Text { text, width } => {
186                let w = width as usize;
187                if col >= left && emitted + w <= target {
188                    out.push_str(bs(text));
189                    emitted += w;
190                }
191                col += w;
192                if emitted >= target {
193                    // Continue scanning so trailing escapes still attach.
194                }
195            }
196        }
197    }
198    out
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn truncate_basic() {
207        assert_eq!(truncate("hello world", 5, ""), "hello");
208    }
209
210    #[test]
211    fn truncate_with_tail() {
212        assert_eq!(truncate("hello world", 8, "..."), "hello...");
213    }
214
215    #[test]
216    fn truncate_no_op() {
217        assert_eq!(truncate("hi", 5, "..."), "hi");
218    }
219
220    #[test]
221    fn truncate_preserves_ansi() {
222        let s = "\x1b[31mhello world\x1b[m";
223        let got = truncate(s, 5, "");
224        assert_eq!(got, "\x1b[31mhello\x1b[m");
225    }
226
227    #[test]
228    fn truncate_wide_chars() {
229        assert_eq!(truncate("中文测试", 4, ""), "中文");
230        // Width-3 budget with 2-wide chars: only first fits.
231        assert_eq!(truncate("中文", 3, ""), "中");
232    }
233
234    #[test]
235    fn truncate_zero_length() {
236        assert_eq!(truncate("hello", 0, ""), "");
237        assert_eq!(truncate("hello", 0, "..."), "");
238    }
239
240    #[test]
241    fn truncate_left_basic() {
242        assert_eq!(truncate_left("hello world", 5, ""), "world");
243    }
244
245    #[test]
246    fn truncate_left_with_prefix() {
247        assert_eq!(truncate_left("hello world", 8, "..."), "...world");
248    }
249
250    #[test]
251    fn cut_basic() {
252        assert_eq!(cut("hello world", 2, 2), "llo wor");
253    }
254
255    #[test]
256    fn cut_zero_zero_is_noop() {
257        assert_eq!(cut("hello", 0, 0), "hello");
258    }
259
260    #[test]
261    fn cut_too_wide() {
262        assert_eq!(cut("hi", 10, 0), "");
263    }
264
265    #[test]
266    fn truncate_preserves_osc_link() {
267        let s = "\x1b]8;;https://example.com\x1b\\link text\x1b]8;;\x1b\\";
268        let got = truncate(s, 4, "");
269        assert_eq!(got, "\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\");
270    }
271}