Skip to main content

tui_realm_stdlib/
utils.rs

1//! Utilities functions to work with components
2
3use std::borrow::Cow;
4
5use tuirealm::props::{Borders, Title};
6use tuirealm::ratatui::style::Style;
7use tuirealm::ratatui::text::{Line, Span, Text};
8use tuirealm::ratatui::widgets::{Block, TitlePosition};
9use unicode_width::UnicodeWidthStr;
10
11/// Given a vector of [`Span`]s, it creates a list of `Spans` which mustn't exceed the provided width parameter.
12/// Each [`Line`] in the returned `Vec` is a line in the text.
13#[must_use]
14pub fn wrap_spans<'a, 'b: 'a>(spans: &[&'b Span<'a>], width: usize) -> Vec<Line<'a>> {
15    // Prepare result (capacity will be at least spans.len)
16    let mut res: Vec<Line> = Vec::with_capacity(spans.len());
17    // Prepare environment
18    let mut line_width: usize = 0; // Incremental line width; mustn't exceed `width`.
19    let mut line_spans: Vec<Span> = Vec::new(); // Current line; when done, push to res and re-initialize
20    for span in spans {
21        // Check if width would exceed...
22        if line_width + span.content.width() > width {
23            // Check if entire line is wider than the area
24            if span.content.width() > width {
25                // Wrap
26                let span_lines = textwrap::wrap(&span.content, width);
27                // iter lines
28                for span_line in span_lines {
29                    // Check if width would exceed...
30                    if line_width + span_line.width() > width {
31                        // New line
32                        res.push(Line::from(line_spans));
33                        line_width = 0;
34                        line_spans = Vec::new();
35                    }
36                    // Increment line width
37                    line_width += span_line.width();
38                    // Push to line
39                    line_spans.push(Span::styled(span_line, span.style));
40                }
41                // Go to next iteration
42                continue;
43            }
44            // Just initialize a new line
45            res.push(Line::from(line_spans));
46            line_width = 0;
47            line_spans = Vec::new();
48        }
49        // Push span to line
50        line_width += span.content.width();
51        line_spans.push(Span::styled(span.content.to_string(), span.style));
52    }
53    // if there are still elements in spans, push to result
54    if !line_spans.is_empty() {
55        res.push(Line::from(line_spans));
56    }
57
58    res
59}
60
61/// Make a new empty [`Line`], but with the original style applied.
62#[inline]
63fn make_new_line<'a>(orig: &Line<'a>) -> Line<'a> {
64    Line::default().style(orig.style)
65}
66
67/// Commit the current `newline` and create a new one in its place
68#[inline]
69fn commit_line<'a>(newline: &mut Line<'a>, newlines: &mut Vec<Line<'a>>, orig: &Line<'a>) {
70    let mut final_line = make_new_line(orig);
71    std::mem::swap(newline, &mut final_line);
72    newlines.push(final_line);
73}
74
75/// Wrap a single [`Span`] into multiple [`Line`]s.
76///
77/// Returns the amount of consumed width in the last line.
78fn wrap_single_span<'a>(
79    span: &'a Span<'a>,
80    newlines: &mut Vec<Line<'a>>,
81    newline: &mut Line<'a>,
82    orig_line: &Line<'a>,
83    width: usize,
84    consumed_width: &mut usize,
85) -> usize {
86    let mut remainder_width = width - *consumed_width;
87
88    // textwrap seemingly adds at least *one* character if the given width is 0
89    // so lets commit the line here so that we dont run into that case.
90    if remainder_width == 0 && newline.width() != 0 {
91        commit_line(newline, newlines, orig_line);
92        remainder_width = width;
93    }
94
95    // Use textwrap for the actual splitting.
96    // We know here that wrapping *is* necessary.
97    let words = textwrap::WordSeparator::AsciiSpace.find_words(&span.content);
98    let split_words =
99        textwrap::word_splitters::split_words(words, &textwrap::WordSplitter::HyphenSplitter);
100    let broken_words = textwrap::core::break_words(split_words, remainder_width);
101
102    let line_widths = [remainder_width, width];
103    let wrapped_words = textwrap::WrapAlgorithm::FirstFit.wrap(&broken_words, &line_widths);
104
105    // The index into "span.content" which is already consumed
106    let mut consumed_idx = 0;
107    let last_idx = wrapped_words.len().saturating_sub(1);
108    let mut final_consumed_width = 0;
109    // Each "words" loop represents a final line, except for the last iteration.
110    for (idx, words) in wrapped_words.iter().enumerate() {
111        if words.is_empty() {
112            continue;
113        }
114
115        // The following is disabled as this can (in its current form) only catch some whitespaces to trim
116        // but it would then not fully align with the fast-path in the other function.
117        // so for our purposes, it is not worth it.
118        // // only trim the last whitespace *if* we know we commit the line here
119        // // as otherwise, we dont know if something *might* follow
120        // let minus_whitespace = if idx != last_idx {
121        //     words.last().map_or(0, |word| word.whitespace.len())
122        // } else {
123        //     0
124        // };
125        let minus_whitespace = 0;
126
127        // length in bytes of the current words line
128        let len = words
129            .iter()
130            .map(|word| word.len() + word.whitespace.len())
131            .sum::<usize>()
132            - minus_whitespace;
133
134        let split_text = &span.content[consumed_idx..consumed_idx + len];
135        consumed_idx += len + minus_whitespace;
136
137        let newspan = Span::styled(split_text, span.style);
138        newline.push_span(newspan);
139
140        // unless this is the last loop, there are more lines to come
141        if idx != last_idx {
142            commit_line(newline, newlines, orig_line);
143        } else {
144            final_consumed_width = newline.width();
145        }
146    }
147
148    final_consumed_width
149}
150
151/// Wrap the given lines to fit within `width`.
152pub fn wrap_lines<'a, 'b: 'a>(lines: &[&'b Line<'a>], width: usize) -> Vec<Line<'a>> {
153    // Prepare result (capacity will be at least lines.len)
154    let mut new_lines: Vec<Line> = Vec::with_capacity(lines.len());
155
156    for line in lines {
157        // fast path for when no wrapping is necessary
158        if line.width() <= width {
159            new_lines.push(borrow_clone_line(line));
160            continue;
161        }
162
163        // Width that already has been consumed with the current line iteration
164        let mut consumed_width: usize = 0;
165        let mut newline = make_new_line(line);
166
167        for span in line.iter() {
168            // fast path for when no wrapping is necessary on a span-level
169            let span_width = span.content.width();
170            if span_width <= width - consumed_width {
171                newline.push_span(borrow_clone_span(span));
172                consumed_width += span_width;
173                continue;
174            }
175
176            let new_consumed = wrap_single_span(
177                span,
178                &mut new_lines,
179                &mut newline,
180                line,
181                width,
182                &mut consumed_width,
183            );
184            consumed_width = new_consumed;
185        }
186
187        // commit the final newline, if it is not empty
188        if !newline.spans.is_empty() {
189            new_lines.push(newline);
190        }
191    }
192
193    new_lines
194}
195
196/// Construct a [`Block`] widget from the given properties.
197///
198/// If `focus` is `true`, [`Borders::style`] is applied as the Border style, if `false` `inactive_style` is applied, if `Some`.
199#[must_use]
200pub fn get_block(
201    props: Borders,
202    title: Option<&Title>,
203    focus: bool,
204    inactive_style: Option<Style>,
205) -> Block<'_> {
206    let mut block = Block::default()
207        .borders(props.sides)
208        .border_style(if focus {
209            props.style()
210        } else {
211            inactive_style.unwrap_or_default()
212        })
213        .border_type(props.modifiers);
214
215    if let Some(title) = title {
216        block = match title.position {
217            TitlePosition::Top => block.title_top(borrow_clone_line(&title.content)),
218            TitlePosition::Bottom => block.title_bottom(borrow_clone_line(&title.content)),
219        };
220    }
221
222    block
223}
224
225/// Calculate the actual amount of terminal space taken up, taking into account UTF things like multi-width, undrawn and combinatory characters.
226///
227/// Use this function to calculate cursor position whenever you want to handle UTF8 texts with cursors
228#[must_use]
229pub fn calc_utf8_cursor_position(chars: &[char]) -> u16 {
230    chars.iter().collect::<String>().width() as u16
231}
232
233/// Convert a `&Span` to a `Span` by using [`Cow::Borrowed`].
234///
235/// Note that a normal [`Span::clone`] (and by extension `Cow::clone`) will preserve the `Cow` Variant.
236pub fn borrow_clone_span<'a, 'b: 'a>(span: &'b Span<'a>) -> Span<'a> {
237    Span {
238        content: Cow::Borrowed(&*span.content),
239        ..*span
240    }
241}
242
243/// Convert a `&Line` to a `Line` by using [`Cow::Borrowed`].
244pub fn borrow_clone_line<'a, 'b: 'a>(line: &'b Line<'a>) -> Line<'a> {
245    Line {
246        spans: line.spans.iter().map(borrow_clone_span).collect(),
247        ..*line
248    }
249}
250
251/// Convert a `&Text` to a `Text` by using [`Cow::Borrowed`].
252pub fn borrow_clone_text<'a, 'b: 'a>(text: &'b Text<'a>) -> Text<'a> {
253    Text {
254        lines: text.lines.iter().map(borrow_clone_line).collect(),
255        ..*text
256    }
257}
258
259#[cfg(test)]
260mod test {
261
262    use pretty_assertions::assert_eq;
263    use tuirealm::props::{BorderSides, BorderType, Color, HorizontalAlignment};
264
265    use super::*;
266
267    #[test]
268    fn test_components_utils_wrap_spans() {
269        // Prepare spans; let's start with two simple spans, which fits the line
270        let spans: Vec<Span> = vec![Span::from("hello, "), Span::from("world!")];
271        let spans: Vec<&Span> = spans.iter().collect();
272        assert_eq!(wrap_spans(&spans, 64).len(), 1);
273        // Let's make a sentence, which would require two lines
274        let spans: Vec<Span> = vec![
275            Span::from("Hello, everybody, I'm Uncle Camel!"),
276            Span::from("How's it going today?"),
277        ];
278        let spans: Vec<&Span> = spans.iter().collect();
279        assert_eq!(wrap_spans(&spans, 32).len(), 2);
280        // Let's make a sentence, which requires 3 lines, but with only one span
281        let spans: Vec<Span> = vec![Span::from(
282            "Hello everybody! My name is Uncle Camel. How's it going today?",
283        )];
284        let spans: Vec<&Span> = spans.iter().collect();
285        // makes Hello everybody, my name is uncle, camel. how's it, goind today
286        assert_eq!(wrap_spans(&spans, 16).len(), 4);
287        // Combine
288        let spans: Vec<Span> = vec![
289            Span::from("Lorem ipsum dolor sit amet, consectetur adipiscing elit."),
290            Span::from("Canem!"),
291            Span::from("In posuere sollicitudin vulputate"),
292            Span::from("Sed vitae rutrum quam."),
293        ];
294        let spans: Vec<&Span> = spans.iter().collect();
295        // "Lorem ipsum dolor sit amet,", "consectetur adipiscing elit. Canem!", "In posuere sollicitudin vulputate", "Sed vitae rutrum quam."
296        assert_eq!(wrap_spans(&spans, 36).len(), 4);
297    }
298
299    #[test]
300    fn wrap_spans_should_preserve_style_if_wrapped() {
301        let input = [
302            Span::styled("hello there", Style::new().fg(Color::Black)),
303            Span::raw("test"),
304        ];
305        let input = input.iter().collect::<Vec<_>>();
306        let res = wrap_spans(&input, 5);
307        assert_eq!(res.len(), 3);
308        assert_eq!(
309            res[0],
310            Line::from(Span::styled("hello", Style::new().fg(Color::Black)))
311        );
312        assert_eq!(
313            res[1],
314            Line::from(Span::styled("there", Style::new().fg(Color::Black)))
315        );
316        assert_eq!(res[2], Line::from(Span::raw("test")));
317    }
318
319    #[test]
320    fn test_components_utils_get_block() {
321        let borders = Borders::default()
322            .sides(BorderSides::ALL)
323            .color(Color::Red)
324            .modifiers(BorderType::Rounded);
325        let _ = get_block(
326            borders,
327            Some(&Title::from("title").alignment(HorizontalAlignment::Center)),
328            true,
329            None,
330        );
331        let _ = get_block(borders, None, false, None);
332    }
333
334    #[test]
335    fn test_components_utils_calc_utf8_cursor_position() {
336        let chars: Vec<char> = vec!['v', 'e', 'e', 's', 'o'];
337        // Entire
338        assert_eq!(calc_utf8_cursor_position(chars.as_slice()), 5);
339        assert_eq!(calc_utf8_cursor_position(&chars[0..3]), 3);
340        // With special characters
341        let chars: Vec<char> = vec!['я', ' ', 'х', 'о', 'ч', 'у', ' ', 'с', 'п', 'а', 'т', 'ь'];
342        assert_eq!(calc_utf8_cursor_position(&chars[0..6]), 6);
343        let chars: Vec<char> = vec!['H', 'i', '😄'];
344        assert_eq!(calc_utf8_cursor_position(chars.as_slice()), 4);
345        let chars: Vec<char> = vec!['我', '之', '😄'];
346        assert_eq!(calc_utf8_cursor_position(chars.as_slice()), 6);
347    }
348
349    mod lines_wrap {
350        use pretty_assertions::assert_eq;
351        use tuirealm::props::{LineStatic, SpanStatic, Style};
352        use tuirealm::ratatui::text::Span;
353
354        use crate::utils::wrap_lines;
355
356        #[test]
357        fn should_not_do_any_wrapping() {
358            // empty
359            assert_eq!(
360                wrap_lines(&[&LineStatic::default()], 10),
361                [LineStatic::default()]
362            );
363
364            // single span, fits within width
365            assert_eq!(
366                wrap_lines(&[&LineStatic::raw("test")], 10),
367                [LineStatic::raw("test")]
368            );
369
370            // multi span, fits within width
371            assert_eq!(
372                wrap_lines(
373                    &[&LineStatic::from_iter([
374                        SpanStatic::raw("hello"),
375                        SpanStatic::raw("there")
376                    ])],
377                    10
378                ),
379                [LineStatic::from_iter([
380                    SpanStatic::raw("hello"),
381                    SpanStatic::raw("there")
382                ])]
383            );
384        }
385
386        #[test]
387        fn should_wrap_single_span() {
388            assert_eq!(
389                wrap_lines(&[&LineStatic::raw("something really long")], 10),
390                [
391                    LineStatic::raw("something "),
392                    LineStatic::raw("really "),
393                    LineStatic::raw("long")
394                ]
395            );
396
397            // should preserve styles
398            assert_eq!(
399                wrap_lines(
400                    &[&LineStatic::from(Span::styled(
401                        "something really long",
402                        Style::default().crossed_out()
403                    ))
404                    .style(Style::default().italic())],
405                    10
406                ),
407                [
408                    LineStatic::from(Span::styled("something ", Style::default().crossed_out()))
409                        .style(Style::default().italic()),
410                    LineStatic::from(Span::styled("really ", Style::default().crossed_out()))
411                        .style(Style::default().italic()),
412                    LineStatic::from(Span::styled("long", Style::default().crossed_out()))
413                        .style(Style::default().italic())
414                ]
415            );
416        }
417
418        #[test]
419        fn should_wrap_multi_span() {
420            assert_eq!(
421                wrap_lines(
422                    &[&LineStatic::from_iter([
423                        SpanStatic::raw("something "),
424                        SpanStatic::raw("really "),
425                        SpanStatic::raw("long")
426                    ])],
427                    10
428                ),
429                [
430                    LineStatic::raw("something "),
431                    LineStatic::from_iter([Span::raw("really "), Span::raw("lon")]),
432                    LineStatic::raw("g")
433                ]
434            );
435
436            // should preserve styles
437            assert_eq!(
438                wrap_lines(
439                    &[&LineStatic::from_iter([
440                        SpanStatic::styled("something ", Style::default().crossed_out()),
441                        SpanStatic::raw("really "),
442                        SpanStatic::styled("long", Style::default().italic())
443                    ])],
444                    10
445                ),
446                [
447                    LineStatic::from(Span::styled("something ", Style::default().crossed_out())),
448                    LineStatic::from_iter([
449                        Span::raw("really "),
450                        Span::styled("lon", Style::default().italic())
451                    ]),
452                    LineStatic::from(Span::styled("g", Style::default().italic()))
453                ]
454            );
455        }
456    }
457}