Skip to main content

qframe/
text.rs

1//! Measuring text in terminal cells: width, truncation with an ellipsis at the end or in the
2//! middle, and word wrapping.
3
4use std::borrow::Cow;
5use std::ops::Range;
6
7use unicode_segmentation::UnicodeSegmentation;
8use unicode_width::UnicodeWidthStr;
9
10/// The ellipsis drawn where text is cut.
11///
12/// [`truncate`] and [`truncate_middle`] always write this mark: they measure text and know
13/// nothing of the terminal. In ASCII glyph mode, where a terminal cannot show `…`, the painter
14/// ([`PaintCx::text`](crate::widget::PaintCx::text)) draws [`ASCII_ELLIPSIS`] in its cell
15/// instead, so every widget that cuts text is covered at once and the cut takes the same cell.
16pub const ELLIPSIS: &str = "…";
17
18/// The mark that stands for [`ELLIPSIS`] in ASCII glyph mode.
19///
20/// A tilde, one cell like the ellipsis it replaces, so a cut text is exactly as wide in every
21/// mode and nothing measured with [`width`] moves. It is the cut mark ASCII terminals already
22/// know from shortened file names (`PROGRA~1`, a file manager's `long~name.txt`); a period would
23/// read as the end of a sentence or, in the middle of a path, as part of the name, and `...`
24/// would take two more cells from a column that is already too narrow.
25pub const ASCII_ELLIPSIS: &str = "~";
26
27/// Display width of `text` in cells.
28#[must_use]
29pub fn width(text: &str) -> u16 {
30    let cells = if is_printable_ascii(text) { text.len() } else { text.width() };
31    u16::try_from(cells).unwrap_or(u16::MAX)
32}
33
34/// Display width of one grapheme cluster.
35#[must_use]
36pub fn grapheme_width(grapheme: &str) -> u16 {
37    width(grapheme)
38}
39
40/// Whether every character of `text` is printable ASCII, one cell and one grapheme cluster each.
41/// Most text a terminal application draws is, and it needs no Unicode tables to measure or split.
42pub(crate) fn is_printable_ascii(text: &str) -> bool {
43    text.bytes().all(|byte| matches!(byte, b' '..=b'~'))
44}
45
46/// Cells between a terminal's tab stops.
47const TAB_STOP: usize = 8;
48
49/// One line written for a terminal, as the terminal would leave it on screen: what a carriage
50/// return wrote over is gone, colour and cursor sequences are taken out, a tab becomes spaces up
51/// to the next stop of eight and no other control character is left.
52///
53/// Programs print for a terminal: a progress line redraws itself after `\r`, a build tool colours
54/// its words with escape sequences. A cell cannot hold a control character, so text from another
55/// program goes through this before it is drawn. Text with nothing to change is borrowed.
56///
57/// ```
58/// use qframe::text::printable;
59/// assert_eq!(printable("10%\r50%\r100%"), "100%");
60/// assert_eq!(printable("sent 2kB\r\r"), "sent 2kB");
61/// assert_eq!(printable("\u{1b}[1;32mok\u{1b}[0m done"), "ok done");
62/// assert_eq!(printable("a\tb"), "a       b");
63/// ```
64#[must_use]
65pub fn printable(text: &str) -> Cow<'_, str> {
66    if !text.chars().any(char::is_control) {
67        return Cow::Borrowed(text);
68    }
69    // A carriage return starts the line over; one at the very end leaves what came before it.
70    let shown = text.split('\r').rev().find(|part| !part.is_empty()).unwrap_or_default();
71    let mut out = String::with_capacity(shown.len());
72    let mut chars = shown.chars().peekable();
73    while let Some(c) = chars.next() {
74        match c {
75            // `ESC [` runs to its final byte; `ESC ]` to BEL or `ESC \`; any other escape takes
76            // its intermediate bytes and one final character, as `ESC ( B` does. A sequence cut
77            // off at the end of the line takes the rest.
78            '\u{1b}' => match chars.next() {
79                Some('[') => while chars.next().is_some_and(|c| !('@'..='~').contains(&c)) {},
80                Some(']') => {
81                    while let Some(c) = chars.next() {
82                        if c == '\u{7}' || (c == '\u{1b}' && chars.next_if_eq(&'\\').is_some()) {
83                            break;
84                        }
85                    }
86                }
87                Some(' '..='/') => {
88                    while chars.next_if(|c| (' '..='/').contains(c)).is_some() {}
89                    chars.next();
90                }
91                _ => {}
92            },
93            '\t' => {
94                let column = usize::from(width(&out));
95                out.extend(std::iter::repeat_n(' ', TAB_STOP - column % TAB_STOP));
96            }
97            c if c.is_control() => {}
98            c => out.push(c),
99        }
100    }
101    Cow::Owned(out)
102}
103
104/// `text` cut to at most `max` cells, ending in `…` when anything was removed.
105#[must_use]
106pub fn truncate(text: &str, max: u16) -> Cow<'_, str> {
107    if width(text) <= max {
108        return Cow::Borrowed(text);
109    }
110    if max == 0 {
111        return Cow::Borrowed("");
112    }
113    let budget = max - 1;
114    let mut used = 0u16;
115    let mut out = String::new();
116    for grapheme in text.graphemes(true) {
117        let w = grapheme_width(grapheme);
118        if used + w > budget {
119            break;
120        }
121        used += w;
122        out.push_str(grapheme);
123    }
124    out.push_str(ELLIPSIS);
125    Cow::Owned(out)
126}
127
128/// `text` cut to at most `max` cells by removing its middle, with `…` where the middle was.
129///
130/// For paths and other text whose start and end both matter: the head says where, the tail
131/// says what. Text that fits is returned unchanged. Otherwise the cells left after the
132/// ellipsis are shared between head and tail, the tail getting the odd one; a grapheme cluster
133/// (a wide character, a letter with its combining marks) is never split, and a cell one side
134/// cannot use goes to the other. With `max` 1 only the ellipsis remains, and with 0 nothing.
135///
136/// ```
137/// use qframe::text::{truncate_middle, width};
138///
139/// let path = "~/.config/quvyta/launcher.conf";
140/// assert_eq!(truncate_middle(path, 40), path);
141/// assert_eq!(truncate_middle(path, 20), "~/.config…ncher.conf");
142/// assert_eq!(width(&truncate_middle("~/文書/設定/launcher.conf", 12)), 12);
143/// ```
144#[must_use]
145pub fn truncate_middle(text: &str, max: u16) -> Cow<'_, str> {
146    if width(text) <= max {
147        return Cow::Borrowed(text);
148    }
149    if max == 0 {
150        return Cow::Borrowed("");
151    }
152    let budget = max - 1;
153    let graphemes: Vec<&str> = text.graphemes(true).collect();
154    let (mut head, mut head_used) = fitting(graphemes.iter(), budget / 2);
155    let (tail, tail_used) = fitting(graphemes[head..].iter().rev(), budget - head_used);
156    // A wide character at the tail's edge can leave a cell the head is able to use.
157    let (more, more_used) = fitting(graphemes[head..graphemes.len() - tail].iter(), budget - head_used - tail_used);
158    head += more;
159    head_used += more_used;
160    debug_assert!(head_used + tail_used <= budget);
161    let mut out = graphemes[..head].concat();
162    out.push_str(ELLIPSIS);
163    out.push_str(&graphemes[graphemes.len() - tail..].concat());
164    Cow::Owned(out)
165}
166
167/// How many of `graphemes`, taken in order, fit in `budget` cells, and the cells they use.
168fn fitting<'a>(graphemes: impl Iterator<Item = &'a &'a str>, budget: u16) -> (usize, u16) {
169    let mut count = 0;
170    let mut used = 0u16;
171    for grapheme in graphemes {
172        let w = grapheme_width(grapheme);
173        if used + w > budget {
174            break;
175        }
176        used += w;
177        count += 1;
178    }
179    (count, used)
180}
181
182/// Splits `text` into lines no wider than `max` cells.
183///
184/// Explicit newlines are kept, words move to the next line whole when they fit on it (with
185/// the punctuation attached to them, so a comma never starts a line), and words longer than a
186/// line are broken between grapheme clusters; the punctuation closing such a word breaks off
187/// with the character before it, so `.` or `)` never starts a line alone either. Spaces at a
188/// break are dropped; no-break spaces (U+00A0, U+202F, U+2007) are part of the word.
189///
190/// Chinese and Japanese, written without spaces, may break between any two ideographs or
191/// kana, except that closing punctuation (`。` `、` `」` `!`), the long vowel mark `ー` and
192/// small kana never start a line and opening brackets (`「` `(`) never end one. Only a run of
193/// such marks wider than the line itself breaks the rule.
194#[must_use]
195pub fn wrap(text: &str, max: u16) -> Vec<String> {
196    wrap_ranges(text, max).into_iter().map(|range| text[range].to_owned()).collect()
197}
198
199/// Like [`wrap`], but returns byte ranges into `text`, so styled text can be wrapped and drawn
200/// with its styles.
201#[must_use]
202pub fn wrap_ranges(text: &str, max: u16) -> Vec<Range<usize>> {
203    let mut lines = Vec::new();
204    if max == 0 {
205        return lines;
206    }
207    let mut paragraph_start = 0;
208    for paragraph in text.split('\n') {
209        let mut line: Option<Range<usize>> = None;
210        let mut line_width = 0u16;
211        for (offset, word, is_space) in runs(paragraph).flat_map(|(offset, run, space)| pieces(offset, run, space)) {
212            let start = paragraph_start + offset;
213            let end = start + word.len();
214            let word_width = width(word);
215            if is_space {
216                match &mut line {
217                    Some(current) if line_width + word_width <= max => {
218                        current.end = end;
219                        line_width += word_width;
220                    }
221                    Some(_) => {
222                        lines.push(trim_end(text, line.take()));
223                        line_width = 0;
224                    }
225                    None => {}
226                }
227                continue;
228            }
229            if line_width + word_width <= max {
230                line = Some(line.map_or(start..end, |current| current.start..end));
231                line_width += word_width;
232                continue;
233            }
234            if line.is_some() && word_width <= max {
235                lines.push(trim_end(text, line.take()));
236                line = Some(start..end);
237                line_width = word_width;
238                continue;
239            }
240            let graphemes: Vec<(usize, &str)> = word.grapheme_indices(true).collect();
241            // The punctuation closing the word never starts a line alone: it breaks off together
242            // with the grapheme before it, as in `ui.add(Badge::new("Paused"))` + `.`.
243            // A no-break space before that punctuation (French `vrai\u{a0}?`) goes with it too.
244            let tail =
245                graphemes.iter().rposition(|(_, g)| !is_closing_punctuation(g) && !is_no_break_space(g)).unwrap_or(0);
246            let tail_width: u16 = graphemes[tail..].iter().map(|(_, g)| grapheme_width(g)).sum();
247            for (index, (g_offset, grapheme)) in graphemes.iter().enumerate() {
248                let g_start = start + g_offset;
249                let g_end = g_start + grapheme.len();
250                let w = grapheme_width(grapheme);
251                let needed = if index == tail && tail_width <= max { tail_width } else { w };
252                if line_width + needed > max && line.is_some() {
253                    lines.push(trim_end(text, line.take()));
254                    line_width = 0;
255                }
256                line = Some(line.map_or(g_start..g_end, |current| current.start..g_end));
257                line_width += w;
258            }
259        }
260        lines.push(line.map_or(paragraph_start..paragraph_start, |current| trim_end(text, Some(current))));
261        paragraph_start += paragraph.len() + 1;
262    }
263    lines
264}
265
266/// The runs of whitespace and of everything between them, with their byte offsets and whether
267/// they are whitespace: a word keeps its punctuation (`boundaries,`, `2026.9.1`) and moves to the
268/// next line as one.
269fn runs(paragraph: &str) -> impl Iterator<Item = (usize, &str, bool)> {
270    let mut position = 0;
271    std::iter::from_fn(move || {
272        let start = position;
273        let (space, first) = char_at(paragraph, start)?;
274        position += first;
275        while let Some((_, len)) = char_at(paragraph, position).filter(|&(next, _)| next == space) {
276            position += len;
277        }
278        Some((start, &paragraph[start..position], space))
279    })
280}
281
282/// The pieces a run from [`runs`] may break between: the run itself, unless it holds Chinese or
283/// Japanese, which is written without spaces and breaks between ideographs and kana instead.
284fn pieces(offset: usize, run: &str, space: bool) -> impl Iterator<Item = (usize, &str, bool)> {
285    let mut ends = Vec::new();
286    if !space && !is_printable_ascii(run) {
287        let graphemes: Vec<(usize, &str)> = run.grapheme_indices(true).collect();
288        ends.extend(graphemes.windows(2).filter(|pair| may_break_between(pair[0].1, pair[1].1)).map(|pair| pair[1].0));
289    }
290    ends.push(run.len());
291    let mut start = 0;
292    ends.into_iter().map(move |end| {
293        let piece = (offset + start, &run[start..end], space);
294        start = end;
295        piece
296    })
297}
298
299/// Whether a line may break between two graphemes of one run: only next to Chinese or Japanese,
300/// and never before closing punctuation or after an opening bracket (the kinsoku rule).
301fn may_break_between(before: &str, after: &str) -> bool {
302    (is_cjk(before) || is_cjk(after)) && !is_closing_punctuation(after) && !is_opening_punctuation(before)
303}
304
305/// Whether `grapheme` is Chinese or Japanese text that breaks between its characters: ideographs,
306/// kana, CJK punctuation and fullwidth forms. Hangul is not: Korean separates words with spaces.
307fn is_cjk(grapheme: &str) -> bool {
308    grapheme.chars().next().is_some_and(|c| {
309        matches!(c,
310            '\u{2E80}'..='\u{2FDF}'      // radicals
311            | '\u{3000}'..='\u{30FF}'    // CJK punctuation, hiragana, katakana
312            | '\u{31C0}'..='\u{31FF}'    // strokes, katakana extensions
313            | '\u{3400}'..='\u{4DBF}'    // extension A
314            | '\u{4E00}'..='\u{9FFF}'    // unified ideographs
315            | '\u{F900}'..='\u{FAFF}'    // compatibility ideographs
316            | '\u{FE30}'..='\u{FE4F}'    // vertical and compatibility forms
317            | '\u{FF00}'..='\u{FFEF}'    // fullwidth and halfwidth forms
318            | '\u{20000}'..='\u{3FFFF}') // supplementary ideographs
319    })
320}
321
322/// Whether the character starting at byte `index` of `text` is a space a line may break at, and
323/// its length in bytes; `None` past the end. Wrapping looks at every character of a text, and
324/// ASCII, most of what is wrapped, needs no decoding.
325fn char_at(text: &str, index: usize) -> Option<(bool, usize)> {
326    let byte = *text.as_bytes().get(index)?;
327    if byte.is_ascii() {
328        return Some((char::from(byte).is_whitespace(), 1));
329    }
330    text.get(index..)?.chars().next().map(|c| (c.is_whitespace() && !is_no_break(c), c.len_utf8()))
331}
332
333/// Whether `c` is a space that binds the words on either side: French puts one before `?` and
334/// `:`, and numbers group their digits with one.
335fn is_no_break(c: char) -> bool {
336    matches!(c, '\u{A0}' | '\u{202F}' | '\u{2007}')
337}
338
339fn is_no_break_space(grapheme: &str) -> bool {
340    grapheme.chars().all(is_no_break)
341}
342
343/// Whether `grapheme` is punctuation that closes what comes before it and must not start a line.
344/// Chinese and Japanese add their own full stops, commas and brackets, the long vowel mark,
345/// iteration marks and the small kana that belong to the syllable before them.
346fn is_closing_punctuation(grapheme: &str) -> bool {
347    grapheme.chars().all(|c| {
348        matches!(
349            c,
350            '.' | ',' | ';' | ':' | '!' | '?' | ')' | ']' | '}' | '"' | '\'' | '…' | '’' | '”' | '»'
351                | '、' | '。' | '〃' | '々' | '〉' | '》' | '」' | '』' | '】' | '〕' | '〗' | '〙' | '〛' | '〞' | '〟'
352                | '〻' | '・' | 'ー' | 'ゝ' | 'ゞ' | 'ヽ' | 'ヾ' | '゛' | '゜' | '゠' | '‼' | '⁇' | '⁈' | '⁉'
353                | 'ぁ' | 'ぃ' | 'ぅ' | 'ぇ' | 'ぉ' | 'っ' | 'ゃ' | 'ゅ' | 'ょ' | 'ゎ' | 'ゕ' | 'ゖ'
354                | 'ァ' | 'ィ' | 'ゥ' | 'ェ' | 'ォ' | 'ッ' | 'ャ' | 'ュ' | 'ョ' | 'ヮ' | 'ヵ' | 'ヶ'
355                | '\u{31F0}'..='\u{31FF}'
356                | '!' | ')' | ',' | '.' | ':' | ';' | '?' | ']' | '}' | '⦆' | '。' | '」' | '、' | '・' | 'ー'
357                | 'ァ'..='ッ' | '゙' | '゚' | '%' | '〜' | '~'
358        )
359    })
360}
361
362/// Whether `grapheme` opens what comes after it and must not end a line.
363fn is_opening_punctuation(grapheme: &str) -> bool {
364    grapheme.chars().all(|c| {
365        matches!(
366            c,
367            '(' | '['
368                | '{'
369                | '‘'
370                | '“'
371                | '«'
372                | '〈'
373                | '《'
374                | '「'
375                | '『'
376                | '【'
377                | '〔'
378                | '〖'
379                | '〘'
380                | '〚'
381                | '〝'
382                | '('
383                | '['
384                | '{'
385                | '⦅'
386                | '「'
387        )
388    })
389}
390
391fn trim_end(text: &str, range: Option<Range<usize>>) -> Range<usize> {
392    let range = range.unwrap_or(0..0);
393    let trimmed = text[range.clone()].trim_end();
394    range.start..range.start + trimmed.len()
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400
401    #[test]
402    fn the_ascii_cut_mark_is_ascii_and_as_wide_as_the_ellipsis() {
403        assert!(is_printable_ascii(ASCII_ELLIPSIS));
404        assert_eq!(width(ASCII_ELLIPSIS), width(ELLIPSIS), "a cut text is as wide in every glyph mode");
405        assert_eq!(width(ELLIPSIS), 1);
406    }
407
408    #[test]
409    fn measures_wide_and_combining_text() {
410        assert_eq!(width("abc"), 3);
411        assert_eq!(width("çığ"), 3);
412        assert_eq!(width("界"), 2);
413        assert_eq!(width("e\u{301}"), 1);
414    }
415
416    #[test]
417    fn truncates_with_ellipsis_by_cells() {
418        assert_eq!(truncate("quvyta", 10), "quvyta");
419        assert_eq!(truncate("quvyta-framework", 8), "quvyta-…");
420        assert_eq!(truncate("界界界", 4), "界…");
421        assert_eq!(truncate("abc", 0), "");
422        assert_eq!(width(&truncate("quvyta-framework", 8)), 8);
423    }
424
425    #[test]
426    fn printable_leaves_what_a_terminal_would_show() {
427        assert!(matches!(printable("plain 防火墙"), Cow::Borrowed(_)), "nothing to change is borrowed");
428        assert_eq!(printable("\u{1b}]0;title\u{7}shown"), "shown", "a title sequence ends at the bell");
429        assert_eq!(printable("\u{1b}]8;;url\u{1b}\\link"), "link", "or at ESC backslash");
430        assert_eq!(printable("cut \u{1b}[38;2;1"), "cut ", "a sequence cut off takes the rest");
431        assert_eq!(printable("\u{1b}(Bx"), "x", "a two-character escape");
432        assert_eq!(printable("防\tx"), "防      x", "a tab counts the cells before it");
433        assert_eq!(printable("\r\r"), "", "nothing but returns leaves nothing");
434    }
435
436    #[test]
437    fn truncate_middle_returns_text_that_fits_unchanged() {
438        assert!(matches!(truncate_middle("launcher.conf", 13), Cow::Borrowed("launcher.conf")));
439        assert!(matches!(truncate_middle("", 0), Cow::Borrowed("")));
440    }
441
442    #[test]
443    fn truncate_middle_keeps_head_and_tail_of_a_path() {
444        let path = "~/.config/quvyta/launcher.conf";
445        assert_eq!(truncate_middle(path, 25), "~/.config/qu…auncher.conf");
446        assert_eq!(truncate_middle(path, 20), "~/.config…ncher.conf", "the tail gets the odd cell");
447        assert_eq!(truncate_middle(path, 5), "~/…nf");
448        for max in 0..=30 {
449            assert_eq!(width(&truncate_middle(path, max)), max, "{max}");
450        }
451    }
452
453    #[test]
454    fn truncate_middle_never_splits_wide_characters() {
455        let path = "~/文書/設定/launcher.conf";
456        assert_eq!(width(path), 25);
457        // The head cannot use its fifth cell for half of 書, so the tail takes it.
458        assert_eq!(truncate_middle(path, 12), "~/文…er.conf");
459        assert_eq!(truncate_middle("界界界界界界", 6), "界…界", "one cell stays empty rather than half a character");
460        for max in 0..=25 {
461            assert!(width(&truncate_middle(path, max)) <= max, "{max}");
462            assert!(width(&truncate_middle("界界界界界界", max)) <= max, "{max}");
463        }
464    }
465
466    #[test]
467    fn truncate_middle_keeps_combining_marks_with_their_letter() {
468        let accented = "e\u{301}e\u{301}e\u{301}e\u{301}e\u{301}";
469        assert_eq!(truncate_middle(accented, 4), "e\u{301}…e\u{301}e\u{301}");
470        assert_eq!(truncate_middle("café\u{301}s/ünïcödé\u{301}", 7), "caf…ödé\u{301}");
471    }
472
473    #[test]
474    fn truncate_middle_at_tiny_widths() {
475        assert_eq!(truncate_middle("launcher.conf", 0), "");
476        assert_eq!(truncate_middle("launcher.conf", 1), "…");
477        assert_eq!(truncate_middle("launcher.conf", 2), "…f");
478        assert_eq!(truncate_middle("文書", 2), "…", "a wide tail does not fit in one cell");
479        assert_eq!(truncate_middle("文書", 3), "…書");
480    }
481
482    #[test]
483    fn wraps_words_and_breaks_long_ones() {
484        assert_eq!(wrap("the quick brown fox", 9), vec!["the quick", "brown fox"]);
485        assert_eq!(wrap("abcdefghij", 4), vec!["abcd", "efgh", "ij"]);
486        assert_eq!(wrap("a\n\nb", 5), vec!["a", "", "b"]);
487        assert_eq!(wrap("one  two", 4), vec!["one", "two"]);
488        assert_eq!(wrap("at word boundaries, never", 18), vec!["at word", "boundaries, never"], "a comma stays");
489        assert_eq!(wrap("deploy 2026.9.1 done", 12), vec!["deploy", "2026.9.1", "done"]);
490        assert_eq!(wrap("abcdefgh.", 8), vec!["abcdefg", "h."], "a broken word keeps its full stop company");
491        assert_eq!(wrap("add abcdefghijk),", 8), vec!["add abcd", "efghij", "k),"]);
492        assert_eq!(wrap("abcdefghijk.", 4), vec!["abcd", "efgh", "ijk."]);
493        assert_eq!(wrap("........", 4), vec!["....", "...."], "all punctuation still breaks");
494        assert!(wrap("x", 0).is_empty());
495    }
496
497    #[test]
498    fn cjk_closing_punctuation_never_starts_a_line() {
499        assert_eq!(wrap("これはテストです。", 16), vec!["これはテストで", "す。"], "a full stop keeps its company");
500        assert_eq!(wrap("你好,世界", 4), vec!["你", "好,", "世界"], "an ideographic comma stays");
501        assert_eq!(
502            wrap("彼は「はい」と言った", 6),
503            vec!["彼は", "「は", "い」と", "言った"],
504            "brackets hold on to what they enclose"
505        );
506        assert_eq!(wrap("コーヒー", 4), vec!["コー", "ヒー"], "the long vowel mark stays after its kana");
507        assert_eq!(wrap("ちょっと", 6), vec!["ちょっ", "と"], "a small kana stays after the one it follows");
508        for text in ["一二三四五六七八九十、一二三。", "(全角)です!次は?", "設定を保存しました!次へ進みますか?"]
509        {
510            for max in 4..12 {
511                for line in wrap(text, max).iter().skip(1) {
512                    let first = line.graphemes(true).next().unwrap_or_default();
513                    assert!(!is_closing_punctuation(first), "{text:?} at {max}: a line starts with {first:?}");
514                }
515                for line in wrap(text, max) {
516                    let last = line.graphemes(true).next_back().unwrap_or_default();
517                    assert!(
518                        line.graphemes(true).count() == 1 || !is_opening_punctuation(last),
519                        "{text:?} at {max}: a line ends with {last:?}"
520                    );
521                }
522            }
523        }
524    }
525
526    #[test]
527    fn cjk_text_without_spaces_breaks_between_ideographs() {
528        assert_eq!(wrap("防火墙已启用", 4), vec!["防火", "墙已", "启用"]);
529        assert_eq!(wrap("状态 防火墙已启用", 10), vec!["状态 防火", "墙已启用"], "the rest of a line is filled");
530        assert_eq!(wrap("hello 你好世界", 8), vec!["hello 你", "好世界"]);
531        assert_eq!(wrap("Rust で書く", 7), vec!["Rust で", "書く"]);
532        assert_eq!(wrap("パッケージを更新", 10), vec!["パッケージ", "を更新"]);
533        assert_eq!(wrap("안녕하세요 세계", 10), vec!["안녕하세요", "세계"], "Korean words stay whole");
534    }
535
536    #[test]
537    fn no_break_spaces_belong_to_the_word() {
538        assert_eq!(wrap("Est-ce vrai\u{a0}? Oui", 11), vec!["Est-ce", "vrai\u{a0}? Oui"]);
539        assert_eq!(wrap("Attention\u{202f}: fin", 10), vec!["Attentio", "n\u{202f}: fin"]);
540        assert_eq!(wrap("total 10\u{2007}000 kr", 8), vec!["total", "10\u{2007}000", "kr"]);
541        assert_eq!(
542            wrap("Vraiment\u{a0}?", 9),
543            vec!["Vraimen", "t\u{a0}?"],
544            "a broken word keeps its space with the mark"
545        );
546        assert_eq!(wrap("a b\u{a0}c", 3), vec!["a", "b\u{a0}c"]);
547    }
548
549    /// Pins wrapping, truncation and width of text the ASCII fast paths do not take: other
550    /// whitespace, control characters, wide and combining characters, emoji sequences.
551    #[test]
552    fn unusual_text_measures_and_wraps_as_before() {
553        /// Text, width, its lines, their ranges, and the text truncated to the width.
554        type Case = (&'static str, u16, &'static [&'static str], &'static [Range<usize>], &'static str);
555        let cases: [Case; 12] = [
556            ("a\u{a0}b c\u{a0}\u{a0}dd", 3, &["a\u{a0}b", "c", "dd"], &[0..4, 5..6, 10..12], "a\u{a0}…"),
557            ("x\u{3000}y z", 2, &["x", "y", "z"], &[0..1, 4..5, 6..7], "x…"),
558            ("tab\there and\u{b}vt", 4, &["tab", "here", "and", "vt"], &[0..3, 4..8, 9..12, 13..15], "tab…"),
559            (
560                "界界 界界界 e\u{301}e\u{301}e\u{301}",
561                3,
562                &["界", "界", "界", "界", "界", "e\u{301}e\u{301}e\u{301}"],
563                &[0..3, 3..6, 7..10, 10..13, 13..16, 17..26],
564                "界…",
565            ),
566            ("  lead  and trail  ", 5, &["lead", "and", "trail", ""], &[2..6, 8..11, 12..17, 0..0], "  le…"),
567            ("😀😀 ok", 3, &["😀", "😀", "ok"], &[0..4, 4..8, 9..11], "😀…"),
568            ("a\r\nb c", 2, &["a", "b", "c"], &[0..1, 3..4, 5..6], "a…"),
569            ("über straße ünïcödé", 6, &["über", "straße", "ünïcöd", "é"], &[0..5, 6..13, 14..23, 23..25], "über …"),
570            ("x\u{85}y\u{2028}z", 1, &["x", "y", "z"], &[0..1, 3..4, 7..8], "…"),
571            ("control\u{7}bell word", 8, &["control\u{7}", "bell", "word"], &[0..8, 8..12, 13..17], "control…"),
572            (
573                "👨\u{200d}👩\u{200d}👧 family",
574                4,
575                &["👨\u{200d}👩\u{200d}👧 f", "amil", "y"],
576                &[0..20, 20..24, 24..25],
577                "👨\u{200d}👩\u{200d}👧 …",
578            ),
579            ("add abcdefghijk),", 8, &["add abcd", "efghij", "k),"], &[0..8, 8..14, 14..17], "add abc…"),
580        ];
581        for (text, max, lines, ranges, truncated) in cases {
582            assert_eq!(wrap(text, max), lines, "{text:?}");
583            assert_eq!(wrap_ranges(text, max), ranges, "{text:?}");
584            assert_eq!(truncate(text, max), truncated, "{text:?}");
585        }
586        let widths = ["\t", "\u{7}", "\u{b}", "\r\n", "\u{a0}", "~", " ", "👨\u{200d}👩", "\u{7f}", ""].map(width);
587        assert_eq!(widths, [1, 1, 1, 1, 1, 1, 1, 2, 1, 0]);
588    }
589}