ratatui-markdown 0.3.6

Markdown rendering, syntax highlighting, collapsible trees, and rich scroll widgets for ratatui
Documentation
use ratatui::{
    style::{Modifier, Style},
    text::{Line, Span},
};

use super::types::{SeqArrowKind, SequenceDiagram, SequenceMessage};
use crate::theme::RichTextTheme;

const HLINE: char = '';
const VLINE: char = '';

pub fn parse_sequence(source: &str) -> Option<SequenceDiagram> {
    let mut participants: Vec<String> = Vec::new();
    let mut messages: Vec<SequenceMessage> = Vec::new();
    let mut part_set = std::collections::HashSet::new();

    for line in source.lines() {
        let line = line.trim();
        if line.is_empty() || line == "sequenceDiagram" {
            continue;
        }

        if let Some(name) = line.strip_prefix("participant ") {
            let name = name.trim().to_string();
            if !part_set.contains(&name) {
                part_set.insert(name.clone());
                participants.push(name);
            }
            continue;
        }

        if let Some(msg) = parse_message(line) {
            for name in &[&msg.from, &msg.to] {
                if !part_set.contains(*name) {
                    part_set.insert((*name).clone());
                    participants.push((*name).clone());
                }
            }
            messages.push(msg);
        }
    }

    if participants.is_empty() {
        return None;
    }
    Some(SequenceDiagram {
        participants,
        messages,
    })
}

fn parse_message(line: &str) -> Option<SequenceMessage> {
    let line = line.trim();

    let (arrow_str, arrow_start, arrow_end) = find_arrow_pos(line)?;

    let arrow_kind = match arrow_str {
        "->" => SeqArrowKind::Solid,
        "-->" => SeqArrowKind::Dotted,
        "->>" => SeqArrowKind::SolidOpen,
        "-->>" => SeqArrowKind::DottedOpen,
        _ => return None,
    };

    let from = line[..arrow_start].trim().to_string();
    let to_and_text = line[arrow_end..].trim();

    let (to, text) = if let Some(colon_pos) = to_and_text.find(':') {
        (
            to_and_text[..colon_pos].trim().to_string(),
            to_and_text[colon_pos + 1..].trim().to_string(),
        )
    } else {
        (to_and_text.trim().to_string(), String::new())
    };

    if from.is_empty() || to.is_empty() {
        return None;
    }

    Some(SequenceMessage {
        from,
        to,
        text,
        arrow_kind,
    })
}

fn find_arrow_pos(s: &str) -> Option<(&str, usize, usize)> {
    for arrow in &["-->>", "->>", "-->", "->"] {
        if let Some(idx) = s.find(arrow) {
            return Some((*arrow, idx, idx + arrow.len()));
        }
    }
    None
}

pub fn render_sequence(
    diagram: &SequenceDiagram,
    max_width: usize,
    theme: &impl RichTextTheme,
) -> Vec<Line<'static>> {
    if diagram.participants.is_empty() {
        return vec![Line::from(Span::styled(
            "(empty sequence diagram)",
            Style::default().fg(theme.get_muted_text_color()),
        ))];
    }

    let n_part = diagram.participants.len();
    let col_width = ((max_width - 2) / n_part).clamp(6, 20);
    let _total_w = col_width * n_part + 2;

    let mut lines: Vec<Line<'static>> = Vec::new();

    let header_style = Style::default()
        .fg(theme.get_primary_color())
        .add_modifier(Modifier::BOLD);
    let line_style = Style::default().fg(theme.get_muted_text_color());
    let msg_style = Style::default().fg(theme.get_text_color());
    let arrow_style = Style::default()
        .fg(theme.get_primary_color())
        .add_modifier(Modifier::BOLD);
    let label_style = Style::default()
        .fg(theme.get_info_color())
        .add_modifier(Modifier::ITALIC);

    {
        let mut spans: Vec<Span<'static>> = Vec::new();
        for (i, name) in diagram.participants.iter().enumerate() {
            let tw = unicode_width::UnicodeWidthStr::width(name.as_str());
            let pad = col_width.saturating_sub(tw);
            let left_pad = pad / 2;
            let right_pad = pad - left_pad;
            for _ in 0..left_pad {
                spans.push(Span::styled(" ".to_string(), header_style));
            }
            spans.push(Span::styled(name.clone(), header_style));
            for _ in 0..right_pad {
                spans.push(Span::styled(" ".to_string(), header_style));
            }
            if i < n_part - 1 {
                spans.push(Span::styled(" ".to_string(), line_style));
            }
        }
        lines.push(Line::from(spans));
    }

    for msg in &diagram.messages {
        let from_idx = diagram
            .participants
            .iter()
            .position(|p| p == &msg.from)
            .unwrap_or(0);
        let to_idx = diagram
            .participants
            .iter()
            .position(|p| p == &msg.to)
            .unwrap_or(0);

        let lifeline_spans = |style: Style| -> Vec<Span<'static>> {
            let mut spans: Vec<Span<'static>> = Vec::new();
            for i in 0..n_part {
                let center = col_width / 2;
                for c in 0..col_width {
                    if c == center {
                        spans.push(Span::styled(VLINE.to_string(), style));
                    } else {
                        spans.push(Span::styled(" ".to_string(), style));
                    }
                }
                if i < n_part - 1 {
                    spans.push(Span::styled(" ".to_string(), style));
                }
            }
            spans
        };

        lines.push(Line::from(lifeline_spans(line_style)));

        if !msg.text.is_empty() {
            let text_w = unicode_width::UnicodeWidthStr::width(msg.text.as_str());
            let mut label_spans: Vec<Span<'static>> = Vec::new();
            for i in 0..n_part {
                let center = col_width / 2;
                for c in 0..col_width {
                    if c == center {
                        label_spans.push(Span::styled(VLINE.to_string(), line_style));
                    } else {
                        label_spans.push(Span::styled(" ".to_string(), line_style));
                    }
                }
                if i < n_part - 1 {
                    label_spans.push(Span::styled(" ".to_string(), line_style));
                }
            }

            let min_idx = from_idx.min(to_idx);
            let max_idx = from_idx.max(to_idx);
            let label_center_x = if min_idx == max_idx {
                min_idx * (col_width + 1) + col_width / 2
            } else {
                let left_x = min_idx * (col_width + 1) + col_width / 2 + 1;
                let right_x = max_idx * (col_width + 1) + col_width / 2;
                (left_x + right_x) / 2
            };
            let label_start = label_center_x.saturating_sub(text_w / 2);

            let chars: Vec<(usize, char)> = {
                let mut v = Vec::new();
                let mut cx = label_start;
                for ch in msg.text.chars() {
                    let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1);
                    v.push((cx, ch));
                    cx += cw;
                }
                v
            };

            for (pos, ch) in chars {
                if pos < label_spans.len() {
                    let existing = &label_spans[pos];
                    if existing.content == " " {
                        label_spans[pos] = Span::styled(ch.to_string(), label_style);
                    }
                }
            }

            lines.push(Line::from(label_spans));
        }

        {
            let mut arrow_spans: Vec<Span<'static>> = Vec::new();
            for i in 0..n_part {
                let center = col_width / 2;
                for c in 0..col_width {
                    if c == center {
                        arrow_spans.push(Span::styled(VLINE.to_string(), line_style));
                    } else {
                        arrow_spans.push(Span::styled(" ".to_string(), msg_style));
                    }
                }
                if i < n_part - 1 {
                    arrow_spans.push(Span::styled(" ".to_string(), msg_style));
                }
            }

            let go_right = to_idx > from_idx;
            let left_idx = from_idx.min(to_idx);
            let right_idx = from_idx.max(to_idx);

            let x_left = left_idx * (col_width + 1) + col_width / 2 + 1;
            let x_right = right_idx * (col_width + 1) + col_width / 2 - 1;

            let line_ch = match msg.arrow_kind {
                SeqArrowKind::Solid | SeqArrowKind::SolidOpen => HLINE,
                SeqArrowKind::Dotted | SeqArrowKind::DottedOpen => '',
            };

            for x in x_left..=x_right {
                if x < arrow_spans.len() {
                    let existing = &arrow_spans[x];
                    if existing.content == " " {
                        arrow_spans[x] = Span::styled(line_ch.to_string(), arrow_style);
                    }
                }
            }

            let head_x = if go_right {
                right_idx * (col_width + 1) + col_width / 2 - 1
            } else {
                left_idx * (col_width + 1) + col_width / 2 + 1
            };

            let head_ch = match msg.arrow_kind {
                SeqArrowKind::Solid | SeqArrowKind::Dotted => {
                    if go_right {
                        ''
                    } else {
                        ''
                    }
                }
                SeqArrowKind::SolidOpen | SeqArrowKind::DottedOpen => {
                    if go_right {
                        ''
                    } else {
                        ''
                    }
                }
            };

            let tail_x = if go_right {
                left_idx * (col_width + 1) + col_width / 2 + 1
            } else {
                right_idx * (col_width + 1) + col_width / 2 - 1
            };

            if head_x < arrow_spans.len() {
                arrow_spans[head_x] = Span::styled(head_ch.to_string(), arrow_style);
            }

            let tail_ch = if go_right { '>' } else { '<' };
            if tail_x < arrow_spans.len() && arrow_spans[tail_x].content == " " {
                arrow_spans[tail_x] = Span::styled(tail_ch.to_string(), arrow_style);
            }

            lines.push(Line::from(arrow_spans));
        }
    }

    let lifeline_style = Style::default().fg(theme.get_muted_text_color());
    lines.push(Line::from({
        let mut spans: Vec<Span<'static>> = Vec::new();
        for i in 0..n_part {
            let center = col_width / 2;
            for c in 0..col_width {
                if c == center {
                    spans.push(Span::styled(VLINE.to_string(), lifeline_style));
                } else {
                    spans.push(Span::styled(" ".to_string(), lifeline_style));
                }
            }
            if i < n_part - 1 {
                spans.push(Span::styled(" ".to_string(), lifeline_style));
            }
        }
        spans
    }));

    lines
}