//! Markdown rendering for `tui2`. //! //! This module has two related but intentionally distinct responsibilities: //! //! 1. **Parse Markdown into styled text** (for display). //! 2. **Preserve width-agnostic structure for reflow** (for streaming + resize). //! //! ## Why logical lines exist //! //! TUI2 supports viewport resize reflow and copy/paste that treats soft-wrapped prose as a single //! logical line. If we apply wrapping while rendering and store the resulting `Vec`, those //! width-derived breaks become indistinguishable from hard newlines and cannot be "unwrapped" when //! the viewport gets wider. //! //! To avoid baking width, streaming uses [`MarkdownLogicalLine`] output: //! //! - `content` holds the styled spans for a single *logical* line (a hard break boundary). //! - `initial_indent` / `subsequent_indent` encode markdown-aware indentation rules for wraps //! (list markers, nested lists, blockquotes, etc.). //! - `line_style` captures line-level styling (e.g., blockquote green) that must apply to all //! wrapped segments. //! - `is_preformatted` marks runs that should not be wrapped like prose (e.g., fenced code). //! //! History cells can then wrap `content` at the *current* width, applying indents appropriately and //! returning soft-wrap joiners for correct copy/paste. //! //! ## Outputs //! //! - [`render_markdown_text_with_width`]: emits a `Text` suitable for immediate display and may //! apply wrapping if a width is provided. //! - [`render_markdown_logical_lines`]: emits width-agnostic logical lines (no wrapping). //! //! The underlying `Writer` can emit either (or both) depending on call site needs. use crate::render::line_utils::line_to_static; use crate::wrapping::RtOptions; use crate::wrapping::word_wrap_line; use pulldown_cmark::CodeBlockKind; use pulldown_cmark::CowStr; use pulldown_cmark::Event; use pulldown_cmark::HeadingLevel; use pulldown_cmark::Options; use pulldown_cmark::Parser; use pulldown_cmark::Tag; use pulldown_cmark::TagEnd; use ratatui::style::Style; use ratatui::text::Line; use ratatui::text::Span; use ratatui::text::Text; /// A single width-agnostic markdown "logical line" plus the metadata required to wrap it later. /// /// A logical line is a hard-break boundary produced by markdown parsing (explicit newlines, /// paragraph boundaries, list item boundaries, etc.). It is not a viewport-derived wrap segment. /// /// Wrapping is performed later (typically in `HistoryCell::transcript_lines_with_joiners(width)`), /// where a cell can: /// /// - prepend a transcript gutter prefix (`• ` / ` `), /// - prepend markdown-specific indents (`initial_indent` / `subsequent_indent`), and /// - wrap `content` to the current width while producing joiners for copy/paste. #[derive(Clone, Debug)] pub(crate) struct MarkdownLogicalLine { /// The raw content for this logical line (does not include markdown prefix/indent spans). pub(crate) content: Line<'static>, /// Prefix/indent spans to apply to the first visual line when wrapping. pub(crate) initial_indent: Line<'static>, /// Prefix/indent spans to apply to wrapped continuation lines. pub(crate) subsequent_indent: Line<'static>, /// Line-level style to apply to all wrapped segments. pub(crate) line_style: Style, /// True when this line is preformatted and should not be wrapped like prose. pub(crate) is_preformatted: bool, } struct MarkdownStyles { h1: Style, h2: Style, h3: Style, h4: Style, h5: Style, h6: Style, code: Style, emphasis: Style, strong: Style, strikethrough: Style, ordered_list_marker: Style, unordered_list_marker: Style, link: Style, blockquote: Style, } impl Default for MarkdownStyles { fn default() -> Self { use ratatui::style::Stylize; Self { h1: Style::new().bold().underlined(), h2: Style::new().bold(), h3: Style::new().bold().italic(), h4: Style::new().italic(), h5: Style::new().italic(), h6: Style::new().italic(), code: Style::new().cyan(), emphasis: Style::new().italic(), strong: Style::new().bold(), strikethrough: Style::new().crossed_out(), ordered_list_marker: Style::new().light_blue(), unordered_list_marker: Style::new(), link: Style::new().cyan().underlined(), blockquote: Style::new().green(), } } } #[derive(Clone, Debug)] struct IndentContext { /// Prefix spans to apply for this nesting level (e.g., blockquote `> `, list indentation). prefix: Vec>, /// Optional list marker spans (e.g., `- ` or `1. `) that apply only to the first visual line of /// a list item. marker: Option>>, /// True if this context represents a list indentation level. is_list: bool, } impl IndentContext { fn new(prefix: Vec>, marker: Option>>, is_list: bool) -> Self { Self { prefix, marker, is_list, } } } pub fn render_markdown_text(input: &str) -> Text<'static> { render_markdown_text_with_width(input, None) } /// Render markdown into a ratatui `Text`, optionally wrapping to a specific width. /// /// This is primarily used for non-streaming rendering where storing width-derived wrapping is /// acceptable or where the caller immediately consumes the output. pub(crate) fn render_markdown_text_with_width(input: &str, width: Option) -> Text<'static> { let mut options = Options::empty(); options.insert(Options::ENABLE_STRIKETHROUGH); let parser = Parser::new_ext(input, options); let mut w = Writer::new(parser, width, true, false); w.run(); w.text } /// Render markdown into width-agnostic logical lines (no wrapping). /// /// This is used by streaming so that the transcript can reflow on resize: wrapping is deferred to /// the history cell at render time. pub(crate) fn render_markdown_logical_lines(input: &str) -> Vec { let mut options = Options::empty(); options.insert(Options::ENABLE_STRIKETHROUGH); let parser = Parser::new_ext(input, options); let mut w = Writer::new(parser, None, false, true); w.run(); w.logical_lines } /// A markdown event sink that builds either: /// - a wrapped `Text` (`emit_text = true`), and/or /// - width-agnostic [`MarkdownLogicalLine`]s (`emit_logical_lines = true`). /// /// The writer tracks markdown structure (paragraphs, lists, blockquotes, code blocks) and builds up /// a "current logical line". `flush_current_line` commits it to the selected output(s). struct Writer<'a, I> where I: Iterator>, { iter: I, text: Text<'static>, logical_lines: Vec, styles: MarkdownStyles, inline_styles: Vec