1use super::text::{Token, WidthMode, string_width, tokenize};
26
27#[inline]
28fn bs(b: &[u8]) -> &str {
29 debug_assert!(
36 std::str::from_utf8(b).is_ok(),
37 "token split a UTF-8 character: {b:?}"
38 );
39 unsafe { std::str::from_utf8_unchecked(b) }
42}
43
44pub fn truncate(s: &str, length: usize, tail: &str) -> String {
48 truncate_mode(s, length, tail, WidthMode::default(), false)
49}
50
51pub 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;
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
102pub fn truncate_left(s: &str, length: usize, prefix: &str) -> String {
106 truncate_left_mode(s, length, prefix, WidthMode::default(), false)
107}
108
109pub 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 result.push_str(&head_escapes);
160 result.push_str(prefix);
161 result.push_str(&out);
162 result
163}
164
165pub fn cut(s: &str, left: usize, right: usize) -> String {
169 cut_mode(s, left, right, WidthMode::default(), false)
170}
171
172pub 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 }
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 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}