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}