Skip to main content

max / makeover-tui

10.8 KB · 279 lines History Blame Raw
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
21 use ratatui::buffer::Buffer;
22 use ratatui::layout::Rect;
23 use ratatui::style::Style;
24 use 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.
39 pub 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.
127 pub 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`.
140 pub 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.
145 pub 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.
150 pub 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.
167 pub 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.
201 pub 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`.
206 pub fn line_height(line: &Line<'_>, width: u16) -> u16 {
207 spans_height(&line.spans, width)
208 }
209
210 #[cfg(test)]
211 mod 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 }
279