1use unicode_width::UnicodeWidthChar;
3
4pub fn display_width(s: &str) -> usize {
6 s.chars()
7 .map(|c| UnicodeWidthChar::width(c).unwrap_or(0))
8 .sum()
9}
10
11pub fn wrap_line_to_width(line: &str, max_cols: usize) -> Vec<String> {
19 if max_cols == 0 || line.is_empty() {
20 return vec![line.to_string()];
21 }
22 let mut chunks: Vec<String> = Vec::new();
23 let mut current = String::new();
24 let mut cur_width = 0usize;
25 let mut chars = line.chars().peekable();
26
27 while let Some(c) = chars.next() {
28 if c == '\x1b' {
29 current.push(c);
31 while let Some(&p) = chars.peek() {
32 chars.next();
33 current.push(p);
34 if p.is_ascii_alphabetic() || p == '~' {
35 break;
36 }
37 }
38 continue;
39 }
40 let w = UnicodeWidthChar::width(c).unwrap_or(0);
41 if cur_width + w > max_cols && !current.is_empty() {
42 chunks.push(std::mem::take(&mut current));
43 cur_width = 0;
44 }
45 current.push(c);
46 cur_width += w;
47 }
48 if !current.is_empty() {
49 chunks.push(current);
50 }
51 if chunks.is_empty() {
52 chunks.push(String::new());
53 }
54 chunks
55}
56
57pub fn wrap_with_cursor(
66 text: &str,
67 max_cols: usize,
68 cursor_byte: usize,
69) -> (Vec<String>, usize, usize) {
70 if max_cols == 0 {
71 return (vec![String::new()], 0, 0);
72 }
73 let mut lines: Vec<String> = vec![String::new()];
74 let mut col = 0usize;
75 let mut byte = 0usize;
76 let mut cursor_row = 0usize;
77 let mut cursor_col = 0usize;
78 let mut cursor_set = false;
79
80 for c in text.chars() {
81 if c != '\n' {
86 let w = UnicodeWidthChar::width(c).unwrap_or(0);
87 if col + w > max_cols && !lines.last().unwrap().is_empty() {
88 lines.push(String::new());
89 col = 0;
90 }
91 }
92 if !cursor_set && byte == cursor_byte {
93 cursor_row = lines.len() - 1;
94 cursor_col = col;
95 cursor_set = true;
96 }
97 if c == '\n' {
98 lines.push(String::new());
99 col = 0;
100 } else {
101 let w = UnicodeWidthChar::width(c).unwrap_or(0);
102 lines.last_mut().unwrap().push(c);
103 col += w;
104 }
105 byte += c.len_utf8();
106 }
107
108 if !cursor_set {
110 cursor_row = lines.len() - 1;
111 cursor_col = col;
112 }
113 (lines, cursor_row, cursor_col)
114}
115
116pub fn slice_cols(s: &str, start_col: usize, max_cols: usize) -> String {
121 let mut col = 0usize;
122 let mut acc = String::new();
123 let mut acc_w = 0usize;
124 for c in s.chars() {
125 let w = UnicodeWidthChar::width(c).unwrap_or(0);
126 if col + w <= start_col {
127 col += w;
128 } else if col < start_col {
129 col += w;
130 } else {
131 if acc_w + w > max_cols {
132 break;
133 }
134 acc.push(c);
135 acc_w += w;
136 col += w;
137 }
138 }
139 acc
140}
141
142pub fn truncate_to_width(s: &str, max_cols: usize) -> String {
145 if max_cols == 0 {
146 return String::new();
147 }
148 let mut acc = String::with_capacity(s.len());
149 let mut cols = 0usize;
150 for c in s.chars() {
151 let w = UnicodeWidthChar::width(c).unwrap_or(0);
152 if cols + w > max_cols {
153 break;
154 }
155 acc.push(c);
156 cols += w;
157 }
158 acc
159}
160
161pub fn truncate_with_ellipsis(s: &str, max_cols: usize) -> String {
167 if max_cols == 0 {
168 return String::new();
169 }
170 if display_width(s) <= max_cols {
171 return s.to_string();
172 }
173 let budget = max_cols.saturating_sub(1).max(1);
174 let mut acc = truncate_to_width(s, budget);
175 acc.push('…');
176 acc
177}
178
179pub fn truncate_path(path: &str, max_cols: usize) -> String {
198 if max_cols == 0 {
199 return String::new();
200 }
201 if display_width(path) <= max_cols {
202 return path.to_string();
203 }
204
205 let last_sep = path.rfind(|c: char| c == '/' || c == '\\');
207 let last_segment = match last_sep {
208 Some(i) => &path[i + 1..],
209 None => path, };
211
212 let ellipsis_prefix = ".../";
214 let candidate = format!("{}{}", ellipsis_prefix, last_segment);
215
216 if display_width(&candidate) <= max_cols {
217 return candidate;
218 }
219
220 let prefix_w = display_width(ellipsis_prefix);
223 let budget = max_cols.saturating_sub(prefix_w).max(1);
224 let truncated_last = truncate_to_width(last_segment, budget);
225 format!("{}{}", ellipsis_prefix, truncated_last)
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231
232 #[test]
233 fn ascii_width_equals_len() {
234 assert_eq!(display_width("hello"), 5);
235 }
236
237 #[test]
238 fn cjk_char_is_width_two() {
239 assert_eq!(display_width("你好"), 4);
240 assert_eq!(display_width("a你b"), 4); }
242
243 #[test]
244 fn emoji_width_is_two() {
245 assert_eq!(display_width("👍"), 2);
246 }
247
248 #[test]
249 fn truncate_to_width_respects_boundary() {
250 assert_eq!(truncate_to_width("hello world", 5), "hello");
252 }
253
254 #[test]
255 fn truncate_to_width_cjk_never_splits_char() {
256 let out = truncate_to_width("你好world", 3);
258 assert_eq!(out, "你");
259 assert_eq!(display_width(&out), 2);
260 }
261
262 #[test]
263 fn truncate_to_width_zero_width_safe() {
264 assert_eq!(truncate_to_width("abc", 0), "");
265 }
266
267 #[test]
268 fn truncate_to_width_exact_fit() {
269 assert_eq!(truncate_to_width("你好", 4), "你好");
270 }
271
272 #[test]
273 fn truncate_to_width_preserves_under_limit() {
274 assert_eq!(truncate_to_width("hi", 10), "hi");
275 }
276
277 #[test]
278 fn slice_cols_window_midway() {
279 assert_eq!(slice_cols("abcdefghij", 3, 4), "defg");
281 }
282
283 #[test]
284 fn slice_cols_cjk_straddle_skipped() {
285 assert_eq!(slice_cols("你好world", 1, 4), "好wo");
288 }
289
290 #[test]
291 fn slice_cols_past_end_empty() {
292 assert_eq!(slice_cols("abc", 10, 5), "");
293 }
294
295 #[test]
296 fn slice_cols_start_zero_matches_truncate() {
297 assert_eq!(slice_cols("hello world", 0, 5), "hello");
298 }
299
300 #[test]
301 fn wrap_with_cursor_short_text_single_row() {
302 let (lines, r, c) = wrap_with_cursor("hi", 10, 2);
303 assert_eq!(lines, vec!["hi".to_string()]);
304 assert_eq!((r, c), (0, 2));
305 }
306
307 #[test]
308 fn wrap_with_cursor_overflow_moves_to_next_row() {
309 let (lines, r, c) = wrap_with_cursor("abcdef", 3, 3);
310 assert_eq!(lines, vec!["abc".to_string(), "def".to_string()]);
311 assert_eq!((r, c), (1, 0));
313 }
314
315 #[test]
316 fn wrap_with_cursor_honours_explicit_newline() {
317 let (lines, r, c) = wrap_with_cursor("ab\ncd", 10, 4);
318 assert_eq!(lines, vec!["ab".to_string(), "cd".to_string()]);
319 assert_eq!((r, c), (1, 1));
320 }
321
322 #[test]
323 fn wrap_with_cursor_end_of_buffer() {
324 let (lines, r, c) = wrap_with_cursor("hello", 10, 5);
325 assert_eq!(lines, vec!["hello".to_string()]);
326 assert_eq!((r, c), (0, 5));
327 }
328
329 #[test]
330 fn wrap_with_cursor_cjk_widths() {
331 let (lines, _, _) = wrap_with_cursor("你好", 3, 0);
334 assert_eq!(lines, vec!["你".to_string(), "好".to_string()]);
335 }
336
337 #[test]
340 fn truncate_path_short_path_unchanged() {
341 assert_eq!(truncate_path("~/foo", 20), "~/foo");
343 }
344
345 #[test]
346 fn truncate_path_keeps_last_segment() {
347 assert_eq!(
349 truncate_path("~/Documents/WPSDrive/NotLoginPage", 20),
350 ".../NotLoginPage"
351 );
352 }
353
354 #[test]
355 fn truncate_path_exact_fit() {
356 assert_eq!(
358 truncate_path("~/Documents/WPSDrive/NotLoginPage", 16),
359 ".../NotLoginPage"
360 );
361 }
362
363 #[test]
364 fn truncate_path_very_tight_budget() {
365 assert_eq!(truncate_path("~/a/b/c", 6), ".../c");
367 }
368
369 #[test]
370 fn truncate_path_last_segment_too_long() {
371 assert_eq!(
375 truncate_path("~/Documents/WPSDrive/NotLoginPage", 10),
376 ".../NotLog"
377 );
378 }
379
380 #[test]
381 fn truncate_path_no_separator() {
382 assert_eq!(truncate_path("verylongname", 8), ".../very");
385 }
386
387 #[test]
388 fn truncate_path_windows_backslash() {
389 assert_eq!(
391 truncate_path(r"~\Documents\WPSDrive\NotLoginPage", 20),
392 ".../NotLoginPage"
393 );
394 }
395
396 #[test]
397 fn truncate_path_zero_cols() {
398 assert_eq!(truncate_path("~/foo", 0), "");
399 }
400
401 #[test]
402 fn truncate_path_cjk_segment() {
403 assert_eq!(
405 truncate_path("~/Documents/工作/项目", 20),
406 ".../项目"
407 );
408 }
409
410 #[test]
411 fn truncate_path_cjk_tight_budget() {
412 assert_eq!(truncate_path("~/a/b/项目", 8), ".../项目");
414 }
415 #[test]
416 fn wrap_line_to_width_truecolor_sgr_passthrough_zero_width() {
417 let tinted = "\x1b[38;2;198;120;221mlet\x1b[23;39m x = 1;";
423 let chunks = wrap_line_to_width(tinted, 10);
424 assert_eq!(chunks.len(), 1, "must not wrap when visible width fits, got: {:?}", chunks);
425 assert!(chunks[0].contains("\x1b[38;2;198;120;221m"));
427 }
428
429 #[test]
430 fn wrap_line_to_width_truecolor_with_italic_passthrough() {
431 let tinted = "\x1b[3;38;2;124;132;153m// comment\x1b[23;39m";
434 let chunks = wrap_line_to_width(tinted, 10);
435 assert_eq!(chunks.len(), 1);
436 }
437}