Skip to main content

requestty_ui/
text.rs

1use std::ops::Range;
2
3use textwrap::{
4    core::{break_words, Word},
5    word_splitters::split_words,
6    WordSeparator, WordSplitter, WrapAlgorithm,
7};
8
9use crate::{backend, layout::Layout, Widget};
10
11/// A string that can render over multiple lines.
12///
13/// If you need to render a single line of text or you don't want the text to wrap, use the [`Widget`]
14/// implementation on [`str`].
15#[derive(Debug, Clone)]
16pub struct Text<S> {
17    /// The text to render.
18    ///
19    /// If this is changed, the updated text is not guaranteed to be rendered. If the text is
20    /// changed, [`force_recompute`](Text::force_recompute) should be called.
21    pub text: S,
22    /// Byte ranges into `text` for each wrapped line.
23    lines: Vec<Range<usize>>,
24    line_offset: u16,
25    width: u16,
26}
27
28impl<S: PartialEq> PartialEq for Text<S> {
29    fn eq(&self, other: &Self) -> bool {
30        self.text == other.text
31    }
32}
33
34impl<S: Eq> Eq for Text<S> {}
35
36impl<S: AsRef<str>> Text<S> {
37    /// Creates a new `Text`
38    pub fn new(text: S) -> Self {
39        Self {
40            text,
41            lines: Vec::new(),
42            width: 0,
43            line_offset: 0,
44        }
45    }
46
47    /// The computed lines are cached between renders, and are only recomputed if the layout changes.
48    /// This will force a recomputation even if the layout is the same. This is useful if you need
49    /// to change the text.
50    pub fn force_recompute(&mut self) {
51        self.line_offset = u16::MAX;
52        self.width = u16::MAX;
53    }
54
55    fn max_height(&mut self, layout: Layout) -> u16 {
56        let width = layout.available_width();
57
58        if self.width != width || self.line_offset != layout.line_offset {
59            wrap(self.text.as_ref(), layout, &mut self.lines);
60            self.width = width;
61            self.line_offset = layout.line_offset;
62        }
63
64        self.lines.len() as u16
65    }
66}
67
68impl<S: AsRef<str>> Widget for Text<S> {
69    /// Renders the Text moving to the next line after its done. This can trigger a recomputation.
70    /// In case the text cannot be fully rendered, [`layout.render_region`] is used to determine the
71    /// lines which are rendered.
72    ///
73    /// [`layout.render_region`]: crate::layout::Layout::render_region
74    fn render<B: backend::Backend>(
75        &mut self,
76        layout: &mut Layout,
77        backend: &mut B,
78    ) -> std::io::Result<()> {
79        // Update just in case the layout is out of date
80        let height = self.max_height(*layout);
81        let text = self.text.as_ref();
82
83        if height == 1 {
84            backend.write_all(text[self.lines[0].clone()].as_bytes())?;
85            layout.offset_y += 1;
86            backend.move_cursor_to(layout.offset_x, layout.offset_y)?;
87        } else {
88            let start = layout.get_start(height) as usize;
89            let nlines = height.min(layout.max_height);
90
91            for (i, line) in self
92                .lines
93                .iter()
94                .skip(start)
95                .take(nlines as usize)
96                .enumerate()
97            {
98                backend.write_all(text[line.clone()].as_bytes())?;
99                backend.move_cursor_to(layout.offset_x, layout.offset_y + i as u16 + 1)?;
100            }
101
102            // note: it may be possible to render things after the end of the last line, but for now
103            // we ignore that space and the text takes all the width.
104            layout.offset_y += nlines;
105        }
106        layout.line_offset = 0;
107
108        Ok(())
109    }
110
111    /// Calculates the height the text will take. This can trigger a recomputation.
112    fn height(&mut self, layout: &mut Layout) -> u16 {
113        let height = self.max_height(*layout).min(layout.max_height);
114        layout.offset_y += height;
115        height
116    }
117
118    /// Returns the location of the first character
119    fn cursor_pos(&mut self, layout: Layout) -> (u16, u16) {
120        layout.offset_cursor((layout.line_offset, 0))
121    }
122
123    /// This widget does not handle any events
124    fn handle_key(&mut self, _: crate::events::KeyEvent) -> bool {
125        false
126    }
127}
128
129impl<S: AsRef<str>> AsRef<str> for Text<S> {
130    fn as_ref(&self) -> &str {
131        self.text.as_ref()
132    }
133}
134
135impl<S: AsRef<str>> From<S> for Text<S> {
136    fn from(text: S) -> Self {
137        Self::new(text)
138    }
139}
140
141/// Wraps `text` into lines, storing the byte range of each line in `lines`.
142///
143/// This mirrors what `textwrap::fill` does with an initial indent of `layout.line_offset`, but
144/// borrows from `text` instead of building a new string.
145fn wrap(text: &str, layout: Layout, lines: &mut Vec<Range<usize>>) {
146    lines.clear();
147
148    let width = layout.available_width() as usize;
149    let line_widths = [width.saturating_sub(layout.line_offset as usize), width];
150
151    let mut start = 0;
152    for line in text.split_inclusive('\n') {
153        let next_start = start + line.len();
154        let line = line.strip_suffix('\n').unwrap_or(line);
155        let line = line.strip_suffix('\r').unwrap_or(line);
156        let first_line = lines.is_empty();
157
158        if line.len() < width && !(first_line && layout.line_offset > 0) {
159            // Fast path: the line fits, so only trailing spaces need to be removed.
160            lines.push(start..start + line.trim_end_matches(' ').len());
161        } else {
162            wrap_line(line, start, &line_widths, first_line, lines);
163        }
164
165        start = next_start;
166    }
167}
168
169fn wrap_line(
170    line: &str,
171    start: usize,
172    line_widths: &[usize; 2],
173    first_line: bool,
174    lines: &mut Vec<Range<usize>>,
175) {
176    let words = WordSeparator::new().find_words(line);
177    let words = split_words(words, &WordSplitter::HyphenSplitter);
178    let mut words = break_words(words, line_widths[1]);
179
180    if first_line && line_widths[0] != line_widths[1] {
181        // Words are broken based on the width of the subsequent lines, so the first word may not
182        // fit on the (shorter) first line. An empty word allows the first line to be empty.
183        words.insert(0, Word::from(""));
184    }
185
186    let mut idx = start;
187    for words in WrapAlgorithm::new().wrap(&words, line_widths) {
188        let last_word = match words.last() {
189            Some(word) => word,
190            None => {
191                lines.push(idx..idx);
192                continue;
193            }
194        };
195
196        // The hyphen splitter only splits after existing hyphens, so no extra characters are
197        // added and every line is a contiguous slice of the original text.
198        debug_assert!(last_word.penalty.is_empty());
199
200        let len = words
201            .iter()
202            .map(|word| word.len() + word.whitespace.len())
203            .sum::<usize>();
204
205        lines.push(idx..idx + len - last_word.whitespace.len());
206        idx += len;
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use crate::{backend::TestBackend, test_consts::*};
213
214    use super::*;
215
216    #[test]
217    fn test_wrap() {
218        fn test(text: &str, indent: usize, max_width: usize, nlines: usize) {
219            let layout = Layout::new(indent as u16, (max_width as u16, 100).into());
220            let mut wrapped = Vec::new();
221            wrap(text, layout, &mut wrapped);
222
223            // Should match the output of `textwrap::fill`
224            let indent_str = " ".repeat(indent);
225            let filled = textwrap::fill(
226                text,
227                textwrap::Options::new(max_width).initial_indent(&indent_str),
228            );
229            let expected: Vec<_> = filled[indent..].lines().collect();
230            let actual: Vec<_> = wrapped.iter().map(|r| &text[r.clone()]).collect();
231            assert_eq!(expected, actual);
232
233            assert_eq!(nlines, wrapped.len());
234            let mut lines = actual.into_iter();
235
236            assert!(lines.next().unwrap().chars().count() <= max_width - indent);
237
238            for line in lines {
239                assert!(line.chars().count() <= max_width);
240            }
241        }
242
243        test("Hello World", 0, 80, 1);
244
245        test("Hello World", 0, 6, 2);
246
247        test(LOREM, 40, 80, 7);
248        test(UNICODE, 40, 80, 7);
249
250        test("Hello\n\nWorld  \n", 0, 80, 3);
251        test("Hello\r\nWorld", 3, 80, 2);
252        test(
253            "a-very-long-hyphenated-word and a supercalifragilisticexpialidocious one",
254            5,
255            12,
256            8,
257        );
258    }
259
260    #[test]
261    fn test_text_height() {
262        let mut layout = Layout::new(40, (80, 100).into());
263        let mut text = Text::new(LOREM);
264
265        assert_eq!(text.max_height(layout), 7);
266        assert_eq!(text.height(&mut layout.with_max_height(5)), 5);
267        layout.line_offset = 0;
268        layout.width = 110;
269        assert_eq!(text.height(&mut layout.clone()), text.max_height(layout));
270        assert_eq!(text.height(&mut layout.clone()), 5);
271
272        let mut layout = Layout::new(40, (80, 100).into());
273        let mut text = Text::new(UNICODE);
274
275        assert_eq!(text.max_height(layout), 7);
276        assert_eq!(text.height(&mut layout.with_max_height(5)), 5);
277        layout.line_offset = 0;
278        layout.width = 110;
279        assert_eq!(text.height(&mut layout.clone()), text.max_height(layout));
280        assert_eq!(text.height(&mut layout.clone()), 5);
281    }
282
283    #[test]
284    fn test_render_single_line() {
285        let size = (100, 20).into();
286        let mut layout = Layout::new(0, size);
287        let mut backend = TestBackend::new(size);
288
289        let mut text = Text::new("Hello, World!");
290        text.render(&mut layout, &mut backend).unwrap();
291
292        crate::assert_backend_snapshot!(backend);
293        assert_eq!(layout, layout.with_offset(0, 1));
294    }
295
296    #[test]
297    fn test_render_multiline() {
298        let size = (100, 20).into();
299        let mut layout = Layout::new(0, size);
300
301        let mut backend = TestBackend::new(size);
302        let mut text = Text::new(LOREM);
303        text.render(&mut layout, &mut backend).unwrap();
304
305        crate::assert_backend_snapshot!(backend);
306        assert_eq!(layout, Layout::new(0, size).with_offset(0, 5));
307
308        layout = Layout::new(0, size).with_offset(10, 10);
309        backend.reset_with_layout(layout);
310
311        let mut text = Text::new(UNICODE);
312        text.render(&mut layout, &mut backend).unwrap();
313
314        crate::assert_backend_snapshot!(backend);
315        assert_eq!(layout, Layout::new(0, size).with_offset(10, 16));
316    }
317}