use unicode_width::UnicodeWidthChar;
#[must_use]
pub(crate) fn char_columns(c: char) -> usize {
UnicodeWidthChar::width(c).unwrap_or(0)
}
#[must_use]
pub(crate) fn visible_width(s: &str) -> usize {
let mut width = 0;
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\u{1b}' {
if chars.next() == Some('[') {
for c in chars.by_ref() {
if ('\u{40}'..='\u{7e}').contains(&c) {
break;
}
}
}
} else {
width += char_columns(c);
}
}
width
}
#[must_use]
pub(crate) fn sanitize_cell(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\u{1b}' {
if chars.next() == Some('[') {
let mut seq = String::from("\u{1b}[");
let mut closed = false;
for c in chars.by_ref() {
seq.push(c);
if ('\u{40}'..='\u{7e}').contains(&c) {
closed = true;
break;
}
}
if closed {
out.push_str(&seq);
}
}
} else {
match c {
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
other if other.is_control() => {} other => out.push(other),
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_styled_string_measures_its_visible_width_not_its_bytes() {
let styled = "\u{1b}[32m(o.o)\u{1b}[0m";
assert_eq!(styled.len(), 14, "the raw string really is longer");
assert_eq!(visible_width(styled), 5);
assert_eq!(visible_width("(o.o)"), 5);
}
#[test]
fn every_escape_in_a_string_is_discounted() {
assert_eq!(visible_width("\u{1b}[1m\u{1b}[32mup\u{1b}[0m"), 2);
assert_eq!(visible_width("\u{1b}[0m"), 0);
assert_eq!(visible_width(""), 0);
}
#[test]
fn non_ascii_text_counts_display_columns() {
assert_eq!("café".len(), 5, "the raw string really is longer");
assert_eq!(visible_width("café"), 4);
assert_eq!("日本".chars().count(), 2);
assert_eq!(visible_width("日本"), 4, "columns, not chars and not bytes");
}
#[test]
fn a_combining_mark_rides_along_with_its_base_character_for_free() {
let decomposed = "cafe\u{301}";
assert_eq!(decomposed.chars().count(), 5);
assert_eq!(visible_width(decomposed), 4);
assert_eq!(visible_width(decomposed), visible_width("café"));
}
#[test]
fn a_wide_character_beside_an_escape_is_measured_and_the_escape_is_not() {
assert_eq!(visible_width("\u{1b}[38;5;166m日本\u{1b}[0m"), 4);
}
#[test]
fn an_embedded_tab_contributes_no_width() {
assert_eq!(visible_width("web\tworker"), 9);
}
#[test]
fn an_embedded_newline_contributes_no_width() {
assert_eq!(visible_width("web\nworker"), 9);
}
#[test]
fn an_embedded_newline_is_escaped_not_literal() {
let sanitized = sanitize_cell("web\nworker");
assert!(!sanitized.contains('\n'), "{sanitized:?}");
assert_eq!(sanitized, "web\\nworker");
}
#[test]
fn a_carriage_return_and_a_tab_are_also_escaped() {
assert_eq!(sanitize_cell("a\rb"), "a\\rb");
assert_eq!(sanitize_cell("a\tb"), "a\\tb");
}
#[test]
fn other_control_characters_are_dropped() {
assert_eq!(sanitize_cell("a\u{7}b"), "ab"); assert_eq!(sanitize_cell("a\u{8}b"), "ab"); }
#[test]
fn a_well_formed_escape_sequence_survives_untouched() {
let styled = "\u{1b}[38;5;29m(o.o) online\u{1b}[0m";
assert_eq!(sanitize_cell(styled), styled);
}
#[test]
fn an_unterminated_or_bare_escape_is_dropped_whole() {
assert_eq!(
sanitize_cell("a\u{1b}bc"),
"ac",
"bare ESC and the one character after it, both gone"
);
assert_eq!(sanitize_cell("a\u{1b}[3;1"), "a");
}
#[test]
fn a_sanitized_cells_width_matches_its_escaped_spelling() {
let sanitized = sanitize_cell("web\nworker");
assert_eq!(visible_width(&sanitized), sanitized.chars().count());
}
}