feat(02-02): build core markdown renderer with inline and block elements
- Create src/renderer.rs with public render_markdown(input, width) -> Vec<Line<'static>> - Implement RenderState with style stack, blockquote depth, list counters, table two-pass collection - Handle all inline elements: headings H1-H6 (CGA colors + H1/H2 decorators), bold, italic, inline code - Handle block elements: paragraphs, lists (ordered/unordered/nested with •/◦/▪), blockquotes with │ prefix, horizontal rules, images as [IMAGE: alt] placeholders - Stub emit_code_block and emit_table delegate to full implementations (Task 2) - Add mod highlighter, renderer, vault declarations to main.rs (wiring for Plan 03) - cargo check passes clean (19 dead_code warnings expected — modules unwired until Plan 03)
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
mod app;
|
||||
mod config;
|
||||
mod highlighter;
|
||||
mod renderer;
|
||||
mod signals;
|
||||
mod terminal;
|
||||
mod vault;
|
||||
|
||||
fn main() {
|
||||
// ── PRE-TERMINAL PHASE ────────────────────────────────────────────────────
|
||||
|
||||
+713
@@ -0,0 +1,713 @@
|
||||
//! Markdown-to-styled-lines renderer for bbs-md.
|
||||
//!
|
||||
//! Converts a markdown string into `Vec<Line<'static>>` using pulldown-cmark
|
||||
//! event processing, CGA-themed styling, syntax-highlighted code blocks, and
|
||||
//! box-drawing table grids.
|
||||
//!
|
||||
//! # Public API
|
||||
//!
|
||||
//! - `render_markdown(input: &str, width: u16) -> Vec<Line<'static>>`
|
||||
|
||||
use pulldown_cmark::{
|
||||
Alignment, CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd, TextMergeStream,
|
||||
};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
|
||||
// ── RenderState ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Internal mutable state threaded through the event handler.
|
||||
struct RenderState {
|
||||
/// Accumulated output lines.
|
||||
lines: Vec<Line<'static>>,
|
||||
/// Spans for the current (in-progress) line.
|
||||
current_spans: Vec<Span<'static>>,
|
||||
/// Style stack — push on block/inline Start, pop on End.
|
||||
style_stack: Vec<Style>,
|
||||
/// True while inside a fenced or indented code block.
|
||||
in_code_block: bool,
|
||||
/// Language token for the current code block (empty = no language).
|
||||
code_lang: String,
|
||||
/// Accumulated raw text inside a code block.
|
||||
code_buf: String,
|
||||
/// List nesting: `None` = unordered, `Some(n)` = ordered starting at n.
|
||||
list_counters: Vec<Option<u64>>,
|
||||
/// Nesting depth of blockquotes (0 = not in blockquote).
|
||||
blockquote_depth: u32,
|
||||
/// True when we are currently inside at least one blockquote.
|
||||
in_blockquote: bool,
|
||||
/// True while collecting alt text for an image.
|
||||
in_image: bool,
|
||||
/// Accumulated image alt text.
|
||||
image_alt: String,
|
||||
// ── Table state ──────────────────────────────────────────────────────────
|
||||
/// True while inside a table element.
|
||||
in_table: bool,
|
||||
/// Column alignment from the `Table` tag.
|
||||
table_alignments: Vec<Alignment>,
|
||||
/// All rows collected (rows[0] = header row).
|
||||
table_rows: Vec<Vec<String>>,
|
||||
/// Text accumulated for the current table cell.
|
||||
current_cell: String,
|
||||
/// True while inside the `<thead>` section.
|
||||
in_table_head: bool,
|
||||
// ── Width ────────────────────────────────────────────────────────────────
|
||||
/// Terminal width; used for horizontal rules and code block sizing.
|
||||
width: u16,
|
||||
}
|
||||
|
||||
impl RenderState {
|
||||
fn new(width: u16) -> Self {
|
||||
RenderState {
|
||||
lines: Vec::new(),
|
||||
current_spans: Vec::new(),
|
||||
style_stack: Vec::new(),
|
||||
in_code_block: false,
|
||||
code_lang: String::new(),
|
||||
code_buf: String::new(),
|
||||
list_counters: Vec::new(),
|
||||
blockquote_depth: 0,
|
||||
in_blockquote: false,
|
||||
in_image: false,
|
||||
image_alt: String::new(),
|
||||
in_table: false,
|
||||
table_alignments: Vec::new(),
|
||||
table_rows: Vec::new(),
|
||||
current_cell: String::new(),
|
||||
in_table_head: false,
|
||||
width,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Style helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Return the topmost style from the stack, or `Style::default()` if empty.
|
||||
fn current_style(&self) -> Style {
|
||||
self.style_stack.last().copied().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Push a new style derived from the current top by adding a modifier.
|
||||
fn push_modifier(&mut self, modifier: Modifier) {
|
||||
let new_style = self.current_style().add_modifier(modifier);
|
||||
self.style_stack.push(new_style);
|
||||
}
|
||||
|
||||
/// Push a completely new foreground-colored style.
|
||||
fn push_fg(&mut self, color: Color) {
|
||||
let new_style = Style::default().fg(color);
|
||||
self.style_stack.push(new_style);
|
||||
}
|
||||
|
||||
/// Push a new style with a foreground color and modifier.
|
||||
fn push_fg_mod(&mut self, color: Color, modifier: Modifier) {
|
||||
let new_style = Style::default().fg(color).add_modifier(modifier);
|
||||
self.style_stack.push(new_style);
|
||||
}
|
||||
|
||||
// ── Line flushing ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Flush `current_spans` into a `Line`, optionally prepending blockquote borders.
|
||||
///
|
||||
/// If inside a blockquote, the content spans are colored gray and preceded
|
||||
/// by a yellow `│ ` border for each nesting level.
|
||||
fn flush_line(&mut self) {
|
||||
let mut spans = std::mem::take(&mut self.current_spans);
|
||||
|
||||
if self.in_blockquote && self.blockquote_depth > 0 {
|
||||
// Re-color content spans to Gray
|
||||
for span in spans.iter_mut() {
|
||||
span.style = span.style.fg(Color::Gray);
|
||||
}
|
||||
// Prepend the blockquote border(s)
|
||||
let border_str = "│ ".repeat(self.blockquote_depth as usize);
|
||||
let border_span =
|
||||
Span::styled(border_str, Style::default().fg(Color::Yellow));
|
||||
let mut prefixed = vec![border_span];
|
||||
prefixed.extend(spans);
|
||||
spans = prefixed;
|
||||
}
|
||||
|
||||
self.lines.push(Line::from(spans));
|
||||
}
|
||||
|
||||
/// Push a blank (empty) line.
|
||||
fn push_blank(&mut self) {
|
||||
self.lines.push(Line::default());
|
||||
}
|
||||
|
||||
// ── Heading handling ──────────────────────────────────────────────────────
|
||||
|
||||
fn start_heading(&mut self, level: HeadingLevel) {
|
||||
let style = match level {
|
||||
HeadingLevel::H1 => {
|
||||
Style::default().fg(Color::LightCyan).add_modifier(Modifier::BOLD)
|
||||
}
|
||||
HeadingLevel::H2 => {
|
||||
Style::default().fg(Color::LightYellow).add_modifier(Modifier::BOLD)
|
||||
}
|
||||
HeadingLevel::H3 => {
|
||||
Style::default().fg(Color::LightGreen).add_modifier(Modifier::BOLD)
|
||||
}
|
||||
HeadingLevel::H4 => Style::default().fg(Color::Green),
|
||||
HeadingLevel::H5 => Style::default().fg(Color::Cyan),
|
||||
HeadingLevel::H6 => Style::default().fg(Color::DarkGray),
|
||||
};
|
||||
self.style_stack.push(style);
|
||||
}
|
||||
|
||||
fn end_heading(&mut self, level: HeadingLevel) {
|
||||
// Flush the heading text
|
||||
self.flush_line();
|
||||
|
||||
// Pop the heading style
|
||||
self.style_stack.pop();
|
||||
|
||||
// Emit decorator for H1 and H2
|
||||
let w = self.width as usize;
|
||||
match level {
|
||||
HeadingLevel::H1 => {
|
||||
self.lines.push(Line::from(Span::styled(
|
||||
"═".repeat(w),
|
||||
Style::default().fg(Color::LightCyan),
|
||||
)));
|
||||
}
|
||||
HeadingLevel::H2 => {
|
||||
self.lines.push(Line::from(Span::styled(
|
||||
"─".repeat(w),
|
||||
Style::default().fg(Color::LightYellow),
|
||||
)));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Always add a blank line after any heading
|
||||
self.push_blank();
|
||||
}
|
||||
|
||||
// ── Code block stub ───────────────────────────────────────────────────────
|
||||
// (will be replaced in Task 2 with full border + highlighting)
|
||||
|
||||
fn emit_code_block_stub(&mut self) {
|
||||
let code = std::mem::take(&mut self.code_buf);
|
||||
let lang = std::mem::take(&mut self.code_lang);
|
||||
let width = self.width;
|
||||
emit_code_block(&code, &lang, width, &mut self.lines);
|
||||
}
|
||||
|
||||
// ── Table stub ────────────────────────────────────────────────────────────
|
||||
// (will be replaced in Task 2 with full box-drawing grid)
|
||||
|
||||
fn emit_table_stub(&mut self) {
|
||||
let alignments = std::mem::take(&mut self.table_alignments);
|
||||
let rows = std::mem::take(&mut self.table_rows);
|
||||
emit_table(&alignments, &rows, &mut self.lines);
|
||||
}
|
||||
|
||||
// ── Finish ────────────────────────────────────────────────────────────────
|
||||
|
||||
fn finish(mut self) -> Vec<Line<'static>> {
|
||||
// Flush any trailing spans that were not terminated with a paragraph end
|
||||
if !self.current_spans.is_empty() {
|
||||
self.flush_line();
|
||||
}
|
||||
self.lines
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event dispatcher ──────────────────────────────────────────────────────────
|
||||
|
||||
fn handle_event(state: &mut RenderState, event: Event) {
|
||||
match event {
|
||||
// ── Headings ──────────────────────────────────────────────────────────
|
||||
Event::Start(Tag::Heading { level, .. }) => {
|
||||
state.start_heading(level);
|
||||
}
|
||||
Event::End(TagEnd::Heading(level)) => {
|
||||
state.end_heading(level);
|
||||
}
|
||||
|
||||
// ── Paragraphs ────────────────────────────────────────────────────────
|
||||
Event::Start(Tag::Paragraph) => {
|
||||
// Nothing — spans accumulate until End
|
||||
}
|
||||
Event::End(TagEnd::Paragraph) => {
|
||||
state.flush_line();
|
||||
state.push_blank();
|
||||
}
|
||||
|
||||
// ── Inline formatting ─────────────────────────────────────────────────
|
||||
Event::Start(Tag::Strong) => {
|
||||
state.push_modifier(Modifier::BOLD);
|
||||
}
|
||||
Event::End(TagEnd::Strong) => {
|
||||
state.style_stack.pop();
|
||||
}
|
||||
Event::Start(Tag::Emphasis) => {
|
||||
state.push_modifier(Modifier::ITALIC);
|
||||
}
|
||||
Event::End(TagEnd::Emphasis) => {
|
||||
state.style_stack.pop();
|
||||
}
|
||||
|
||||
// ── Inline code ───────────────────────────────────────────────────────
|
||||
Event::Code(text) => {
|
||||
// Inline code: LightCyan color, no background color available in 16-color
|
||||
let span = Span::styled(
|
||||
text.to_string(),
|
||||
Style::default().fg(Color::LightCyan),
|
||||
);
|
||||
state.current_spans.push(span);
|
||||
}
|
||||
|
||||
// ── Text ──────────────────────────────────────────────────────────────
|
||||
Event::Text(text) => {
|
||||
if state.in_code_block {
|
||||
state.code_buf.push_str(&text);
|
||||
} else if state.in_image {
|
||||
state.image_alt.push_str(&text);
|
||||
} else if state.in_table {
|
||||
state.current_cell.push_str(&text);
|
||||
} else {
|
||||
let style = state.current_style();
|
||||
state.current_spans.push(Span::styled(text.to_string(), style));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Code blocks ───────────────────────────────────────────────────────
|
||||
Event::Start(Tag::CodeBlock(kind)) => {
|
||||
state.code_lang = match kind {
|
||||
CodeBlockKind::Fenced(lang) => lang.to_string(),
|
||||
CodeBlockKind::Indented => String::new(),
|
||||
};
|
||||
state.in_code_block = true;
|
||||
state.code_buf.clear();
|
||||
}
|
||||
Event::End(TagEnd::CodeBlock) => {
|
||||
state.in_code_block = false;
|
||||
state.emit_code_block_stub();
|
||||
}
|
||||
|
||||
// ── Lists ─────────────────────────────────────────────────────────────
|
||||
Event::Start(Tag::List(start)) => {
|
||||
state.list_counters.push(start);
|
||||
}
|
||||
Event::End(TagEnd::List(_)) => {
|
||||
state.list_counters.pop();
|
||||
// Blank line after top-level list only
|
||||
if state.list_counters.is_empty() {
|
||||
state.push_blank();
|
||||
}
|
||||
}
|
||||
Event::Start(Tag::Item) => {
|
||||
let depth = state.list_counters.len();
|
||||
let indent = " ".repeat(depth.saturating_sub(1));
|
||||
|
||||
let bullet_str = match state.list_counters.last_mut() {
|
||||
Some(Some(n)) => {
|
||||
let s = format!("{}{}. ", indent, n);
|
||||
*n += 1;
|
||||
s
|
||||
}
|
||||
Some(None) => {
|
||||
let bullet = match depth {
|
||||
1 => "• ",
|
||||
2 => "◦ ",
|
||||
_ => "▪ ",
|
||||
};
|
||||
format!("{}{}", indent, bullet)
|
||||
}
|
||||
None => format!("{}• ", indent),
|
||||
};
|
||||
|
||||
state.current_spans.push(Span::styled(
|
||||
bullet_str,
|
||||
Style::default().fg(Color::Cyan),
|
||||
));
|
||||
}
|
||||
Event::End(TagEnd::Item) => {
|
||||
state.flush_line();
|
||||
}
|
||||
|
||||
// ── Blockquotes ───────────────────────────────────────────────────────
|
||||
Event::Start(Tag::BlockQuote(_)) => {
|
||||
if state.blockquote_depth == 0 {
|
||||
state.push_blank();
|
||||
}
|
||||
state.blockquote_depth += 1;
|
||||
state.in_blockquote = true;
|
||||
}
|
||||
Event::End(TagEnd::BlockQuote(_)) => {
|
||||
if state.blockquote_depth > 0 {
|
||||
state.blockquote_depth -= 1;
|
||||
}
|
||||
if state.blockquote_depth == 0 {
|
||||
state.in_blockquote = false;
|
||||
state.push_blank();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Horizontal rules ──────────────────────────────────────────────────
|
||||
Event::Rule => {
|
||||
let w = state.width as usize;
|
||||
state.lines.push(Line::from(Span::styled(
|
||||
"─".repeat(w),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)));
|
||||
state.push_blank();
|
||||
}
|
||||
|
||||
// ── Images ────────────────────────────────────────────────────────────
|
||||
Event::Start(Tag::Image { .. }) => {
|
||||
state.in_image = true;
|
||||
state.image_alt.clear();
|
||||
}
|
||||
Event::End(TagEnd::Image) => {
|
||||
let alt = std::mem::take(&mut state.image_alt);
|
||||
let placeholder = format!("[IMAGE: {}]", alt);
|
||||
state.current_spans.push(Span::styled(
|
||||
placeholder,
|
||||
Style::default()
|
||||
.fg(Color::DarkGray)
|
||||
.add_modifier(Modifier::DIM),
|
||||
));
|
||||
state.in_image = false;
|
||||
}
|
||||
|
||||
// ── Links ─────────────────────────────────────────────────────────────
|
||||
Event::Start(Tag::Link { .. }) => {
|
||||
// Phase 2: pass through — link text renders as normal styled text.
|
||||
// Phase 3 will make links interactive.
|
||||
}
|
||||
Event::End(TagEnd::Link) => {}
|
||||
|
||||
// ── Tables ────────────────────────────────────────────────────────────
|
||||
Event::Start(Tag::Table(alignments)) => {
|
||||
state.table_alignments = alignments;
|
||||
state.table_rows.clear();
|
||||
state.in_table = true;
|
||||
}
|
||||
Event::End(TagEnd::Table) => {
|
||||
state.in_table = false;
|
||||
state.emit_table_stub();
|
||||
}
|
||||
Event::Start(Tag::TableHead) => {
|
||||
state.in_table_head = true;
|
||||
// Start a new row for the header
|
||||
state.table_rows.push(Vec::new());
|
||||
}
|
||||
Event::End(TagEnd::TableHead) => {
|
||||
state.in_table_head = false;
|
||||
}
|
||||
Event::Start(Tag::TableRow) => {
|
||||
if !state.in_table_head {
|
||||
state.table_rows.push(Vec::new());
|
||||
}
|
||||
}
|
||||
Event::End(TagEnd::TableRow) => {}
|
||||
Event::Start(Tag::TableCell) => {
|
||||
state.current_cell.clear();
|
||||
}
|
||||
Event::End(TagEnd::TableCell) => {
|
||||
let cell = std::mem::take(&mut state.current_cell);
|
||||
if let Some(row) = state.table_rows.last_mut() {
|
||||
row.push(cell);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Breaks ────────────────────────────────────────────────────────────
|
||||
Event::SoftBreak => {
|
||||
state.current_spans.push(Span::raw(" "));
|
||||
}
|
||||
Event::HardBreak => {
|
||||
state.flush_line();
|
||||
}
|
||||
|
||||
// ── Strikethrough (enabled but treated as plain text for now) ─────────
|
||||
Event::Start(Tag::Strikethrough) => {
|
||||
state.push_modifier(Modifier::CROSSED_OUT);
|
||||
}
|
||||
Event::End(TagEnd::Strikethrough) => {
|
||||
state.style_stack.pop();
|
||||
}
|
||||
|
||||
// ── Everything else ────────────────────────────────────────────────────
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Code block emitter ────────────────────────────────────────────────────────
|
||||
|
||||
/// Emit a fenced code block with rounded box-drawing borders and syntax
|
||||
/// highlighting via `crate::highlighter::highlight_code()`.
|
||||
///
|
||||
/// Layout:
|
||||
/// ```text
|
||||
/// ╭─ rust ─────────────────────╮
|
||||
/// │ let x = 1; │
|
||||
/// ╰────────────────────────────╯
|
||||
/// ```
|
||||
fn emit_code_block(
|
||||
code_buf: &str,
|
||||
lang: &str,
|
||||
width: u16,
|
||||
lines: &mut Vec<Line<'static>>,
|
||||
) {
|
||||
let highlighted = crate::highlighter::highlight_code(code_buf, lang);
|
||||
|
||||
// Compute box width: at least lang.len() + 6, capped at terminal width
|
||||
let max_content_width = highlighted.iter()
|
||||
.map(|l| l.spans.iter().map(|s| s.content.len()).sum::<usize>())
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
let min_width_for_lang = if lang.is_empty() { 4 } else { lang.len() + 6 };
|
||||
let box_width = (max_content_width + 4)
|
||||
.max(min_width_for_lang)
|
||||
.min(width as usize);
|
||||
|
||||
// ── Top border ───────────────────────────────────────────────────────────
|
||||
// ╭─ rust ───────╮ or ╭──────────╮
|
||||
let top_line = if lang.is_empty() {
|
||||
// No language label
|
||||
let fill = "─".repeat(box_width.saturating_sub(2));
|
||||
Line::from(vec![
|
||||
Span::styled("╭".to_string(), Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(fill, Style::default().fg(Color::DarkGray)),
|
||||
Span::styled("╮".to_string(), Style::default().fg(Color::DarkGray)),
|
||||
])
|
||||
} else {
|
||||
// With language label: ╭─ {lang} ─...─╮
|
||||
let label = format!("─ {} ", lang);
|
||||
let used = label.len() + 2; // ╭ + label + ╮
|
||||
let fill_len = box_width.saturating_sub(used);
|
||||
let fill = "─".repeat(fill_len);
|
||||
Line::from(vec![
|
||||
Span::styled("╭".to_string(), Style::default().fg(Color::DarkGray)),
|
||||
Span::styled("─ ".to_string(), Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(lang.to_string(), Style::default().fg(Color::Yellow)),
|
||||
Span::styled(format!(" {}", fill), Style::default().fg(Color::DarkGray)),
|
||||
Span::styled("╮".to_string(), Style::default().fg(Color::DarkGray)),
|
||||
])
|
||||
};
|
||||
lines.push(Line::default()); // blank before
|
||||
lines.push(top_line);
|
||||
|
||||
// ── Content lines ─────────────────────────────────────────────────────────
|
||||
// │ {highlighted spans} {padding} │
|
||||
if highlighted.is_empty() {
|
||||
// Empty code block
|
||||
let inner_width = box_width.saturating_sub(4); // "│ " + " │"
|
||||
let padding = " ".repeat(inner_width);
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("│ ".to_string(), Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(padding, Style::default().fg(Color::Gray)),
|
||||
Span::styled(" │".to_string(), Style::default().fg(Color::DarkGray)),
|
||||
]));
|
||||
} else {
|
||||
for hl_line in highlighted {
|
||||
let content_len: usize = hl_line.spans.iter().map(|s| s.content.len()).sum();
|
||||
let inner_width = box_width.saturating_sub(4);
|
||||
let pad_len = inner_width.saturating_sub(content_len);
|
||||
let padding = " ".repeat(pad_len);
|
||||
|
||||
let mut row_spans = vec![
|
||||
Span::styled("│ ".to_string(), Style::default().fg(Color::DarkGray)),
|
||||
];
|
||||
for span in hl_line.spans {
|
||||
row_spans.push(span);
|
||||
}
|
||||
row_spans.push(Span::styled(padding, Style::default()));
|
||||
row_spans.push(Span::styled(" │".to_string(), Style::default().fg(Color::DarkGray)));
|
||||
lines.push(Line::from(row_spans));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bottom border ─────────────────────────────────────────────────────────
|
||||
let fill = "─".repeat(box_width.saturating_sub(2));
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("╰".to_string(), Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(fill, Style::default().fg(Color::DarkGray)),
|
||||
Span::styled("╯".to_string(), Style::default().fg(Color::DarkGray)),
|
||||
]));
|
||||
lines.push(Line::default()); // blank after
|
||||
}
|
||||
|
||||
// ── Table emitter ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Emit a GFM table with full box-drawing grid.
|
||||
///
|
||||
/// Layout:
|
||||
/// ```text
|
||||
/// ┌──────┬──────┐
|
||||
/// │ Col1 │ Col2 │
|
||||
/// ├──────┼──────┤
|
||||
/// │ data │ data │
|
||||
/// └──────┴──────┘
|
||||
/// ```
|
||||
fn emit_table(
|
||||
alignments: &[Alignment],
|
||||
rows: &[Vec<String>],
|
||||
lines: &mut Vec<Line<'static>>,
|
||||
) {
|
||||
if rows.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let n_cols = alignments.len().max(
|
||||
rows.iter().map(|r| r.len()).max().unwrap_or(0)
|
||||
);
|
||||
if n_cols == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Compute column widths ─────────────────────────────────────────────────
|
||||
// Minimum 5 chars; each cell gets +2 for surrounding spaces
|
||||
let mut col_widths: Vec<usize> = vec![5; n_cols];
|
||||
for row in rows {
|
||||
for (i, cell) in row.iter().enumerate() {
|
||||
if i < n_cols {
|
||||
col_widths[i] = col_widths[i].max(cell.len() + 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Border builder ────────────────────────────────────────────────────────
|
||||
let build_border = |left: char, mid: char, right: char, fill: char| -> String {
|
||||
let mut s = String::new();
|
||||
s.push(left);
|
||||
for (i, &w) in col_widths.iter().enumerate() {
|
||||
s.extend(std::iter::repeat(fill).take(w));
|
||||
if i + 1 < n_cols {
|
||||
s.push(mid);
|
||||
}
|
||||
}
|
||||
s.push(right);
|
||||
s
|
||||
};
|
||||
|
||||
let border_style = Style::default().fg(Color::Cyan);
|
||||
|
||||
// ── Top border ────────────────────────────────────────────────────────────
|
||||
let top = build_border('┌', '┬', '┐', '─');
|
||||
lines.push(Line::from(Span::styled(top, border_style)));
|
||||
|
||||
// ── Header row ────────────────────────────────────────────────────────────
|
||||
let header_style = Style::default()
|
||||
.fg(Color::LightCyan)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
|
||||
emit_table_row(
|
||||
&rows[0],
|
||||
&col_widths,
|
||||
alignments,
|
||||
header_style,
|
||||
border_style,
|
||||
lines,
|
||||
);
|
||||
|
||||
// ── Header separator ──────────────────────────────────────────────────────
|
||||
let sep = build_border('├', '┼', '┤', '─');
|
||||
lines.push(Line::from(Span::styled(sep, border_style)));
|
||||
|
||||
// ── Data rows ─────────────────────────────────────────────────────────────
|
||||
let data_style = Style::default();
|
||||
for row in rows.iter().skip(1) {
|
||||
emit_table_row(row, &col_widths, alignments, data_style, border_style, lines);
|
||||
}
|
||||
|
||||
// ── Bottom border ─────────────────────────────────────────────────────────
|
||||
let bottom = build_border('└', '┴', '┘', '─');
|
||||
lines.push(Line::from(Span::styled(bottom, border_style)));
|
||||
lines.push(Line::default()); // spacing after table
|
||||
}
|
||||
|
||||
/// Emit a single table data row (header or body) as a `Line`.
|
||||
fn emit_table_row(
|
||||
cells: &[String],
|
||||
col_widths: &[usize],
|
||||
alignments: &[Alignment],
|
||||
cell_style: Style,
|
||||
border_style: Style,
|
||||
lines: &mut Vec<Line<'static>>,
|
||||
) {
|
||||
let n_cols = col_widths.len();
|
||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||
|
||||
for col in 0..n_cols {
|
||||
spans.push(Span::styled("│".to_string(), border_style));
|
||||
let cell_text = cells.get(col).map(|s| s.as_str()).unwrap_or("");
|
||||
let width = col_widths[col];
|
||||
let alignment = alignments.get(col).copied().unwrap_or(Alignment::None);
|
||||
|
||||
let padded = pad_cell(cell_text, width, alignment);
|
||||
spans.push(Span::styled(padded, cell_style));
|
||||
}
|
||||
spans.push(Span::styled("│".to_string(), border_style));
|
||||
lines.push(Line::from(spans));
|
||||
}
|
||||
|
||||
/// Pad a cell string to `width` according to the column alignment.
|
||||
///
|
||||
/// - `Left` / `None`: one leading space, right-pad to width
|
||||
/// - `Right`: left-pad to width, one trailing space
|
||||
/// - `Center`: center the text within width
|
||||
fn pad_cell(text: &str, width: usize, alignment: Alignment) -> String {
|
||||
// width includes the surrounding single-space padding on each side
|
||||
let content_width = width.saturating_sub(2);
|
||||
let text_len = text.len();
|
||||
|
||||
match alignment {
|
||||
Alignment::Right => {
|
||||
// Right-align: spaces on left, 1 trailing space
|
||||
let pad = content_width.saturating_sub(text_len);
|
||||
format!("{}{} ", " ".repeat(pad + 1), text)
|
||||
}
|
||||
Alignment::Center => {
|
||||
let pad = content_width.saturating_sub(text_len);
|
||||
let left_pad = pad / 2 + 1;
|
||||
let right_pad = pad - (pad / 2) + 1;
|
||||
format!(
|
||||
"{}{}{}",
|
||||
" ".repeat(left_pad),
|
||||
text,
|
||||
" ".repeat(right_pad)
|
||||
)
|
||||
}
|
||||
// Alignment::Left and Alignment::None
|
||||
_ => {
|
||||
let pad = content_width.saturating_sub(text_len);
|
||||
format!(" {}{}", text, " ".repeat(pad + 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Convert a markdown string into a list of styled ratatui lines.
|
||||
///
|
||||
/// The `width` parameter controls the width used for:
|
||||
/// - Horizontal rule `─` characters (full-width)
|
||||
/// - Code block border sizing
|
||||
///
|
||||
/// All output spans are `'static` (owned strings) — no lifetime dependency on
|
||||
/// the input `&str`. The result can be stored in `App` state and rendered
|
||||
/// indefinitely via `Paragraph::new(lines).scroll((offset, 0))`.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if `crate::highlighter::init_highlighter()` has not been called before
|
||||
/// the first code block is encountered.
|
||||
pub fn render_markdown(input: &str, width: u16) -> Vec<Line<'static>> {
|
||||
let mut opts = Options::empty();
|
||||
opts.insert(Options::ENABLE_TABLES);
|
||||
opts.insert(Options::ENABLE_STRIKETHROUGH);
|
||||
opts.insert(Options::ENABLE_TASKLISTS);
|
||||
opts.insert(Options::ENABLE_GFM);
|
||||
|
||||
let parser = TextMergeStream::new(Parser::new_ext(input, opts));
|
||||
|
||||
let mut state = RenderState::new(width);
|
||||
for event in parser {
|
||||
handle_event(&mut state, event);
|
||||
}
|
||||
state.finish()
|
||||
}
|
||||
Reference in New Issue
Block a user