use ratatui::buffer::CellWidth;
use unicode_segmentation::UnicodeSegmentation;
pub fn is_hidden(c: char) -> bool {
(c.is_control() && c != '\n' && c != '\t')
|| matches!(
c,
'\u{202A}'..='\u{202E}'
| '\u{2066}'..='\u{2069}'
| '\u{200E}'
| '\u{200F}'
| '\u{061C}'
| '\u{00AD}'
| '\u{034F}'
| '\u{115F}'
| '\u{1160}'
| '\u{17B4}'
| '\u{17B5}'
| '\u{180B}'..='\u{180F}'
| '\u{2800}'
| '\u{200B}'
| '\u{2028}'
| '\u{2029}'
| '\u{2060}'..='\u{2064}'
| '\u{206A}'..='\u{206F}'
| '\u{3164}'
| '\u{FEFF}'
| '\u{FFA0}'
| '\u{FFF9}'..='\u{FFFB}'
| '\u{1D173}'..='\u{1D17A}'
| '\u{E0001}'
| '\u{10EEEE}'
)
}
pub fn clean(text: &str) -> String {
text.chars().filter(|&c| !is_hidden(c)).collect()
}
pub fn one_line(text: &str, max: usize) -> String {
let text = first_chars(text, max.saturating_mul(8).max(256));
let mut out = String::new();
for word in clean(text).split_whitespace() {
if !out.is_empty() {
out.push(' ');
}
out.push_str(word);
}
if out.chars().count() > max {
out = first_chars(&out, max.saturating_sub(1)).to_string();
out.push('…');
}
out
}
pub fn first_chars(text: &str, n: usize) -> &str {
text.char_indices().nth(n).map_or(text, |(i, _)| &text[..i])
}
pub fn cols(text: &str) -> usize {
text.graphemes(true)
.filter(|g| !g.contains(char::is_control))
.map(|g| usize::from(g.cell_width()))
.sum()
}
pub fn wrap(text: &str, width: usize) -> Vec<&str> {
let width = width.max(1);
let mut out = Vec::new();
for line in text.split('\n') {
let before = out.len();
let mut open: Option<(usize, usize, usize)> = None;
let mut spaces = 0;
let mut at = 0;
while at <= line.len() {
let word_end = line[at..].find(' ').map_or(line.len(), |n| at + n);
let next = line[word_end..]
.find(|c| c != ' ')
.map_or(line.len(), |n| word_end + n);
let word = &line[at..word_end];
let w = cols(word);
match open {
Some((start, _, used)) if used + spaces + w <= width => {
open = Some((start, word_end, used + spaces + w));
}
_ => {
if let Some((start, end, _)) = open {
out.push(&line[start..end]);
}
let (mut start, mut used) = (at, 0);
let mut units = Vec::new();
for (i, g) in word.grapheme_indices(true) {
if cols(g) <= width {
units.push((i, g.len()));
} else {
units.extend(g.char_indices().map(|(j, c)| (i + j, c.len_utf8())));
}
}
for (i, len) in units {
let gw = cols(&word[i..i + len]);
if used > 0 && used + gw > width {
out.push(&line[start..at + i]);
(start, used) = (at + i, 0);
}
used += gw;
}
open = Some((start, word_end, used));
}
}
spaces = next - word_end;
if next == line.len() {
break;
}
at = next;
}
if let Some((start, end, _)) = open {
out.push(&line[start..end]);
}
if out.len() == before {
out.push("");
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn controls_and_bidi_overrides_are_dropped_but_line_breaks_and_tabs_stay() {
assert_eq!(clean("invoice\u{202E}fdp.exe"), "invoicefdp.exe");
assert_eq!(clean("a\u{1b}[2Jb\rc\u{7}d\u{9b}e"), "a[2Jbcde");
assert_eq!(clean("one\ntwo\tthree"), "one\ntwo\tthree");
assert_eq!(clean("עברית and العربية"), "עברית and العربية");
assert_eq!(clean("legit\u{2028}spoof\u{2029}more"), "legitspoofmore");
}
#[test]
fn invisible_fillers_go_but_emoji_keep_their_joiners_and_selectors() {
let filler = format!("x{}SPOOF", "\u{3164}".repeat(40));
assert_eq!(clean(&filler), "xSPOOF");
assert_eq!(clean("a\u{FFA0}\u{00AD}\u{200B}\u{FEFF}b"), "ab");
assert_eq!(clean("a\u{2800}\u{2800}b\u{180B}c"), "abc");
let family = "👨\u{200D}👩\u{200D}👧";
assert_eq!(clean(family), family);
assert_eq!(clean("❤\u{FE0F}"), "❤\u{FE0F}");
let england = "🏴\u{E0067}\u{E0062}\u{E0065}\u{E006E}\u{E0067}\u{E007F}";
assert_eq!(clean(england), england);
}
#[test]
fn one_line_collapses_whitespace_and_caps_the_length() {
assert_eq!(
one_line(" Re:\r\n lunch\t tomorrow? ", 50),
"Re: lunch tomorrow?"
);
assert_eq!(one_line("abcdef", 4), "abc…");
assert_eq!(one_line("a\u{202E}b", 10), "ab");
assert_eq!(one_line(&"x ".repeat(10_000), 5), "x x …");
assert_eq!(one_line(&format!("{}hi", " ".repeat(100)), 5), "hi");
}
#[test]
fn halfwidth_sound_marks_take_the_column_they_are_drawn_in() {
assert_eq!(cols("ab"), 2);
assert_eq!(cols("\u{FF9E}\u{FF9F}"), 2);
assert_eq!(cols("\u{FF76}\u{FF9E}"), 2);
assert_eq!(cols("❤\u{FE0F}"), 2);
assert_eq!(cols("a\nb\tc"), 3);
}
#[test]
fn wrap_breaks_at_spaces_by_columns_and_keeps_pieces_of_the_text() {
assert_eq!(wrap("one two three", 7), ["one two", "three"]);
assert_eq!(wrap(" indented text", 10), [" indented", "text"]);
assert_eq!(wrap("abcdefgh", 3), ["abc", "def", "gh"]);
assert_eq!(wrap("a\n\nb", 5), ["a", "", "b"]);
assert_eq!(wrap("", 5), [""]);
assert_eq!(wrap("x ", 5), ["x"]);
let marks = "\u{FF9E}".repeat(10);
for piece in wrap(&format!("{marks} {marks}"), 8) {
assert!(cols(piece) <= 8, "{piece}");
}
let text = "it's the pieces themselves";
for piece in wrap(text, 9) {
assert!(text.contains(piece));
}
}
#[test]
fn first_chars_stops_on_a_character_boundary() {
assert_eq!(first_chars("héllo", 2), "hé");
assert_eq!(first_chars("hi", 5), "hi");
}
}