ratatui-markdown 0.3.0

Markdown rendering, syntax highlighting, collapsible trees, and rich scroll widgets for ratatui
Documentation
use std::borrow::Cow;

use ratatui::{
    style::{Color, Modifier, Style},
    text::{Line, Span},
};

use super::types::{CursorShape, CursorStyle, Selection, SelectionStyle};
use crate::theme::RichTextTheme;

#[allow(clippy::too_many_arguments)]
pub(super) fn apply_cursor_and_selection(
    line: &mut Line<'static>,
    cursor_col: usize,
    _horizontal_scroll: usize,
    cursor_style: &CursorStyle,
    selection: Option<&Selection>,
    selection_style: &SelectionStyle,
    blink_visible: bool,
    theme: &impl RichTextTheme,
) {
    apply_selection(line, selection, selection_style, theme);

    if !blink_visible {
        return;
    }

    let total_chars: usize = line.spans.iter().map(|s| s.content.chars().count()).sum();
    let fg = cursor_style.fg.unwrap_or_else(|| theme.get_primary_color());
    let bg = cursor_style
        .bg
        .unwrap_or_else(|| theme.get_background_color());

    if total_chars == 0 || cursor_col >= total_chars {
        line.spans
            .push(make_end_cursor_span(cursor_style.shape, fg, bg));
        return;
    }

    let is_insert = matches!(cursor_style.shape, CursorShape::Bar);

    let mut char_pos = 0usize;
    let mut idx = 0;

    while idx < line.spans.len() {
        let span_len = line.spans[idx].content.chars().count();
        if span_len == 0 {
            idx += 1;
            continue;
        }
        if cursor_col >= char_pos && cursor_col < char_pos + span_len {
            let offset_in_span = cursor_col - char_pos;
            if is_insert {
                insert_cursor_before(&mut line.spans, idx, offset_in_span, fg);
            } else {
                split_and_apply_on_char(
                    &mut line.spans,
                    idx,
                    offset_in_span,
                    cursor_style.shape,
                    fg,
                    bg,
                );
            }
            return;
        }
        char_pos += span_len;
        idx += 1;
    }

    line.spans
        .push(make_end_cursor_span(cursor_style.shape, fg, bg));
}

fn insert_cursor_before(spans: &mut Vec<Span<'static>>, span_idx: usize, offset: usize, fg: Color) {
    let original = spans.remove(span_idx);
    let chars: Vec<char> = original.content.chars().collect();
    let base_style = original.style;

    let before: String = chars[..offset].iter().collect();
    let after: String = chars[offset..].iter().collect();

    let mut parts = Vec::new();
    if !before.is_empty() {
        parts.push(Span::styled(before, base_style));
    }
    parts.push(Span::styled(Cow::Borrowed(""), Style::default().fg(fg)));
    if !after.is_empty() {
        parts.push(Span::styled(after, base_style));
    }

    for (i, part) in parts.into_iter().enumerate() {
        spans.insert(span_idx + i, part);
    }
}

fn split_and_apply_on_char(
    spans: &mut Vec<Span<'static>>,
    span_idx: usize,
    offset: usize,
    shape: CursorShape,
    fg: Color,
    bg: Color,
) {
    let original = spans.remove(span_idx);
    let chars: Vec<char> = original.content.chars().collect();
    let base_style = original.style;

    let before: String = chars[..offset].iter().collect();
    let target = chars[offset];
    let after: String = chars[offset + 1..].iter().collect();

    let on_char_style = match shape {
        CursorShape::Block => Style::default().fg(bg).bg(fg),
        CursorShape::Underline => base_style.add_modifier(Modifier::UNDERLINED).fg(fg),
        CursorShape::HollowBlock => base_style.fg(fg),
        CursorShape::Bar => unreachable!(),
    };

    let mut parts = Vec::new();
    if !before.is_empty() {
        parts.push(Span::styled(before, base_style));
    }
    parts.push(Span::styled(target.to_string(), on_char_style));
    if !after.is_empty() {
        parts.push(Span::styled(after, base_style));
    }

    for (i, part) in parts.into_iter().enumerate() {
        spans.insert(span_idx + i, part);
    }
}

fn make_end_cursor_span(shape: CursorShape, fg: Color, bg: Color) -> Span<'static> {
    match shape {
        CursorShape::Bar => Span::styled(Cow::Borrowed(""), Style::default().fg(fg)),
        CursorShape::Block => Span::styled(Cow::Borrowed(" "), Style::default().fg(bg).bg(fg)),
        CursorShape::Underline => Span::styled(
            Cow::Borrowed(" "),
            Style::default().fg(fg).add_modifier(Modifier::UNDERLINED),
        ),
        CursorShape::HollowBlock => Span::styled(Cow::Borrowed(" "), Style::default().fg(fg)),
    }
}

fn apply_selection(
    line: &mut Line<'static>,
    selection: Option<&Selection>,
    selection_style: &SelectionStyle,
    theme: &impl RichTextTheme,
) {
    let sel = match selection {
        Some(s) => s,
        None => return,
    };
    let sel_bg = selection_style
        .bg
        .unwrap_or_else(|| theme.get_popup_selected_background());
    let (sel_start, sel_end) = sel.ordered();
    let mut char_start = 0usize;

    for span in &mut line.spans {
        let span_len = span.content.chars().count();
        let span_end = char_start + span_len;
        if span_end > sel_start && char_start < sel_end {
            span.style = span.style.bg(sel_bg);
            if let Some(fg) = selection_style.fg {
                span.style = span.style.fg(fg);
            }
        }
        char_start = span_end;
    }
}