1use console::{measure_text_width, pad_str, strip_ansi_codes, Alignment};
7use standout_bbparser::strip_tags;
8
9pub fn display_width(s: &str) -> usize {
25 measure_text_width(s)
26}
27
28pub fn visible_width(s: &str) -> usize {
47 let no_ansi = strip_ansi_codes(s);
48 measure_text_width(&strip_tags(&no_ansi))
49}
50
51pub fn truncate_end(s: &str, max_width: usize, ellipsis: &str) -> String {
73 let width = measure_text_width(s);
74 if width <= max_width {
75 return s.to_string();
76 }
77
78 let ellipsis_width = measure_text_width(ellipsis);
79 if max_width < ellipsis_width {
80 return truncate_to_display_width(ellipsis, max_width);
82 }
83 if max_width == ellipsis_width {
84 return ellipsis.to_string();
86 }
87
88 let target_width = max_width - ellipsis_width;
89 let mut result = truncate_to_display_width(s, target_width);
90 result.push_str(ellipsis);
91 result
92}
93
94pub fn truncate_start(s: &str, max_width: usize, ellipsis: &str) -> String {
111 let width = measure_text_width(s);
112 if width <= max_width {
113 return s.to_string();
114 }
115
116 let ellipsis_width = measure_text_width(ellipsis);
117 if max_width < ellipsis_width {
118 return truncate_to_display_width(ellipsis, max_width);
120 }
121 if max_width == ellipsis_width {
122 return ellipsis.to_string();
124 }
125
126 let target_width = max_width - ellipsis_width;
127 let truncated = find_suffix_with_width(s, target_width);
128 format!("{}{}", ellipsis, truncated)
129}
130
131pub fn truncate_middle(s: &str, max_width: usize, ellipsis: &str) -> String {
148 let width = measure_text_width(s);
149 if width <= max_width {
150 return s.to_string();
151 }
152
153 let ellipsis_width = measure_text_width(ellipsis);
154 if max_width < ellipsis_width {
155 return truncate_to_display_width(ellipsis, max_width);
157 }
158 if max_width == ellipsis_width {
159 return ellipsis.to_string();
161 }
162
163 let available = max_width - ellipsis_width;
164 let right_width = available.div_ceil(2); let left_width = available - right_width;
166
167 let left = truncate_to_display_width(s, left_width);
168 let right = find_suffix_with_width(s, right_width);
169
170 format!("{}{}{}", left, ellipsis, right)
171}
172
173pub fn pad_left(s: &str, width: usize) -> String {
190 pad_str(s, width, Alignment::Right, None).into_owned()
191}
192
193pub fn pad_right(s: &str, width: usize) -> String {
210 pad_str(s, width, Alignment::Left, None).into_owned()
211}
212
213pub fn pad_center(s: &str, width: usize) -> String {
231 pad_str(s, width, Alignment::Center, None).into_owned()
232}
233
234pub fn wrap(s: &str, width: usize) -> Vec<String> {
265 wrap_indent(s, width, 0)
266}
267
268pub fn wrap_indent(s: &str, width: usize, indent: usize) -> Vec<String> {
290 if width == 0 {
291 return vec![];
292 }
293
294 let s = s.trim();
295 if s.is_empty() {
296 return vec![];
297 }
298
299 if measure_text_width(s) <= width {
301 return vec![s.to_string()];
302 }
303
304 let mut lines = Vec::new();
305 let mut current_line = String::new();
306 let mut current_width = 0;
307 let mut is_first_line = true;
308
309 for word in s.split_whitespace() {
311 let word_width = measure_text_width(word);
312 let effective_width = if is_first_line {
313 width
314 } else {
315 width.saturating_sub(indent)
316 };
317
318 if word_width > effective_width {
320 if !current_line.is_empty() {
322 lines.push(current_line);
323 current_line = String::new();
324 current_width = 0;
325 is_first_line = false;
326 }
327
328 let broken = break_long_word(word, effective_width, indent, is_first_line);
330 let broken_len = broken.len();
331 for (i, part) in broken.into_iter().enumerate() {
332 if i == 0 && is_first_line {
333 lines.push(part);
334 is_first_line = false;
335 } else if i < broken_len - 1 {
336 lines.push(part);
338 } else {
339 current_line = part;
341 current_width = measure_text_width(¤t_line);
342 }
343 }
344 continue;
345 }
346
347 let needed_width = if current_line.is_empty() {
349 word_width
350 } else {
351 current_width + 1 + word_width };
353
354 if needed_width <= effective_width {
355 if !current_line.is_empty() {
357 current_line.push(' ');
358 current_width += 1;
359 }
360 current_line.push_str(word);
361 current_width += word_width;
362 } else {
363 if !current_line.is_empty() {
365 lines.push(current_line);
366 }
367 is_first_line = false;
368
369 let indent_str: String = " ".repeat(indent);
371 current_line = format!("{}{}", indent_str, word);
372 current_width = indent + word_width;
373 }
374 }
375
376 if !current_line.is_empty() {
378 lines.push(current_line);
379 }
380
381 if lines.is_empty() && !s.is_empty() {
383 lines.push(truncate_to_display_width(s, width));
384 }
385
386 lines
387}
388
389fn break_long_word(word: &str, width: usize, indent: usize, is_first: bool) -> Vec<String> {
391 let mut parts = Vec::new();
392 let mut remaining = word;
393 let mut first_part = is_first;
394
395 while !remaining.is_empty() {
396 let effective_width = if first_part {
397 width
398 } else {
399 width.saturating_sub(indent)
400 };
401
402 if effective_width == 0 {
403 break;
405 }
406
407 let remaining_width = measure_text_width(remaining);
408 if remaining_width <= effective_width {
409 let prefix = if first_part {
411 String::new()
412 } else {
413 " ".repeat(indent)
414 };
415 parts.push(format!("{}{}", prefix, remaining));
416 break;
417 }
418
419 let break_width = effective_width.saturating_sub(1); if break_width == 0 {
422 let prefix = if first_part {
424 String::new()
425 } else {
426 " ".repeat(indent)
427 };
428 parts.push(format!("{}…", prefix));
429 break;
430 }
431
432 let prefix = if first_part {
433 String::new()
434 } else {
435 " ".repeat(indent)
436 };
437 let truncated = truncate_to_display_width(remaining, break_width);
438 parts.push(format!("{}{}…", prefix, truncated));
439
440 let truncated_len = truncated.chars().count();
442 remaining = &remaining[remaining
443 .char_indices()
444 .nth(truncated_len)
445 .map(|(i, _)| i)
446 .unwrap_or(remaining.len())..];
447 first_part = false;
448 }
449
450 parts
451}
452
453fn truncate_to_display_width(s: &str, max_width: usize) -> String {
458 if max_width == 0 {
459 return String::new();
460 }
461
462 if measure_text_width(s) <= max_width {
464 return s.to_string();
465 }
466
467 let mut result = String::new();
470 let mut current_width = 0;
471 let chars = s.chars().peekable();
472 let mut in_escape = false;
473
474 for c in chars {
475 if c == '\x1b' {
476 result.push(c);
478 in_escape = true;
479 continue;
480 }
481
482 if in_escape {
483 result.push(c);
484 if c.is_ascii_alphabetic() || c == '~' {
486 in_escape = false;
487 }
488 continue;
489 }
490
491 let char_width = unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
493 if current_width + char_width > max_width {
494 break;
495 }
496 result.push(c);
497 current_width += char_width;
498 }
499
500 result
501}
502
503fn find_suffix_with_width(s: &str, max_width: usize) -> String {
505 if max_width == 0 {
506 return String::new();
507 }
508
509 let total_width = measure_text_width(s);
510 if total_width <= max_width {
511 return s.to_string();
512 }
513
514 let skip_width = total_width - max_width;
517
518 let mut current_width = 0;
519 let mut byte_offset = 0;
520 let mut in_escape = false;
521
522 for (i, c) in s.char_indices() {
523 if c == '\x1b' {
524 in_escape = true;
525 byte_offset = i + c.len_utf8();
526 continue;
527 }
528
529 if in_escape {
530 byte_offset = i + c.len_utf8();
531 if c.is_ascii_alphabetic() || c == '~' {
532 in_escape = false;
533 }
534 continue;
535 }
536
537 let char_width = unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
538 current_width += char_width;
539 byte_offset = i + c.len_utf8();
540
541 if current_width >= skip_width {
542 break;
543 }
544 }
545
546 s[byte_offset..].to_string()
547}
548
549#[cfg(test)]
550mod tests {
551 use super::*;
552
553 #[test]
556 fn display_width_ascii() {
557 assert_eq!(display_width("hello"), 5);
558 assert_eq!(display_width(""), 0);
559 assert_eq!(display_width(" "), 1);
560 }
561
562 #[test]
563 fn display_width_ansi() {
564 assert_eq!(display_width("\x1b[31mred\x1b[0m"), 3);
565 assert_eq!(display_width("\x1b[1;32mbold green\x1b[0m"), 10);
566 assert_eq!(display_width("\x1b[38;5;196mcolor\x1b[0m"), 5);
567 }
568
569 #[test]
570 fn display_width_unicode() {
571 assert_eq!(display_width("日本語"), 6); assert_eq!(display_width("café"), 4);
573 assert_eq!(display_width("🎉"), 2); }
575
576 #[test]
579 fn truncate_end_no_truncation() {
580 assert_eq!(truncate_end("hello", 10, "…"), "hello");
581 assert_eq!(truncate_end("hello", 5, "…"), "hello");
582 }
583
584 #[test]
585 fn truncate_end_basic() {
586 assert_eq!(truncate_end("hello world", 8, "…"), "hello w…");
587 assert_eq!(truncate_end("hello world", 6, "…"), "hello…");
588 }
589
590 #[test]
591 fn truncate_end_multi_char_ellipsis() {
592 assert_eq!(truncate_end("hello world", 8, "..."), "hello...");
593 }
594
595 #[test]
596 fn truncate_end_exact_fit() {
597 assert_eq!(truncate_end("hello", 5, "…"), "hello");
598 }
599
600 #[test]
601 fn truncate_end_tiny_width() {
602 assert_eq!(truncate_end("hello", 1, "…"), "…");
603 assert_eq!(truncate_end("hello", 0, "…"), "");
604 }
605
606 #[test]
607 fn truncate_end_ansi() {
608 let styled = "\x1b[31mhello world\x1b[0m";
609 let result = truncate_end(styled, 8, "…");
610 assert_eq!(display_width(&result), 8);
611 assert!(result.contains("\x1b[31m")); }
613
614 #[test]
615 fn truncate_end_cjk() {
616 assert_eq!(truncate_end("日本語テスト", 7, "…"), "日本語…"); }
618
619 #[test]
622 fn truncate_start_no_truncation() {
623 assert_eq!(truncate_start("hello", 10, "…"), "hello");
624 }
625
626 #[test]
627 fn truncate_start_basic() {
628 assert_eq!(truncate_start("hello world", 8, "…"), "…o world");
629 }
630
631 #[test]
632 fn truncate_start_path() {
633 assert_eq!(truncate_start("/path/to/file.rs", 12, "…"), "…/to/file.rs");
634 }
635
636 #[test]
637 fn truncate_start_tiny_width() {
638 assert_eq!(truncate_start("hello", 1, "…"), "…");
639 assert_eq!(truncate_start("hello", 0, "…"), "");
640 }
641
642 #[test]
645 fn truncate_middle_no_truncation() {
646 assert_eq!(truncate_middle("hello", 10, "…"), "hello");
647 }
648
649 #[test]
650 fn truncate_middle_basic() {
651 assert_eq!(truncate_middle("hello world", 8, "…"), "hel…orld");
652 }
653
654 #[test]
655 fn truncate_middle_multi_char_ellipsis() {
656 assert_eq!(truncate_middle("abcdefghij", 7, "..."), "ab...ij");
657 }
658
659 #[test]
660 fn truncate_middle_tiny_width() {
661 assert_eq!(truncate_middle("hello", 1, "…"), "…");
662 assert_eq!(truncate_middle("hello", 0, "…"), "");
663 }
664
665 #[test]
666 fn truncate_middle_even_split() {
667 assert_eq!(truncate_middle("abcdefghij", 6, "…"), "ab…hij");
669 }
670
671 #[test]
674 fn pad_left_basic() {
675 assert_eq!(pad_left("42", 5), " 42");
676 assert_eq!(pad_left("hello", 10), " hello");
677 }
678
679 #[test]
680 fn pad_left_no_padding_needed() {
681 assert_eq!(pad_left("hello", 5), "hello");
682 assert_eq!(pad_left("hello", 3), "hello"); }
684
685 #[test]
686 fn pad_left_empty() {
687 assert_eq!(pad_left("", 5), " ");
688 }
689
690 #[test]
691 fn pad_left_ansi() {
692 let styled = "\x1b[31mhi\x1b[0m";
693 let result = pad_left(styled, 5);
694 assert!(result.ends_with("\x1b[0m"));
695 assert_eq!(display_width(&result), 5);
696 }
697
698 #[test]
701 fn pad_right_basic() {
702 assert_eq!(pad_right("42", 5), "42 ");
703 assert_eq!(pad_right("hello", 10), "hello ");
704 }
705
706 #[test]
707 fn pad_right_no_padding_needed() {
708 assert_eq!(pad_right("hello", 5), "hello");
709 assert_eq!(pad_right("hello", 3), "hello");
710 }
711
712 #[test]
713 fn pad_right_empty() {
714 assert_eq!(pad_right("", 5), " ");
715 }
716
717 #[test]
720 fn pad_center_basic() {
721 assert_eq!(pad_center("hi", 6), " hi ");
722 }
723
724 #[test]
725 fn pad_center_odd_space() {
726 assert_eq!(pad_center("hi", 5), " hi "); }
728
729 #[test]
730 fn pad_center_no_padding() {
731 assert_eq!(pad_center("hello", 5), "hello");
732 assert_eq!(pad_center("hello", 3), "hello");
733 }
734
735 #[test]
736 fn pad_center_empty() {
737 assert_eq!(pad_center("", 4), " ");
738 }
739
740 #[test]
743 fn empty_string_operations() {
744 assert_eq!(display_width(""), 0);
745 assert_eq!(truncate_end("", 5, "…"), "");
746 assert_eq!(truncate_start("", 5, "…"), "");
747 assert_eq!(truncate_middle("", 5, "…"), "");
748 assert_eq!(pad_left("", 0), "");
749 assert_eq!(pad_right("", 0), "");
750 }
751
752 #[test]
753 fn zero_width_target() {
754 assert_eq!(truncate_end("hello", 0, "…"), "");
755 assert_eq!(truncate_start("hello", 0, "…"), "");
756 assert_eq!(truncate_middle("hello", 0, "…"), "");
757 }
758
759 #[test]
762 fn wrap_single_line_fits() {
763 assert_eq!(wrap("hello world", 20), vec!["hello world"]);
764 assert_eq!(wrap("short", 10), vec!["short"]);
765 }
766
767 #[test]
768 fn wrap_basic_multiline() {
769 assert_eq!(wrap("hello world foo", 11), vec!["hello world", "foo"]);
770 assert_eq!(
771 wrap("one two three four", 10),
772 vec!["one two", "three four"]
773 );
774 }
775
776 #[test]
777 fn wrap_exact_fit() {
778 assert_eq!(wrap("hello", 5), vec!["hello"]);
779 assert_eq!(wrap("hello world", 11), vec!["hello world"]);
780 }
781
782 #[test]
783 fn wrap_empty_string() {
784 let result: Vec<String> = wrap("", 10);
785 assert!(result.is_empty());
786 }
787
788 #[test]
789 fn wrap_whitespace_only() {
790 let result: Vec<String> = wrap(" ", 10);
791 assert!(result.is_empty());
792 }
793
794 #[test]
795 fn wrap_zero_width() {
796 let result: Vec<String> = wrap("hello", 0);
797 assert!(result.is_empty());
798 }
799
800 #[test]
801 fn wrap_single_word_per_line() {
802 assert_eq!(wrap("a b c d", 1), vec!["a", "b", "c", "d"]);
803 }
804
805 #[test]
806 fn wrap_long_word_force_break() {
807 let result = wrap("supercalifragilistic", 10);
810 assert!(result.len() >= 2, "should produce multiple lines");
811 for line in &result {
812 assert!(display_width(line) <= 10, "line '{}' exceeds width", line);
813 }
814 }
815
816 #[test]
817 fn wrap_preserves_word_boundaries() {
818 let result = wrap("hello world test", 10);
819 assert_eq!(result[0], "hello");
821 assert_eq!(result[1], "world test");
822 }
823
824 #[test]
825 fn wrap_multiple_spaces_normalized_when_wrapping() {
826 let result = wrap("hello world foo", 12);
829 assert_eq!(result, vec!["hello world", "foo"]);
831 }
832
833 #[test]
836 fn wrap_indent_basic() {
837 let result = wrap_indent("hello world foo bar", 12, 2);
838 assert_eq!(result.len(), 2);
839 assert_eq!(result[0], "hello world");
840 assert!(result[1].starts_with(" ")); }
842
843 #[test]
844 fn wrap_indent_no_wrap_needed() {
845 assert_eq!(wrap_indent("short", 20, 4), vec!["short"]);
846 }
847
848 #[test]
849 fn wrap_indent_multiple_lines() {
850 let result = wrap_indent("one two three four five six", 10, 2);
851 assert!(!result[0].starts_with(' '));
854 for line in result.iter().skip(1) {
855 assert!(line.starts_with(" "), "continuation should be indented");
856 }
857 }
858
859 #[test]
860 fn wrap_indent_zero_indent() {
861 let result = wrap_indent("hello world foo", 11, 0);
863 assert_eq!(result, vec!["hello world", "foo"]);
864 }
865
866 #[test]
867 fn wrap_cjk_characters() {
868 let result = wrap("日本語 テスト", 8);
871 assert_eq!(result.len(), 2);
872 for line in &result {
873 assert!(display_width(line) <= 8);
874 }
875 }
876}
877
878#[cfg(test)]
879mod proptests {
880 use super::*;
881 use proptest::prelude::*;
882
883 proptest! {
884 #[test]
885 fn truncate_end_respects_max_width(
886 s in "[a-zA-Z0-9 ]{0,100}",
887 max_width in 0usize..50,
888 ) {
889 let result = truncate_end(&s, max_width, "…");
890 let result_width = display_width(&result);
891 prop_assert!(
892 result_width <= max_width,
893 "truncate_end exceeded max_width: result '{}' has width {}, max was {}",
894 result, result_width, max_width
895 );
896 }
897
898 #[test]
899 fn truncate_start_respects_max_width(
900 s in "[a-zA-Z0-9 ]{0,100}",
901 max_width in 0usize..50,
902 ) {
903 let result = truncate_start(&s, max_width, "…");
904 let result_width = display_width(&result);
905 prop_assert!(
906 result_width <= max_width,
907 "truncate_start exceeded max_width: result '{}' has width {}, max was {}",
908 result, result_width, max_width
909 );
910 }
911
912 #[test]
913 fn truncate_middle_respects_max_width(
914 s in "[a-zA-Z0-9 ]{0,100}",
915 max_width in 0usize..50,
916 ) {
917 let result = truncate_middle(&s, max_width, "…");
918 let result_width = display_width(&result);
919 prop_assert!(
920 result_width <= max_width,
921 "truncate_middle exceeded max_width: result '{}' has width {}, max was {}",
922 result, result_width, max_width
923 );
924 }
925
926 #[test]
927 fn truncate_preserves_short_strings(
928 s in "[a-zA-Z0-9]{0,20}",
929 extra_width in 0usize..30,
930 ) {
931 let width = display_width(&s);
932 let max_width = width + extra_width;
933
934 prop_assert_eq!(truncate_end(&s, max_width, "…"), s.clone());
936 prop_assert_eq!(truncate_start(&s, max_width, "…"), s.clone());
937 prop_assert_eq!(truncate_middle(&s, max_width, "…"), s);
938 }
939
940 #[test]
941 fn pad_produces_exact_width_when_larger(
942 s in "[a-zA-Z0-9]{0,20}",
943 extra in 1usize..30,
944 ) {
945 let original_width = display_width(&s);
946 let target_width = original_width + extra;
947
948 prop_assert_eq!(display_width(&pad_left(&s, target_width)), target_width);
949 prop_assert_eq!(display_width(&pad_right(&s, target_width)), target_width);
950 prop_assert_eq!(display_width(&pad_center(&s, target_width)), target_width);
951 }
952
953 #[test]
954 fn pad_preserves_content_when_smaller(
955 s in "[a-zA-Z0-9]{1,30}",
956 ) {
957 let original_width = display_width(&s);
958 let target_width = original_width.saturating_sub(5);
959
960 prop_assert_eq!(pad_left(&s, target_width), s.clone());
962 prop_assert_eq!(pad_right(&s, target_width), s.clone());
963 prop_assert_eq!(pad_center(&s, target_width), s);
964 }
965
966 #[test]
967 fn truncate_end_contains_ellipsis_when_truncated(
968 s in "[a-zA-Z0-9]{10,50}",
969 max_width in 3usize..9,
970 ) {
971 let result = truncate_end(&s, max_width, "…");
972 if display_width(&s) > max_width {
973 prop_assert!(
974 result.contains("…"),
975 "truncated string should contain ellipsis"
976 );
977 }
978 }
979
980 #[test]
981 fn truncate_start_contains_ellipsis_when_truncated(
982 s in "[a-zA-Z0-9]{10,50}",
983 max_width in 3usize..9,
984 ) {
985 let result = truncate_start(&s, max_width, "…");
986 if display_width(&s) > max_width {
987 prop_assert!(
988 result.contains("…"),
989 "truncated string should contain ellipsis"
990 );
991 }
992 }
993
994 #[test]
995 fn truncate_middle_contains_ellipsis_when_truncated(
996 s in "[a-zA-Z0-9]{10,50}",
997 max_width in 3usize..9,
998 ) {
999 let result = truncate_middle(&s, max_width, "…");
1000 if display_width(&s) > max_width {
1001 prop_assert!(
1002 result.contains("…"),
1003 "truncated string should contain ellipsis"
1004 );
1005 }
1006 }
1007
1008 #[test]
1009 fn wrap_all_lines_respect_width(
1010 s in "[a-zA-Z]{1,10}( [a-zA-Z]{1,10}){0,10}",
1011 width in 5usize..30,
1012 ) {
1013 let lines = wrap(&s, width);
1014 for line in &lines {
1015 let line_width = display_width(line);
1016 prop_assert!(
1017 line_width <= width,
1018 "wrap produced line '{}' with width {}, max was {}",
1019 line, line_width, width
1020 );
1021 }
1022 }
1023
1024 #[test]
1025 fn wrap_preserves_all_words(
1026 words in prop::collection::vec("[a-zA-Z]{1,8}", 1..10),
1027 width in 10usize..40,
1028 ) {
1029 let input = words.join(" ");
1030 let lines = wrap(&input, width);
1031 let rejoined = lines.join(" ");
1032
1033 for word in &words {
1035 prop_assert!(
1036 rejoined.contains(word),
1037 "word '{}' missing from wrapped output",
1038 word
1039 );
1040 }
1041 }
1042
1043 #[test]
1044 fn wrap_indent_continuation_lines_are_indented(
1045 s in "[a-zA-Z]{1,5}( [a-zA-Z]{1,5}){3,8}",
1046 width in 10usize..20,
1047 indent in 1usize..4,
1048 ) {
1049 let lines = wrap_indent(&s, width, indent);
1050 if lines.len() > 1 {
1051 let indent_str: String = " ".repeat(indent);
1052 for line in lines.iter().skip(1) {
1053 prop_assert!(
1054 line.starts_with(&indent_str),
1055 "continuation line '{}' should start with {} spaces",
1056 line, indent
1057 );
1058 }
1059 }
1060 }
1061
1062 #[test]
1063 fn wrap_nonempty_input_produces_nonempty_output(
1064 s in "[a-zA-Z]{1,20}",
1065 width in 1usize..30,
1066 ) {
1067 let lines = wrap(&s, width);
1068 prop_assert!(
1069 !lines.is_empty(),
1070 "non-empty input '{}' should produce non-empty output",
1071 s
1072 );
1073 }
1074 }
1075}