1use unicode_segmentation::UnicodeSegmentation;
2use unicode_width::UnicodeWidthChar;
3
4pub const CJK_BREAK_REGEX: &str = r"[\p{Script_Extensions=Han}\p{Script_Extensions=Hiragana}\p{Script_Extensions=Katakana}\p{Script_Extensions=Hangul}\p{Script_Extensions=Bopomofo}]";
7
8pub fn visible_width(str: &str) -> usize {
12 if str.is_empty() {
13 return 0;
14 }
15
16 if is_printable_ascii(str) {
18 return str.len();
19 }
20
21 WIDTH_CACHE.with(|cache| {
23 let mut cache = cache.borrow_mut();
24 if let Some(&w) = cache.get(str) {
25 return w;
26 }
27 let w = compute_visible_width_inner(str);
28 if cache.len() >= WIDTH_CACHE_SIZE {
29 cache.clear();
30 }
31 cache.insert(str.to_string(), w);
32 w
33 })
34}
35
36fn is_printable_ascii(str: &str) -> bool {
38 str.bytes().all(|b| (0x20..=0x7e).contains(&b))
39}
40
41fn grapheme_width(grapheme: &str) -> usize {
43 if grapheme == "\t" {
44 return 3;
45 }
46
47 let first_char = grapheme.chars().next();
49 if let Some(c) = first_char {
50 if is_zero_width_char(c) {
52 return 0;
53 }
54
55 if could_be_emoji(grapheme) {
57 return 2;
58 }
59
60 let _cp = c as u32;
62 if (0x1f1e6..=0x1f1ff).contains(&(c as u32)) {
63 return 2;
64 }
65
66 if let Some(w) = c.width()
68 && w > 0
69 {
70 return w;
71 }
72
73 let mut w = 0;
75 for ch in grapheme.chars() {
76 if (0xff00..=0xffef).contains(&(ch as u32)) {
77 w += 2;
78 } else if ch as u32 == 0x0e33 || ch as u32 == 0x0eb3 {
79 w += 1;
80 }
81 }
82 if w > 0 {
83 return w;
84 }
85
86 return 2; }
88 0
89}
90
91fn could_be_emoji(grapheme: &str) -> bool {
93 let first_cp = grapheme.chars().next().map(|c| c as u32).unwrap_or(0);
94 ((0x1f000..=0x1fbff).contains(&first_cp))
95 || ((0x2300..=0x23ff).contains(&first_cp))
96 || ((0x2600..=0x27bf).contains(&first_cp))
97 || ((0x2b50..=0x2b55).contains(&first_cp))
98 || grapheme.contains('\u{FE0F}') || grapheme.chars().count() > 2 }
101
102fn is_zero_width_char(c: char) -> bool {
104 let _cp = c as u32;
105 matches!(
106 c,
107 '\u{200B}'..='\u{200F}' | '\u{2028}'..='\u{2029}' | '\u{202A}'..='\u{202E}' | '\u{2060}'..='\u{2064}' | '\u{FEFF}' ) || c.is_control()
113 || (unicode_width::UnicodeWidthChar::width(c) == Some(0))
114}
115
116fn extract_ansi_code_at(str: &str, pos: usize) -> Option<&str> {
119 let bytes = str.as_bytes();
120 if pos >= bytes.len() || bytes[pos] != 0x1b {
121 return None;
122 }
123
124 let next = bytes.get(pos + 1).copied();
125
126 if next == Some(b'[') {
128 let mut j = pos + 2;
129 while j < bytes.len() && !(0x40..=0x7e).contains(&bytes[j]) {
130 j += 1;
131 }
132 if j < bytes.len() {
133 return Some(&str[pos..=j]);
134 }
135 return None;
136 }
137
138 if next == Some(b']') {
140 let mut j = pos + 2;
141 while j < bytes.len() {
142 if bytes[j] == 0x07 {
143 return Some(&str[pos..=j]);
144 }
145 if bytes[j] == 0x1b && bytes.get(j + 1) == Some(&b'\\') {
146 return Some(&str[pos..=j + 1]);
147 }
148 j += 1;
149 }
150 return None;
151 }
152
153 if next == Some(b'_') {
155 let mut j = pos + 2;
156 while j < bytes.len() {
157 if bytes[j] == 0x07 {
158 return Some(&str[pos..=j]);
159 }
160 if bytes[j] == 0x1b && bytes.get(j + 1) == Some(&b'\\') {
161 return Some(&str[pos..=j + 1]);
162 }
163 j += 1;
164 }
165 return None;
166 }
167
168 None
169}
170
171pub fn truncate_to_width(text: &str, max_width: usize, ellipsis: &str, pad: bool) -> String {
176 if max_width == 0 {
177 return String::new();
178 }
179
180 if text.is_empty() {
181 return if pad {
182 " ".repeat(max_width)
183 } else {
184 String::new()
185 };
186 }
187
188 let text_width = visible_width(text);
189 let ellipsis_width = visible_width(ellipsis);
190
191 if text_width <= max_width {
193 return if pad {
194 let mut result = text.to_string();
195 result.push_str(&" ".repeat(max_width - text_width));
196 result
197 } else {
198 text.to_string()
199 };
200 }
201
202 if ellipsis_width >= max_width {
204 return if pad {
205 " ".repeat(max_width)
206 } else {
207 String::new()
208 };
209 }
210
211 let target_width = max_width - ellipsis_width;
212
213 if is_printable_ascii(text) {
215 let prefix = &text[..target_width.min(text.len())];
216 let mut result = String::with_capacity(max_width + 20);
217 result.push_str(prefix);
218 result.push_str("\x1b[0m");
219 result.push_str(ellipsis);
220 result.push_str("\x1b[0m");
221 if pad {
222 let visible = target_width.min(text.len()) + ellipsis_width;
223 if visible < max_width {
224 result.push_str(&" ".repeat(max_width - visible));
225 }
226 }
227 return result;
228 }
229
230 let mut kept = String::new();
232 let mut kept_width: usize = 0;
233 let mut pending_ansi = String::new();
234 let mut i = 0;
235 let bytes = text.as_bytes();
236
237 while i < bytes.len() {
238 if bytes[i] == 0x1b
239 && let Some(ansi) = extract_ansi_code_at(text, i)
240 {
241 pending_ansi.push_str(ansi);
242 i += ansi.len();
243 continue;
244 }
245
246 let rest = &text[i..];
248 let mut _grapheme_end = i;
249 for g in rest.graphemes(true) {
250 _grapheme_end += g.len();
251 let g_width = grapheme_width(g);
252
253 if kept_width + g_width <= target_width {
254 if !pending_ansi.is_empty() {
255 kept.push_str(&pending_ansi);
256 pending_ansi.clear();
257 }
258 kept.push_str(g);
259 kept_width += g_width;
260 } else {
261 break;
263 }
264 }
265 break;
266 }
267
268 let mut result = String::new();
269 result.push_str(&kept);
270 result.push_str("\x1b[0m");
271 result.push_str(ellipsis);
272 result.push_str("\x1b[0m");
273 if pad {
274 let visible = kept_width + ellipsis_width;
275 if visible < max_width {
276 result.push_str(&" ".repeat(max_width - visible));
277 }
278 }
279 result
280}
281
282pub fn wrap_text_with_ansi(text: &str, width: usize) -> Vec<String> {
285 if text.is_empty() {
286 return vec![String::new()];
287 }
288
289 let mut result: Vec<String> = Vec::new();
291 let mut active_codes = String::new();
292
293 for (line_idx, input_line) in text.split('\n').enumerate() {
294 let prefix = if line_idx > 0 {
295 active_codes.clone()
296 } else {
297 String::new()
298 };
299 let wrapped = wrap_single_line(&format!("{}{}", prefix, input_line), width);
300 for line in wrapped {
301 result.push(line);
302 }
303 update_tracker_from_text(input_line, &mut active_codes);
305 }
306
307 if result.is_empty() {
308 vec![String::new()]
309 } else {
310 result
311 }
312}
313
314fn wrap_single_line(line: &str, width: usize) -> Vec<String> {
315 if line.is_empty() {
316 return vec![String::new()];
317 }
318
319 let visible = visible_width(line);
320 if visible <= width {
321 return vec![line.to_string()];
322 }
323
324 let tokens = split_into_tokens(line);
326 let mut wrapped: Vec<String> = Vec::new();
327 let mut current_line = String::new();
328 let mut current_width: usize = 0;
329 let mut tracker = AnsiState::new();
330
331 for token in &tokens {
332 let token_width = visible_width(token);
333 let is_space = token.trim().is_empty();
334
335 if token_width > width && !is_space {
337 if !current_line.is_empty() {
338 let line_end = tracker.line_end_reset();
339 if !line_end.is_empty() {
340 current_line.push_str(&line_end);
341 }
342 wrapped.push(current_line);
343 current_line = String::new();
344 current_width = 0;
345 }
346
347 let broken = break_long_word(token, width, &mut tracker);
348 let last = broken.len().saturating_sub(1);
349 for (i, line) in broken.iter().enumerate() {
350 if i < last {
351 wrapped.push(line.clone());
352 } else {
353 current_line = line.clone();
354 current_width = visible_width(line);
355 }
356 }
357 continue;
358 }
359
360 let total = current_width + token_width;
361 if total > width && current_width > 0 {
362 let mut line_to_wrap = current_line.trim_end().to_string();
363 let line_end = tracker.line_end_reset();
364 if !line_end.is_empty() {
365 line_to_wrap.push_str(&line_end);
366 }
367 wrapped.push(line_to_wrap);
368 if is_space {
369 current_line = tracker.active_codes();
370 current_width = 0;
371 } else {
372 let codes = tracker.active_codes();
373 current_line = format!("{}{}", codes, token);
374 current_width = token_width;
375 }
376 } else {
377 current_line.push_str(token);
378 current_width += token_width;
379 }
380
381 tracker.update(token);
382 }
383
384 if !current_line.is_empty() {
385 wrapped.push(current_line.trim_end().to_string());
386 }
387
388 if wrapped.is_empty() {
389 vec![String::new()]
390 } else {
391 wrapped
392 }
393}
394
395fn split_into_tokens(text: &str) -> Vec<String> {
398 let mut tokens: Vec<String> = Vec::new();
399 let mut current = String::new();
400 let mut pending_ansi = String::new();
401 let mut current_is_space: Option<bool> = None;
402 let mut i = 0;
403 let bytes = text.as_bytes();
404
405 while i < bytes.len() {
406 if bytes[i] == 0x1b
407 && let Some(ansi) = extract_ansi_code_at(text, i)
408 {
409 pending_ansi.push_str(ansi);
410 i += ansi.len();
411 continue;
412 }
413
414 let mut end = i;
416 while end < bytes.len() && bytes[end] != 0x1b {
417 end += 1;
418 }
419
420 let segment_str = &text[i..end];
421 let mut seg_pos = 0;
422 while seg_pos < segment_str.len() {
423 if segment_str[seg_pos..].starts_with("[paste #") {
425 if !current.is_empty() {
426 tokens.push(std::mem::take(&mut current));
427 current_is_space = None;
428 }
429 if let Some(end) = segment_str[seg_pos..].find(']') {
430 let marker = &segment_str[seg_pos..=seg_pos + end];
431 let token = format!("{}{}", pending_ansi, marker);
432 pending_ansi.clear();
433 tokens.push(token);
434 seg_pos += end + 1;
435 continue;
436 }
437 }
438
439 let grapheme = if let Some(g) = segment_str[seg_pos..].graphemes(true).next() {
441 g
442 } else {
443 break;
444 };
445 let g_len = grapheme.len();
446 let is_space = grapheme == " ";
447
448 if !is_space && is_cjk_break(grapheme) {
450 if !current.is_empty() {
451 tokens.push(std::mem::take(&mut current));
452 current_is_space = None;
453 }
454 let token = format!("{}{}", pending_ansi, grapheme);
455 pending_ansi.clear();
456 tokens.push(token);
457 seg_pos += g_len;
458 continue;
459 }
460
461 let segment_is_space = is_space;
462 if current_is_space.is_some_and(|s| s != segment_is_space) && !current.is_empty() {
463 tokens.push(std::mem::take(&mut current));
464 }
465
466 if !pending_ansi.is_empty() {
467 current.push_str(&pending_ansi);
468 pending_ansi.clear();
469 }
470
471 current_is_space = Some(segment_is_space);
472 current.push_str(grapheme);
473 seg_pos += g_len;
474 }
475
476 i = end;
477 }
478
479 if !pending_ansi.is_empty() {
481 if !current.is_empty() {
482 current.push_str(&pending_ansi);
483 } else if let Some(last) = tokens.last_mut() {
484 last.push_str(&pending_ansi);
485 } else {
486 current = pending_ansi;
487 }
488 }
489
490 if !current.is_empty() {
491 tokens.push(current);
492 }
493
494 tokens
495}
496
497fn break_long_word(word: &str, width: usize, tracker: &mut AnsiState) -> Vec<String> {
499 let mut lines: Vec<String> = Vec::new();
500 let mut current_line = tracker.active_codes();
501 let mut current_width: usize = 0;
502 let mut i = 0;
503 let bytes = word.as_bytes();
504
505 while i < bytes.len() {
506 if bytes[i] == 0x1b
507 && let Some(ansi) = extract_ansi_code_at(word, i)
508 {
509 current_line.push_str(ansi);
510 tracker.update(ansi);
511 i += ansi.len();
512 continue;
513 }
514
515 let rest = &word[i..];
516 let mut grapheme_end = i;
517 for g in rest.graphemes(true) {
518 grapheme_end += g.len();
519 let g_width = grapheme_width(g);
520
521 if current_width + g_width > width && current_width > 0 {
522 let line_end = tracker.line_end_reset();
523 if !line_end.is_empty() {
524 current_line.push_str(&line_end);
525 }
526 lines.push(std::mem::take(&mut current_line));
527 current_line = tracker.active_codes();
528 current_width = 0;
529 }
530
531 current_line.push_str(g);
532 current_width += g_width;
533 }
534 i = grapheme_end;
535 }
536
537 if !current_line.is_empty() {
538 lines.push(current_line);
539 }
540
541 if lines.is_empty() {
542 vec![String::new()]
543 } else {
544 lines
545 }
546}
547
548pub fn slice_by_column(line: &str, start_col: usize, length: usize) -> String {
550 if length == 0 {
551 return String::new();
552 }
553
554 let end_col = start_col + length;
555 let mut result = String::new();
556 let mut current_col: usize = 0;
557 let mut pending_ansi = String::new();
558 let mut i = 0;
559 let bytes = line.as_bytes();
560
561 while i < bytes.len() {
562 if bytes[i] == 0x1b
563 && let Some(ansi) = extract_ansi_code_at(line, i)
564 {
565 if current_col >= start_col && current_col < end_col {
566 result.push_str(ansi);
567 } else if current_col < start_col {
568 pending_ansi.push_str(ansi);
569 }
570 i += ansi.len();
571 continue;
572 }
573
574 let mut text_end = i;
576 while text_end < bytes.len() && bytes[text_end] != 0x1b {
577 text_end += 1;
578 }
579
580 let segment_str = &line[i..text_end];
581 for grapheme in segment_str.graphemes(true) {
582 let w = grapheme_width(grapheme);
583 let in_range = current_col >= start_col && current_col < end_col;
584
585 if in_range && current_col + w <= end_col {
586 if !pending_ansi.is_empty() {
587 result.push_str(&pending_ansi);
588 pending_ansi.clear();
589 }
590 result.push_str(grapheme);
591 }
592
593 current_col += w;
594 if current_col >= end_col {
595 return result;
596 }
597 }
598 i = text_end;
599 if current_col >= end_col {
600 return result;
601 }
602 }
603
604 result
605}
606
607pub fn visual_col_to_byte_offset(text: &str, visual_col: usize) -> usize {
610 if text.is_empty() {
611 return 0;
612 }
613
614 let mut vis_so_far: usize = 0;
615 let mut i = 0;
616 let bytes = text.as_bytes();
617
618 while i < bytes.len() {
619 if bytes[i] == 0x1b
620 && let Some(ansi) = extract_ansi_code_at(text, i)
621 {
622 i += ansi.len();
623 continue;
624 }
625
626 let rest = &text[i..];
627 if let Some(g) = rest.graphemes(true).next() {
628 let gw = grapheme_width(g);
629 if vis_so_far + gw > visual_col {
630 return i;
631 }
632 vis_so_far += gw;
633 i += g.len();
634 continue;
635 }
636 break;
637 }
638
639 text.len()
640}
641
642struct AnsiState {
644 bold: bool,
645 underline: bool,
646 fg_color: Option<String>,
647 bg_color: Option<String>,
648}
649
650impl AnsiState {
651 fn new() -> Self {
652 Self {
653 bold: false,
654 underline: false,
655 fg_color: None,
656 bg_color: None,
657 }
658 }
659
660 fn update(&mut self, text: &str) {
661 let mut i = 0;
662 let bytes = text.as_bytes();
663 while i < bytes.len() {
664 if bytes[i] == 0x1b
665 && let Some(ansi) = extract_ansi_code_at(text, i)
666 {
667 self.process_ansi(ansi);
668 i += ansi.len();
669 continue;
670 }
671 i += 1;
672 }
673 }
674
675 fn process_ansi(&mut self, code: &str) {
676 let code_bytes = code.as_bytes();
677 if code_bytes.len() < 4 || code_bytes[code_bytes.len() - 1] != b'm' {
679 return;
680 }
681
682 let inner = &code[2..code.len() - 1]; if inner.is_empty() || inner == "0" {
684 self.bold = false;
685 self.underline = false;
686 self.fg_color = None;
687 self.bg_color = None;
688 return;
689 }
690
691 let params: Vec<&str> = inner.split(';').collect();
692 let mut i = 0;
693 while i < params.len() {
694 let Ok(parsed) = params[i].parse::<u8>() else {
695 i += 1;
696 continue;
697 };
698 match parsed {
699 0 => {
700 self.bold = false;
701 self.underline = false;
702 self.fg_color = None;
703 self.bg_color = None;
704 }
705 1 => self.bold = true,
706 4 => self.underline = true,
707 22 => self.bold = false,
708 24 => self.underline = false,
709 30..=37 | 90..=97 => {
710 self.fg_color = Some(parsed.to_string());
711 }
712 40..=47 | 100..=107 => {
713 self.bg_color = Some(parsed.to_string());
714 }
715 38 => {
716 if i + 1 < params.len() {
718 match params[i + 1] {
719 "5" if i + 2 < params.len() => {
720 self.fg_color = Some(params[i..=i + 2].join(";"));
721 i += 2;
722 }
723 "2" if i + 4 < params.len() => {
724 self.fg_color = Some(params[i..=i + 4].join(";"));
725 i += 4;
726 }
727 _ => {}
728 }
729 }
730 }
731 48 => {
732 if i + 1 < params.len() {
734 match params[i + 1] {
735 "5" if i + 2 < params.len() => {
736 self.bg_color = Some(params[i..=i + 2].join(";"));
737 i += 2;
738 }
739 "2" if i + 4 < params.len() => {
740 self.bg_color = Some(params[i..=i + 4].join(";"));
741 i += 4;
742 }
743 _ => {}
744 }
745 }
746 }
747 39 => self.fg_color = None,
748 49 => self.bg_color = None,
749 _ => {}
750 }
751 i += 1;
752 }
753 }
754
755 fn active_codes(&self) -> String {
756 let mut codes: Vec<String> = Vec::new();
757 if self.bold {
758 codes.push("1".to_string());
759 }
760 if self.underline {
761 codes.push("4".to_string());
762 }
763 if let Some(ref fg) = self.fg_color {
764 codes.push(fg.clone());
765 }
766 if let Some(ref bg) = self.bg_color {
767 codes.push(bg.clone());
768 }
769 if codes.is_empty() {
770 String::new()
771 } else {
772 format!("\x1b[{}m", codes.join(";"))
773 }
774 }
775
776 fn line_end_reset(&self) -> String {
778 if self.underline {
779 "\x1b[24m".to_string()
780 } else {
781 String::new()
782 }
783 }
784}
785
786pub fn normalize_terminal_output(line: &str) -> String {
790 format!("{}\x1b[0m\x1b]8;;\x07", line)
791}
792
793pub fn is_whitespace_char(grapheme: &str) -> bool {
796 grapheme == " " || grapheme == "\t"
797}
798
799pub fn extract_segments(
805 line: &str,
806 before_end: usize,
807 after_start: usize,
808 after_len: usize,
809 strict: bool,
810) -> (String, usize, String, usize) {
811 let before = slice_by_column(line, 0, before_end);
812 let before_width = visible_width(&before);
813 let after = slice_by_column(line, after_start, after_len);
814 let after_width = visible_width(&after);
815
816 if strict {
817 if before_width > before_end {
819 return (String::new(), 0, after, after_width);
820 }
821 }
822
823 (before, before_width, after, after_width)
824}
825
826pub fn apply_background_to_line(
829 line: &str,
830 width: usize,
831 bg_fn: &dyn Fn(&str) -> String,
832) -> String {
833 let vis = visible_width(line);
834 let padded = if vis < width {
835 let mut result = line.to_string();
836 result.push_str(&" ".repeat(width - vis));
837 result
838 } else {
839 line.to_string()
840 };
841 bg_fn(&padded)
842}
843
844pub fn is_image_line(line: &str) -> bool {
848 line.trim_start().starts_with("data:image/") && line.contains(";base64,")
849}
850
851pub fn slice_with_width(line: &str, start_col: usize, length: usize) -> (String, usize) {
855 let text = slice_by_column(line, start_col, length);
856 let width = visible_width(&text);
857 (text, width)
858}
859
860use std::cell::RefCell;
862use std::collections::HashMap;
863
864const WIDTH_CACHE_SIZE: usize = 512;
865
866thread_local! {
867 static WIDTH_CACHE: RefCell<HashMap<String, usize>> = RefCell::new(HashMap::new());
868}
869
870fn compute_visible_width_inner(s: &str) -> usize {
872 if s.is_empty() {
873 return 0;
874 }
875 let mut clean = String::with_capacity(s.len());
877 let mut i = 0;
878 let bytes = s.as_bytes();
879 while i < bytes.len() {
880 if bytes[i] == b'\t' {
881 clean.push_str(" ");
882 i += 1;
883 continue;
884 }
885 if bytes[i] == 0x1b
886 && let Some(ansi) = extract_ansi_code_at(s, i)
887 {
888 i += ansi.len();
889 continue;
890 }
891 if let Some(ch) = s[i..].chars().next() {
892 clean.push(ch);
893 i += ch.len_utf8();
894 } else {
895 i += 1;
896 }
897 }
898
899 let mut width = 0;
900 for grapheme in clean.graphemes(true) {
901 width += grapheme_width(grapheme);
902 }
903 width
904}
905
906pub fn is_cjk_break(grapheme: &str) -> bool {
908 if let Some(c) = grapheme.chars().next() {
909 let block = c as u32;
910 (0x4E00..=0x9FFF).contains(&block)
912 || (0x3040..=0x309F).contains(&block)
913 || (0x30A0..=0x30FF).contains(&block)
914 || (0xAC00..=0xD7AF).contains(&block)
915 || (0x3100..=0x312F).contains(&block)
916 } else {
917 false
918 }
919}
920
921fn update_tracker_from_text(text: &str, active_codes: &mut String) {
922 let mut tracker = AnsiState::new();
924 tracker.update(text);
925 *active_codes = tracker.active_codes();
926}
927
928#[cfg(test)]
929mod tests {
930 use super::*;
931
932 #[test]
933 fn test_visible_width_ascii() {
934 assert_eq!(visible_width("hello"), 5);
935 assert_eq!(visible_width(""), 0);
936 }
937
938 #[test]
939 fn test_visible_width_with_ansi() {
940 assert_eq!(visible_width("\x1b[31mhello\x1b[0m"), 5);
941 assert_eq!(visible_width("\t\x1b[31m界\x1b[0m"), 5); }
943
944 #[test]
945 fn test_visible_width_cjk() {
946 assert_eq!(visible_width("世界"), 4);
947 assert_eq!(visible_width("hello世界"), 9);
948 }
949
950 #[test]
951 fn test_visible_width_emoji() {
952 assert_eq!(visible_width("🙂"), 2);
953 assert_eq!(visible_width("👋"), 2);
954 }
955
956 #[test]
957 fn test_truncate_to_width_no_truncation() {
958 let result = truncate_to_width("hello", 10, "...", false);
959 assert_eq!(result, "hello");
960 }
961
962 #[test]
963 fn test_truncate_to_width_with_ellipsis() {
964 let result = truncate_to_width("hello world", 8, "...", false);
965 assert!(visible_width(&result) <= 8);
966 assert!(result.contains("..."));
967 }
968
969 #[test]
970 fn test_truncate_to_width_with_pad() {
971 let result = truncate_to_width("hi", 8, "...", true);
972 assert_eq!(visible_width(&result), 8);
973 }
974
975 #[test]
976 fn test_truncate_to_width_empty() {
977 assert_eq!(truncate_to_width("", 5, "...", false), "");
978 assert_eq!(truncate_to_width("", 5, "...", true), " ".repeat(5));
979 }
980
981 #[test]
982 fn test_truncate_to_width_max_zero() {
983 assert_eq!(truncate_to_width("hello", 0, "...", false), "");
984 }
985
986 #[test]
987 fn test_wrap_basic() {
988 let text = "hello world this is a test";
989 let wrapped = wrap_text_with_ansi(text, 10);
990 assert!(wrapped.len() > 1);
991 for line in &wrapped {
992 assert!(visible_width(line) <= 10);
993 }
994 }
995
996 #[test]
997 fn test_wrap_no_wrap_needed() {
998 let text = "hello";
999 let wrapped = wrap_text_with_ansi(text, 10);
1000 assert_eq!(wrapped.len(), 1);
1001 assert_eq!(wrapped[0], "hello");
1002 }
1003
1004 #[test]
1005 fn test_wrap_preserves_ansi() {
1006 let text = "\x1b[31mhello world this is red\x1b[0m";
1007 let wrapped = wrap_text_with_ansi(text, 10);
1008 for line in wrapped.iter().skip(1) {
1010 assert!(line.starts_with("\x1b[31m"));
1011 }
1012 }
1013
1014 #[test]
1015 fn test_slice_by_column_basic() {
1016 let line = "hello world";
1017 assert_eq!(slice_by_column(line, 0, 5), "hello");
1018 assert_eq!(slice_by_column(line, 6, 5), "world");
1019 assert_eq!(slice_by_column(line, 3, 4), "lo w");
1020 }
1021
1022 #[test]
1023 fn test_slice_by_column_empty() {
1024 assert_eq!(slice_by_column("test", 0, 0), "");
1025 }
1026
1027 #[test]
1028 fn test_normalize_terminal_output() {
1029 let result = normalize_terminal_output("hello");
1030 assert_eq!(result, "hello\x1b[0m\x1b]8;;\x07");
1031 }
1032
1033 #[test]
1034 fn test_is_whitespace_char() {
1035 assert!(is_whitespace_char(" "));
1036 assert!(is_whitespace_char("\t"));
1037 assert!(!is_whitespace_char("a"));
1038 assert!(!is_whitespace_char(""));
1039 }
1040
1041 #[test]
1042 fn test_extract_segments_basic() {
1043 let line = "hello beautiful world";
1044 let (before, bw, after, aw) = extract_segments(line, 5, 15, 5, true);
1047 assert_eq!(before, "hello");
1048 assert_eq!(bw, 5);
1049 assert_eq!(after, " worl");
1050 assert_eq!(aw, 5);
1051 }
1052
1053 #[test]
1054 fn test_extract_segments_overflow() {
1055 let line = "short";
1056 let (before, bw, after, _aw) = extract_segments(line, 10, 15, 5, true);
1059 assert_eq!(before, "short");
1060 assert_eq!(bw, 5);
1061 assert!(after.is_empty());
1062 }
1063}
1064
1065#[test]
1066fn test_wrap_multiline_preserves_line_count() {
1067 let text = "hello world this is a test\nshort\nanother long line here yes";
1069 let wrapped = wrap_text_with_ansi(text, 10);
1070 let total_wrapped = wrapped.len();
1074 let expected_min = 3; assert!(
1076 total_wrapped >= expected_min,
1077 "Expected at least {} lines, got {}",
1078 expected_min,
1079 total_wrapped
1080 );
1081 for (i, line) in wrapped.iter().enumerate() {
1083 let w = visible_width(line);
1084 assert!(
1085 w <= 10,
1086 "Line {}: '{}' has visible_width {} > 10",
1087 i,
1088 line,
1089 w
1090 );
1091 }
1092}
1093
1094#[test]
1095fn test_wrap_text_with_ansi_no_duplicate_lines() {
1096 let text = "abc def ghi\njk lm no pq rs";
1099 let result = wrap_text_with_ansi(text, 5);
1100 assert_eq!(
1104 result.len(),
1105 6,
1106 "Expected 6 wrapped lines (3+3), got {}: {:?}",
1107 result.len(),
1108 result
1109 );
1110
1111 let mut seen = std::collections::HashSet::new();
1113 for line in &result {
1114 let trimmed = line.trim().to_string();
1115 if !trimmed.is_empty() && !seen.insert(trimmed.clone()) {
1116 panic!("Duplicate line found: '{}'", trimmed);
1117 }
1118 }
1119}
1120
1121#[test]
1122fn test_wrap_user_text_does_not_introduce_duplicates() {
1123 let t1 = "ghhh jjj jkkk jrjrnr jrnr rkr rrkr rmrrkrr k ghhh jjj jkkk jrjrnr jrnr rkr rrkr rmrrkrr k";
1124
1125 fn count_occurrences(text: &str, pattern: &str) -> usize {
1132 text.matches(pattern).count()
1133 }
1134
1135 let pattern = "ghhh jjj jkkk jrjrnr jrnr rkr rrkr rmrrkrr k";
1136 let original_count = count_occurrences(t1, pattern);
1137 assert_eq!(
1138 original_count, 2,
1139 "Input should have 2 occurrences of pattern"
1140 );
1141
1142 for width in [40, 50, 60, 80, 100] {
1143 let wrapped = wrap_text_with_ansi(t1, width);
1144 let wrapped_count: usize = wrapped
1146 .iter()
1147 .map(|line| count_occurrences(line, pattern))
1148 .sum();
1149 assert!(
1151 wrapped_count <= original_count,
1152 "Width {}: wrapped has {} occurrences, input has {}",
1153 width,
1154 wrapped_count,
1155 original_count
1156 );
1157 }
1158}