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