ratatui-markdown 0.3.3

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

use super::MarkdownRenderer;
use crate::{
    constants::{BD_DL, BD_DR, BD_T_UP},
    theme::{RichTextTheme, ThemeConfig},
};

fn test_theme() -> ThemeConfig {
    ThemeConfig::default()
        .with_info_color(ratatui::style::Color::Blue)
        .with_focused_border_color(ratatui::style::Color::Cyan)
        .with_secondary_color(ratatui::style::Color::Yellow)
        .with_json_key_color(ratatui::style::Color::Cyan)
        .with_json_bool_color(ratatui::style::Color::Yellow)
        .with_json_number_color(ratatui::style::Color::Magenta)
}

#[test]
fn wraps_by_word_and_keeps_ascii_token_intact() {
    let renderer = MarkdownRenderer::new(8);
    let lines = renderer.wrap_text_simple("搜索型 AI 公司");
    assert_eq!(lines, vec!["搜索型", "AI 公司"]);
}

#[test]
fn long_ascii_token_falls_back_without_losing_chars() {
    let renderer = MarkdownRenderer::new(4);
    let lines = renderer.wrap_text_simple("company");
    assert_eq!(lines.join(""), "company");
}

#[test]
fn ai_word_never_split() {
    let renderer = MarkdownRenderer::new(7);
    let lines = renderer.wrap_text_simple("搜索型 AI公司");
    assert!(!lines[0].ends_with('A'));
    assert!(lines.iter().any(|l| l.contains("AI")));
}

#[test]
fn narrow_width_keeps_short_words_intact() {
    let renderer = MarkdownRenderer::new(3);
    let lines = renderer.wrap_text_simple("AI ML DL");
    assert_eq!(lines, vec!["AI", "ML", "DL"]);
}

#[test]
fn single_word_on_narrow_line() {
    let renderer = MarkdownRenderer::new(1);
    let lines = renderer.wrap_text_simple("AI");
    assert_eq!(lines, vec!["A", "I"]);
}

#[test]
fn ai_word_stays_together_on_reasonable_width() {
    let renderer = MarkdownRenderer::new(2);
    let lines = renderer.wrap_text_simple("AI");
    assert_eq!(lines, vec!["AI"]);
}

#[test]
fn perplexity_desc_width8() {
    let renderer = MarkdownRenderer::new(8);
    let lines = renderer.wrap_text_simple("搜索型 AI 公司");
    let joined: String = lines.join("");
    assert_eq!(joined.replace(' ', ""), "搜索型AI公司");
    assert!(lines.iter().any(|l| l.contains("AI")));
    for l in &lines {
        assert!(!l.trim().ends_with('A'), "line ends with lone A: {:?}", l);
        assert!(
            !l.trim().starts_with('I'),
            "line starts with lone I: {:?}",
            l
        );
    }
}

#[test]
fn perplexity_desc_width7_ai_intact() {
    let renderer = MarkdownRenderer::new(7);
    let lines = renderer.wrap_text_simple("搜索型 AI 公司");
    let joined: String = lines.join("");
    assert_eq!(joined.replace(' ', ""), "搜索型AI公司");
    for l in &lines {
        assert!(!l.trim().ends_with('A'), "lone A at end: {:?}", l);
        assert!(!l.trim().starts_with('I'), "lone I at start: {:?}", l);
    }
}

#[test]
fn perplexity_desc_width10_fits_one_line() {
    let renderer = MarkdownRenderer::new(10);
    let lines = renderer.wrap_text_simple("AI 公司");
    assert_eq!(lines.len(), 1);
    assert_eq!(lines[0], "AI 公司");
}

#[test]
fn cjk_chars_break_individually() {
    let renderer = MarkdownRenderer::new(2);
    let lines = renderer.wrap_text_simple("你好世");
    assert_eq!(lines, vec!["", "", ""]);
}

#[test]
fn cjk_then_ascii_no_overflow() {
    let renderer = MarkdownRenderer::new(4);
    let lines = renderer.wrap_text_simple("大型LLM");
    for l in &lines {
        let w: usize = l.chars().map(MarkdownRenderer::display_width).sum();
        assert!(w <= 4, "line {:?} display width {} > 4", l, w);
    }
    assert_eq!(lines.join(""), "大型LLM");
}

#[test]
fn fullwidth_punctuation_breaks_individually() {
    let renderer = MarkdownRenderer::new(4);
    let lines = renderer.wrap_text_simple("!!!");
    for l in &lines {
        let w: usize = l.chars().map(MarkdownRenderer::display_width).sum();
        assert!(w <= 4, "line {:?} display width {} > 4", l, w);
    }
    assert_eq!(lines.join(""), "!!!");
}

#[test]
fn no_char_loss_various_widths() {
    let cases: &[(&str, usize)] = &[
        ("搜索型 AI 公司是知名品牌", 6),
        ("Perplexity AI is a search company", 10),
        ("GPT-4o is fast", 5),
        ("中文English混合 token wrap", 8),
    ];
    for (text, width) in cases {
        let renderer = MarkdownRenderer::new(*width);
        let lines = renderer.wrap_text_simple(text);
        let original_chars: String = text.chars().filter(|c| !c.is_whitespace()).collect();
        let wrapped_chars: String = lines
            .join("")
            .chars()
            .filter(|c| !c.is_whitespace())
            .collect();
        assert_eq!(
            original_chars, wrapped_chars,
            "chars lost wrapping {:?} at width {}",
            text, width
        );
    }
}

#[test]
fn no_line_exceeds_max_width() {
    let cases: &[(&str, usize)] = &[
        ("搜索型 AI 公司", 8),
        ("Hello world from TUI markdown", 10),
        ("你好世界 Hello World", 6),
    ];
    for (text, width) in cases {
        let renderer = MarkdownRenderer::new(*width);
        let lines = renderer.wrap_text_simple(text);
        for l in &lines {
            let w: usize = l.chars().map(MarkdownRenderer::display_width).sum();
            assert!(
                w <= *width,
                "line {:?} has display width {} > {} (text={:?})",
                l,
                w,
                width,
                text
            );
        }
    }
}

#[test]
fn empty_string_returns_single_empty_line() {
    let renderer = MarkdownRenderer::new(10);
    let lines = renderer.wrap_text_simple("");
    assert_eq!(lines, vec![""]);
}

#[test]
fn max_width_zero_returns_input_as_single_line() {
    let renderer = MarkdownRenderer::new(0);
    let lines = renderer.wrap_text_simple("AI 公司");
    assert_eq!(lines, vec!["AI 公司"]);
}

#[test]
fn explicit_newline_in_input_creates_new_line() {
    let renderer = MarkdownRenderer::new(20);
    let lines = renderer.wrap_text_simple("first\nsecond");
    assert_eq!(lines, vec!["first", "second"]);
}

#[test]
fn long_url_no_char_loss() {
    let url = "https://example.com/very/long/path";
    let renderer = MarkdownRenderer::new(8);
    let lines = renderer.wrap_text_simple(url);
    assert_eq!(lines.join(""), url);
}

#[test]
fn list_item_prefix_with_mixed_text() {
    let renderer = MarkdownRenderer::new(8);
    let lines = renderer.wrap_text_simple("• AI 技术");
    let joined: String = lines.join("");
    assert!(joined.contains("AI"), "AI should not be split");
    assert!(joined.contains(""), "CJK chars should not be lost");
}

#[test]
fn table_hline_and_row_same_display_width() {
    let col_widths: Vec<usize> = vec![10, 8, 12];
    let hline = MarkdownRenderer::build_table_hline(&col_widths, BD_DR, BD_T_UP, BD_DL);
    let hline_w: usize = hline.chars().map(MarkdownRenderer::display_width).sum();
    assert_eq!(hline_w, 10 + 8 + 12 + 4);

    let cells = ["abc".to_string(), "de".to_string(), "fgh".to_string()];
    let theme = test_theme();
    let cell_spans: Vec<Vec<Span<'static>>> = cells
        .iter()
        .map(|s| {
            vec![Span::styled(
                s.clone(),
                Style::default().fg(theme.get_text_color()),
            )]
        })
        .collect();
    let row = MarkdownRenderer::build_table_row_from_spans(&col_widths, &cell_spans, &theme, false);
    let row_text: String = row.spans.iter().map(|s| s.content.as_ref()).collect();
    let row_w: usize = row_text.chars().map(MarkdownRenderer::display_width).sum();
    assert_eq!(
        row_w, hline_w,
        "row display width {} != hline display width {} (row={:?})",
        row_w, hline_w, row_text
    );
}

#[test]
fn table_cjk_cells_aligned() {
    let col_widths: Vec<usize> = vec![12, 12];
    let theme = test_theme();
    let cells = ["你好世界".to_string(), "テスト".to_string()];
    let cell_spans: Vec<Vec<Span<'static>>> = cells
        .iter()
        .map(|s| {
            vec![Span::styled(
                s.clone(),
                Style::default().fg(theme.get_text_color()),
            )]
        })
        .collect();
    let row = MarkdownRenderer::build_table_row_from_spans(&col_widths, &cell_spans, &theme, false);
    let row_text: String = row.spans.iter().map(|s| s.content.as_ref()).collect();
    let row_w: usize = row_text.chars().map(MarkdownRenderer::display_width).sum();
    let hline = MarkdownRenderer::build_table_hline(&col_widths, BD_DR, BD_T_UP, BD_DL);
    let hline_w: usize = hline.chars().map(MarkdownRenderer::display_width).sum();
    assert_eq!(
        row_w, hline_w,
        "CJK row misaligned: row={:?} hline={:?}",
        row_text, hline
    );
}