mural 0.2.0

Conversational terminal rendering for command-line applications.
Documentation
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;

use crate::{
    Span, Style,
    text::wrap::{SourceRange, WrappedFragment},
};

pub(super) struct RenderCursor {
    byte_index: usize,
    style: Style,
    pub(super) overlay_grapheme_end: Option<usize>,
    pub(super) rendered: bool,
}

impl RenderCursor {
    pub(super) fn new(byte_index: usize, style: Style) -> Self {
        Self {
            byte_index,
            style,
            overlay_grapheme_end: None,
            rendered: false,
        }
    }
}

pub(super) fn push_rendered_fragments(
    row: &mut ContentRow,
    source: &str,
    fragments: &[WrappedFragment],
    style: Style,
    cursor: &mut RenderCursor,
) {
    for (fragment_index, fragment) in fragments.iter().enumerate() {
        push_rendered_range(row, source, fragment.word_range, style, cursor);
        if fragment_index + 1 < fragments.len() {
            push_rendered_range(row, source, fragment.whitespace_range, style, cursor);
        } else if !fragment.penalty.is_empty() {
            row.push_unit_unwrapped(CursorUnit::text(&fragment.penalty, style));
        }
    }
}

fn push_rendered_range(
    row: &mut ContentRow,
    source: &str,
    range: SourceRange,
    style: Style,
    cursor: &mut RenderCursor,
) {
    for (offset, grapheme) in range.text(source).grapheme_indices(true) {
        let start = range.start + offset;
        let end = start + grapheme.len();
        let cursor_at_grapheme_end = cursor.overlay_grapheme_end == Some(end);
        let under_cursor =
            !cursor.rendered && (cursor.byte_index == start || cursor_at_grapheme_end);
        if grapheme == "\t" && under_cursor && cursor_at_grapheme_end {
            row.push_unit_unwrapped(CursorUnit::text("    ", cursor.style));
        } else if grapheme == "\t" {
            row.push_tab_unwrapped(under_cursor, cursor.style);
        } else {
            row.push_unit_unwrapped(CursorUnit::text(
                grapheme,
                if under_cursor { cursor.style } else { style },
            ));
        }
        cursor.rendered |= under_cursor;
    }
}

#[derive(Default)]
pub(super) struct ContentRow {
    spans: Vec<Span>,
    content: String,
    style: Style,
    width: usize,
}

impl ContentRow {
    pub(super) fn push_unit(
        &mut self,
        unit: CursorUnit,
        content_width: usize,
        rows: &mut Vec<Vec<Span>>,
    ) {
        if self.width > 0 && self.width + unit.width > content_width {
            rows.push(self.finish_and_reset());
        }

        for piece in unit.pieces {
            self.push_text(&piece.content, piece.style);
        }
        self.width += unit.width;
    }

    pub(super) fn push_unit_unwrapped(&mut self, unit: CursorUnit) {
        for piece in unit.pieces {
            self.push_text(&piece.content, piece.style);
        }
        self.width += unit.width;
    }

    fn push_tab_unwrapped(&mut self, under_cursor: bool, cursor_style: Style) {
        if under_cursor {
            self.push_unit_unwrapped(CursorUnit::tab_under_cursor(cursor_style));
        } else {
            self.push_unit_unwrapped(CursorUnit::text("    ", Style::new()));
        }
    }

    fn push_text(&mut self, content: &str, style: Style) {
        if !self.content.is_empty() && self.style != style {
            self.flush();
        }
        self.style = style;
        self.content.push_str(content);
    }

    pub(super) fn finish(mut self) -> Vec<Span> {
        self.flush();
        self.spans
    }

    fn finish_and_reset(&mut self) -> Vec<Span> {
        self.flush();
        self.width = 0;
        std::mem::take(&mut self.spans)
    }

    fn flush(&mut self) {
        if self.content.is_empty() {
            return;
        }

        self.spans.push(Span::from_trusted_content(
            std::mem::take(&mut self.content),
            self.style,
        ));
    }
}

pub(super) struct CursorUnit {
    pieces: Vec<StyledPiece>,
    width: usize,
}

impl CursorUnit {
    pub(super) fn text(content: &str, style: Style) -> Self {
        Self {
            pieces: vec![StyledPiece {
                content: content.to_owned(),
                style,
            }],
            width: UnicodeWidthStr::width(content),
        }
    }

    pub(super) fn space(style: Style) -> Self {
        Self::text(" ", style)
    }

    fn tab_under_cursor(cursor_style: Style) -> Self {
        Self {
            pieces: vec![
                StyledPiece {
                    content: " ".to_owned(),
                    style: cursor_style,
                },
                StyledPiece {
                    content: "   ".to_owned(),
                    style: Style::new(),
                },
            ],
            width: 4,
        }
    }
}

struct StyledPiece {
    content: String,
    style: Style,
}