use console::{measure_text_width, pad_str, Alignment};
use standout_bbparser::strip_tags;
pub fn display_width(s: &str) -> usize {
measure_text_width(s)
}
pub fn visible_width(s: &str) -> usize {
display_width(&strip_tags(s))
}
pub fn truncate_end(s: &str, max_width: usize, ellipsis: &str) -> String {
let width = measure_text_width(s);
if width <= max_width {
return s.to_string();
}
let ellipsis_width = measure_text_width(ellipsis);
if max_width < ellipsis_width {
return truncate_to_display_width(ellipsis, max_width);
}
if max_width == ellipsis_width {
return ellipsis.to_string();
}
let target_width = max_width - ellipsis_width;
let mut result = truncate_to_display_width(s, target_width);
result.push_str(ellipsis);
result
}
pub fn truncate_start(s: &str, max_width: usize, ellipsis: &str) -> String {
let width = measure_text_width(s);
if width <= max_width {
return s.to_string();
}
let ellipsis_width = measure_text_width(ellipsis);
if max_width < ellipsis_width {
return truncate_to_display_width(ellipsis, max_width);
}
if max_width == ellipsis_width {
return ellipsis.to_string();
}
let target_width = max_width - ellipsis_width;
let truncated = find_suffix_with_width(s, target_width);
format!("{}{}", ellipsis, truncated)
}
pub fn truncate_middle(s: &str, max_width: usize, ellipsis: &str) -> String {
let width = measure_text_width(s);
if width <= max_width {
return s.to_string();
}
let ellipsis_width = measure_text_width(ellipsis);
if max_width < ellipsis_width {
return truncate_to_display_width(ellipsis, max_width);
}
if max_width == ellipsis_width {
return ellipsis.to_string();
}
let available = max_width - ellipsis_width;
let right_width = available.div_ceil(2); let left_width = available - right_width;
let left = truncate_to_display_width(s, left_width);
let right = find_suffix_with_width(s, right_width);
format!("{}{}{}", left, ellipsis, right)
}
pub fn pad_left(s: &str, width: usize) -> String {
pad_str(s, width, Alignment::Right, None).into_owned()
}
pub fn pad_right(s: &str, width: usize) -> String {
pad_str(s, width, Alignment::Left, None).into_owned()
}
pub fn pad_center(s: &str, width: usize) -> String {
pad_str(s, width, Alignment::Center, None).into_owned()
}
pub fn wrap(s: &str, width: usize) -> Vec<String> {
wrap_indent(s, width, 0)
}
pub fn wrap_indent(s: &str, width: usize, indent: usize) -> Vec<String> {
if width == 0 {
return vec![];
}
let s = s.trim();
if s.is_empty() {
return vec![];
}
if measure_text_width(s) <= width {
return vec![s.to_string()];
}
let mut lines = Vec::new();
let mut current_line = String::new();
let mut current_width = 0;
let mut is_first_line = true;
for word in s.split_whitespace() {
let word_width = measure_text_width(word);
let effective_width = if is_first_line {
width
} else {
width.saturating_sub(indent)
};
if word_width > effective_width {
if !current_line.is_empty() {
lines.push(current_line);
current_line = String::new();
current_width = 0;
is_first_line = false;
}
let broken = break_long_word(word, effective_width, indent, is_first_line);
let broken_len = broken.len();
for (i, part) in broken.into_iter().enumerate() {
if i == 0 && is_first_line {
lines.push(part);
is_first_line = false;
} else if i < broken_len - 1 {
lines.push(part);
} else {
current_line = part;
current_width = measure_text_width(¤t_line);
}
}
continue;
}
let needed_width = if current_line.is_empty() {
word_width
} else {
current_width + 1 + word_width };
if needed_width <= effective_width {
if !current_line.is_empty() {
current_line.push(' ');
current_width += 1;
}
current_line.push_str(word);
current_width += word_width;
} else {
if !current_line.is_empty() {
lines.push(current_line);
}
is_first_line = false;
let indent_str: String = " ".repeat(indent);
current_line = format!("{}{}", indent_str, word);
current_width = indent + word_width;
}
}
if !current_line.is_empty() {
lines.push(current_line);
}
if lines.is_empty() && !s.is_empty() {
lines.push(truncate_to_display_width(s, width));
}
lines
}
fn break_long_word(word: &str, width: usize, indent: usize, is_first: bool) -> Vec<String> {
let mut parts = Vec::new();
let mut remaining = word;
let mut first_part = is_first;
while !remaining.is_empty() {
let effective_width = if first_part {
width
} else {
width.saturating_sub(indent)
};
if effective_width == 0 {
break;
}
let remaining_width = measure_text_width(remaining);
if remaining_width <= effective_width {
let prefix = if first_part {
String::new()
} else {
" ".repeat(indent)
};
parts.push(format!("{}{}", prefix, remaining));
break;
}
let break_width = effective_width.saturating_sub(1); if break_width == 0 {
let prefix = if first_part {
String::new()
} else {
" ".repeat(indent)
};
parts.push(format!("{}…", prefix));
break;
}
let prefix = if first_part {
String::new()
} else {
" ".repeat(indent)
};
let truncated = truncate_to_display_width(remaining, break_width);
parts.push(format!("{}{}…", prefix, truncated));
let truncated_len = truncated.chars().count();
remaining = &remaining[remaining
.char_indices()
.nth(truncated_len)
.map(|(i, _)| i)
.unwrap_or(remaining.len())..];
first_part = false;
}
parts
}
fn truncate_to_display_width(s: &str, max_width: usize) -> String {
if max_width == 0 {
return String::new();
}
if measure_text_width(s) <= max_width {
return s.to_string();
}
let mut result = String::new();
let mut current_width = 0;
let chars = s.chars().peekable();
let mut in_escape = false;
for c in chars {
if c == '\x1b' {
result.push(c);
in_escape = true;
continue;
}
if in_escape {
result.push(c);
if c.is_ascii_alphabetic() || c == '~' {
in_escape = false;
}
continue;
}
let char_width = unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
if current_width + char_width > max_width {
break;
}
result.push(c);
current_width += char_width;
}
result
}
fn find_suffix_with_width(s: &str, max_width: usize) -> String {
if max_width == 0 {
return String::new();
}
let total_width = measure_text_width(s);
if total_width <= max_width {
return s.to_string();
}
let skip_width = total_width - max_width;
let mut current_width = 0;
let mut byte_offset = 0;
let mut in_escape = false;
for (i, c) in s.char_indices() {
if c == '\x1b' {
in_escape = true;
byte_offset = i + c.len_utf8();
continue;
}
if in_escape {
byte_offset = i + c.len_utf8();
if c.is_ascii_alphabetic() || c == '~' {
in_escape = false;
}
continue;
}
let char_width = unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
current_width += char_width;
byte_offset = i + c.len_utf8();
if current_width >= skip_width {
break;
}
}
s[byte_offset..].to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn display_width_ascii() {
assert_eq!(display_width("hello"), 5);
assert_eq!(display_width(""), 0);
assert_eq!(display_width(" "), 1);
}
#[test]
fn display_width_ansi() {
assert_eq!(display_width("\x1b[31mred\x1b[0m"), 3);
assert_eq!(display_width("\x1b[1;32mbold green\x1b[0m"), 10);
assert_eq!(display_width("\x1b[38;5;196mcolor\x1b[0m"), 5);
}
#[test]
fn display_width_unicode() {
assert_eq!(display_width("日本語"), 6); assert_eq!(display_width("café"), 4);
assert_eq!(display_width("🎉"), 2); }
#[test]
fn truncate_end_no_truncation() {
assert_eq!(truncate_end("hello", 10, "…"), "hello");
assert_eq!(truncate_end("hello", 5, "…"), "hello");
}
#[test]
fn truncate_end_basic() {
assert_eq!(truncate_end("hello world", 8, "…"), "hello w…");
assert_eq!(truncate_end("hello world", 6, "…"), "hello…");
}
#[test]
fn truncate_end_multi_char_ellipsis() {
assert_eq!(truncate_end("hello world", 8, "..."), "hello...");
}
#[test]
fn truncate_end_exact_fit() {
assert_eq!(truncate_end("hello", 5, "…"), "hello");
}
#[test]
fn truncate_end_tiny_width() {
assert_eq!(truncate_end("hello", 1, "…"), "…");
assert_eq!(truncate_end("hello", 0, "…"), "");
}
#[test]
fn truncate_end_ansi() {
let styled = "\x1b[31mhello world\x1b[0m";
let result = truncate_end(styled, 8, "…");
assert_eq!(display_width(&result), 8);
assert!(result.contains("\x1b[31m")); }
#[test]
fn truncate_end_cjk() {
assert_eq!(truncate_end("日本語テスト", 7, "…"), "日本語…"); }
#[test]
fn truncate_start_no_truncation() {
assert_eq!(truncate_start("hello", 10, "…"), "hello");
}
#[test]
fn truncate_start_basic() {
assert_eq!(truncate_start("hello world", 8, "…"), "…o world");
}
#[test]
fn truncate_start_path() {
assert_eq!(truncate_start("/path/to/file.rs", 12, "…"), "…/to/file.rs");
}
#[test]
fn truncate_start_tiny_width() {
assert_eq!(truncate_start("hello", 1, "…"), "…");
assert_eq!(truncate_start("hello", 0, "…"), "");
}
#[test]
fn truncate_middle_no_truncation() {
assert_eq!(truncate_middle("hello", 10, "…"), "hello");
}
#[test]
fn truncate_middle_basic() {
assert_eq!(truncate_middle("hello world", 8, "…"), "hel…orld");
}
#[test]
fn truncate_middle_multi_char_ellipsis() {
assert_eq!(truncate_middle("abcdefghij", 7, "..."), "ab...ij");
}
#[test]
fn truncate_middle_tiny_width() {
assert_eq!(truncate_middle("hello", 1, "…"), "…");
assert_eq!(truncate_middle("hello", 0, "…"), "");
}
#[test]
fn truncate_middle_even_split() {
assert_eq!(truncate_middle("abcdefghij", 6, "…"), "ab…hij");
}
#[test]
fn pad_left_basic() {
assert_eq!(pad_left("42", 5), " 42");
assert_eq!(pad_left("hello", 10), " hello");
}
#[test]
fn pad_left_no_padding_needed() {
assert_eq!(pad_left("hello", 5), "hello");
assert_eq!(pad_left("hello", 3), "hello"); }
#[test]
fn pad_left_empty() {
assert_eq!(pad_left("", 5), " ");
}
#[test]
fn pad_left_ansi() {
let styled = "\x1b[31mhi\x1b[0m";
let result = pad_left(styled, 5);
assert!(result.ends_with("\x1b[0m"));
assert_eq!(display_width(&result), 5);
}
#[test]
fn pad_right_basic() {
assert_eq!(pad_right("42", 5), "42 ");
assert_eq!(pad_right("hello", 10), "hello ");
}
#[test]
fn pad_right_no_padding_needed() {
assert_eq!(pad_right("hello", 5), "hello");
assert_eq!(pad_right("hello", 3), "hello");
}
#[test]
fn pad_right_empty() {
assert_eq!(pad_right("", 5), " ");
}
#[test]
fn pad_center_basic() {
assert_eq!(pad_center("hi", 6), " hi ");
}
#[test]
fn pad_center_odd_space() {
assert_eq!(pad_center("hi", 5), " hi "); }
#[test]
fn pad_center_no_padding() {
assert_eq!(pad_center("hello", 5), "hello");
assert_eq!(pad_center("hello", 3), "hello");
}
#[test]
fn pad_center_empty() {
assert_eq!(pad_center("", 4), " ");
}
#[test]
fn empty_string_operations() {
assert_eq!(display_width(""), 0);
assert_eq!(truncate_end("", 5, "…"), "");
assert_eq!(truncate_start("", 5, "…"), "");
assert_eq!(truncate_middle("", 5, "…"), "");
assert_eq!(pad_left("", 0), "");
assert_eq!(pad_right("", 0), "");
}
#[test]
fn zero_width_target() {
assert_eq!(truncate_end("hello", 0, "…"), "");
assert_eq!(truncate_start("hello", 0, "…"), "");
assert_eq!(truncate_middle("hello", 0, "…"), "");
}
#[test]
fn wrap_single_line_fits() {
assert_eq!(wrap("hello world", 20), vec!["hello world"]);
assert_eq!(wrap("short", 10), vec!["short"]);
}
#[test]
fn wrap_basic_multiline() {
assert_eq!(wrap("hello world foo", 11), vec!["hello world", "foo"]);
assert_eq!(
wrap("one two three four", 10),
vec!["one two", "three four"]
);
}
#[test]
fn wrap_exact_fit() {
assert_eq!(wrap("hello", 5), vec!["hello"]);
assert_eq!(wrap("hello world", 11), vec!["hello world"]);
}
#[test]
fn wrap_empty_string() {
let result: Vec<String> = wrap("", 10);
assert!(result.is_empty());
}
#[test]
fn wrap_whitespace_only() {
let result: Vec<String> = wrap(" ", 10);
assert!(result.is_empty());
}
#[test]
fn wrap_zero_width() {
let result: Vec<String> = wrap("hello", 0);
assert!(result.is_empty());
}
#[test]
fn wrap_single_word_per_line() {
assert_eq!(wrap("a b c d", 1), vec!["a", "b", "c", "d"]);
}
#[test]
fn wrap_long_word_force_break() {
let result = wrap("supercalifragilistic", 10);
assert!(result.len() >= 2, "should produce multiple lines");
for line in &result {
assert!(display_width(line) <= 10, "line '{}' exceeds width", line);
}
}
#[test]
fn wrap_preserves_word_boundaries() {
let result = wrap("hello world test", 10);
assert_eq!(result[0], "hello");
assert_eq!(result[1], "world test");
}
#[test]
fn wrap_multiple_spaces_normalized_when_wrapping() {
let result = wrap("hello world foo", 12);
assert_eq!(result, vec!["hello world", "foo"]);
}
#[test]
fn wrap_indent_basic() {
let result = wrap_indent("hello world foo bar", 12, 2);
assert_eq!(result.len(), 2);
assert_eq!(result[0], "hello world");
assert!(result[1].starts_with(" ")); }
#[test]
fn wrap_indent_no_wrap_needed() {
assert_eq!(wrap_indent("short", 20, 4), vec!["short"]);
}
#[test]
fn wrap_indent_multiple_lines() {
let result = wrap_indent("one two three four five six", 10, 2);
assert!(!result[0].starts_with(' '));
for line in result.iter().skip(1) {
assert!(line.starts_with(" "), "continuation should be indented");
}
}
#[test]
fn wrap_indent_zero_indent() {
let result = wrap_indent("hello world foo", 11, 0);
assert_eq!(result, vec!["hello world", "foo"]);
}
#[test]
fn wrap_cjk_characters() {
let result = wrap("日本語 テスト", 8);
assert_eq!(result.len(), 2);
for line in &result {
assert!(display_width(line) <= 8);
}
}
}
#[cfg(test)]
mod proptests {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn truncate_end_respects_max_width(
s in "[a-zA-Z0-9 ]{0,100}",
max_width in 0usize..50,
) {
let result = truncate_end(&s, max_width, "…");
let result_width = display_width(&result);
prop_assert!(
result_width <= max_width,
"truncate_end exceeded max_width: result '{}' has width {}, max was {}",
result, result_width, max_width
);
}
#[test]
fn truncate_start_respects_max_width(
s in "[a-zA-Z0-9 ]{0,100}",
max_width in 0usize..50,
) {
let result = truncate_start(&s, max_width, "…");
let result_width = display_width(&result);
prop_assert!(
result_width <= max_width,
"truncate_start exceeded max_width: result '{}' has width {}, max was {}",
result, result_width, max_width
);
}
#[test]
fn truncate_middle_respects_max_width(
s in "[a-zA-Z0-9 ]{0,100}",
max_width in 0usize..50,
) {
let result = truncate_middle(&s, max_width, "…");
let result_width = display_width(&result);
prop_assert!(
result_width <= max_width,
"truncate_middle exceeded max_width: result '{}' has width {}, max was {}",
result, result_width, max_width
);
}
#[test]
fn truncate_preserves_short_strings(
s in "[a-zA-Z0-9]{0,20}",
extra_width in 0usize..30,
) {
let width = display_width(&s);
let max_width = width + extra_width;
prop_assert_eq!(truncate_end(&s, max_width, "…"), s.clone());
prop_assert_eq!(truncate_start(&s, max_width, "…"), s.clone());
prop_assert_eq!(truncate_middle(&s, max_width, "…"), s);
}
#[test]
fn pad_produces_exact_width_when_larger(
s in "[a-zA-Z0-9]{0,20}",
extra in 1usize..30,
) {
let original_width = display_width(&s);
let target_width = original_width + extra;
prop_assert_eq!(display_width(&pad_left(&s, target_width)), target_width);
prop_assert_eq!(display_width(&pad_right(&s, target_width)), target_width);
prop_assert_eq!(display_width(&pad_center(&s, target_width)), target_width);
}
#[test]
fn pad_preserves_content_when_smaller(
s in "[a-zA-Z0-9]{1,30}",
) {
let original_width = display_width(&s);
let target_width = original_width.saturating_sub(5);
prop_assert_eq!(pad_left(&s, target_width), s.clone());
prop_assert_eq!(pad_right(&s, target_width), s.clone());
prop_assert_eq!(pad_center(&s, target_width), s);
}
#[test]
fn truncate_end_contains_ellipsis_when_truncated(
s in "[a-zA-Z0-9]{10,50}",
max_width in 3usize..9,
) {
let result = truncate_end(&s, max_width, "…");
if display_width(&s) > max_width {
prop_assert!(
result.contains("…"),
"truncated string should contain ellipsis"
);
}
}
#[test]
fn truncate_start_contains_ellipsis_when_truncated(
s in "[a-zA-Z0-9]{10,50}",
max_width in 3usize..9,
) {
let result = truncate_start(&s, max_width, "…");
if display_width(&s) > max_width {
prop_assert!(
result.contains("…"),
"truncated string should contain ellipsis"
);
}
}
#[test]
fn truncate_middle_contains_ellipsis_when_truncated(
s in "[a-zA-Z0-9]{10,50}",
max_width in 3usize..9,
) {
let result = truncate_middle(&s, max_width, "…");
if display_width(&s) > max_width {
prop_assert!(
result.contains("…"),
"truncated string should contain ellipsis"
);
}
}
#[test]
fn wrap_all_lines_respect_width(
s in "[a-zA-Z]{1,10}( [a-zA-Z]{1,10}){0,10}",
width in 5usize..30,
) {
let lines = wrap(&s, width);
for line in &lines {
let line_width = display_width(line);
prop_assert!(
line_width <= width,
"wrap produced line '{}' with width {}, max was {}",
line, line_width, width
);
}
}
#[test]
fn wrap_preserves_all_words(
words in prop::collection::vec("[a-zA-Z]{1,8}", 1..10),
width in 10usize..40,
) {
let input = words.join(" ");
let lines = wrap(&input, width);
let rejoined = lines.join(" ");
for word in &words {
prop_assert!(
rejoined.contains(word),
"word '{}' missing from wrapped output",
word
);
}
}
#[test]
fn wrap_indent_continuation_lines_are_indented(
s in "[a-zA-Z]{1,5}( [a-zA-Z]{1,5}){3,8}",
width in 10usize..20,
indent in 1usize..4,
) {
let lines = wrap_indent(&s, width, indent);
if lines.len() > 1 {
let indent_str: String = " ".repeat(indent);
for line in lines.iter().skip(1) {
prop_assert!(
line.starts_with(&indent_str),
"continuation line '{}' should start with {} spaces",
line, indent
);
}
}
}
#[test]
fn wrap_nonempty_input_produces_nonempty_output(
s in "[a-zA-Z]{1,20}",
width in 1usize..30,
) {
let lines = wrap(&s, width);
prop_assert!(
!lines.is_empty(),
"non-empty input '{}' should produce non-empty output",
s
);
}
}
}