ratatui-markdown 0.3.6

Markdown rendering, syntax highlighting, collapsible trees, and rich scroll widgets for ratatui
Documentation
use std::boxed::Box;

use ratatui::{
    layout::Rect,
    style::{Modifier, Style},
    text::{Line, Span},
    Frame,
};

use crate::{
    markdown::{MarkdownRenderer, RenderHooks},
    scroll::{FocusableItemRange, FocusableRegion, HybridScrollView},
    theme::RichTextTheme,
    tree::CollapsibleTree,
};

const FRONTMATTER_DELIMITER: &str = "+++";

pub struct ActionItem {
    pub id: String,
    pub label: String,
}

pub struct MarkdownPreview {
    content: String,
    scroll_view: HybridScrollView,
    cached_width: usize,
    cached_generation: crate::theme::Generation,
    strip_frontmatter: bool,
    tree: Option<CollapsibleTree>,
    tree_dirty: bool,
    action_items: Vec<ActionItem>,
    actions_dirty: bool,
    hooks: Option<Box<dyn RenderHooks>>,
}

impl Default for MarkdownPreview {
    fn default() -> Self {
        Self::new()
    }
}

impl MarkdownPreview {
    pub fn new() -> Self {
        Self {
            content: String::new(),
            scroll_view: HybridScrollView::new()
                .with_left_padding(false)
                .with_cursor_indicator(true),
            cached_width: 0,
            cached_generation: crate::theme::Generation::default(),
            strip_frontmatter: true,
            tree: None,
            tree_dirty: false,
            action_items: Vec::new(),
            actions_dirty: false,
            hooks: None,
        }
    }

    pub fn with_strip_frontmatter(mut self, strip: bool) -> Self {
        self.strip_frontmatter = strip;
        self
    }

    pub fn with_render_hooks(mut self, hooks: Box<dyn RenderHooks>) -> Self {
        self.hooks = Some(hooks);
        self
    }

    pub fn with_left_padding(mut self, padding: bool) -> Self {
        self.scroll_view = self.scroll_view.with_left_padding(padding);
        self
    }

    pub fn set_tree(&mut self, tree: Option<CollapsibleTree>) {
        self.tree = tree;
        self.tree_dirty = true;
        self.scroll_view.clear();
    }

    pub fn tree_mut(&mut self) -> Option<&mut CollapsibleTree> {
        self.tree.as_mut()
    }

    pub fn has_tree(&self) -> bool {
        self.tree.is_some()
    }

    pub fn set_action_items(&mut self, items: Vec<ActionItem>) {
        self.action_items = items;
        self.actions_dirty = true;
        self.scroll_view.clear();
    }

    pub fn selected_action_id(&self) -> Option<&str> {
        let id = self.scroll_view.selected_item_id()?;
        if let Some(action_id) = id.strip_prefix("action:") {
            Some(action_id)
        } else {
            None
        }
    }

    pub fn set_content(&mut self, content: &str) {
        if self.content != content {
            self.content = content.to_string();
            self.scroll_view.clear();
            self.cached_width = 0;
        }
    }

    pub fn clear(&mut self) {
        self.content.clear();
        self.tree = None;
        self.tree_dirty = false;
        self.action_items.clear();
        self.actions_dirty = false;
        self.scroll_view.clear();
        self.cached_width = 0;
        self.cached_generation = crate::theme::Generation::default();
    }

    pub fn is_empty(&self) -> bool {
        self.content.is_empty() && self.tree.is_none() && self.action_items.is_empty()
    }

    pub fn scroll_up(&mut self) {
        self.scroll_view.scroll_up();
    }

    pub fn scroll_down(&mut self) {
        self.scroll_view.scroll_down();
    }

    pub fn scroll_to_top(&mut self) {
        self.scroll_view.scroll_to_top();
    }

    pub fn scroll_to_bottom(&mut self) {
        self.scroll_view.scroll_to_bottom();
    }

    pub fn page_up(&mut self, lines: usize) {
        self.scroll_view.page_up(lines);
    }

    pub fn page_down(&mut self, lines: usize) {
        self.scroll_view.page_down(lines);
    }

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

    pub fn scroll_offset(&self) -> usize {
        self.scroll_view.get_scroll_offset()
    }

    pub fn visible_height(&self) -> usize {
        self.scroll_view.get_viewport_height()
    }

    pub fn is_engaged(&self) -> bool {
        self.scroll_view.is_engaged()
    }

    pub fn engaged_cursor(&self) -> Option<(usize, usize)> {
        self.scroll_view.engaged_cursor()
    }

    pub fn selected_item_id(&self) -> Option<&str> {
        self.scroll_view.selected_item_id()
    }

    pub fn toggle_tree_node(&mut self) -> bool {
        if let Some(id) = self.scroll_view.selected_item_id().map(|s| s.to_string()) {
            if let Some(tree) = &mut self.tree {
                if tree.handle_toggle(&id) {
                    self.tree_dirty = true;
                    return true;
                }
            }
        }
        false
    }

    pub fn render(
        &mut self,
        f: &mut Frame,
        inner_area: Rect,
        outer_area: Rect,
        theme: &impl RichTextTheme,
    ) {
        let render_width = self.content_width(inner_area.width);
        let generation = theme.generation();

        if (self.scroll_view.is_empty() && !self.content.is_empty())
            || self.cached_width != render_width
            || self.cached_generation != generation
            || self.tree_dirty
            || self.actions_dirty
        {
            self.rebuild_lines(render_width, theme);
            self.tree_dirty = false;
            self.actions_dirty = false;
            self.cached_generation = generation;
        }

        self.scroll_view.render(f, inner_area, outer_area, theme);
    }

    fn rebuild_lines(&mut self, width: usize, theme: &impl RichTextTheme) {
        let saved_id = self.scroll_view.selected_item_id().map(|s| s.to_string());
        let saved_offset = self.scroll_view.get_scroll_offset();

        let mut all_lines: Vec<Line<'static>> = Vec::new();
        let mut regions: Vec<FocusableRegion> = Vec::new();

        if let Some(tree) = &self.tree {
            let tree_lines = tree.render_lines(width, theme);
            let tree_items = tree.build_focusable_items();
            all_lines.extend(tree_lines);
            if !tree_items.is_empty() {
                regions.push(FocusableRegion { items: tree_items });
            }
        }

        let content = if self.strip_frontmatter {
            Self::strip_toml_frontmatter(&self.content)
        } else {
            self.content.clone()
        };

        if !content.trim().is_empty() {
            let mut renderer = MarkdownRenderer::new(width);
            if let Some(hooks) = self.hooks.take() {
                renderer = renderer.with_render_hooks(hooks);
            }
            let blocks = renderer.parse(&content);
            let md_lines = renderer.render(&blocks, theme);
            self.hooks = renderer.hooks.take();
            all_lines.extend(md_lines);
        }

        if !self.action_items.is_empty() {
            if !all_lines.is_empty() {
                all_lines.push(Line::raw(""));
            }
            let mut action_ranges: Vec<FocusableItemRange> = Vec::new();
            let bracket_color = theme.get_primary_color();
            let text_color = theme.get_text_color();
            for item in &self.action_items {
                let line_idx = all_lines.len();
                all_lines.push(Line::from(vec![
                    Span::styled("[", Style::default().fg(bracket_color)),
                    Span::styled(
                        item.label.clone(),
                        Style::default().fg(text_color).add_modifier(Modifier::BOLD),
                    ),
                    Span::styled("]", Style::default().fg(bracket_color)),
                ]));
                action_ranges.push(FocusableItemRange {
                    start_line: line_idx,
                    end_line: line_idx + 1,
                    id: format!("action:{}", item.id),
                });
            }
            if !action_ranges.is_empty() {
                regions.push(FocusableRegion {
                    items: action_ranges,
                });
            }
        }

        self.cached_width = width;
        let has_focusable = regions.iter().any(|r| !r.items.is_empty());
        self.scroll_view.set_content(all_lines, regions);

        if let Some(ref id) = saved_id {
            let found = self.scroll_view.engage_by_id(id);
            if found {
                let max_off = self
                    .scroll_view
                    .total_lines()
                    .saturating_sub(self.scroll_view.get_viewport_height());
                self.scroll_view
                    .set_scroll_offset(saved_offset.min(max_off));
            } else {
                self.scroll_view.engage_first();
            }
        } else if has_focusable {
            self.scroll_view.engage_first();
        }
    }

    fn content_width(&self, inner_width: u16) -> usize {
        usize::from(inner_width).saturating_sub(self.scroll_view.effective_padding())
    }

    fn strip_toml_frontmatter(content: &str) -> String {
        let trimmed = content.trim_start_matches('\n');
        if !trimmed.starts_with(FRONTMATTER_DELIMITER) {
            return content.to_string();
        }

        let mut in_frontmatter = false;
        let mut content_lines = Vec::new();

        for line in trimmed.lines() {
            if line.trim() == FRONTMATTER_DELIMITER {
                if in_frontmatter {
                    in_frontmatter = false;
                    continue;
                } else {
                    in_frontmatter = true;
                    continue;
                }
            }

            if !in_frontmatter {
                content_lines.push(line);
            }
        }

        content_lines.join("\n")
    }
}

#[cfg(test)]
mod tests {
    use super::MarkdownPreview;

    #[test]
    fn content_width_reserves_cursor_indicator_columns() {
        let preview = MarkdownPreview::new();
        assert_eq!(preview.content_width(10), 8);
    }

    #[test]
    fn content_width_never_underflows_when_padding_enabled() {
        let preview = MarkdownPreview::new();
        assert_eq!(preview.content_width(0), 0);
        assert_eq!(preview.content_width(1), 0);
    }
}