ratatui-markdown 0.3.4

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

use super::{CollapsibleTree, EntryKind, KeyStyle};
use crate::{constants::*, scroll::FocusableItemRange, theme::RichTextTheme};

impl CollapsibleTree {
    pub fn render_lines(&self, width: usize, theme: &impl RichTextTheme) -> Vec<Line<'static>> {
        let entries = self.flatten();
        let base = "  ".repeat(self.base_indent);
        let key_color = theme.get_json_key_color();
        let muted = theme.get_muted_text_color();

        entries
            .iter()
            .map(|entry| {
                let indent = build_indent(&entry.is_last_stack, entry.depth);
                let prefix = if self.base_indent > 0 {
                    Span::styled(base.clone(), Style::default())
                } else {
                    Span::raw("")
                };
                let indent_span = Span::styled(indent, Style::default().fg(muted));

                match &entry.kind {
                    EntryKind::Collapsed { label, count_str } => Line::from(vec![
                        prefix,
                        indent_span,
                        Span::styled("", Style::default().fg(key_color)),
                        Span::styled(label.clone(), Style::default().fg(key_color)),
                        Span::styled(format!(" {}", count_str), Style::default().fg(muted)),
                    ]),
                    EntryKind::Expanded { label, count_str } => Line::from(vec![
                        prefix,
                        indent_span,
                        Span::styled(
                            format!("{} ", TRIANGLE_DOWN),
                            Style::default().fg(key_color),
                        ),
                        Span::styled(label.clone(), Style::default().fg(key_color)),
                        Span::styled(format!(" {}", count_str), Style::default().fg(muted)),
                    ]),
                    EntryKind::Leaf {
                        key,
                        value,
                        value_type,
                    } => {
                        let val_color = match value_type {
                            super::ValueType::String => theme.get_json_string_color(),
                            super::ValueType::Number => theme.get_json_number_color(),
                            super::ValueType::Boolean => theme.get_json_bool_color(),
                            super::ValueType::Null => theme.get_json_null_color(),
                        };
                        let (key_prefix, separator) = match self.key_style {
                            KeyStyle::Json => ("\"", "\": "),
                            KeyStyle::Toml => ("", " = "),
                        };
                        let truncated =
                            truncate_value(value, width, entry.depth + self.base_indent);
                        Line::from(vec![
                            prefix,
                            indent_span,
                            Span::styled(
                                format!("{}{}{}", key_prefix, key, separator),
                                Style::default().fg(key_color),
                            ),
                            Span::styled(truncated, Style::default().fg(val_color)),
                        ])
                    }
                }
            })
            .collect()
    }

    pub fn total_lines(&self) -> usize {
        self.flatten().len()
    }

    pub fn build_focusable_items(&self) -> Vec<FocusableItemRange> {
        self.flatten()
            .iter()
            .enumerate()
            .filter(|(_, e)| {
                matches!(
                    e.kind,
                    EntryKind::Collapsed { .. } | EntryKind::Expanded { .. }
                )
            })
            .map(|(i, e)| FocusableItemRange {
                start_line: i,
                end_line: i + 1,
                id: e.path.clone(),
            })
            .collect()
    }

    pub fn handle_toggle(&mut self, node_id: &str) -> bool {
        let is_collapsible = self
            .flatten()
            .iter()
            .find(|e| e.path == node_id)
            .map(|e| {
                matches!(
                    e.kind,
                    EntryKind::Collapsed { .. } | EntryKind::Expanded { .. }
                )
            })
            .unwrap_or(false);
        if is_collapsible {
            self.toggle(node_id);
            true
        } else {
            false
        }
    }

    pub fn count_expanded_lines(value: &serde_json::Value) -> usize {
        let mut tree = Self::from_value(value.clone());
        tree.expand_all();
        tree.total_lines()
    }
}

fn build_indent(is_last_stack: &[bool], depth: usize) -> String {
    if depth == 0 {
        return String::new();
    }
    let effective = is_last_stack.len().saturating_sub(1);
    let mut s = String::new();
    for &is_last in is_last_stack.iter().take(effective) {
        s.push_str(if is_last { "   " } else { BRANCH_VERT_PAD });
    }
    if let Some(&is_last) = is_last_stack.last() {
        s.push_str(if is_last {
            BRANCH_END_SP
        } else {
            BRANCH_MID_SP
        });
    }
    s
}

fn truncate_value(value: &str, total_width: usize, depth: usize) -> String {
    let indent_len = depth * 3 + 4;
    let max_len = total_width.saturating_sub(indent_len + 4);
    let chars: Vec<char> = value.chars().collect();
    if chars.len() > max_len {
        let truncated: String = chars.into_iter().take(max_len.saturating_sub(3)).collect();
        format!("{}...", truncated)
    } else {
        value.to_string()
    }
}