use unicode_width::UnicodeWidthChar;
pub fn display_width(s: &str) -> usize {
s.chars()
.map(|c| UnicodeWidthChar::width(c).unwrap_or(0))
.sum()
}
pub fn wrap_line_to_width(line: &str, max_cols: usize) -> Vec<String> {
if max_cols == 0 || line.is_empty() {
return vec![line.to_string()];
}
let mut chunks: Vec<String> = Vec::new();
let mut current = String::new();
let mut cur_width = 0usize;
let mut chars = line.chars().peekable();
while let Some(c) = chars.next() {
if c == '\x1b' {
current.push(c);
while let Some(&p) = chars.peek() {
chars.next();
current.push(p);
if p.is_ascii_alphabetic() || p == '~' {
break;
}
}
continue;
}
let w = UnicodeWidthChar::width(c).unwrap_or(0);
if cur_width + w > max_cols && !current.is_empty() {
chunks.push(std::mem::take(&mut current));
cur_width = 0;
}
current.push(c);
cur_width += w;
}
if !current.is_empty() {
chunks.push(current);
}
if chunks.is_empty() {
chunks.push(String::new());
}
chunks
}
pub fn wrap_with_cursor(
text: &str,
max_cols: usize,
cursor_byte: usize,
) -> (Vec<String>, usize, usize) {
if max_cols == 0 {
return (vec![String::new()], 0, 0);
}
let mut lines: Vec<String> = vec![String::new()];
let mut col = 0usize;
let mut byte = 0usize;
let mut cursor_row = 0usize;
let mut cursor_col = 0usize;
let mut cursor_set = false;
for c in text.chars() {
if c != '\n' {
let w = UnicodeWidthChar::width(c).unwrap_or(0);
if col + w > max_cols && !lines.last().unwrap().is_empty() {
lines.push(String::new());
col = 0;
}
}
if !cursor_set && byte == cursor_byte {
cursor_row = lines.len() - 1;
cursor_col = col;
cursor_set = true;
}
if c == '\n' {
lines.push(String::new());
col = 0;
} else {
let w = UnicodeWidthChar::width(c).unwrap_or(0);
lines.last_mut().unwrap().push(c);
col += w;
}
byte += c.len_utf8();
}
if !cursor_set {
cursor_row = lines.len() - 1;
cursor_col = col;
}
(lines, cursor_row, cursor_col)
}
pub fn slice_cols(s: &str, start_col: usize, max_cols: usize) -> String {
let mut col = 0usize;
let mut acc = String::new();
let mut acc_w = 0usize;
for c in s.chars() {
let w = UnicodeWidthChar::width(c).unwrap_or(0);
if col + w <= start_col {
col += w;
} else if col < start_col {
col += w;
} else {
if acc_w + w > max_cols {
break;
}
acc.push(c);
acc_w += w;
col += w;
}
}
acc
}
pub fn truncate_to_width(s: &str, max_cols: usize) -> String {
if max_cols == 0 {
return String::new();
}
let mut acc = String::with_capacity(s.len());
let mut cols = 0usize;
for c in s.chars() {
let w = UnicodeWidthChar::width(c).unwrap_or(0);
if cols + w > max_cols {
break;
}
acc.push(c);
cols += w;
}
acc
}
pub fn truncate_with_ellipsis(s: &str, max_cols: usize) -> String {
if max_cols == 0 {
return String::new();
}
if display_width(s) <= max_cols {
return s.to_string();
}
let budget = max_cols.saturating_sub(1).max(1);
let mut acc = truncate_to_width(s, budget);
acc.push('…');
acc
}
pub fn truncate_path(path: &str, max_cols: usize) -> String {
if max_cols == 0 {
return String::new();
}
if display_width(path) <= max_cols {
return path.to_string();
}
let last_sep = path.rfind(|c: char| c == '/' || c == '\\');
let last_segment = match last_sep {
Some(i) => &path[i + 1..],
None => path, };
let ellipsis_prefix = ".../";
let candidate = format!("{}{}", ellipsis_prefix, last_segment);
if display_width(&candidate) <= max_cols {
return candidate;
}
let prefix_w = display_width(ellipsis_prefix);
let budget = max_cols.saturating_sub(prefix_w).max(1);
let truncated_last = truncate_to_width(last_segment, budget);
format!("{}{}", ellipsis_prefix, truncated_last)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ascii_width_equals_len() {
assert_eq!(display_width("hello"), 5);
}
#[test]
fn cjk_char_is_width_two() {
assert_eq!(display_width("你好"), 4);
assert_eq!(display_width("a你b"), 4); }
#[test]
fn emoji_width_is_two() {
assert_eq!(display_width("👍"), 2);
}
#[test]
fn truncate_to_width_respects_boundary() {
assert_eq!(truncate_to_width("hello world", 5), "hello");
}
#[test]
fn truncate_to_width_cjk_never_splits_char() {
let out = truncate_to_width("你好world", 3);
assert_eq!(out, "你");
assert_eq!(display_width(&out), 2);
}
#[test]
fn truncate_to_width_zero_width_safe() {
assert_eq!(truncate_to_width("abc", 0), "");
}
#[test]
fn truncate_to_width_exact_fit() {
assert_eq!(truncate_to_width("你好", 4), "你好");
}
#[test]
fn truncate_to_width_preserves_under_limit() {
assert_eq!(truncate_to_width("hi", 10), "hi");
}
#[test]
fn slice_cols_window_midway() {
assert_eq!(slice_cols("abcdefghij", 3, 4), "defg");
}
#[test]
fn slice_cols_cjk_straddle_skipped() {
assert_eq!(slice_cols("你好world", 1, 4), "好wo");
}
#[test]
fn slice_cols_past_end_empty() {
assert_eq!(slice_cols("abc", 10, 5), "");
}
#[test]
fn slice_cols_start_zero_matches_truncate() {
assert_eq!(slice_cols("hello world", 0, 5), "hello");
}
#[test]
fn wrap_with_cursor_short_text_single_row() {
let (lines, r, c) = wrap_with_cursor("hi", 10, 2);
assert_eq!(lines, vec!["hi".to_string()]);
assert_eq!((r, c), (0, 2));
}
#[test]
fn wrap_with_cursor_overflow_moves_to_next_row() {
let (lines, r, c) = wrap_with_cursor("abcdef", 3, 3);
assert_eq!(lines, vec!["abc".to_string(), "def".to_string()]);
assert_eq!((r, c), (1, 0));
}
#[test]
fn wrap_with_cursor_honours_explicit_newline() {
let (lines, r, c) = wrap_with_cursor("ab\ncd", 10, 4);
assert_eq!(lines, vec!["ab".to_string(), "cd".to_string()]);
assert_eq!((r, c), (1, 1));
}
#[test]
fn wrap_with_cursor_end_of_buffer() {
let (lines, r, c) = wrap_with_cursor("hello", 10, 5);
assert_eq!(lines, vec!["hello".to_string()]);
assert_eq!((r, c), (0, 5));
}
#[test]
fn wrap_with_cursor_cjk_widths() {
let (lines, _, _) = wrap_with_cursor("你好", 3, 0);
assert_eq!(lines, vec!["你".to_string(), "好".to_string()]);
}
#[test]
fn truncate_path_short_path_unchanged() {
assert_eq!(truncate_path("~/foo", 20), "~/foo");
}
#[test]
fn truncate_path_keeps_last_segment() {
assert_eq!(
truncate_path("~/Documents/WPSDrive/NotLoginPage", 20),
".../NotLoginPage"
);
}
#[test]
fn truncate_path_exact_fit() {
assert_eq!(
truncate_path("~/Documents/WPSDrive/NotLoginPage", 16),
".../NotLoginPage"
);
}
#[test]
fn truncate_path_very_tight_budget() {
assert_eq!(truncate_path("~/a/b/c", 6), ".../c");
}
#[test]
fn truncate_path_last_segment_too_long() {
assert_eq!(
truncate_path("~/Documents/WPSDrive/NotLoginPage", 10),
".../NotLog"
);
}
#[test]
fn truncate_path_no_separator() {
assert_eq!(truncate_path("verylongname", 8), ".../very");
}
#[test]
fn truncate_path_windows_backslash() {
assert_eq!(
truncate_path(r"~\Documents\WPSDrive\NotLoginPage", 20),
".../NotLoginPage"
);
}
#[test]
fn truncate_path_zero_cols() {
assert_eq!(truncate_path("~/foo", 0), "");
}
#[test]
fn truncate_path_cjk_segment() {
assert_eq!(
truncate_path("~/Documents/工作/项目", 20),
".../项目"
);
}
#[test]
fn truncate_path_cjk_tight_budget() {
assert_eq!(truncate_path("~/a/b/项目", 8), ".../项目");
}
#[test]
fn wrap_line_to_width_truecolor_sgr_passthrough_zero_width() {
let tinted = "\x1b[38;2;198;120;221mlet\x1b[23;39m x = 1;";
let chunks = wrap_line_to_width(tinted, 10);
assert_eq!(chunks.len(), 1, "must not wrap when visible width fits, got: {:?}", chunks);
assert!(chunks[0].contains("\x1b[38;2;198;120;221m"));
}
#[test]
fn wrap_line_to_width_truecolor_with_italic_passthrough() {
let tinted = "\x1b[3;38;2;124;132;153m// comment\x1b[23;39m";
let chunks = wrap_line_to_width(tinted, 10);
assert_eq!(chunks.len(), 1);
}
}