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