makeover-tui 0.19.0

The terminal renderer for makeover-layout, on ratatui. Colour stops being the constraint above 256 entries; geometry never does, because an edge occupies a whole cell on every side.
Documentation
//! Words into cells.
//!
//! A terminal wraps on words and counts rows, and both halves have to agree or
//! a node draws over the one under it. So the wrap is written once here and
//! both [`height`] and [`draw`] read it, rather than each having its own idea
//! of how many rows a paragraph takes.
//!
//! Arrived here from `quasi-tui` in 0.16.0, which is where it was written and
//! where it stopped being quasi's: nothing below is about a described screen.
//! Flow layout is the shape every terminal consumer in the tree ends up with —
//! ask for a height at a width, then draw into the rect you were given — and it
//! needs a wrap that answers both questions the same way. ratatui's own
//! `Paragraph` wraps but will not tell you how many rows it took, which is the
//! half a flow layout cannot do without.
//!
//! Width is counted in `char`s. That is wrong for a terminal in the general
//! case -- a CJK glyph occupies two cells and a combining mark none -- and it
//! is deliberately not fixed here: the fix is a `unicode-width` dependency, and
//! taking one before anything in the tree has non-ASCII content to draw is
//! paying for a problem nobody has yet. Filed rather than hidden.

use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::{Line, Span};

/// Break `spans` into lines no wider than `width`, keeping each word under the
/// style it arrived with.
///
/// Breaks on whitespace, and breaks inside a word only when the word cannot fit
/// on a line of its own. A word longer than the whole width is the case that
/// has no good answer; cutting it is the least bad one, because the alternative
/// is a line wider than the region and a buffer that swallows the overflow
/// silently.
///
/// The one wrap in this crate. [`wrap`] is this with a single style over the
/// whole string, rather than a second implementation that would be free to
/// disagree with it about how many rows a paragraph takes -- and a disagreement
/// there is a node drawing over the one under it.
pub fn wrap_spans(spans: &[Span<'_>], width: u16) -> Vec<Line<'static>> {
    if width == 0 {
        return Vec::new();
    }
    let width = width as usize;
    let mut lines: Vec<Vec<Span<'static>>> = Vec::new();
    let mut line: Vec<Span<'static>> = Vec::new();
    let mut column = 0usize;
    // The style of the whitespace last passed over, held until a word turns up
    // to need a separator before it. Kept rather than taken from the word,
    // because the space between `*lean*` and `~~gone~~` belongs to the plain
    // run that held it: a strikethrough that starts one cell early is drawn
    // through a space the author never struck.
    let mut separator: Option<Style> = None;

    for span in spans {
        let mut rest: &str = span.content.as_ref();
        while !rest.is_empty() {
            let gap = rest
                .find(|c: char| !c.is_whitespace())
                .unwrap_or(rest.len());
            if gap > 0 {
                // Authored breaks are breaks. A description that put a newline
                // in a string meant it, and rewrapping across it would join two
                // paragraphs.
                for _ in 0..rest[..gap].matches('\n').count() {
                    lines.push(std::mem::take(&mut line));
                    column = 0;
                }
                separator = Some(span.style);
                rest = &rest[gap..];
                continue;
            }

            let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
            let (mut word, after) = rest.split_at(end);
            rest = after;

            // A word too long for any line, cut to fit rather than overflowed.
            // The case has no good answer; cutting is the least bad one,
            // because the alternative is a line wider than the region and a
            // buffer that swallows the overflow silently.
            while word.chars().count() > width {
                if column > 0 {
                    lines.push(std::mem::take(&mut line));
                    column = 0;
                }
                let cut = word
                    .char_indices()
                    .nth(width)
                    .map_or(word.len(), |(index, _)| index);
                lines.push(vec![Span::styled(word[..cut].to_string(), span.style)]);
                word = &word[cut..];
            }

            let room = width - column;
            let wanted = word.chars().count() + usize::from(column > 0);
            if wanted > room && column > 0 {
                lines.push(std::mem::take(&mut line));
                column = 0;
            }
            // Leading whitespace on a line is the wrap's own business and not
            // the author's, so a separator is drawn only between two words that
            // ended up on the same row.
            if column > 0 {
                line.push(Span::styled(" ", separator.unwrap_or(span.style)));
                column += 1;
            }
            separator = None;
            column += word.chars().count();
            line.push(Span::styled(word.to_string(), span.style));
        }
    }
    lines.push(line);

    // Nothing to say is no rows rather than one blank one, so a node with an
    // empty string costs nothing. A blank line inside a paragraph survives,
    // because that one was authored.
    if lines.len() == 1 && lines[0].is_empty() {
        return Vec::new();
    }
    lines.into_iter().map(Line::from).collect()
}

/// Break `text` into lines no wider than `width`.
///
/// [`wrap_spans`] under one style, flattened back to strings for the callers
/// that have no styles to keep.
pub fn wrap(text: &str, width: u16) -> Vec<String> {
    wrap_spans(&[Span::raw(text.to_string())], width)
        .into_iter()
        .map(|line| {
            line.spans
                .iter()
                .map(|span| span.content.as_ref())
                .collect()
        })
        .collect()
}

/// The rows `text` takes at `width`.
pub fn height(text: &str, width: u16) -> u16 {
    u16::try_from(wrap(text, width).len()).unwrap_or(u16::MAX)
}

/// The rows `spans` take at `width`, wrapped as a block.
pub fn spans_height(spans: &[Span<'_>], width: u16) -> u16 {
    u16::try_from(wrap_spans(spans, width).len()).unwrap_or(u16::MAX)
}

/// Draw wrapped text at the top of `area`, and answer the rows it used.
pub fn draw(text: &str, style: Style, area: Rect, buf: &mut Buffer) -> u16 {
    let mut used = 0;
    for line in wrap(text, area.width) {
        if used >= area.height {
            break;
        }
        buf.set_stringn(area.x, area.y + used, &line, area.width as usize, style);
        used += 1;
    }
    used
}

/// Draw wrapped spans at the top of `area`, and answer the rows they used.
///
/// The block counterpart to [`draw_line`]: that one takes a run that is one
/// line by construction and wraps it because it might not fit, and this one
/// takes a run with authored breaks in it and keeps them.
pub fn draw_spans(spans: &[Span<'_>], area: Rect, buf: &mut Buffer) -> u16 {
    let mut used = 0;
    for line in wrap_spans(spans, area.width) {
        if used >= area.height {
            break;
        }
        let mut column = 0u16;
        for span in &line.spans {
            let room = area.width.saturating_sub(column) as usize;
            if room == 0 {
                break;
            }
            buf.set_stringn(
                area.x + column,
                area.y + used,
                &span.content,
                room,
                span.style,
            );
            column += u16::try_from(span.content.chars().count().min(room)).unwrap_or(u16::MAX);
        }
        used += 1;
    }
    used
}

/// Draw a line of spans at the top of `area`, wrapping onto further rows.
///
/// A run that is one line by construction -- a row's parts, a control, a meter
/// -- rather than a block that may carry breaks of its own. It is the same wrap
/// either way, and was its own implementation until a rich node started putting
/// styled runs inside a row: the separate copy drew the space before a struck
/// word struck, because it took the separator's style from the word after it
/// instead of from the whitespace it replaced.
pub fn draw_line(line: &Line<'_>, area: Rect, buf: &mut Buffer) -> u16 {
    draw_spans(&line.spans, area, buf)
}

/// The rows a line of spans takes at `width`.
pub fn line_height(line: &Line<'_>, width: u16) -> u16 {
    spans_height(&line.spans, width)
}

#[cfg(test)]
mod tests {
    use super::*;
    use ratatui::style::Modifier;

    #[test]
    fn a_paragraph_wraps_on_words_and_counts_the_rows_it_took() {
        // The two halves that have to agree. A height that disagreed with the
        // drawing by one row is a node drawing over the one under it.
        assert_eq!(wrap("the quick brown fox", 10), ["the quick", "brown fox"]);
        assert_eq!(height("the quick brown fox", 10), 2);
    }

    #[test]
    fn nothing_to_say_costs_no_rows_rather_than_one_blank_one() {
        assert_eq!(height("", 10), 0);
        assert!(wrap("", 10).is_empty());
        // A width of zero is a region with no room, not a division to do.
        assert!(wrap("anything", 0).is_empty());
    }

    #[test]
    fn an_authored_break_is_a_break() {
        // A description that put a newline in a string meant it, and rewrapping
        // across it would join two paragraphs.
        assert_eq!(wrap("one\ntwo", 20), ["one", "two"]);
    }

    #[test]
    fn a_word_wider_than_the_region_is_cut_rather_than_overflowed() {
        // The case with no good answer. Cutting is the least bad one: the
        // alternative is a line wider than the region and a buffer that
        // swallows the overflow silently.
        assert_eq!(
            wrap("supercalifragilistic", 6),
            ["superc", "alifra", "gilist", "ic"]
        );
    }

    #[test]
    fn the_space_between_two_runs_belongs_to_the_run_that_held_it() {
        // A strikethrough that starts one cell early is drawn through a space
        // the author never struck. This is why the separator's style is kept
        // rather than taken from the word after it.
        let struck = Style::new().add_modifier(Modifier::CROSSED_OUT);
        let spans = [Span::raw("lean "), Span::styled("gone", struck)];
        let lines = wrap_spans(&spans, 20);
        assert_eq!(lines.len(), 1);
        let separator = lines[0]
            .spans
            .iter()
            .find(|span| span.content.as_ref() == " ")
            .expect("a separator between the two words");
        assert!(!separator.style.add_modifier.contains(Modifier::CROSSED_OUT));
    }

    #[test]
    fn a_drawing_stops_at_the_bottom_of_the_area_it_was_given() {
        // Never below the rect, which is what a terminal does with everything.
        let mut buf = Buffer::empty(Rect::new(0, 0, 10, 2));
        let used = draw(
            "the quick brown fox jumps over",
            Style::new(),
            buf.area,
            &mut buf,
        );
        assert_eq!(used, 2);
    }
}