quvyta-framework 0.1.29

A Rust framework for building terminal applications
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
//! Measuring text in terminal cells: width, truncation with an ellipsis at the end or in the
//! middle, and word wrapping.

use std::borrow::Cow;
use std::ops::Range;

use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;

/// The ellipsis drawn where text is cut.
///
/// [`truncate`] and [`truncate_middle`] always write this mark: they measure text and know
/// nothing of the terminal. In ASCII glyph mode, where a terminal cannot show `…`, the painter
/// ([`PaintCx::text`](crate::widget::PaintCx::text)) draws [`ASCII_ELLIPSIS`] in its cell
/// instead, so every widget that cuts text is covered at once and the cut takes the same cell.
pub const ELLIPSIS: &str = "…";

/// The mark that stands for [`ELLIPSIS`] in ASCII glyph mode.
///
/// A tilde, one cell like the ellipsis it replaces, so a cut text is exactly as wide in every
/// mode and nothing measured with [`width`] moves. It is the cut mark ASCII terminals already
/// know from shortened file names (`PROGRA~1`, a file manager's `long~name.txt`); a period would
/// read as the end of a sentence or, in the middle of a path, as part of the name, and `...`
/// would take two more cells from a column that is already too narrow.
pub const ASCII_ELLIPSIS: &str = "~";

/// Display width of `text` in cells.
#[must_use]
pub fn width(text: &str) -> u16 {
    let cells = if is_printable_ascii(text) { text.len() } else { text.width() };
    u16::try_from(cells).unwrap_or(u16::MAX)
}

/// Display width of one grapheme cluster.
#[must_use]
pub fn grapheme_width(grapheme: &str) -> u16 {
    width(grapheme)
}

/// Whether every character of `text` is printable ASCII, one cell and one grapheme cluster each.
/// Most text a terminal application draws is, and it needs no Unicode tables to measure or split.
pub(crate) fn is_printable_ascii(text: &str) -> bool {
    text.bytes().all(|byte| matches!(byte, b' '..=b'~'))
}

/// Cells between a terminal's tab stops.
const TAB_STOP: usize = 8;

/// One line written for a terminal, as the terminal would leave it on screen: what a carriage
/// return wrote over is gone, colour and cursor sequences are taken out, a tab becomes spaces up
/// to the next stop of eight and no other control character is left.
///
/// Programs print for a terminal: a progress line redraws itself after `\r`, a build tool colours
/// its words with escape sequences. A cell cannot hold a control character, so text from another
/// program goes through this before it is drawn. Text with nothing to change is borrowed.
///
/// ```
/// use qframe::text::printable;
/// assert_eq!(printable("10%\r50%\r100%"), "100%");
/// assert_eq!(printable("sent 2kB\r\r"), "sent 2kB");
/// assert_eq!(printable("\u{1b}[1;32mok\u{1b}[0m done"), "ok done");
/// assert_eq!(printable("a\tb"), "a       b");
/// ```
#[must_use]
pub fn printable(text: &str) -> Cow<'_, str> {
    if !text.chars().any(char::is_control) {
        return Cow::Borrowed(text);
    }
    // A carriage return starts the line over; one at the very end leaves what came before it.
    let shown = text.split('\r').rev().find(|part| !part.is_empty()).unwrap_or_default();
    let mut out = String::with_capacity(shown.len());
    let mut chars = shown.chars().peekable();
    while let Some(c) = chars.next() {
        match c {
            // `ESC [` runs to its final byte; `ESC ]` to BEL or `ESC \`; any other escape takes
            // its intermediate bytes and one final character, as `ESC ( B` does. A sequence cut
            // off at the end of the line takes the rest.
            '\u{1b}' => match chars.next() {
                Some('[') => while chars.next().is_some_and(|c| !('@'..='~').contains(&c)) {},
                Some(']') => {
                    while let Some(c) = chars.next() {
                        if c == '\u{7}' || (c == '\u{1b}' && chars.next_if_eq(&'\\').is_some()) {
                            break;
                        }
                    }
                }
                Some(' '..='/') => {
                    while chars.next_if(|c| (' '..='/').contains(c)).is_some() {}
                    chars.next();
                }
                _ => {}
            },
            '\t' => {
                let column = usize::from(width(&out));
                out.extend(std::iter::repeat_n(' ', TAB_STOP - column % TAB_STOP));
            }
            c if c.is_control() => {}
            c => out.push(c),
        }
    }
    Cow::Owned(out)
}

/// `text` cut to at most `max` cells, ending in `…` when anything was removed.
#[must_use]
pub fn truncate(text: &str, max: u16) -> Cow<'_, str> {
    if width(text) <= max {
        return Cow::Borrowed(text);
    }
    if max == 0 {
        return Cow::Borrowed("");
    }
    let budget = max - 1;
    let mut used = 0u16;
    let mut out = String::new();
    for grapheme in text.graphemes(true) {
        let w = grapheme_width(grapheme);
        if used + w > budget {
            break;
        }
        used += w;
        out.push_str(grapheme);
    }
    out.push_str(ELLIPSIS);
    Cow::Owned(out)
}

/// `text` cut to at most `max` cells by removing its middle, with `…` where the middle was.
///
/// For paths and other text whose start and end both matter: the head says where, the tail
/// says what. Text that fits is returned unchanged. Otherwise the cells left after the
/// ellipsis are shared between head and tail, the tail getting the odd one; a grapheme cluster
/// (a wide character, a letter with its combining marks) is never split, and a cell one side
/// cannot use goes to the other. With `max` 1 only the ellipsis remains, and with 0 nothing.
///
/// ```
/// use qframe::text::{truncate_middle, width};
///
/// let path = "~/.config/quvyta/launcher.conf";
/// assert_eq!(truncate_middle(path, 40), path);
/// assert_eq!(truncate_middle(path, 20), "~/.config…ncher.conf");
/// assert_eq!(width(&truncate_middle("~/文書/設定/launcher.conf", 12)), 12);
/// ```
#[must_use]
pub fn truncate_middle(text: &str, max: u16) -> Cow<'_, str> {
    if width(text) <= max {
        return Cow::Borrowed(text);
    }
    if max == 0 {
        return Cow::Borrowed("");
    }
    let budget = max - 1;
    let graphemes: Vec<&str> = text.graphemes(true).collect();
    let (mut head, mut head_used) = fitting(graphemes.iter(), budget / 2);
    let (tail, tail_used) = fitting(graphemes[head..].iter().rev(), budget - head_used);
    // A wide character at the tail's edge can leave a cell the head is able to use.
    let (more, more_used) = fitting(graphemes[head..graphemes.len() - tail].iter(), budget - head_used - tail_used);
    head += more;
    head_used += more_used;
    debug_assert!(head_used + tail_used <= budget);
    let mut out = graphemes[..head].concat();
    out.push_str(ELLIPSIS);
    out.push_str(&graphemes[graphemes.len() - tail..].concat());
    Cow::Owned(out)
}

/// How many of `graphemes`, taken in order, fit in `budget` cells, and the cells they use.
fn fitting<'a>(graphemes: impl Iterator<Item = &'a &'a str>, budget: u16) -> (usize, u16) {
    let mut count = 0;
    let mut used = 0u16;
    for grapheme in graphemes {
        let w = grapheme_width(grapheme);
        if used + w > budget {
            break;
        }
        used += w;
        count += 1;
    }
    (count, used)
}

/// Splits `text` into lines no wider than `max` cells.
///
/// Explicit newlines are kept, words move to the next line whole when they fit on it (with
/// the punctuation attached to them, so a comma never starts a line), and words longer than a
/// line are broken between grapheme clusters; the punctuation closing such a word breaks off
/// with the character before it, so `.` or `)` never starts a line alone either. Spaces at a
/// break are dropped; no-break spaces (U+00A0, U+202F, U+2007) are part of the word.
///
/// Chinese and Japanese, written without spaces, may break between any two ideographs or
/// kana, except that closing punctuation (`。` `、` `」` `!`), the long vowel mark `ー` and
/// small kana never start a line and opening brackets (`「` `(`) never end one. Only a run of
/// such marks wider than the line itself breaks the rule.
#[must_use]
pub fn wrap(text: &str, max: u16) -> Vec<String> {
    wrap_ranges(text, max).into_iter().map(|range| text[range].to_owned()).collect()
}

/// Like [`wrap`], but returns byte ranges into `text`, so styled text can be wrapped and drawn
/// with its styles.
#[must_use]
pub fn wrap_ranges(text: &str, max: u16) -> Vec<Range<usize>> {
    let mut lines = Vec::new();
    if max == 0 {
        return lines;
    }
    let mut paragraph_start = 0;
    for paragraph in text.split('\n') {
        let mut line: Option<Range<usize>> = None;
        let mut line_width = 0u16;
        for (offset, word, is_space) in runs(paragraph).flat_map(|(offset, run, space)| pieces(offset, run, space)) {
            let start = paragraph_start + offset;
            let end = start + word.len();
            let word_width = width(word);
            if is_space {
                match &mut line {
                    Some(current) if line_width + word_width <= max => {
                        current.end = end;
                        line_width += word_width;
                    }
                    Some(_) => {
                        lines.push(trim_end(text, line.take()));
                        line_width = 0;
                    }
                    None => {}
                }
                continue;
            }
            if line_width + word_width <= max {
                line = Some(line.map_or(start..end, |current| current.start..end));
                line_width += word_width;
                continue;
            }
            if line.is_some() && word_width <= max {
                lines.push(trim_end(text, line.take()));
                line = Some(start..end);
                line_width = word_width;
                continue;
            }
            let graphemes: Vec<(usize, &str)> = word.grapheme_indices(true).collect();
            // The punctuation closing the word never starts a line alone: it breaks off together
            // with the grapheme before it, as in `ui.add(Badge::new("Paused"))` + `.`.
            // A no-break space before that punctuation (French `vrai\u{a0}?`) goes with it too.
            let tail =
                graphemes.iter().rposition(|(_, g)| !is_closing_punctuation(g) && !is_no_break_space(g)).unwrap_or(0);
            let tail_width: u16 = graphemes[tail..].iter().map(|(_, g)| grapheme_width(g)).sum();
            for (index, (g_offset, grapheme)) in graphemes.iter().enumerate() {
                let g_start = start + g_offset;
                let g_end = g_start + grapheme.len();
                let w = grapheme_width(grapheme);
                let needed = if index == tail && tail_width <= max { tail_width } else { w };
                if line_width + needed > max && line.is_some() {
                    lines.push(trim_end(text, line.take()));
                    line_width = 0;
                }
                line = Some(line.map_or(g_start..g_end, |current| current.start..g_end));
                line_width += w;
            }
        }
        lines.push(line.map_or(paragraph_start..paragraph_start, |current| trim_end(text, Some(current))));
        paragraph_start += paragraph.len() + 1;
    }
    lines
}

/// The runs of whitespace and of everything between them, with their byte offsets and whether
/// they are whitespace: a word keeps its punctuation (`boundaries,`, `2026.9.1`) and moves to the
/// next line as one.
fn runs(paragraph: &str) -> impl Iterator<Item = (usize, &str, bool)> {
    let mut position = 0;
    std::iter::from_fn(move || {
        let start = position;
        let (space, first) = char_at(paragraph, start)?;
        position += first;
        while let Some((_, len)) = char_at(paragraph, position).filter(|&(next, _)| next == space) {
            position += len;
        }
        Some((start, &paragraph[start..position], space))
    })
}

/// The pieces a run from [`runs`] may break between: the run itself, unless it holds Chinese or
/// Japanese, which is written without spaces and breaks between ideographs and kana instead.
fn pieces(offset: usize, run: &str, space: bool) -> impl Iterator<Item = (usize, &str, bool)> {
    let mut ends = Vec::new();
    if !space && !is_printable_ascii(run) {
        let graphemes: Vec<(usize, &str)> = run.grapheme_indices(true).collect();
        ends.extend(graphemes.windows(2).filter(|pair| may_break_between(pair[0].1, pair[1].1)).map(|pair| pair[1].0));
    }
    ends.push(run.len());
    let mut start = 0;
    ends.into_iter().map(move |end| {
        let piece = (offset + start, &run[start..end], space);
        start = end;
        piece
    })
}

/// Whether a line may break between two graphemes of one run: only next to Chinese or Japanese,
/// and never before closing punctuation or after an opening bracket (the kinsoku rule).
fn may_break_between(before: &str, after: &str) -> bool {
    (is_cjk(before) || is_cjk(after)) && !is_closing_punctuation(after) && !is_opening_punctuation(before)
}

/// Whether `grapheme` is Chinese or Japanese text that breaks between its characters: ideographs,
/// kana, CJK punctuation and fullwidth forms. Hangul is not: Korean separates words with spaces.
fn is_cjk(grapheme: &str) -> bool {
    grapheme.chars().next().is_some_and(|c| {
        matches!(c,
            '\u{2E80}'..='\u{2FDF}'      // radicals
            | '\u{3000}'..='\u{30FF}'    // CJK punctuation, hiragana, katakana
            | '\u{31C0}'..='\u{31FF}'    // strokes, katakana extensions
            | '\u{3400}'..='\u{4DBF}'    // extension A
            | '\u{4E00}'..='\u{9FFF}'    // unified ideographs
            | '\u{F900}'..='\u{FAFF}'    // compatibility ideographs
            | '\u{FE30}'..='\u{FE4F}'    // vertical and compatibility forms
            | '\u{FF00}'..='\u{FFEF}'    // fullwidth and halfwidth forms
            | '\u{20000}'..='\u{3FFFF}') // supplementary ideographs
    })
}

/// Whether the character starting at byte `index` of `text` is a space a line may break at, and
/// its length in bytes; `None` past the end. Wrapping looks at every character of a text, and
/// ASCII, most of what is wrapped, needs no decoding.
fn char_at(text: &str, index: usize) -> Option<(bool, usize)> {
    let byte = *text.as_bytes().get(index)?;
    if byte.is_ascii() {
        return Some((char::from(byte).is_whitespace(), 1));
    }
    text.get(index..)?.chars().next().map(|c| (c.is_whitespace() && !is_no_break(c), c.len_utf8()))
}

/// Whether `c` is a space that binds the words on either side: French puts one before `?` and
/// `:`, and numbers group their digits with one.
fn is_no_break(c: char) -> bool {
    matches!(c, '\u{A0}' | '\u{202F}' | '\u{2007}')
}

fn is_no_break_space(grapheme: &str) -> bool {
    grapheme.chars().all(is_no_break)
}

/// Whether `grapheme` is punctuation that closes what comes before it and must not start a line.
/// Chinese and Japanese add their own full stops, commas and brackets, the long vowel mark,
/// iteration marks and the small kana that belong to the syllable before them.
fn is_closing_punctuation(grapheme: &str) -> bool {
    grapheme.chars().all(|c| {
        matches!(
            c,
            '.' | ',' | ';' | ':' | '!' | '?' | ')' | ']' | '}' | '"' | '\'' | '…' | '’' | '”' | '»'
                | '、' | '。' | '〃' | '々' | '〉' | '》' | '」' | '』' | '】' | '〕' | '〗' | '〙' | '〛' | '〞' | '〟'
                | '〻' | '・' | 'ー' | 'ゝ' | 'ゞ' | 'ヽ' | 'ヾ' | '゛' | '゜' | '゠' | '‼' | '⁇' | '⁈' | '⁉'
                | 'ぁ' | 'ぃ' | 'ぅ' | 'ぇ' | 'ぉ' | 'っ' | 'ゃ' | 'ゅ' | 'ょ' | 'ゎ' | 'ゕ' | 'ゖ'
                | 'ァ' | 'ィ' | 'ゥ' | 'ェ' | 'ォ' | 'ッ' | 'ャ' | 'ュ' | 'ョ' | 'ヮ' | 'ヵ' | 'ヶ'
                | '\u{31F0}'..='\u{31FF}'
                | '!' | ')' | ',' | '.' | ':' | ';' | '?' | ']' | '}' | '⦆' | '。' | '」' | '、' | '・' | 'ー'
                | 'ァ'..='ッ' | '゙' | '゚' | '%' | '〜' | '~'
        )
    })
}

/// Whether `grapheme` opens what comes after it and must not end a line.
fn is_opening_punctuation(grapheme: &str) -> bool {
    grapheme.chars().all(|c| {
        matches!(
            c,
            '(' | '['
                | '{'
                | '‘'
                | '“'
                | '«'
                | '〈'
                | '《'
                | '「'
                | '『'
                | '【'
                | '〔'
                | '〖'
                | '〘'
                | '〚'
                | '〝'
                | '('
                | '['
                | '{'
                | '⦅'
                | '「'
        )
    })
}

fn trim_end(text: &str, range: Option<Range<usize>>) -> Range<usize> {
    let range = range.unwrap_or(0..0);
    let trimmed = text[range.clone()].trim_end();
    range.start..range.start + trimmed.len()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn the_ascii_cut_mark_is_ascii_and_as_wide_as_the_ellipsis() {
        assert!(is_printable_ascii(ASCII_ELLIPSIS));
        assert_eq!(width(ASCII_ELLIPSIS), width(ELLIPSIS), "a cut text is as wide in every glyph mode");
        assert_eq!(width(ELLIPSIS), 1);
    }

    #[test]
    fn measures_wide_and_combining_text() {
        assert_eq!(width("abc"), 3);
        assert_eq!(width("çığ"), 3);
        assert_eq!(width("界"), 2);
        assert_eq!(width("e\u{301}"), 1);
    }

    #[test]
    fn truncates_with_ellipsis_by_cells() {
        assert_eq!(truncate("quvyta", 10), "quvyta");
        assert_eq!(truncate("quvyta-framework", 8), "quvyta-…");
        assert_eq!(truncate("界界界", 4), "界…");
        assert_eq!(truncate("abc", 0), "");
        assert_eq!(width(&truncate("quvyta-framework", 8)), 8);
    }

    #[test]
    fn printable_leaves_what_a_terminal_would_show() {
        assert!(matches!(printable("plain 防火墙"), Cow::Borrowed(_)), "nothing to change is borrowed");
        assert_eq!(printable("\u{1b}]0;title\u{7}shown"), "shown", "a title sequence ends at the bell");
        assert_eq!(printable("\u{1b}]8;;url\u{1b}\\link"), "link", "or at ESC backslash");
        assert_eq!(printable("cut \u{1b}[38;2;1"), "cut ", "a sequence cut off takes the rest");
        assert_eq!(printable("\u{1b}(Bx"), "x", "a two-character escape");
        assert_eq!(printable("防\tx"), "防      x", "a tab counts the cells before it");
        assert_eq!(printable("\r\r"), "", "nothing but returns leaves nothing");
    }

    #[test]
    fn truncate_middle_returns_text_that_fits_unchanged() {
        assert!(matches!(truncate_middle("launcher.conf", 13), Cow::Borrowed("launcher.conf")));
        assert!(matches!(truncate_middle("", 0), Cow::Borrowed("")));
    }

    #[test]
    fn truncate_middle_keeps_head_and_tail_of_a_path() {
        let path = "~/.config/quvyta/launcher.conf";
        assert_eq!(truncate_middle(path, 25), "~/.config/qu…auncher.conf");
        assert_eq!(truncate_middle(path, 20), "~/.config…ncher.conf", "the tail gets the odd cell");
        assert_eq!(truncate_middle(path, 5), "~/…nf");
        for max in 0..=30 {
            assert_eq!(width(&truncate_middle(path, max)), max, "{max}");
        }
    }

    #[test]
    fn truncate_middle_never_splits_wide_characters() {
        let path = "~/文書/設定/launcher.conf";
        assert_eq!(width(path), 25);
        // The head cannot use its fifth cell for half of 書, so the tail takes it.
        assert_eq!(truncate_middle(path, 12), "~/文…er.conf");
        assert_eq!(truncate_middle("界界界界界界", 6), "界…界", "one cell stays empty rather than half a character");
        for max in 0..=25 {
            assert!(width(&truncate_middle(path, max)) <= max, "{max}");
            assert!(width(&truncate_middle("界界界界界界", max)) <= max, "{max}");
        }
    }

    #[test]
    fn truncate_middle_keeps_combining_marks_with_their_letter() {
        let accented = "e\u{301}e\u{301}e\u{301}e\u{301}e\u{301}";
        assert_eq!(truncate_middle(accented, 4), "e\u{301}…e\u{301}e\u{301}");
        assert_eq!(truncate_middle("café\u{301}s/ünïcödé\u{301}", 7), "caf…ödé\u{301}");
    }

    #[test]
    fn truncate_middle_at_tiny_widths() {
        assert_eq!(truncate_middle("launcher.conf", 0), "");
        assert_eq!(truncate_middle("launcher.conf", 1), "…");
        assert_eq!(truncate_middle("launcher.conf", 2), "…f");
        assert_eq!(truncate_middle("文書", 2), "…", "a wide tail does not fit in one cell");
        assert_eq!(truncate_middle("文書", 3), "…書");
    }

    #[test]
    fn wraps_words_and_breaks_long_ones() {
        assert_eq!(wrap("the quick brown fox", 9), vec!["the quick", "brown fox"]);
        assert_eq!(wrap("abcdefghij", 4), vec!["abcd", "efgh", "ij"]);
        assert_eq!(wrap("a\n\nb", 5), vec!["a", "", "b"]);
        assert_eq!(wrap("one  two", 4), vec!["one", "two"]);
        assert_eq!(wrap("at word boundaries, never", 18), vec!["at word", "boundaries, never"], "a comma stays");
        assert_eq!(wrap("deploy 2026.9.1 done", 12), vec!["deploy", "2026.9.1", "done"]);
        assert_eq!(wrap("abcdefgh.", 8), vec!["abcdefg", "h."], "a broken word keeps its full stop company");
        assert_eq!(wrap("add abcdefghijk),", 8), vec!["add abcd", "efghij", "k),"]);
        assert_eq!(wrap("abcdefghijk.", 4), vec!["abcd", "efgh", "ijk."]);
        assert_eq!(wrap("........", 4), vec!["....", "...."], "all punctuation still breaks");
        assert!(wrap("x", 0).is_empty());
    }

    #[test]
    fn cjk_closing_punctuation_never_starts_a_line() {
        assert_eq!(wrap("これはテストです。", 16), vec!["これはテストで", "す。"], "a full stop keeps its company");
        assert_eq!(wrap("你好,世界", 4), vec!["你", "好,", "世界"], "an ideographic comma stays");
        assert_eq!(
            wrap("彼は「はい」と言った", 6),
            vec!["彼は", "「は", "い」と", "言った"],
            "brackets hold on to what they enclose"
        );
        assert_eq!(wrap("コーヒー", 4), vec!["コー", "ヒー"], "the long vowel mark stays after its kana");
        assert_eq!(wrap("ちょっと", 6), vec!["ちょっ", "と"], "a small kana stays after the one it follows");
        for text in ["一二三四五六七八九十、一二三。", "(全角)です!次は?", "設定を保存しました!次へ進みますか?"]
        {
            for max in 4..12 {
                for line in wrap(text, max).iter().skip(1) {
                    let first = line.graphemes(true).next().unwrap_or_default();
                    assert!(!is_closing_punctuation(first), "{text:?} at {max}: a line starts with {first:?}");
                }
                for line in wrap(text, max) {
                    let last = line.graphemes(true).next_back().unwrap_or_default();
                    assert!(
                        line.graphemes(true).count() == 1 || !is_opening_punctuation(last),
                        "{text:?} at {max}: a line ends with {last:?}"
                    );
                }
            }
        }
    }

    #[test]
    fn cjk_text_without_spaces_breaks_between_ideographs() {
        assert_eq!(wrap("防火墙已启用", 4), vec!["防火", "墙已", "启用"]);
        assert_eq!(wrap("状态 防火墙已启用", 10), vec!["状态 防火", "墙已启用"], "the rest of a line is filled");
        assert_eq!(wrap("hello 你好世界", 8), vec!["hello 你", "好世界"]);
        assert_eq!(wrap("Rust で書く", 7), vec!["Rust で", "書く"]);
        assert_eq!(wrap("パッケージを更新", 10), vec!["パッケージ", "を更新"]);
        assert_eq!(wrap("안녕하세요 세계", 10), vec!["안녕하세요", "세계"], "Korean words stay whole");
    }

    #[test]
    fn no_break_spaces_belong_to_the_word() {
        assert_eq!(wrap("Est-ce vrai\u{a0}? Oui", 11), vec!["Est-ce", "vrai\u{a0}? Oui"]);
        assert_eq!(wrap("Attention\u{202f}: fin", 10), vec!["Attentio", "n\u{202f}: fin"]);
        assert_eq!(wrap("total 10\u{2007}000 kr", 8), vec!["total", "10\u{2007}000", "kr"]);
        assert_eq!(
            wrap("Vraiment\u{a0}?", 9),
            vec!["Vraimen", "t\u{a0}?"],
            "a broken word keeps its space with the mark"
        );
        assert_eq!(wrap("a b\u{a0}c", 3), vec!["a", "b\u{a0}c"]);
    }

    /// Pins wrapping, truncation and width of text the ASCII fast paths do not take: other
    /// whitespace, control characters, wide and combining characters, emoji sequences.
    #[test]
    fn unusual_text_measures_and_wraps_as_before() {
        /// Text, width, its lines, their ranges, and the text truncated to the width.
        type Case = (&'static str, u16, &'static [&'static str], &'static [Range<usize>], &'static str);
        let cases: [Case; 12] = [
            ("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}…"),
            ("x\u{3000}y z", 2, &["x", "y", "z"], &[0..1, 4..5, 6..7], "x…"),
            ("tab\there and\u{b}vt", 4, &["tab", "here", "and", "vt"], &[0..3, 4..8, 9..12, 13..15], "tab…"),
            (
                "界界 界界界 e\u{301}e\u{301}e\u{301}",
                3,
                &["界", "界", "界", "界", "界", "e\u{301}e\u{301}e\u{301}"],
                &[0..3, 3..6, 7..10, 10..13, 13..16, 17..26],
                "界…",
            ),
            ("  lead  and trail  ", 5, &["lead", "and", "trail", ""], &[2..6, 8..11, 12..17, 0..0], "  le…"),
            ("😀😀 ok", 3, &["😀", "😀", "ok"], &[0..4, 4..8, 9..11], "😀…"),
            ("a\r\nb c", 2, &["a", "b", "c"], &[0..1, 3..4, 5..6], "a…"),
            ("über straße ünïcödé", 6, &["über", "straße", "ünïcöd", "é"], &[0..5, 6..13, 14..23, 23..25], "über …"),
            ("x\u{85}y\u{2028}z", 1, &["x", "y", "z"], &[0..1, 3..4, 7..8], "…"),
            ("control\u{7}bell word", 8, &["control\u{7}", "bell", "word"], &[0..8, 8..12, 13..17], "control…"),
            (
                "👨\u{200d}👩\u{200d}👧 family",
                4,
                &["👨\u{200d}👩\u{200d}👧 f", "amil", "y"],
                &[0..20, 20..24, 24..25],
                "👨\u{200d}👩\u{200d}👧 …",
            ),
            ("add abcdefghijk),", 8, &["add abcd", "efghij", "k),"], &[0..8, 8..14, 14..17], "add abc…"),
        ];
        for (text, max, lines, ranges, truncated) in cases {
            assert_eq!(wrap(text, max), lines, "{text:?}");
            assert_eq!(wrap_ranges(text, max), ranges, "{text:?}");
            assert_eq!(truncate(text, max), truncated, "{text:?}");
        }
        let widths = ["\t", "\u{7}", "\u{b}", "\r\n", "\u{a0}", "~", " ", "👨\u{200d}👩", "\u{7f}", ""].map(width);
        assert_eq!(widths, [1, 1, 1, 1, 1, 1, 1, 2, 1, 0]);
    }
}