Skip to main content

makeover_tui/
text.rs

1//! Words into cells.
2//!
3//! A terminal wraps on words and counts rows, and both halves have to agree or
4//! a node draws over the one under it. So the wrap is written once here and
5//! both [`height`] and [`draw`] read it, rather than each having its own idea
6//! of how many rows a paragraph takes.
7//!
8//! Arrived here from `quasi-tui` in 0.16.0, which is where it was written and
9//! where it stopped being quasi's: nothing below is about a described screen.
10//! Flow layout is the shape every terminal consumer in the tree ends up with —
11//! ask for a height at a width, then draw into the rect you were given — and it
12//! needs a wrap that answers both questions the same way. ratatui's own
13//! `Paragraph` wraps but will not tell you how many rows it took, which is the
14//! half a flow layout cannot do without.
15//!
16//! Width is counted in `char`s. That is wrong for a terminal in the general
17//! case -- a CJK glyph occupies two cells and a combining mark none -- and it
18//! is deliberately not fixed here: the fix is a `unicode-width` dependency, and
19//! taking one before anything in the tree has non-ASCII content to draw is
20//! paying for a problem nobody has yet. Filed rather than hidden.
21
22use ratatui::buffer::Buffer;
23use ratatui::layout::Rect;
24use ratatui::style::Style;
25use ratatui::text::{Line, Span};
26
27/// Break `spans` into lines no wider than `width`, keeping each word under the
28/// style it arrived with.
29///
30/// Breaks on whitespace, and breaks inside a word only when the word cannot fit
31/// on a line of its own. A word longer than the whole width is the case that
32/// has no good answer; cutting it is the least bad one, because the alternative
33/// is a line wider than the region and a buffer that swallows the overflow
34/// silently.
35///
36/// The one wrap in this crate. [`wrap`] is this with a single style over the
37/// whole string, rather than a second implementation that would be free to
38/// disagree with it about how many rows a paragraph takes -- and a disagreement
39/// there is a node drawing over the one under it.
40pub fn wrap_spans(spans: &[Span<'_>], width: u16) -> Vec<Line<'static>> {
41    if width == 0 {
42        return Vec::new();
43    }
44    let width = width as usize;
45    let mut lines: Vec<Vec<Span<'static>>> = Vec::new();
46    let mut line: Vec<Span<'static>> = Vec::new();
47    let mut column = 0usize;
48    // The style of the whitespace last passed over, held until a word turns up
49    // to need a separator before it. Kept rather than taken from the word,
50    // because the space between `*lean*` and `~~gone~~` belongs to the plain
51    // run that held it: a strikethrough that starts one cell early is drawn
52    // through a space the author never struck.
53    let mut separator: Option<Style> = None;
54
55    for span in spans {
56        let mut rest: &str = span.content.as_ref();
57        while !rest.is_empty() {
58            let gap = rest
59                .find(|c: char| !c.is_whitespace())
60                .unwrap_or(rest.len());
61            if gap > 0 {
62                // Authored breaks are breaks. A description that put a newline
63                // in a string meant it, and rewrapping across it would join two
64                // paragraphs.
65                for _ in 0..rest[..gap].matches('\n').count() {
66                    lines.push(std::mem::take(&mut line));
67                    column = 0;
68                }
69                separator = Some(span.style);
70                rest = &rest[gap..];
71                continue;
72            }
73
74            let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
75            let (mut word, after) = rest.split_at(end);
76            rest = after;
77
78            // A word too long for any line, cut to fit rather than overflowed.
79            // The case has no good answer; cutting is the least bad one,
80            // because the alternative is a line wider than the region and a
81            // buffer that swallows the overflow silently.
82            while word.chars().count() > width {
83                if column > 0 {
84                    lines.push(std::mem::take(&mut line));
85                    column = 0;
86                }
87                let cut = word
88                    .char_indices()
89                    .nth(width)
90                    .map_or(word.len(), |(index, _)| index);
91                lines.push(vec![Span::styled(word[..cut].to_string(), span.style)]);
92                word = &word[cut..];
93            }
94
95            let room = width - column;
96            let wanted = word.chars().count() + usize::from(column > 0);
97            if wanted > room && column > 0 {
98                lines.push(std::mem::take(&mut line));
99                column = 0;
100            }
101            // Leading whitespace on a line is the wrap's own business and not
102            // the author's, so a separator is drawn only between two words that
103            // ended up on the same row.
104            if column > 0 {
105                line.push(Span::styled(" ", separator.unwrap_or(span.style)));
106                column += 1;
107            }
108            separator = None;
109            column += word.chars().count();
110            line.push(Span::styled(word.to_string(), span.style));
111        }
112    }
113    lines.push(line);
114
115    // Nothing to say is no rows rather than one blank one, so a node with an
116    // empty string costs nothing. A blank line inside a paragraph survives,
117    // because that one was authored.
118    if lines.len() == 1 && lines[0].is_empty() {
119        return Vec::new();
120    }
121    lines.into_iter().map(Line::from).collect()
122}
123
124/// Break `text` into lines no wider than `width`.
125///
126/// [`wrap_spans`] under one style, flattened back to strings for the callers
127/// that have no styles to keep.
128pub fn wrap(text: &str, width: u16) -> Vec<String> {
129    wrap_spans(&[Span::raw(text.to_string())], width)
130        .into_iter()
131        .map(|line| {
132            line.spans
133                .iter()
134                .map(|span| span.content.as_ref())
135                .collect()
136        })
137        .collect()
138}
139
140/// The rows `text` takes at `width`.
141pub fn height(text: &str, width: u16) -> u16 {
142    u16::try_from(wrap(text, width).len()).unwrap_or(u16::MAX)
143}
144
145/// The rows `spans` take at `width`, wrapped as a block.
146pub fn spans_height(spans: &[Span<'_>], width: u16) -> u16 {
147    u16::try_from(wrap_spans(spans, width).len()).unwrap_or(u16::MAX)
148}
149
150/// Draw wrapped text at the top of `area`, and answer the rows it used.
151pub fn draw(text: &str, style: Style, area: Rect, buf: &mut Buffer) -> u16 {
152    let mut used = 0;
153    for line in wrap(text, area.width) {
154        if used >= area.height {
155            break;
156        }
157        buf.set_stringn(area.x, area.y + used, &line, area.width as usize, style);
158        used += 1;
159    }
160    used
161}
162
163/// Draw wrapped spans at the top of `area`, and answer the rows they used.
164///
165/// The block counterpart to [`draw_line`]: that one takes a run that is one
166/// line by construction and wraps it because it might not fit, and this one
167/// takes a run with authored breaks in it and keeps them.
168pub fn draw_spans(spans: &[Span<'_>], area: Rect, buf: &mut Buffer) -> u16 {
169    let mut used = 0;
170    for line in wrap_spans(spans, area.width) {
171        if used >= area.height {
172            break;
173        }
174        let mut column = 0u16;
175        for span in &line.spans {
176            let room = area.width.saturating_sub(column) as usize;
177            if room == 0 {
178                break;
179            }
180            buf.set_stringn(
181                area.x + column,
182                area.y + used,
183                &span.content,
184                room,
185                span.style,
186            );
187            column += u16::try_from(span.content.chars().count().min(room)).unwrap_or(u16::MAX);
188        }
189        used += 1;
190    }
191    used
192}
193
194/// Draw a line of spans at the top of `area`, wrapping onto further rows.
195///
196/// A run that is one line by construction -- a row's parts, a control, a meter
197/// -- rather than a block that may carry breaks of its own. It is the same wrap
198/// either way, and was its own implementation until a rich node started putting
199/// styled runs inside a row: the separate copy drew the space before a struck
200/// word struck, because it took the separator's style from the word after it
201/// instead of from the whitespace it replaced.
202pub fn draw_line(line: &Line<'_>, area: Rect, buf: &mut Buffer) -> u16 {
203    draw_spans(&line.spans, area, buf)
204}
205
206/// The rows a line of spans takes at `width`.
207pub fn line_height(line: &Line<'_>, width: u16) -> u16 {
208    spans_height(&line.spans, width)
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use ratatui::style::Modifier;
215
216    #[test]
217    fn a_paragraph_wraps_on_words_and_counts_the_rows_it_took() {
218        // The two halves that have to agree. A height that disagreed with the
219        // drawing by one row is a node drawing over the one under it.
220        assert_eq!(wrap("the quick brown fox", 10), ["the quick", "brown fox"]);
221        assert_eq!(height("the quick brown fox", 10), 2);
222    }
223
224    #[test]
225    fn nothing_to_say_costs_no_rows_rather_than_one_blank_one() {
226        assert_eq!(height("", 10), 0);
227        assert!(wrap("", 10).is_empty());
228        // A width of zero is a region with no room, not a division to do.
229        assert!(wrap("anything", 0).is_empty());
230    }
231
232    #[test]
233    fn an_authored_break_is_a_break() {
234        // A description that put a newline in a string meant it, and rewrapping
235        // across it would join two paragraphs.
236        assert_eq!(wrap("one\ntwo", 20), ["one", "two"]);
237    }
238
239    #[test]
240    fn a_word_wider_than_the_region_is_cut_rather_than_overflowed() {
241        // The case with no good answer. Cutting is the least bad one: the
242        // alternative is a line wider than the region and a buffer that
243        // swallows the overflow silently.
244        assert_eq!(
245            wrap("supercalifragilistic", 6),
246            ["superc", "alifra", "gilist", "ic"]
247        );
248    }
249
250    #[test]
251    fn the_space_between_two_runs_belongs_to_the_run_that_held_it() {
252        // A strikethrough that starts one cell early is drawn through a space
253        // the author never struck. This is why the separator's style is kept
254        // rather than taken from the word after it.
255        let struck = Style::new().add_modifier(Modifier::CROSSED_OUT);
256        let spans = [Span::raw("lean "), Span::styled("gone", struck)];
257        let lines = wrap_spans(&spans, 20);
258        assert_eq!(lines.len(), 1);
259        let separator = lines[0]
260            .spans
261            .iter()
262            .find(|span| span.content.as_ref() == " ")
263            .expect("a separator between the two words");
264        assert!(!separator.style.add_modifier.contains(Modifier::CROSSED_OUT));
265    }
266
267    #[test]
268    fn a_drawing_stops_at_the_bottom_of_the_area_it_was_given() {
269        // Never below the rect, which is what a terminal does with everything.
270        let mut buf = Buffer::empty(Rect::new(0, 0, 10, 2));
271        let used = draw(
272            "the quick brown fox jumps over",
273            Style::new(),
274            buf.area,
275            &mut buf,
276        );
277        assert_eq!(used, 2);
278    }
279}