use crate::text::slice_ansi::slice_ansi;
use crate::text::string_width::string_width;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TruncatePosition {
Start,
Middle,
End,
}
#[derive(Debug, Clone)]
pub struct TruncateOptions {
pub position: TruncatePosition,
pub space: bool,
pub prefer_truncation_on_space: bool,
pub truncation_character: String,
}
impl Default for TruncateOptions {
fn default() -> Self {
Self {
position: TruncatePosition::End,
space: false,
prefer_truncation_on_space: false,
truncation_character: "\u{2026}".to_owned(),
}
}
}
fn get_index_of_nearest_space(chars: &[char], wanted_index: isize, search_right: bool) -> isize {
if char_at(chars, wanted_index) == Some(' ') {
return wanted_index;
}
let direction: isize = if search_right { 1 } else { -1 };
for index in 0..=3 {
let final_index = wanted_index + index * direction;
if char_at(chars, final_index) == Some(' ') {
return final_index;
}
}
wanted_index
}
fn char_at(chars: &[char], index: isize) -> Option<char> {
if index < 0 {
return None;
}
chars.get(index as usize).copied()
}
const ANSI_ESC: u32 = 27;
const ANSI_LEFT_BRACKET: u32 = 91;
const ANSI_LETTER_M: u32 = 109;
fn is_sgr_parameter(code: u32) -> bool {
(48..=57).contains(&code) || code == 59
}
fn leading_sgr_span_end_index(chars: &[char]) -> usize {
let cp = |i: usize| chars.get(i).map(|c| *c as u32);
let len = chars.len();
let mut index = 0;
while index + 2 < len && cp(index) == Some(ANSI_ESC) && cp(index + 1) == Some(ANSI_LEFT_BRACKET)
{
let mut j = index + 2;
while j < len && cp(j).is_some_and(is_sgr_parameter) {
j += 1;
}
if j < len && cp(j) == Some(ANSI_LETTER_M) {
index = j + 1;
continue;
}
break;
}
index
}
fn trailing_sgr_span_start_index(chars: &[char]) -> usize {
let cp = |i: usize| chars.get(i).map(|c| *c as u32);
let mut start = chars.len();
while start > 1 && cp(start - 1) == Some(ANSI_LETTER_M) {
let mut j: isize = start as isize - 2;
while j >= 0 && cp(j as usize).is_some_and(is_sgr_parameter) {
j -= 1;
}
if j >= 1
&& cp((j - 1) as usize) == Some(ANSI_ESC)
&& cp(j as usize) == Some(ANSI_LEFT_BRACKET)
{
start = (j - 1) as usize;
continue;
}
break;
}
start
}
fn append_with_inherited_style_from_end(visible: &str, suffix: &str) -> String {
let chars: Vec<char> = visible.chars().collect();
let start = trailing_sgr_span_start_index(&chars);
if start == chars.len() {
return format!("{visible}{suffix}");
}
let before: String = chars[..start].iter().collect();
let after: String = chars[start..].iter().collect();
format!("{before}{suffix}{after}")
}
fn prepend_with_inherited_style_from_start(prefix: &str, visible: &str) -> String {
let chars: Vec<char> = visible.chars().collect();
let end = leading_sgr_span_end_index(&chars);
if end == 0 {
return format!("{prefix}{visible}");
}
let before: String = chars[..end].iter().collect();
let after: String = chars[end..].iter().collect();
format!("{before}{prefix}{after}")
}
pub fn cli_truncate(text: &str, columns: usize) -> String {
cli_truncate_with(text, columns, &TruncateOptions::default())
}
pub fn cli_truncate_with(text: &str, columns: usize, opts: &TruncateOptions) -> String {
if columns < 1 {
return String::new();
}
let length = string_width(text);
if length <= columns {
return text.to_owned();
}
if columns == 1 {
return opts.truncation_character.clone();
}
let text_chars: Vec<char> = text.chars().collect();
match opts.position {
TruncatePosition::Start => truncate_start(text, &text_chars, columns, length, opts),
TruncatePosition::Middle => truncate_middle(text, &text_chars, columns, length, opts),
TruncatePosition::End => truncate_end(text, &text_chars, columns, opts),
}
}
fn truncate_start(
text: &str,
text_chars: &[char],
columns: usize,
length: usize,
opts: &TruncateOptions,
) -> String {
if opts.prefer_truncation_on_space {
let wanted = length as isize - columns as isize + 1;
let nearest_space = get_index_of_nearest_space(text_chars, wanted, true);
let right = slice_ansi(text, clamp_index(nearest_space), Some(length))
.trim()
.to_owned();
return prepend_with_inherited_style_from_start(&opts.truncation_character, &right);
}
let truncation_character = if opts.space {
format!("{} ", opts.truncation_character)
} else {
opts.truncation_character.clone()
};
let start = length as isize - columns as isize + string_width(&truncation_character) as isize;
let right = slice_ansi(text, clamp_index(start), Some(length));
prepend_with_inherited_style_from_start(&truncation_character, &right)
}
fn truncate_middle(
text: &str,
text_chars: &[char],
columns: usize,
length: usize,
opts: &TruncateOptions,
) -> String {
let truncation_character = if opts.space {
format!(" {} ", opts.truncation_character)
} else {
opts.truncation_character.clone()
};
let half = columns / 2;
if opts.prefer_truncation_on_space {
let space_near_first = get_index_of_nearest_space(text_chars, half as isize, false);
let wanted_second = length as isize - (columns as isize - half as isize) + 1;
let space_near_second = get_index_of_nearest_space(text_chars, wanted_second, true);
let left = slice_ansi(text, 0, Some(clamp_index(space_near_first)));
let right = slice_ansi(text, clamp_index(space_near_second), Some(length))
.trim()
.to_owned();
return format!("{left}{truncation_character}{right}");
}
let left = slice_ansi(text, 0, Some(half));
let right_start = length as isize - (columns as isize - half as isize)
+ string_width(&truncation_character) as isize;
let right = slice_ansi(text, clamp_index(right_start), Some(length));
format!("{left}{truncation_character}{right}")
}
fn truncate_end(text: &str, text_chars: &[char], columns: usize, opts: &TruncateOptions) -> String {
if opts.prefer_truncation_on_space {
let nearest_space = get_index_of_nearest_space(text_chars, columns as isize - 1, false);
let left = slice_ansi(text, 0, Some(clamp_index(nearest_space)));
return append_with_inherited_style_from_end(&left, &opts.truncation_character);
}
let truncation_character = if opts.space {
format!(" {}", opts.truncation_character)
} else {
opts.truncation_character.clone()
};
let end = columns as isize - string_width(&truncation_character) as isize;
let left = slice_ansi(text, 0, Some(clamp_index(end)));
append_with_inherited_style_from_end(&left, &truncation_character)
}
fn clamp_index(index: isize) -> usize {
index.max(0) as usize
}
#[cfg(test)]
mod tests {
use super::*;
fn start() -> TruncateOptions {
TruncateOptions {
position: TruncatePosition::Start,
..Default::default()
}
}
fn middle() -> TruncateOptions {
TruncateOptions {
position: TruncatePosition::Middle,
..Default::default()
}
}
fn with_space(mut o: TruncateOptions) -> TruncateOptions {
o.space = true;
o
}
fn with_prefer(mut o: TruncateOptions) -> TruncateOptions {
o.prefer_truncation_on_space = true;
o
}
#[test]
fn no_truncation() {
assert_eq!(
cli_truncate("the quick brown fox", 20),
"the quick brown fox"
);
}
#[test]
fn columns_zero() {
assert_eq!(cli_truncate("unicorn", 0), "");
}
#[test]
fn columns_one() {
assert_eq!(cli_truncate("unicorn", 1), "\u{2026}");
}
#[test]
fn end_basic() {
assert_eq!(cli_truncate("unicorn", 4), "uni\u{2026}");
}
#[test]
fn end_with_space() {
assert_eq!(
cli_truncate_with("unicorn", 5, &with_space(TruncateOptions::default())),
"uni \u{2026}"
);
}
#[test]
fn start_basic() {
assert_eq!(
cli_truncate_with("the quick brown fox", 10, &start()),
"\u{2026}brown fox"
);
}
#[test]
fn start_with_space() {
assert_eq!(
cli_truncate_with("the quick brown fox", 10, &with_space(start())),
"\u{2026} rown fox"
);
}
#[test]
fn middle_basic() {
assert_eq!(
cli_truncate_with("the quick brown fox", 10, &middle()),
"the q\u{2026} fox"
);
}
#[test]
fn middle_with_space() {
assert_eq!(
cli_truncate_with("the quick brown fox", 10, &with_space(middle())),
"the q \u{2026} ox"
);
}
#[test]
fn prefer_space_end() {
assert_eq!(
cli_truncate_with(
"the quick brown fox",
10,
&with_prefer(TruncateOptions::default())
),
"the quick\u{2026}"
);
}
#[test]
fn prefer_space_start() {
assert_eq!(
cli_truncate_with("the quick brown fox", 10, &with_prefer(start())),
"\u{2026}brown fox"
);
}
#[test]
fn prefer_space_middle() {
assert_eq!(
cli_truncate_with("the quick brown fox", 10, &with_prefer(middle())),
"the\u{2026}fox"
);
}
#[test]
fn ansi_end_truncate() {
assert_eq!(
cli_truncate("\x1b[31municorn\x1b[39m", 4),
"\x1b[31muni\u{2026}\x1b[39m"
);
}
#[test]
fn ansi_start_truncate() {
assert_eq!(
cli_truncate_with("\x1b[31municorn\x1b[39m", 4, &start()),
"\x1b[31m\u{2026}orn\x1b[39m"
);
}
#[test]
fn cjk_truncation() {
assert_eq!(
cli_truncate("古池や蛙飛び込む水の音", 10),
"古池や蛙\u{2026}"
);
}
#[test]
fn custom_truncation_character() {
let opts = TruncateOptions {
truncation_character: ".".to_owned(),
..Default::default()
};
assert_eq!(cli_truncate_with("unicorn", 5, &opts), "unic.");
}
#[test]
fn emoji_truncation() {
assert_eq!(cli_truncate("😀😁😂😃😄", 4), "😀\u{2026}");
}
#[test]
fn end_plain() {
assert_eq!(cli_truncate("hello world", 8), "hello w\u{2026}");
}
#[test]
fn start_plain() {
assert_eq!(
cli_truncate_with("hello world", 8, &start()),
"\u{2026}o world"
);
}
#[test]
fn middle_plain() {
assert_eq!(
cli_truncate_with("hello world", 8, &middle()),
"hell\u{2026}rld"
);
}
#[test]
fn default_options() {
let d = TruncateOptions::default();
assert_eq!(d.position, TruncatePosition::End);
assert!(!d.space);
assert!(!d.prefer_truncation_on_space);
assert_eq!(d.truncation_character, "\u{2026}");
}
#[test]
fn prefer_space_end_astral_divergence() {
assert_eq!(
cli_truncate_with("🦄🦄 🦄🦄", 5, &with_prefer(TruncateOptions::default())),
"🦄\u{2026}"
);
}
fn tc(s: &str) -> TruncateOptions {
TruncateOptions {
truncation_character: s.to_owned(),
..Default::default()
}
}
#[test]
fn truncate_end_cjk_middle_space_keeps_trailing_space() {
let opts = with_space(middle());
assert_eq!(
cli_truncate_with("古池や蛙飛び", 8, &opts),
"古池 \u{2026} "
);
}
#[test]
fn truncate_end_overlong_truncation_char_exceeds_columns() {
assert_eq!(cli_truncate_with("abcdef", 2, &tc("...")), "...");
}
#[test]
fn truncate_end_wide_truncation_char_col_two() {
assert_eq!(cli_truncate_with("abcdef", 2, &tc("古")), "古");
}
#[test]
fn truncate_empty_truncation_char_all_positions() {
assert_eq!(cli_truncate_with("unicorn", 4, &tc("")), "unic"); let mut start_empty = start();
start_empty.truncation_character = String::new();
assert_eq!(cli_truncate_with("unicorn", 4, &start_empty), "corn");
let mut mid_empty = middle();
mid_empty.truncation_character = String::new();
assert_eq!(cli_truncate_with("unicorn", 4, &mid_empty), "unrn");
}
#[test]
fn truncate_end_ansi_inside_truncation_char_measured_by_visible_width() {
assert_eq!(
cli_truncate_with("unicorn", 4, &tc("\x1b[31m.\x1b[39m")),
"uni\x1b[31m.\x1b[39m"
);
}
#[test]
fn truncate_prefer_on_space_no_space_present_hard_cut() {
assert_eq!(
cli_truncate_with("abcdefghij", 5, &with_prefer(TruncateOptions::default())),
"abcd\u{2026}"
);
assert_eq!(
cli_truncate_with("abcdefghij", 5, &with_prefer(start())),
"\u{2026}ghij"
);
assert_eq!(
cli_truncate_with("abcdefghij", 5, &with_prefer(middle())),
"ab\u{2026}ij"
);
}
#[test]
fn truncate_astral_no_space_prefer_matches_node_all_positions() {
assert_eq!(
cli_truncate_with("🦄🦄🦄🦄🦄", 5, &with_prefer(TruncateOptions::default())),
"🦄🦄\u{2026}"
);
assert_eq!(
cli_truncate_with("🦄🦄🦄🦄🦄", 5, &with_prefer(start())),
"\u{2026}🦄🦄"
);
assert_eq!(
cli_truncate_with("🦄🦄🦄🦄🦄", 5, &with_prefer(middle())),
"🦄\u{2026}🦄"
);
}
}