//! Low-level markdown event renderer for the TUI transcript. //! //! This module consumes `pulldown-cmark` events and emits styled `ratatui` //! lines, including table layout, task-list checkboxes, Mermaid previews, wrapping, and local file-link //! display. It is the final rendering stage used by higher-level helpers in //! `markdown.rs`. Launch-time `tui.rendering` preferences preserve disabled features as source. //! //! Local file-link parsing and display policy live in [`local_links`]. //! List spacing stays a renderer policy: compact while streaming in an owned viewport, uniform //! after source-backed consolidation, and historical spacing for native scrollback. //! //! ## Table rendering pipeline //! //! When the parser emits `Tag::Table` .. `TagEnd::Table`, the writer //! accumulates header and body rows into a `TableState`, then hands it to //! `render_table_lines` which runs this pipeline: //! //! 1. **Filter spillover rows** -- heuristic extraction of rows that are //! artifacts of pulldown-cmark's lenient parsing. //! 2. **Normalize column counts** -- pad or truncate so every row matches the //! alignment count. //! 3. **Compute column widths** -- allocate widths with content-aware //! priority and iterative shrinking. //! 4. **Choose presentation** -- render theme-accented row-separated columns //! while values remain scannable, otherwise transpose body rows //! into key/value records separated by muted rules. //! 5. **Append spillover** -- extracted spillover rows rendered as plain text //! after the table. //! //! ## Width allocation //! //! Columns are classified as Narrative (long prose), TokenHeavy (paths, URLs, //! or hashes), or Compact (short values such as counts and status labels). //! Token-heavy columns give up excess width before narrative columns so an //! oversized path does not collapse readable prose; compact values are //! preserved last. When compact values split, token-heavy values collapse into //! unusably short chunks, expansive cells form tall narrow strips across enough //! body rows, or even 3-char-wide columns cannot fit, body rows render as //! key/value records. //! //! Markdown prose uses foregrounds from the active syntax theme's markup scopes, //! retaining its usual modifiers and falling back when a scope has no foreground. //! Inline code and local file paths share the active syntax theme's raw-markup foreground. use crate::markdown_text_merge::DecodedTextMerge; use crate::render::highlight::current_syntax_theme; use crate::render::highlight::foreground_style_for_scopes; use crate::render::highlight::foreground_style_for_scopes_with_theme; use crate::render::highlight::highlight_code_to_lines; use crate::render::line_utils::line_to_static; use crate::style::accent_color; use crate::style::table_separator_style; use crate::terminal_hyperlinks::HyperlinkLine; use crate::terminal_hyperlinks::remap_wrapped_line; use crate::terminal_hyperlinks::visible_lines; use crate::terminal_hyperlinks::web_destination; use crate::width::char_width; use crate::width::display_width; use crate::wrapping::RtOptions; use crate::wrapping::word_wrap_line; use pulldown_cmark::Alignment; 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; use std::ops::Range; use std::path::Path; use std::path::PathBuf; mod inline_directives; mod list_spacing; mod local_links; mod math; mod mermaid; pub(crate) mod preferences; mod source_tables; mod streaming; pub(crate) use streaming::render_with_copy_sources; mod table_key_value; mod task_lists; mod web_links; use inline_directives::InlineDirectives; pub(crate) use inline_directives::followup_labels; pub(crate) use list_spacing::ListSpacing; use list_spacing::UniformList; use local_links::is_local_path_like_link; use local_links::render_local_link_target; pub(crate) use streaming::StreamingMarkdownRender; pub(crate) use streaming::render_streaming_markdown_lines_with_width_and_cwd; #[cfg(test)] #[path = "markdown_render/list_spacing_tests.rs"] mod list_spacing_tests; #[cfg(test)] #[path = "markdown_render/task_list_tests.rs"] mod task_list_tests; pub(crate) use web_links::hide_web_link_destination; use web_links::style_bare_web_urls; const TABLE_COLUMN_GAP: usize = 2; const TABLE_CELL_PADDING: usize = 1; const TABLE_HEADER_SEPARATOR_CHAR: char = '━'; const TABLE_BODY_SEPARATOR_CHAR: char = '─'; 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 { Self::for_theme(&crate::render::highlight::current_syntax_theme()) } } impl MarkdownStyles { fn for_theme(theme: &syntect::highlighting::Theme) -> Self { let foreground = |scopes: &[&str]| { let mut style = foreground_style_for_scopes_with_theme(theme, scopes).unwrap_or_default(); style.fg = style.fg.map(|foreground| { crate::style::readable_color_on(foreground, /*background*/ None) }); style }; Self { h1: foreground(&["markup.heading.1.markdown", "markup.heading.markdown"]) .bold() .underlined(), h2: foreground(&["markup.heading.2.markdown", "markup.heading.markdown"]).bold(), h3: foreground(&["markup.heading.3.markdown", "markup.heading.markdown"]) .bold() .italic(), h4: foreground(&["markup.heading.4.markdown", "markup.heading.markdown"]).italic(), h5: foreground(&["markup.heading.5.markdown", "markup.heading.markdown"]).italic(), h6: foreground(&["markup.heading.6.markdown", "markup.heading.markdown"]).italic(), code: foreground_style_for_scopes_with_theme( theme, &[ "markup.inline.raw.string.markdown", "markup.raw.inline.markdown", ], ) .unwrap_or_else(|| Style::new().fg(accent_color())), emphasis: foreground(&["markup.italic.markdown"]).italic(), strong: foreground(&["markup.bold.markdown"]).bold(), strikethrough: Style::new().crossed_out(), ordered_list_marker: Style::new() .light_blue() .patch(foreground(&["markup.list.numbered.markdown"])), unordered_list_marker: foreground(&[ "markup.list.unnumbered.markdown", "markup.list.bullet.markdown", ]), link: Style::new() .fg(accent_color()) .patch(foreground(&["markup.underline.link.markdown"])) .underlined(), blockquote: Style::new() .green() .patch(foreground(&["markup.quote.markdown"])), } } } #[derive(Clone, Debug)] struct IndentContext { prefix: Vec>, marker: Option>>, copy_marker: String, is_list: bool, } impl IndentContext { fn new(prefix: Vec>, marker: Option>>, is_list: bool) -> Self { // Keep the canonical marker separate from depth-dependent display padding. let copy_marker = marker .iter() .flatten() .map(|span| span.content.as_ref()) .collect::() .trim_start() .replace('•', "-"); Self { prefix, marker, copy_marker, is_list, } } } /// Styled content of a single cell in the table being parsed. /// /// A cell can contain multiple lines (hard breaks inside the cell) and rich inline spans (bold, /// code, links). The `plain_text()` projection is used for column-width measurement; the styled /// `lines` are used for final rendering. #[derive(Clone, Debug, Default)] struct TableCell { lines: Vec, copies: Vec, } // TableCell mutators inlined — called per-span during table event parsing. impl TableCell { #[inline] fn ensure_line(&mut self) { if self.lines.is_empty() { self.lines.push(HyperlinkLine::new(Line::default())); self.copies.push(Default::default()); } } fn push_annotated( &mut self, mut appended: HyperlinkLine, inline: &[crate::markdown_copy::Inline], ) { self.ensure_line(); if let Some(copy) = self.copies.last_mut() { copy.push( appended .line .spans .iter() .map(|span| span.content.len()) .sum(), inline, ); } if let Some(line) = self.lines.last_mut() { let shift = line.width(); line.line.spans.append(&mut appended.line.spans); line.hyperlinks .extend(appended.hyperlinks.into_iter().map(|mut link| { link.columns = link.columns.start + shift..link.columns.end + shift; link })); } } #[inline] fn hard_break(&mut self) { self.lines.push(HyperlinkLine::new(Line::default())); self.copies.push(Default::default()); } fn plain_text(&self) -> String { use std::fmt::Write; let mut buf = String::new(); for (i, line) in self.lines.iter().enumerate() { if i > 0 { buf.push(' '); } for span in &line.line.spans { let _ = write!(buf, "{}", span.content); } } buf } } /// Accumulates pulldown-cmark table events into a structured representation. /// /// `TableState` is created on `Tag::Table` and consumed on `TagEnd::Table`. Between those events, /// the Writer delegates cell content (text, code, html, breaks) into the `current_cell`, which is /// flushed into `current_row` on `TagEnd::TableCell`, then into `header`/`rows` on row/head end /// events. #[derive(Debug)] struct TableBodyRow { cells: Vec, has_table_pipe_syntax: bool, } #[derive(Debug)] struct TableState { alignments: Vec, header: Option>, rows: Vec, current_row: Option>, current_row_has_table_pipe_syntax: bool, current_cell: Option, in_header: bool, } impl TableState { fn new(alignments: Vec) -> Self { Self { alignments, header: None, rows: Vec::new(), current_row: None, current_row_has_table_pipe_syntax: false, current_cell: None, in_header: false, } } } /// Rendered table output split by wrapping behavior. /// /// `table_lines` are prewrapped aligned rows or key/value records, except /// header-only tables may retain pipe fallback rows for normal wrapping. /// `spillover_lines` are prose rows extracted from parser artifacts and should /// be routed through normal wrapping. struct RenderedTableLines { table_lines: Vec, table_lines_prewrapped: bool, spillover_lines: Vec, } /// Classification of a table column for width-allocation priority. /// /// Token-heavy columns such as paths and URLs are allowed to wrap before prose becomes unreadable. /// Compact columns such as counts or status words resist wrapping so their values stay scannable. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum TableColumnKind { /// Long-form prose content (>= 4 avg words/cell or >= 28 avg char width). Narrative, /// Content dominated by long tokens, such as paths, URLs, and hashes. TokenHeavy, /// Short values, such as counts and status labels, that should resist wrapping. Compact, } /// Per-column statistics used to drive the width-allocation algorithm. /// /// Collected in a single pass over the header and body rows before any /// shrinking decisions are made. #[derive(Clone, Debug)] struct TableColumnMetrics { /// Widest cell content (display width) across header and all body rows. max_width: usize, /// Display width of the longest whitespace-delimited token in the header. header_token_width: usize, /// Display width of the longest whitespace-delimited token across body rows. body_token_width: usize, /// Classification derived from body token density and average cell content. kind: TableColumnKind, } /// Render markdown with default wrapping behavior. /// /// Use this when the caller does not have a concrete render width yet (for /// example, snapshot tests or contexts that intentionally defer wrapping). If /// a viewport width is known, prefer [`render_markdown_text_with_width`] so /// table fallback and line wrapping decisions match the visible terminal. pub fn render_markdown_text(input: &str) -> Text<'static> { render_markdown_text_with_width(input, /*width*/ None) } /// Render markdown constrained to a known terminal width. /// /// The renderer preserves columnar table structure while values remain /// scannable and falls back to key/value records when body rows cannot fit /// readably. Passing `None` keeps intrinsic line widths and disables /// width-driven wrapping in the markdown writer. Local file links render /// relative to the current process working directory. pub(crate) fn render_markdown_text_with_width(input: &str, width: Option) -> Text<'static> { let cwd = std::env::current_dir().ok(); render_markdown_text_with_width_and_cwd(input, width, cwd.as_deref()) } /// Render markdown with an explicit working directory for local file links. /// /// The `cwd` parameter controls how absolute local targets are shortened before display. Passing /// the session cwd keeps full renders, history cells, and streamed deltas visually aligned even /// when rendering happens away from the process cwd. pub(crate) fn render_markdown_text_with_width_and_cwd( input: &str, width: Option, cwd: Option<&Path>, ) -> Text<'static> { Text::from(visible_lines(render_markdown_lines_with_width_and_cwd( input, width, cwd, ))) } /// Keep destinations visible by default, including for callers that discard hyperlink metadata. /// Semantic output paths supply their hidden-destination policy explicitly. pub(crate) fn render_markdown_lines_with_width_and_cwd( input: &str, width: Option, cwd: Option<&Path>, ) -> Vec { render_markdown_lines_with_width_cwd_and_hidden_link_destinations( input, width, cwd, &never_hide_link_destination, ) } fn never_hide_link_destination(_: &str) -> bool { false } pub(crate) fn render_markdown_lines_with_width_cwd_and_hidden_link_destinations( input: &str, width: Option, cwd: Option<&Path>, is_hidden_link_destination: &dyn Fn(&str) -> bool, ) -> Vec { let mut options = Options::empty(); options.insert(Options::ENABLE_STRIKETHROUGH); options.insert(Options::ENABLE_TABLES); options.set(Options::ENABLE_TASKLISTS, preferences::current().lists); let math = math::MathMarkdown::new(input, options, width); let parser = DecodedTextMerge::new(source_tables::preserve( input, math.events(Parser::new_ext(&math.markdown, options).into_offset_iter()), )); let mut w = Writer::new(input, width, cwd, is_hidden_link_destination); // Drop the consumed parser before the rendering state, including on unwind. let mut parser = parser; w.run(&mut parser); w.text } #[derive(Clone, Debug)] struct LinkState { destination: String, show_destination: bool, style_label: bool, has_visible_label: bool, /// Pre-rendered display text for local file links. /// /// When this is present, label spans are buffered until the link closes so path-like labels /// can collapse to this canonical target without losing descriptive labels. local_target_display: Option, local_label_spans: Vec>, local_label_inline: Vec>, } fn should_render_link_destination(dest_url: &str) -> bool { !is_local_path_like_link(dest_url) } /// Stateful pulldown-cmark event consumer that builds styled `ratatui` output. /// /// Tracks inline style nesting, indent/blockquote context, list numbering, /// and an optional `TableState` for accumulating table events. The /// `wrap_width` field enables width-aware line wrapping and table column /// allocation; when `None`, lines keep their intrinsic width. /// Layout state is independent of the parser so rendering routines are compiled /// once; event traversal remains statically dispatched over each iterator. struct Writer<'a, 'policy> { input: &'a str, text: Vec, styles: MarkdownStyles, inline_styles: Vec