use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum Ellipsis {
#[default]
Ascii,
Unicode,
}
impl Ellipsis {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Ascii => "...",
Self::Unicode => "\u{2026}",
}
}
#[must_use]
pub const fn width(self) -> usize {
match self {
Self::Ascii => 3,
Self::Unicode => 1,
}
}
}
#[must_use]
pub fn display_width(text: &str) -> usize {
UnicodeWidthStr::width(text)
}
fn take_prefix(text: &str, budget: usize) -> (&str, usize) {
let mut width = 0usize;
let mut end = 0usize;
for (offset, ch) in text.char_indices() {
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
if width + ch_width > budget {
break;
}
width += ch_width;
end = offset + ch.len_utf8();
}
(text.get(..end).unwrap_or(""), width)
}
fn take_suffix(text: &str, budget: usize) -> (&str, usize) {
let mut width = 0usize;
let mut start = text.len();
for (offset, ch) in text.char_indices().rev() {
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
if width + ch_width > budget {
break;
}
width += ch_width;
start = offset;
}
(text.get(start..).unwrap_or(""), width)
}
#[must_use]
pub fn truncate_tail(text: &str, max_width: usize, ellipsis: Ellipsis) -> String {
if display_width(text) <= max_width {
return text.to_owned();
}
if max_width == 0 {
return String::new();
}
if max_width <= ellipsis.width() {
let (prefix, _) = take_prefix(ellipsis.as_str(), max_width);
return prefix.to_owned();
}
let (prefix, _) = take_prefix(text, max_width - ellipsis.width());
let mut out = String::with_capacity(prefix.len() + ellipsis.as_str().len());
out.push_str(prefix);
out.push_str(ellipsis.as_str());
out
}
#[must_use]
pub fn truncate_middle(text: &str, max_width: usize, ellipsis: Ellipsis) -> String {
if display_width(text) <= max_width {
return text.to_owned();
}
if max_width == 0 {
return String::new();
}
if max_width <= ellipsis.width() {
let (prefix, _) = take_prefix(ellipsis.as_str(), max_width);
return prefix.to_owned();
}
let content = max_width - ellipsis.width();
let tail_budget = content / 2;
let head_budget = content - tail_budget;
let (head, head_width) = take_prefix(text, head_budget);
let (tail, _) = take_suffix(text, tail_budget + (head_budget - head_width));
let mut out = String::with_capacity(head.len() + ellipsis.as_str().len() + tail.len());
out.push_str(head);
out.push_str(ellipsis.as_str());
out.push_str(tail);
if display_width(&out) > max_width {
return truncate_tail(text, max_width, ellipsis);
}
out
}
#[must_use]
pub fn pad_left(text: &str, width: usize, ellipsis: Ellipsis) -> String {
let text = truncate_tail(text, width, ellipsis);
let pad = width.saturating_sub(display_width(&text));
let mut out = String::with_capacity(pad + text.len());
for _ in 0..pad {
out.push(' ');
}
out.push_str(&text);
out
}
#[must_use]
pub fn pad_right(text: &str, width: usize, ellipsis: Ellipsis) -> String {
let text = truncate_tail(text, width, ellipsis);
let pad = width.saturating_sub(display_width(&text));
let mut out = String::with_capacity(pad + text.len());
out.push_str(&text);
for _ in 0..pad {
out.push(' ');
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn short_text_is_returned_unchanged() {
assert_eq!(truncate_tail("rustc", 10, Ellipsis::Ascii), "rustc");
assert_eq!(truncate_middle("rustc", 10, Ellipsis::Ascii), "rustc");
}
#[test]
fn tail_truncation_keeps_the_head() {
assert_eq!(
truncate_tail("rustc-driver", 9, Ellipsis::Ascii),
"rustc-..."
);
assert_eq!(
truncate_tail("rustc-driver", 9, Ellipsis::Unicode),
"rustc-dr\u{2026}"
);
}
#[test]
fn middle_truncation_keeps_both_ends() {
let path = "/opt/build/monitrs/target/debug/monitrs";
let out = truncate_middle(path, 20, Ellipsis::Ascii);
assert_eq!(display_width(&out), 20, "{out:?}");
assert!(out.starts_with("/opt"), "{out:?}");
assert!(out.ends_with("monitrs"), "{out:?}");
assert!(out.contains("..."), "{out:?}");
}
#[test]
fn zero_width_yields_empty_and_never_panics() {
assert_eq!(truncate_tail("anything", 0, Ellipsis::Ascii), "");
assert_eq!(truncate_middle("anything", 0, Ellipsis::Ascii), "");
assert_eq!(pad_left("anything", 0, Ellipsis::Ascii), "");
}
#[test]
fn width_below_the_marker_still_respects_the_budget() {
for width in 0..=3 {
let out = truncate_tail("some-long-name", width, Ellipsis::Ascii);
assert!(display_width(&out) <= width, "width {width} gave {out:?}");
}
}
#[test]
fn double_width_characters_never_overflow_the_budget() {
let name = "日本語のプロセス名";
for width in 0..=20 {
let tail = truncate_tail(name, width, Ellipsis::Ascii);
let middle = truncate_middle(name, width, Ellipsis::Ascii);
assert!(display_width(&tail) <= width, "tail {width}: {tail:?}");
assert!(
display_width(&middle) <= width,
"middle {width}: {middle:?}"
);
}
}
#[test]
fn an_odd_budget_with_double_width_text_is_filled_not_wasted() {
let out = truncate_middle("日本語のプロセス名", 8, Ellipsis::Ascii);
assert!(display_width(&out) <= 8, "{out:?}");
}
#[test]
fn padding_right_aligns_numeric_columns() {
assert_eq!(pad_left("287%", 6, Ellipsis::Ascii), " 287%");
assert_eq!(pad_right("rustc", 8, Ellipsis::Ascii), "rustc ");
assert_eq!(display_width(&pad_left("日本", 6, Ellipsis::Ascii)), 6);
}
#[test]
fn padding_truncates_rather_than_breaking_the_column() {
assert_eq!(
display_width(&pad_left("1234567890", 5, Ellipsis::Ascii)),
5
);
}
}