ratatui-markdown 0.3.5

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

use crate::theme::RichTextTheme;

#[derive(Debug, Clone, PartialEq)]
pub struct QuadrantChart {
    pub title: Option<String>,
    pub x_axis_left: String,
    pub x_axis_right: String,
    pub y_axis_bottom: String,
    pub y_axis_top: String,
    pub quadrants: Vec<Option<String>>,
    pub points: Vec<QuadrantPoint>,
}

#[derive(Debug, Clone, PartialEq)]
pub struct QuadrantPoint {
    pub label: String,
    pub x: f64,
    pub y: f64,
}

pub fn parse_quadrant(source: &str) -> Option<QuadrantChart> {
    let mut title: Option<String> = None;
    let mut x_left = String::new();
    let mut x_right = String::new();
    let mut y_bottom = String::new();
    let mut y_top = String::new();
    let mut quadrants: Vec<Option<String>> = vec![None; 4];
    let mut points: Vec<QuadrantPoint> = Vec::new();

    for line in source.lines() {
        let line = line.trim();
        if line.is_empty() || line.starts_with('%') {
            continue;
        }

        if let Some(rest) = line.strip_prefix("quadrantChart") {
            let part = rest.trim();
            if !part.is_empty() {
                title = Some(part.to_string());
            }
            continue;
        }

        if let Some(rest) = line.strip_prefix("title ") {
            title = Some(rest.trim().to_string());
            continue;
        }

        if let Some(rest) = line.strip_prefix("x-axis ") {
            let parts: Vec<&str> = rest.split("-->").collect();
            if parts.len() == 2 {
                x_left = parts[0].trim().to_string();
                x_right = parts[1].trim().to_string();
            }
            continue;
        }
        if line == "x-axis" {
            continue;
        }

        if let Some(rest) = line.strip_prefix("y-axis ") {
            let parts: Vec<&str> = rest.split("-->").collect();
            if parts.len() == 2 {
                y_bottom = parts[0].trim().to_string();
                y_top = parts[1].trim().to_string();
            }
            continue;
        }
        if line == "y-axis" {
            continue;
        }

        for (i, q) in quadrants.iter_mut().enumerate() {
            let prefix = format!("quadrant-{} ", i + 1);
            let alt_prefix = format!("quadrant-{}", i + 1);
            if let Some(rest) = line.strip_prefix(&prefix) {
                *q = Some(rest.trim().to_string());
            } else if line == alt_prefix {
                *q = Some(format!("Q{}", i + 1));
            }
        }

        if let Some(colon) = line.find(':') {
            let label = line[..colon].trim();
            let rest = &line[colon + 1..].trim();
            if let (Some(open), Some(close)) = (rest.find('['), rest.find(']')) {
                let coords = &rest[open + 1..close].trim();
                let parts: Vec<&str> = coords.split(',').collect();
                if parts.len() == 2 {
                    if let (Ok(x), Ok(y)) = (
                        parts[0].trim().parse::<f64>(),
                        parts[1].trim().parse::<f64>(),
                    ) {
                        points.push(QuadrantPoint {
                            label: label.to_string(),
                            x,
                            y,
                        });
                    }
                }
            }
        }
    }

    if points.is_empty() {
        return None;
    }

    Some(QuadrantChart {
        title,
        x_axis_left: if x_left.is_empty() {
            "Low".to_string()
        } else {
            x_left
        },
        x_axis_right: if x_right.is_empty() {
            "High".to_string()
        } else {
            x_right
        },
        y_axis_bottom: if y_bottom.is_empty() {
            "Low".to_string()
        } else {
            y_bottom
        },
        y_axis_top: if y_top.is_empty() {
            "High".to_string()
        } else {
            y_top
        },
        quadrants,
        points,
    })
}

pub fn render_quadrant(
    chart: &QuadrantChart,
    max_width: usize,
    theme: &impl RichTextTheme,
) -> Vec<Line<'static>> {
    let plot_w = (max_width.saturating_sub(4)).clamp(20, 50);
    let plot_h = (plot_w / 2).clamp(8, 20);

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

    if let Some(ref t) = chart.title {
        let t_w: usize = t.chars().map(|c| c.width().unwrap_or(1)).sum();
        let pad = (max_width.saturating_sub(t_w)) / 2;
        lines.push(Line::from(Span::styled(
            format!("{}{}", " ".repeat(pad), t),
            Style::default()
                .fg(theme.get_primary_color())
                .add_modifier(Modifier::BOLD),
        )));
        lines.push(Line::raw(""));
    }

    let q_top_left = chart.quadrants.first().and_then(|s| s.clone());

    let left_label_w = 14;

    for row in 0..=plot_h {
        let mut spans: Vec<Span<'static>> = Vec::new();

        if row == 0 {
            let label = if let Some(ref q) = q_top_left {
                format!("{:>width$} ", q, width = left_label_w)
            } else {
                " ".repeat(left_label_w + 1)
            };
            spans.push(Span::styled(
                label,
                Style::default().fg(theme.get_muted_text_color()),
            ));
        } else {
            spans.push(Span::raw(" ".repeat(left_label_w + 1)));
        }

        spans.push(Span::styled(
            "\u{2502}",
            Style::default().fg(theme.get_muted_text_color()),
        ));

        let mut grid_row: Vec<char> = vec![' '; plot_w];
        let mid = plot_w / 2;

        if row == plot_h {
            for (x, cell) in grid_row.iter_mut().enumerate().take(plot_w) {
                if x == mid {
                    *cell = '\u{2534}';
                } else {
                    *cell = '\u{2500}';
                }
            }
        } else {
            grid_row[mid] = '\u{2502}';
            if plot_h > 0 && row == plot_h / 2 {
                for (x, cell) in grid_row.iter_mut().enumerate().take(plot_w) {
                    if x == mid {
                        *cell = '\u{253C}';
                    } else {
                        *cell = '\u{2500}';
                    }
                }
            }
        }

        for point in &chart.points {
            let px = ((point.x * (plot_w - 1) as f64) as usize).min(plot_w - 1);
            let py = (((1.0 - point.y) * (plot_h as f64)) as usize).min(plot_h);
            if py == row && px < plot_w {
                grid_row[px] = '\u{25CF}';
            }
        }

        let row_str: String = grid_row.iter().collect();
        spans.push(Span::styled(
            row_str,
            Style::default().fg(theme.get_secondary_color()),
        ));

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

    let mut axis_labels: Vec<Span<'static>> = Vec::new();
    axis_labels.push(Span::raw(" ".repeat(left_label_w + 1)));
    axis_labels.push(Span::styled(
        format!(
            "{} -- {} -- {}",
            chart.x_axis_left, "\u{25B2}", chart.x_axis_right
        ),
        Style::default().fg(theme.get_muted_text_color()),
    ));
    lines.push(Line::from(axis_labels));

    lines.push(Line::raw(""));

    let mut legend_lines: Vec<Line<'static>> = Vec::new();
    for point in &chart.points {
        legend_lines.push(Line::from(vec![
            Span::styled(" \u{25CF} ", Style::default().fg(theme.get_primary_color())),
            Span::styled(
                format!("{} ({:.2}, {:.2})", point.label, point.x, point.y),
                Style::default().fg(theme.get_text_color()),
            ),
        ]));
    }
    lines.extend(legend_lines);

    lines
}

#[cfg(test)]
mod tests {
    use super::*;
    use anyhow::Result;

    #[test]
    fn test_parse_quadrant_chart() -> Result<()> {
        let source = "quadrantChart\n    x-axis Low --> High\n    y-axis Low --> High\n    A: [0.3, 0.6]\n    B: [0.45, 0.23]\n";
        let chart =
            parse_quadrant(source).ok_or_else(|| anyhow::anyhow!("failed to parse quadrant"))?;
        assert_eq!(chart.points.len(), 2);
        assert_eq!(chart.x_axis_left, "Low");
        assert_eq!(chart.x_axis_right, "High");
        Ok(())
    }

    #[test]
    fn test_parse_quadrant_with_quadrants() -> Result<()> {
        let source = "quadrantChart\n    quadrant-1 We should expand\n    A: [0.3, 0.6]\n";
        let chart =
            parse_quadrant(source).ok_or_else(|| anyhow::anyhow!("failed to parse quadrant"))?;
        assert_eq!(chart.quadrants[0].as_deref(), Some("We should expand"));
        Ok(())
    }

    #[test]
    fn test_parse_empty_returns_none() {
        let chart = parse_quadrant("quadrantChart\n");
        assert!(chart.is_none());
    }
}