Skip to main content

qframe/
text.rs

1//! Measuring text in terminal cells: width, truncation with an ellipsis, and word wrapping.
2
3use std::borrow::Cow;
4use std::ops::Range;
5
6use unicode_segmentation::UnicodeSegmentation;
7use unicode_width::UnicodeWidthStr;
8
9/// The ellipsis drawn where text is cut.
10pub const ELLIPSIS: &str = "…";
11
12/// Display width of `text` in cells.
13#[must_use]
14pub fn width(text: &str) -> u16 {
15    let cells = if is_printable_ascii(text) { text.len() } else { text.width() };
16    u16::try_from(cells).unwrap_or(u16::MAX)
17}
18
19/// Display width of one grapheme cluster.
20#[must_use]
21pub fn grapheme_width(grapheme: &str) -> u16 {
22    width(grapheme)
23}
24
25/// Whether every character of `text` is printable ASCII, one cell and one grapheme cluster each.
26/// Most text a terminal application draws is, and it needs no Unicode tables to measure or split.
27pub(crate) fn is_printable_ascii(text: &str) -> bool {
28    text.bytes().all(|byte| matches!(byte, b' '..=b'~'))
29}
30
31/// `text` cut to at most `max` cells, ending in `…` when anything was removed.
32#[must_use]
33pub fn truncate(text: &str, max: u16) -> Cow<'_, str> {
34    if width(text) <= max {
35        return Cow::Borrowed(text);
36    }
37    if max == 0 {
38        return Cow::Borrowed("");
39    }
40    let budget = max - 1;
41    let mut used = 0u16;
42    let mut out = String::new();
43    for grapheme in text.graphemes(true) {
44        let w = grapheme_width(grapheme);
45        if used + w > budget {
46            break;
47        }
48        used += w;
49        out.push_str(grapheme);
50    }
51    out.push_str(ELLIPSIS);
52    Cow::Owned(out)
53}
54
55/// Splits `text` into lines no wider than `max` cells.
56///
57/// Explicit newlines are kept, words move to the next line whole when they fit on it (with
58/// the punctuation attached to them, so a comma never starts a line), and words longer than a
59/// line are broken between grapheme clusters; the punctuation closing such a word breaks off
60/// with the character before it, so `.` or `)` never starts a line alone either. Spaces at a
61/// break are dropped.
62#[must_use]
63pub fn wrap(text: &str, max: u16) -> Vec<String> {
64    wrap_ranges(text, max).into_iter().map(|range| text[range].to_owned()).collect()
65}
66
67/// Like [`wrap`], but returns byte ranges into `text`, so styled text can be wrapped and drawn
68/// with its styles.
69#[must_use]
70pub fn wrap_ranges(text: &str, max: u16) -> Vec<Range<usize>> {
71    let mut lines = Vec::new();
72    if max == 0 {
73        return lines;
74    }
75    let mut paragraph_start = 0;
76    for paragraph in text.split('\n') {
77        let mut line: Option<Range<usize>> = None;
78        let mut line_width = 0u16;
79        for (offset, word, is_space) in runs(paragraph) {
80            let start = paragraph_start + offset;
81            let end = start + word.len();
82            let word_width = width(word);
83            if is_space {
84                match &mut line {
85                    Some(current) if line_width + word_width <= max => {
86                        current.end = end;
87                        line_width += word_width;
88                    }
89                    Some(_) => {
90                        lines.push(trim_end(text, line.take()));
91                        line_width = 0;
92                    }
93                    None => {}
94                }
95                continue;
96            }
97            if line_width + word_width <= max {
98                line = Some(line.map_or(start..end, |current| current.start..end));
99                line_width += word_width;
100                continue;
101            }
102            if line.is_some() && word_width <= max {
103                lines.push(trim_end(text, line.take()));
104                line = Some(start..end);
105                line_width = word_width;
106                continue;
107            }
108            let graphemes: Vec<(usize, &str)> = word.grapheme_indices(true).collect();
109            // The punctuation closing the word never starts a line alone: it breaks off together
110            // with the grapheme before it, as in `ui.add(Badge::new("Paused"))` + `.`.
111            let tail = graphemes.iter().rposition(|(_, g)| !is_closing_punctuation(g)).unwrap_or(0);
112            let tail_width: u16 = graphemes[tail..].iter().map(|(_, g)| grapheme_width(g)).sum();
113            for (index, (g_offset, grapheme)) in graphemes.iter().enumerate() {
114                let g_start = start + g_offset;
115                let g_end = g_start + grapheme.len();
116                let w = grapheme_width(grapheme);
117                let needed = if index == tail && tail_width <= max { tail_width } else { w };
118                if line_width + needed > max && line.is_some() {
119                    lines.push(trim_end(text, line.take()));
120                    line_width = 0;
121                }
122                line = Some(line.map_or(g_start..g_end, |current| current.start..g_end));
123                line_width += w;
124            }
125        }
126        lines.push(line.map_or(paragraph_start..paragraph_start, |current| trim_end(text, Some(current))));
127        paragraph_start += paragraph.len() + 1;
128    }
129    lines
130}
131
132/// The runs of whitespace and of everything between them, with their byte offsets and whether
133/// they are whitespace: a word keeps its punctuation (`boundaries,`, `2026.9.1`) and moves to the
134/// next line as one.
135fn runs(paragraph: &str) -> impl Iterator<Item = (usize, &str, bool)> {
136    let mut position = 0;
137    std::iter::from_fn(move || {
138        let start = position;
139        let (space, first) = char_at(paragraph, start)?;
140        position += first;
141        while let Some((_, len)) = char_at(paragraph, position).filter(|&(next, _)| next == space) {
142            position += len;
143        }
144        Some((start, &paragraph[start..position], space))
145    })
146}
147
148/// Whether the character starting at byte `index` of `text` is whitespace, and its length in
149/// bytes; `None` past the end. Wrapping looks at every character of a text, and ASCII, most of
150/// what is wrapped, needs no decoding.
151fn char_at(text: &str, index: usize) -> Option<(bool, usize)> {
152    let byte = *text.as_bytes().get(index)?;
153    if byte.is_ascii() {
154        return Some((char::from(byte).is_whitespace(), 1));
155    }
156    text.get(index..)?.chars().next().map(|c| (c.is_whitespace(), c.len_utf8()))
157}
158
159/// Whether `grapheme` is punctuation that closes what comes before it and must not start a line.
160fn is_closing_punctuation(grapheme: &str) -> bool {
161    grapheme
162        .chars()
163        .all(|c| matches!(c, '.' | ',' | ';' | ':' | '!' | '?' | ')' | ']' | '}' | '"' | '\'' | '…' | '’' | '”' | '»'))
164}
165
166fn trim_end(text: &str, range: Option<Range<usize>>) -> Range<usize> {
167    let range = range.unwrap_or(0..0);
168    let trimmed = text[range.clone()].trim_end();
169    range.start..range.start + trimmed.len()
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn measures_wide_and_combining_text() {
178        assert_eq!(width("abc"), 3);
179        assert_eq!(width("çığ"), 3);
180        assert_eq!(width("界"), 2);
181        assert_eq!(width("e\u{301}"), 1);
182    }
183
184    #[test]
185    fn truncates_with_ellipsis_by_cells() {
186        assert_eq!(truncate("quvyta", 10), "quvyta");
187        assert_eq!(truncate("quvyta-framework", 8), "quvyta-…");
188        assert_eq!(truncate("界界界", 4), "界…");
189        assert_eq!(truncate("abc", 0), "");
190        assert_eq!(width(&truncate("quvyta-framework", 8)), 8);
191    }
192
193    #[test]
194    fn wraps_words_and_breaks_long_ones() {
195        assert_eq!(wrap("the quick brown fox", 9), vec!["the quick", "brown fox"]);
196        assert_eq!(wrap("abcdefghij", 4), vec!["abcd", "efgh", "ij"]);
197        assert_eq!(wrap("a\n\nb", 5), vec!["a", "", "b"]);
198        assert_eq!(wrap("one  two", 4), vec!["one", "two"]);
199        assert_eq!(wrap("at word boundaries, never", 18), vec!["at word", "boundaries, never"], "a comma stays");
200        assert_eq!(wrap("deploy 2026.9.1 done", 12), vec!["deploy", "2026.9.1", "done"]);
201        assert_eq!(wrap("abcdefgh.", 8), vec!["abcdefg", "h."], "a broken word keeps its full stop company");
202        assert_eq!(wrap("add abcdefghijk),", 8), vec!["add abcd", "efghij", "k),"]);
203        assert_eq!(wrap("abcdefghijk.", 4), vec!["abcd", "efgh", "ijk."]);
204        assert_eq!(wrap("........", 4), vec!["....", "...."], "all punctuation still breaks");
205        assert!(wrap("x", 0).is_empty());
206    }
207
208    /// Pins wrapping, truncation and width of text the ASCII fast paths do not take: other
209    /// whitespace, control characters, wide and combining characters, emoji sequences.
210    #[test]
211    fn unusual_text_measures_and_wraps_as_before() {
212        /// Text, width, its lines, their ranges, and the text truncated to the width.
213        type Case = (&'static str, u16, &'static [&'static str], &'static [Range<usize>], &'static str);
214        let cases: [Case; 12] = [
215            ("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}…"),
216            ("x\u{3000}y z", 2, &["x", "y", "z"], &[0..1, 4..5, 6..7], "x…"),
217            ("tab\there and\u{b}vt", 4, &["tab", "here", "and", "vt"], &[0..3, 4..8, 9..12, 13..15], "tab…"),
218            (
219                "界界 界界界 e\u{301}e\u{301}e\u{301}",
220                3,
221                &["界", "界", "界", "界", "界", "e\u{301}e\u{301}e\u{301}"],
222                &[0..3, 3..6, 7..10, 10..13, 13..16, 17..26],
223                "界…",
224            ),
225            ("  lead  and trail  ", 5, &["lead", "and", "trail", ""], &[2..6, 8..11, 12..17, 0..0], "  le…"),
226            ("😀😀 ok", 3, &["😀", "😀", "ok"], &[0..4, 4..8, 9..11], "😀…"),
227            ("a\r\nb c", 2, &["a", "b", "c"], &[0..1, 3..4, 5..6], "a…"),
228            ("über straße ünïcödé", 6, &["über", "straße", "ünïcöd", "é"], &[0..5, 6..13, 14..23, 23..25], "über …"),
229            ("x\u{85}y\u{2028}z", 1, &["x", "y", "z"], &[0..1, 3..4, 7..8], "…"),
230            ("control\u{7}bell word", 8, &["control\u{7}", "bell", "word"], &[0..8, 8..12, 13..17], "control…"),
231            (
232                "👨\u{200d}👩\u{200d}👧 family",
233                4,
234                &["👨\u{200d}👩\u{200d}👧 f", "amil", "y"],
235                &[0..20, 20..24, 24..25],
236                "👨\u{200d}👩\u{200d}👧 …",
237            ),
238            ("add abcdefghijk),", 8, &["add abcd", "efghij", "k),"], &[0..8, 8..14, 14..17], "add abc…"),
239        ];
240        for (text, max, lines, ranges, truncated) in cases {
241            assert_eq!(wrap(text, max), lines, "{text:?}");
242            assert_eq!(wrap_ranges(text, max), ranges, "{text:?}");
243            assert_eq!(truncate(text, max), truncated, "{text:?}");
244        }
245        let widths = ["\t", "\u{7}", "\u{b}", "\r\n", "\u{a0}", "~", " ", "👨\u{200d}👩", "\u{7f}", ""].map(width);
246        assert_eq!(widths, [1, 1, 1, 1, 1, 1, 1, 2, 1, 0]);
247    }
248}