ratatui-markdown 0.3.6

Markdown rendering, syntax highlighting, collapsible trees, and rich scroll widgets for ratatui
Documentation
#[path = "utils/mod.rs"]
mod common;

use common::{restore_terminal, setup_terminal, Theme};
use ratatui::{
    crossterm::event::{self, Event, KeyCode, KeyEventKind},
    layout::Rect,
    style::{Color, Modifier, Style},
    text::Span,
    widgets::{Block, Borders, Padding, Paragraph},
    Frame,
};
use ratatui_markdown::{
    constants::{BRANCH_END_SP, BRANCH_MID_SP, VLINE},
    scroll::{CursorLineMode, SpanTree, SpanTreeEntry},
};

struct AgentGroup {
    id: String,
    label: String,
    is_root: bool,
    is_last_child: bool,
    details: Vec<String>,
}

struct App {
    span_tree: SpanTree,
    groups: Vec<AgentGroup>,
}

fn build_groups() -> Vec<AgentGroup> {
    let raw = lipsum::lipsum(120);
    let words: Vec<String> = raw.split_whitespace().map(|w| {
        let mut c = w.chars();
        match c.next() {
            None => String::new(),
            Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
        }
    }).collect();
    let mut wi = 0;
    let mut t = |n: usize| -> String {
        let end = (wi + n).min(words.len());
        let phrase: Vec<&str> = words[wi..end].iter().map(|s| s.as_str()).collect();
        wi = end;
        phrase.join(" ")
    };

    vec![
        AgentGroup { id: "r0".into(), label: format!("#demiurge {}", t(5)), is_root: true,  is_last_child: false, details: vec![] },
        AgentGroup { id: "a0".into(), label: format!("#demiurge.001 {} \u{2713}", t(4)), is_root: false, is_last_child: false, details: vec![format!("\u{2026}{}", t(8)), "{} hubris::task_decompose".into()] },
        AgentGroup { id: "a1".into(), label: format!("#demiurge.002 {}", t(5)), is_root: false, is_last_child: true,  details: vec![format!("\u{2026}{}", t(6)), "hubris::task_decompose [exec]".into()] },
        AgentGroup { id: "r1".into(), label: format!("#demiurge {}", t(5)), is_root: true,  is_last_child: false, details: vec![] },
        AgentGroup { id: "b0".into(), label: format!("#demiurge.003 {}", t(4)), is_root: false, is_last_child: false, details: vec![t(6), format!("\u{2026}{}", t(10)), "hubris::code_review [exec]".into()] },
        AgentGroup { id: "b1".into(), label: format!("#demiurge.004 {}", t(4)), is_root: false, is_last_child: false, details: vec![t(5)] },
        AgentGroup { id: "b2".into(), label: format!("#demiurge.005 {}", t(4)), is_root: false, is_last_child: true,  details: vec![t(6), t(8), "hubris::planning [done]".into()] },
        AgentGroup { id: "r2".into(), label: format!("#demiurge {}", t(5)), is_root: true,  is_last_child: true, details: vec![] },
        AgentGroup { id: "c0".into(), label: format!("#demiurge.006 {}", t(4)), is_root: false, is_last_child: false, details: vec![t(5)] },
        AgentGroup { id: "c1".into(), label: format!("#demiurge.007 {}", t(4)), is_root: false, is_last_child: true,  details: vec![t(6), "hubris::analysis [running]".into()] },
    ]
}

fn build_entries(groups: &[AgentGroup]) -> Vec<SpanTreeEntry> {
    let muted = Color::DarkGray;
    groups
        .iter()
        .map(|g| {
            let mut lines: Vec<Vec<Span<'static>>> = Vec::new();

            if g.is_root {
                lines.push(vec![
                    Span::raw("  "),
                    Span::styled(g.label.clone(), Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
                ]);
            } else {
                let conn = if g.is_last_child { BRANCH_END_SP } else { BRANCH_MID_SP };
                lines.push(vec![
                    Span::raw("  "),
                    Span::styled(conn.to_string(), Style::default().fg(muted)),
                    Span::styled(g.label.clone(), Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
                ]);

                let stem = if g.is_last_child { "   " } else { &*format!("{}  ", VLINE) };
                let n = g.details.len();
                for (di, detail) in g.details.iter().enumerate() {
                    let dc = if di == n - 1 { BRANCH_END_SP } else { BRANCH_MID_SP };
                    lines.push(vec![
                        Span::raw("  "),
                        Span::styled(format!("{}{}", stem, dc), Style::default().fg(muted)),
                        Span::styled(detail.clone(), Style::default().fg(Color::White)),
                    ]);
                }
            }

            SpanTreeEntry::new(g.id.clone(), lines)
        })
        .collect()
}

fn run(
    terminal: &mut ratatui::Terminal<ratatui::backend::CrosstermBackend<std::io::Stdout>>,
) -> anyhow::Result<()> {
    let groups = build_groups();
    let entries = build_entries(&groups);

    let mut span_tree = SpanTree::new()
        .with_cursor_style(Span::styled("", Style::default()), Span::raw("  "))
        .with_cursor_line_mode(CursorLineMode::HeaderOnly);

    span_tree.set_entries(entries);
    if !groups.is_empty() { span_tree.set_selected_index(0); }

    let mut app = App { span_tree, groups };

    loop {
        terminal.draw(|f| draw(f, &mut app))?;
        if event::poll(std::time::Duration::from_millis(100))? {
            let Event::Key(key) = event::read()? else { continue; };
            if key.kind != KeyEventKind::Press { continue; }
            match key.code {
                KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
                KeyCode::Up | KeyCode::Char('k') => app.span_tree.navigate_up(),
                KeyCode::Down | KeyCode::Char('j') => app.span_tree.navigate_down(),
                KeyCode::Home => app.span_tree.navigate_to_first(),
                KeyCode::End => app.span_tree.navigate_to_last(),
                KeyCode::Char('c') => {
                    app.span_tree = SpanTree::new()
                        .with_cursor_style(Span::styled("", Style::default()), Span::raw("  "))
                        .with_cursor_line_mode(match app.span_tree.cursor_line_mode() {
                            CursorLineMode::HeaderOnly => CursorLineMode::AllLines,
                            CursorLineMode::AllLines => CursorLineMode::HeaderOnly,
                        });
                    app.span_tree.set_entries(build_entries(&app.groups));
                    if !app.groups.is_empty() { app.span_tree.set_selected_index(0); }
                }
                _ => {}
            }
        }
    }
}

fn draw(f: &mut Frame, app: &mut App) {
    let area = f.area();
    let mode_label = match app.span_tree.cursor_line_mode() {
        CursorLineMode::HeaderOnly => "HeaderOnly",
        CursorLineMode::AllLines => "AllLines",
    };
    let title = format!(" Tree-Style List w/ Cursor ({}) ", mode_label);

    let block = Block::default()
        .borders(Borders::ALL)
        .title(title)
        .border_style(Style::default().fg(Color::Cyan))
        .padding(Padding::new(1, 1, 0, 0));
    let inner = block.inner(area);
    f.render_widget(block, area);
    app.span_tree.render(f, inner, area, &Theme);

    let status = " \u{2191}\u{2193}/jk navigate \u{00b7} Home/End \u{00b7} c:cursor mode \u{00b7} q quit ";
    let sa = Rect::new(area.x, area.height.saturating_sub(1), area.width, 1);
    f.render_widget(Paragraph::new(Span::styled(status, Style::default().fg(Color::DarkGray))), sa);
}

fn main() -> anyhow::Result<()> {
    let mut terminal = setup_terminal()?;
    let result = run(&mut terminal);
    restore_terminal(&mut terminal)?;
    result
}