Skip to main content

pito_tui/
text.rs

1use unicode_segmentation::UnicodeSegmentation;
2use unicode_width::UnicodeWidthStr;
3
4const ELLIPSIS: &str = "…";
5const TAB: &str = "    ";
6
7pub fn clean(text: &str) -> String {
8    let mut out = String::with_capacity(text.len());
9    for c in text.chars() {
10        match c {
11            '\t' => out.push_str(TAB),
12            '\n' | '\r' => out.push(' '),
13            c if c.is_control() => {}
14            c => out.push(c),
15        }
16    }
17    out
18}
19
20pub fn cells(text: &str) -> usize {
21    text.width()
22}
23
24pub fn split(text: &str, width: usize) -> (&str, &str) {
25    let mut used = 0;
26    let mut end = 0;
27    for (index, glyph) in text.grapheme_indices(true) {
28        let wide = glyph.width();
29        if used + wide > width {
30            break;
31        }
32        used += wide;
33        end = index + glyph.len();
34    }
35    text.split_at(end)
36}
37
38pub fn clip(text: &str, width: usize) -> String {
39    if cells(text) <= width {
40        return text.to_string();
41    }
42    if width == 0 {
43        return String::new();
44    }
45    let (kept, _) = split(text, width - 1);
46    format!("{kept}{ELLIPSIS}")
47}
48
49pub fn fit(text: &str, width: usize) -> String {
50    let clipped = clip(text, width);
51    let pad = width.saturating_sub(cells(&clipped));
52    format!("{clipped}{}", " ".repeat(pad))
53}
54
55pub fn wrap(text: &str, width: usize) -> Vec<String> {
56    if width == 0 {
57        return Vec::new();
58    }
59    let mut lines = Vec::new();
60    for paragraph in text.split('\n') {
61        let paragraph = clean(paragraph);
62        let mut rest = paragraph.trim_end();
63        if rest.is_empty() {
64            lines.push(String::new());
65            continue;
66        }
67        while !rest.is_empty() {
68            if cells(rest) <= width {
69                lines.push(rest.to_string());
70                break;
71            }
72            let (head, _) = split(rest, width);
73            let cut = match head.rfind(' ') {
74                Some(space) if space > 0 && rest[head.len()..].starts_with(' ') => head.len(),
75                Some(space) if space > 0 => space,
76                _ if head.is_empty() => rest
77                    .grapheme_indices(true)
78                    .nth(1)
79                    .map_or(rest.len(), |(index, _)| index),
80                _ => head.len(),
81            };
82            lines.push(rest[..cut].trim_end().to_string());
83            rest = rest[cut..].trim_start();
84        }
85    }
86    lines
87}
88
89pub fn hard_wrap(text: &str, width: usize) -> Vec<String> {
90    if width == 0 {
91        return Vec::new();
92    }
93    let mut lines = Vec::new();
94    for paragraph in text.split('\n') {
95        let paragraph = clean(paragraph);
96        let mut rest = paragraph.trim_end();
97        if rest.is_empty() {
98            lines.push(String::new());
99            continue;
100        }
101        while !rest.is_empty() {
102            let (head, tail) = split(rest, width);
103            let (head, tail) = if head.is_empty() {
104                let cut = rest
105                    .grapheme_indices(true)
106                    .nth(1)
107                    .map_or(rest.len(), |(index, _)| index);
108                rest.split_at(cut)
109            } else {
110                (head, tail)
111            };
112            lines.push(head.to_string());
113            rest = tail;
114        }
115    }
116    lines
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[test]
124    fn widths_are_counted_in_cells_and_wide_glyphs_are_never_cut() {
125        assert_eq!(cells("ăîșț"), 4);
126        assert_eq!(cells("日本"), 4);
127        assert_eq!(clip("日本語のテキスト", 5), "日本…");
128        assert_eq!(fit("ab", 4), "ab  ");
129        assert_eq!(clip("short", 9), "short");
130        assert_eq!(wrap("the quick brown fox", 9), ["the quick", "brown fox"]);
131        assert_eq!(wrap("abcdefghij", 4), ["abcd", "efgh", "ij"]);
132        assert_eq!(wrap("日本語", 1), ["日", "本", "語"]);
133        assert_eq!(hard_wrap("the quick brown", 6), ["the qu", "ick br", "own"]);
134        assert_eq!(hard_wrap("a日本\n\nb", 2), ["a", "日", "本", "", "b"]);
135    }
136}