mural 0.2.1

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

use crate::text::wrap::SourceRange;

use super::source::{textarea_display_width, wrapped_source_rows};

#[derive(Debug)]
pub(super) struct VisualRow {
    pub(super) start: usize,
    pub(super) visible_end: usize,
    pub(super) end: usize,
    pub(super) width: usize,
    pub(super) cells: Vec<VisualCell>,
}

impl VisualRow {
    fn new(start: usize) -> Self {
        Self {
            start,
            visible_end: start,
            end: start,
            width: 0,
            cells: Vec::new(),
        }
    }

    fn push(&mut self, start: usize, end: usize, width: usize) {
        let column = self.width;
        self.cells.push(VisualCell {
            start,
            end,
            column,
            width,
        });
        self.width += width;
        self.end = end;
    }

    pub(super) fn column_for_cursor(&self, cursor: usize) -> usize {
        for cell in &self.cells {
            if cell.start == cursor {
                return cell.column;
            }
            if cell.end == cursor {
                return cell.column + cell.width;
            }
        }
        self.width
    }

    pub(super) fn cursor_for_column(&self, column: usize) -> usize {
        for cell in &self.cells {
            if column < cell.column + cell.width {
                return if column <= cell.column {
                    cell.start
                } else {
                    cell.end
                };
            }
        }
        self.visible_end
    }
}

#[derive(Debug)]
pub(super) struct VisualCell {
    start: usize,
    pub(super) end: usize,
    column: usize,
    width: usize,
}

pub(super) fn visual_rows(value: &str, content_width: usize) -> Vec<VisualRow> {
    wrapped_source_rows(value, content_width)
        .into_iter()
        .map(|source_row| {
            let mut row = VisualRow::new(source_row.start);
            for (fragment_index, fragment) in source_row.fragments.iter().enumerate() {
                push_visual_range(&mut row, value, fragment.word_range);
                if fragment_index + 1 < source_row.fragments.len() {
                    push_visual_range(&mut row, value, fragment.whitespace_range);
                } else {
                    row.width += UnicodeWidthStr::width(fragment.penalty.as_str());
                }
            }
            row.visible_end = source_row.visible_end;
            row.end = source_row.end;
            row
        })
        .collect()
}

fn push_visual_range(row: &mut VisualRow, source: &str, range: SourceRange) {
    for (offset, grapheme) in range.text(source).grapheme_indices(true) {
        let start = range.start + offset;
        let end = start + grapheme.len();
        row.push(start, end, textarea_display_width(grapheme));
    }
}

pub(super) fn visual_cursor_row(rows: &[VisualRow], cursor: usize) -> usize {
    rows.iter()
        .enumerate()
        .position(|(index, row)| {
            let next_starts_at_cursor =
                rows.get(index + 1).is_some_and(|next| next.start == cursor);
            cursor >= row.start
                && cursor <= row.end
                && !(cursor == row.end && next_starts_at_cursor)
        })
        .unwrap_or_else(|| rows.len().saturating_sub(1))
}