mural 0.1.0

Conversational terminal rendering for command-line applications.
Documentation
use std::borrow::Cow;

use mural::{
    Color, Line, Render, Size, Span, Style, Terminal, Text,
    backend::fake::{FakeBackend, Operation},
};

#[test]
fn display_measurement_uses_terminal_widths() {
    let line = Line::from_spans(vec![
        Span::new("a", Style::new()).unwrap(),
        Span::new("", Style::new()).unwrap(),
        Span::new("e\u{301}", Style::new()).unwrap(),
        Span::new("👩‍🚀", Style::new()).unwrap(),
    ]);
    let text = Text::from_lines(vec![line.clone(), Line::from_plain("").unwrap()]);

    assert_eq!(line.spans()[0].display_width(), 1);
    assert_eq!(line.spans()[1].display_width(), 2);
    assert_eq!(line.spans()[2].display_width(), 1);
    assert_eq!(line.spans()[3].display_width(), 2);
    assert_eq!(line.display_width(), 6);
    assert_eq!(text.display_width(), 6);
    assert_eq!(text.display_height(), 2);
    assert_eq!(Text::empty().display_height(), 0);
}

#[test]
fn borrowed_wrap_preserves_styles_empty_lines_and_graphemes() {
    let red = Style::new().fg(Color::Red);
    let blue = Style::new().fg(Color::Blue);
    let text = Text::from_lines(vec![
        Line::from_spans(vec![
            Span::new("a界", red).unwrap(),
            Span::new("e\u{301}👩‍🚀z", blue).unwrap(),
        ]),
        Line::from_plain("").unwrap(),
    ]);

    let wrapped = text.wrap(4);

    assert!(matches!(wrapped, Cow::Owned(_)));
    assert_eq!(
        wrapped.lines(),
        &[
            Line::from_spans(vec![
                Span::new("a界", red).unwrap(),
                Span::new("e\u{301}", blue).unwrap(),
            ]),
            Line::from_spans(vec![Span::new("👩‍🚀z", blue).unwrap()]),
            Line::from_plain("").unwrap(),
        ]
    );
    assert!(wrapped.lines().iter().all(|line| line.display_width() <= 4));
}

#[test]
fn wrap_has_borrowed_fast_path_and_consuming_api() {
    let text = Text::from_plain("short\n").unwrap();

    assert!(matches!(text.wrap(80), Cow::Borrowed(_)));
    assert_eq!(text.clone().into_wrapped(80), text);
    assert_eq!(
        Text::from_plain("abcdef").unwrap().into_wrapped(3).lines(),
        &[
            Line::from_plain("abc").unwrap(),
            Line::from_plain("def").unwrap(),
        ]
    );
    assert_eq!(
        Text::from_plain("").unwrap().wrap(80).lines(),
        &[Line::from_plain("").unwrap()]
    );
    assert!(Text::empty().wrap(80).lines().is_empty());
    assert!(Text::from_plain("abc").unwrap().wrap(0).lines().is_empty());
}

#[test]
fn wrapping_uses_textwrap_break_points_for_whitespace_hyphens_and_slashes() {
    assert_eq!(
        Text::from_plain("a  b  c").unwrap().wrap(4).lines(),
        &[
            Line::from_plain("a  b").unwrap(),
            Line::from_plain("c").unwrap(),
        ]
    );
    assert_eq!(
        Text::from_raw_lossy("a\tb").unwrap().wrap(5).lines(),
        &[
            Line::from_plain("a").unwrap(),
            Line::from_plain("b").unwrap()
        ]
    );
    assert_eq!(
        Text::from_plain("hello-world").unwrap().wrap(6).lines(),
        &[
            Line::from_plain("hello-").unwrap(),
            Line::from_plain("world").unwrap(),
        ]
    );
    assert_eq!(
        Text::from_plain("hello/world").unwrap().wrap(6).lines(),
        &[
            Line::from_plain("hello/").unwrap(),
            Line::from_plain("world").unwrap(),
        ]
    );
}

#[test]
fn wrapping_breaks_long_words_without_losing_or_reordering_repeated_text() {
    let red = Style::new().fg(Color::Red);
    let blue = Style::new().fg(Color::Blue);
    let text = Text::from_lines(vec![Line::from_spans(vec![
        Span::new("abcabc", red).unwrap(),
        Span::new("abc", blue).unwrap(),
    ])]);

    assert_eq!(
        text.wrap(3).lines(),
        &[
            Line::from_spans(vec![Span::new("abc", red).unwrap()]),
            Line::from_spans(vec![Span::new("abc", red).unwrap()]),
            Line::from_spans(vec![Span::new("abc", blue).unwrap()]),
        ]
    );
}

#[test]
fn wrapping_long_styled_sentence_preserves_style_on_every_line() {
    let style = Style::new().fg(Color::Magenta).bold().underline();
    let text = Text::from_lines(vec![Line::from_spans(vec![
        Span::new(
            "This is a long styled sentence that should wrap across several terminal lines cleanly.",
            style,
        )
        .unwrap(),
    ])]);

    let wrapped = text.wrap(18);

    assert!(wrapped.lines().len() > 3);
    assert!(
        wrapped
            .lines()
            .iter()
            .all(|line| line.display_width() <= 18)
    );
    for line in wrapped.lines() {
        assert_eq!(line.spans().len(), 1);
        assert_eq!(line.spans()[0].style(), style);
    }
    assert_eq!(
        wrapped
            .lines()
            .iter()
            .map(Line::plain_content)
            .collect::<Vec<_>>(),
        vec![
            "This is a long",
            "styled sentence",
            "that should wrap",
            "across several",
            "terminal lines",
            "cleanly.",
        ]
    );
}

#[test]
fn wrapping_preserves_styles_when_breaking_inside_and_between_spans() {
    let red = Style::new().fg(Color::Red);
    let blue = Style::new().fg(Color::Blue);
    let green = Style::new().fg(Color::Green);
    let text = Text::from_lines(vec![Line::from_spans(vec![
        Span::new("ab", red).unwrap(),
        Span::new("cd", blue).unwrap(),
        Span::new("ef", green).unwrap(),
    ])]);

    assert_eq!(
        text.wrap(3).lines(),
        &[
            Line::from_spans(vec![
                Span::new("ab", red).unwrap(),
                Span::new("c", blue).unwrap(),
            ]),
            Line::from_spans(vec![
                Span::new("d", blue).unwrap(),
                Span::new("ef", green).unwrap(),
            ]),
        ]
    );
}

#[test]
fn wrapping_never_splits_combining_marks_cjk_or_zwj_emoji_clusters() {
    assert_eq!(
        Text::from_plain("e\u{301}e\u{301}👩‍🚀z")
            .unwrap()
            .wrap(2)
            .lines(),
        &[
            Line::from_plain("e\u{301}e\u{301}").unwrap(),
            Line::from_plain("👩‍🚀").unwrap(),
            Line::from_plain("z").unwrap(),
        ]
    );
    assert_eq!(
        Text::from_plain("界界界").unwrap().wrap(4).lines(),
        &[
            Line::from_plain("界界").unwrap(),
            Line::from_plain("").unwrap(),
        ]
    );
}

#[test]
fn wrapping_respects_existing_line_boundaries_blank_lines_and_zero_width() {
    let text = Text::from_plain("abcd\n\nef").unwrap();

    assert_eq!(
        text.wrap(2).lines(),
        &[
            Line::from_plain("ab").unwrap(),
            Line::from_plain("cd").unwrap(),
            Line::from_plain("").unwrap(),
            Line::from_plain("ef").unwrap(),
        ]
    );
    assert!(text.wrap(0).lines().is_empty());
}

#[test]
fn terminal_defensively_wraps_blocks_that_ignore_the_requested_width() {
    struct IgnoresWidth;

    impl Render for IgnoresWidth {
        fn render(&self, _width: u16) -> Text {
            Text::from_plain("abcdef").unwrap()
        }
    }

    let mut terminal = Terminal::new(FakeBackend::new(Size::new(4, 24))).unwrap();
    terminal.push_live(IgnoresWidth).unwrap();

    terminal.render().unwrap();

    assert_eq!(
        terminal.backend().operations(),
        &[
            Operation::QuerySize,
            Operation::HideCursor,
            Operation::Print(Line::from_plain("abc").unwrap()),
            Operation::Newline,
            Operation::Print(Line::from_plain("def").unwrap()),
            Operation::Flush,
        ]
    );
}