ratatui-markdown 0.2.0

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

use super::layout::{Layout, LayoutEdge, LayoutNode};
use super::types::{Direction, EdgeType, NodeShape};
use crate::theme::RichTextTheme;

const HLINE: char = '';
const VLINE: char = '';
const TLC: char = '';
const TRC: char = '';
const BLC: char = '';
const BRC: char = '';
const RTLC: char = '';
const RTRC: char = '';
const RBLC: char = '';
const RBRC: char = '';

#[derive(Clone)]
struct Cell {
    ch: char,
    style: Style,
}

pub fn render_layout(
    layout: &Layout,
    direction: &Direction,
    theme: &impl RichTextTheme,
) -> Vec<Line<'static>> {
    if layout.nodes.is_empty() {
        return vec![Line::from(Span::styled(
            "(empty diagram)",
            Style::default().fg(theme.get_muted_text_color()),
        ))];
    }

    let gw = layout.grid_width;
    let gh = layout.grid_height;
    if gw == 0 || gh == 0 {
        return Vec::new();
    }

    let mut grid = vec![vec![Cell { ch: ' ', style: Style::default() }; gw]; gh];

    for node in &layout.nodes {
        draw_node(&mut grid, node, theme);
    }

    let is_vertical = matches!(direction, Direction::TopDown | Direction::BottomUp);
    for edge in &layout.edges {
        draw_edge(&mut grid, edge, is_vertical, theme);
    }

    let mut lines = Vec::new();
    for row in grid.iter() {
        let spans: Vec<Span<'static>> = row
            .iter()
            .map(|cell| Span::styled(cell.ch.to_string(), cell.style))
            .collect();
        lines.push(Line::from(spans));
    }

    lines
}

fn draw_node(grid: &mut [Vec<Cell>], node: &LayoutNode, theme: &impl RichTextTheme) {
    let x = node.x;
    let y = node.y;
    let w = node.width;
    let h = node.height;

    let (tl, tr, bl, br) = match node.shape {
        NodeShape::Rounded | NodeShape::Circle => (RTLC, RTRC, RBLC, RBRC),
        NodeShape::Diamond => ('/', '\\', '\\', '/'),
        NodeShape::Rect => (TLC, TRC, BLC, BRC),
    };

    let border_style = Style::default().fg(theme.get_muted_text_color());
    let text_style = Style::default().fg(theme.get_text_color());

    if y < grid.len() && x + w <= grid[0].len() {
        let row = &mut grid[y];
        row[x] = Cell { ch: tl, style: border_style };
        row[x + w - 1] = Cell { ch: tr, style: border_style };
        for cell in row.iter_mut().take(x + w - 1).skip(x + 1) {
            *cell = Cell { ch: HLINE, style: border_style };
        }
    }

    let text_row = y + h / 2;
    if text_row < grid.len() && x + w <= grid[0].len() {
        let row = &mut grid[text_row];
        row[x] = Cell {
            ch: VLINE,
            style: border_style,
        };
        row[x + w - 1] = Cell {
            ch: VLINE,
            style: border_style,
        };
        let inner_w = w.saturating_sub(2);
        let label_chars: Vec<char> = node.label.chars().collect();
        let label_w = unicode_width::UnicodeWidthStr::width(node.label.as_str());
        let pad = if label_w < inner_w {
            (inner_w - label_w) / 2
        } else {
            0
        };
        let mut cx = x + 1;
        for _ in 0..pad {
            if cx < x + w - 1 {
                row[cx] = Cell { ch: ' ', style: text_style };
                cx += 1;
            }
        }
        for ch in &label_chars {
            if cx < x + w - 1 {
                row[cx] = Cell {
                    ch: *ch,
                    style: text_style,
                };
                cx += 1;
            }
        }
        while cx < x + w - 1 {
            row[cx] = Cell { ch: ' ', style: text_style };
            cx += 1;
        }
    }

    for vy in (y + 1)..(y + h - 1) {
        if vy == text_row {
            continue;
        }
        if vy < grid.len() && x + w <= grid[0].len() {
            let row = &mut grid[vy];
            row[x] = Cell {
                ch: VLINE,
                style: border_style,
            };
            row[x + w - 1] = Cell {
                ch: VLINE,
                style: border_style,
            };
            for cell in row.iter_mut().take(x + w - 1).skip(x + 1) {
                *cell = Cell { ch: ' ', style: text_style };
            }
        }
    }

    let bottom_row = y + h - 1;
    if bottom_row < grid.len() && x + w <= grid[0].len() {
        let row = &mut grid[bottom_row];
        row[x] = Cell { ch: bl, style: border_style };
        row[x + w - 1] = Cell { ch: br, style: border_style };
        for cell in row.iter_mut().take(x + w - 1).skip(x + 1) {
            *cell = Cell { ch: HLINE, style: border_style };
        }
    }
}

fn draw_edge(
    grid: &mut [Vec<Cell>],
    edge: &LayoutEdge,
    is_vertical: bool,
    theme: &impl RichTextTheme,
) {
    let wp = &edge.waypoints;
    if wp.len() < 2 {
        return;
    }

    let edge_style = Style::default().fg(theme.get_secondary_color());
    let arrow_style = Style::default()
        .fg(theme.get_primary_color())
        .add_modifier(Modifier::BOLD);

    for i in 0..wp.len() - 1 {
        let (x1, y1) = wp[i];
        let (x2, y2) = wp[i + 1];

        if y1 == y2 {
            let (start, end) = if x1 < x2 { (x1, x2) } else { (x2, x1) };
            for x in start..=end {
                set_grid_char(grid, x, y1, HLINE, edge_style);
            }
        } else if x1 == x2 {
            let (start, end) = if y1 < y2 { (y1, y2) } else { (y2, y1) };
            for y in start..=end {
                set_grid_char(grid, x1, y, VLINE, edge_style);
            }
        }
    }

    if edge.edge_type == EdgeType::Arrow {
        let &(sx, sy) = &wp[wp.len() - 2];
        let &(tx, ty) = &wp[wp.len() - 1];

        let arrow_ch = if is_vertical {
            if ty > sy {
                ''
            } else {
                ''
            }
        } else {
            if tx > sx {
                ''
            } else {
                ''
            }
        };
        set_grid_char(grid, tx, ty, arrow_ch, arrow_style);
    }

    if let Some(ref label) = edge.label {
        if wp.len() >= 2 {
            let mid = wp.len() / 2;
            let (mx, my) = wp[mid];
            let label_style = Style::default()
                .fg(theme.get_info_color())
                .add_modifier(Modifier::ITALIC);
            let lw = unicode_width::UnicodeWidthStr::width(label.as_str());
            let lx = mx.saturating_sub(lw / 2);
            for (i, ch) in label.chars().enumerate() {
                set_grid_char(grid, lx + i, my.saturating_sub(1), ch, label_style);
            }
        }
    }
}

fn set_grid_char(grid: &mut [Vec<Cell>], x: usize, y: usize, ch: char, style: Style) {
    if y < grid.len() && x < grid[0].len() {
        let cell = &mut grid[y][x];
        if cell.ch == ' ' {
            cell.ch = ch;
            cell.style = style;
        } else {
            match (cell.ch, ch) {
                (HLINE, VLINE) | (VLINE, HLINE) => {
                    cell.ch = '';
                }
                (HLINE, '') | (VLINE, '') | ('', _) if ch == VLINE => {
                    cell.ch = '';
                }
                _ => {
                    if ch != ' ' && ch != HLINE && ch != VLINE {
                        cell.ch = ch;
                        cell.style = style;
                    }
                }
            }
        }
    }
}