use unicode_width::UnicodeWidthChar;
use unicode_width::UnicodeWidthStr;
const ESC: char = '\u{1b}';
const CSI_FINAL_MIN: char = '\u{40}';
const CSI_FINAL_MAX: char = '\u{7e}';
pub fn visible_width(text: &str) -> usize {
if !text.contains(ESC) {
return UnicodeWidthStr::width(text);
}
UnicodeWidthStr::width(strip_ansi(text).as_str())
}
pub fn strip_ansi(text: &str) -> String {
if !text.contains(ESC) {
return text.to_string();
}
let mut out = String::with_capacity(text.len());
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
if ch != ESC {
out.push(ch);
continue;
}
skip_escape_sequence(&mut chars);
}
out
}
fn skip_escape_sequence(chars: &mut std::iter::Peekable<std::str::Chars>) {
match chars.peek() {
Some('[') => skip_csi(chars),
Some(']') => skip_osc(chars),
_ => {
chars.next();
}
}
}
fn skip_csi(chars: &mut std::iter::Peekable<std::str::Chars>) {
chars.next(); for ch in chars.by_ref() {
if (CSI_FINAL_MIN..=CSI_FINAL_MAX).contains(&ch) {
break;
}
}
}
fn skip_osc(chars: &mut std::iter::Peekable<std::str::Chars>) {
chars.next(); while let Some(ch) = chars.next() {
if ch == '\u{7}' {
break;
}
if ch == ESC && chars.peek() == Some(&'\\') {
chars.next();
break;
}
}
}
pub fn truncate(text: &str, max_cols: usize, ellipsis: &str) -> String {
if max_cols == 0 {
return String::new();
}
if UnicodeWidthStr::width(text) <= max_cols {
return text.to_string();
}
let ellipsis_width = UnicodeWidthStr::width(ellipsis);
if ellipsis_width >= max_cols {
return ellipsis.chars().take(max_cols).collect();
}
let budget = max_cols - ellipsis_width;
let mut out = String::new();
let mut used = 0;
for ch in text.chars() {
let w = UnicodeWidthChar::width(ch).unwrap_or(0);
if used + w > budget {
break;
}
out.push(ch);
used += w;
}
out.push_str(ellipsis);
out
}
pub fn wrap(text: &str, width: usize) -> Vec<String> {
if width == 0 {
return vec![text.to_string()];
}
let mut lines = Vec::new();
for raw_line in text.split('\n') {
wrap_single_line(raw_line, width, &mut lines);
}
if lines.is_empty() {
lines.push(String::new());
}
lines
}
fn wrap_single_line(line: &str, width: usize, out: &mut Vec<String>) {
let mut current = String::new();
let mut current_width = 0;
for word in line.split_whitespace() {
let word_width = UnicodeWidthStr::width(word);
let sep = usize::from(!current.is_empty());
if current_width + sep + word_width > width && !current.is_empty() {
out.push(std::mem::take(&mut current));
current_width = 0;
}
if word_width > width {
flush_long_word(word, width, &mut current, &mut current_width, out);
continue;
}
if !current.is_empty() {
current.push(' ');
current_width += 1;
}
current.push_str(word);
current_width += word_width;
}
out.push(current);
}
fn flush_long_word(
word: &str,
width: usize,
current: &mut String,
current_width: &mut usize,
out: &mut Vec<String>,
) {
if !current.is_empty() {
out.push(std::mem::take(current));
*current_width = 0;
}
for ch in word.chars() {
let w = UnicodeWidthChar::width(ch).unwrap_or(0);
if *current_width + w > width && !current.is_empty() {
out.push(std::mem::take(current));
*current_width = 0;
}
current.push(ch);
*current_width += w;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn visible_width_counts_wide_glyphs_as_two() {
assert_eq!(visible_width("ab"), 2);
assert_eq!(visible_width("中"), 2);
}
#[test]
fn visible_width_ignores_ansi_escapes() {
assert_eq!(visible_width("\u{1b}[31mred\u{1b}[0m"), 3);
}
#[test]
fn strip_ansi_removes_csi_and_osc() {
assert_eq!(strip_ansi("\u{1b}[1mhi\u{1b}[0m"), "hi");
let link = "\u{1b}]8;;http://x\u{1b}\\t\u{1b}]8;;\u{1b}\\";
assert_eq!(strip_ansi(link), "t");
}
#[test]
fn truncate_appends_ellipsis_only_when_needed() {
assert_eq!(truncate("hello", 10, "…"), "hello");
assert_eq!(truncate("hello world", 7, "…"), "hello …");
}
#[test]
fn truncate_does_not_split_wide_glyphs() {
assert_eq!(truncate("中文字", 3, "…"), "中…");
}
#[test]
fn truncate_with_zero_columns_is_empty() {
assert_eq!(truncate("hello", 0, "…"), "");
}
#[test]
fn truncate_clamps_ellipsis_wider_than_max() {
assert_eq!(truncate("hello", 2, "..."), "..");
assert_eq!(visible_width(&truncate("hello", 2, "...")), 2);
}
#[test]
fn strip_ansi_handles_non_letter_csi_final_byte() {
assert_eq!(strip_ansi("\u{1b}[1@x"), "x");
}
#[test]
fn wrap_breaks_on_word_boundaries() {
let lines = wrap("the quick brown fox", 9);
assert_eq!(lines, vec!["the quick", "brown fox"]);
}
#[test]
fn wrap_hard_splits_overlong_words() {
let lines = wrap("abcdefgh", 3);
assert_eq!(lines, vec!["abc", "def", "gh"]);
}
}