Skip to main content

tui/markdown/
mod.rs

1mod table;
2
3use pulldown_cmark::{Alignment, CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
4use std::borrow::Cow;
5
6use crate::line::Line;
7use crate::rendering::render_context::ViewContext;
8use crate::span::Span;
9use crate::style::Style;
10use crate::theme::Theme;
11
12use table::{CellBuilder, TableCell, TableState, line_display_width};
13
14pub fn render_markdown(text: &str, context: &ViewContext) -> Vec<Line> {
15    let renderer = MarkdownRenderer::new(context);
16    renderer.render(text)
17}
18
19struct MarkdownRenderer<'a> {
20    context: &'a ViewContext,
21    theme: &'a Theme,
22    lines: Vec<Line>,
23    current_line: Line,
24    style_stack: Vec<Style>,
25    /// Stack of list counters: None = unordered, Some(n) = ordered at n
26    list_stack: Vec<Option<u64>>,
27    /// Accumulated code block text
28    code_buffer: String,
29    /// Language hint for the current code block
30    code_lang: String,
31    /// Whether we're inside a code block (accumulating text)
32    in_code_block: bool,
33    /// Current blockquote nesting depth
34    blockquote_depth: usize,
35    /// Table state when rendering tables
36    table_state: Option<TableState>,
37    /// Active cell when parsing inline table content.
38    active_cell: Option<CellBuilder>,
39}
40
41impl<'a> MarkdownRenderer<'a> {
42    fn new(context: &'a ViewContext) -> Self {
43        Self {
44            context,
45            theme: &context.theme,
46            lines: Vec::new(),
47            current_line: Line::default(),
48            style_stack: Vec::new(),
49            list_stack: Vec::new(),
50            code_buffer: String::new(),
51            code_lang: String::new(),
52            in_code_block: false,
53            blockquote_depth: 0,
54            table_state: None,
55            active_cell: None,
56        }
57    }
58
59    fn render(mut self, text: &str) -> Vec<Line> {
60        let options = Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES;
61        let parser = Parser::new_ext(text, options);
62
63        for event in parser {
64            self.handle_event(event);
65        }
66
67        self.flush_line();
68
69        // Remove trailing empty lines
70        while self.lines.last().is_some_and(Line::is_empty) {
71            self.lines.pop();
72        }
73
74        self.lines
75    }
76
77    fn handle_event(&mut self, event: Event<'_>) {
78        match event {
79            Event::Start(tag) => self.handle_start(tag),
80            Event::End(tag_end) => self.handle_end(tag_end),
81            Event::Text(text) => self.push_inline_text(&text),
82            Event::Code(code) => self.push_inline_code(&code),
83            Event::SoftBreak => self.push_soft_break(),
84            Event::HardBreak => self.push_hard_break(),
85            Event::Rule => {
86                self.finish_current_line();
87                self.lines.push(Line::with_style(
88                    "───────────────",
89                    Style::fg(self.theme.muted()),
90                ));
91                self.lines.push(Line::default());
92            }
93            _ => {}
94        }
95    }
96
97    fn handle_start(&mut self, tag: Tag<'_>) {
98        match tag {
99            Tag::Heading { .. }
100            | Tag::BlockQuote(_)
101            | Tag::List(_)
102            | Tag::Item
103            | Tag::Paragraph => self.handle_block_start(&tag),
104
105            Tag::Strong | Tag::Emphasis | Tag::Strikethrough | Tag::Link { .. } => {
106                self.handle_inline_start(tag);
107            }
108
109            Tag::CodeBlock(_) => self.handle_code_block_start(tag),
110
111            Tag::Table(_) | Tag::TableRow | Tag::TableCell => self.handle_table_start(tag),
112
113            _ => {}
114        }
115    }
116
117    fn handle_end(&mut self, tag_end: TagEnd) {
118        match tag_end {
119            TagEnd::Paragraph
120            | TagEnd::Heading(_)
121            | TagEnd::BlockQuote(_)
122            | TagEnd::List(_)
123            | TagEnd::Item => self.handle_block_end(tag_end),
124
125            TagEnd::Strong | TagEnd::Emphasis | TagEnd::Strikethrough | TagEnd::Link => {
126                self.handle_inline_end(tag_end);
127            }
128
129            TagEnd::CodeBlock => self.handle_code_block_end(),
130
131            TagEnd::Table | TagEnd::TableRow | TagEnd::TableHead | TagEnd::TableCell => {
132                self.handle_table_end(tag_end);
133            }
134
135            _ => {}
136        }
137    }
138
139    fn handle_block_start(&mut self, tag: &Tag<'_>) {
140        match tag {
141            Tag::Heading { level, .. } => {
142                self.finish_current_line();
143                let prefix = "#".repeat(*level as usize);
144                self.push_styled_text(
145                    &format!("{prefix} "),
146                    Style::fg(self.theme.heading()).bold(),
147                );
148                self.style_stack
149                    .push(Style::fg(self.theme.heading()).bold());
150            }
151            Tag::BlockQuote(_) => {
152                self.finish_current_line();
153                self.blockquote_depth += 1;
154                self.style_stack.push(Style::fg(self.theme.blockquote()));
155            }
156            Tag::List(start) => {
157                if self.list_stack.is_empty() {
158                    self.finish_current_line();
159                }
160                self.list_stack.push(*start);
161            }
162            Tag::Item => {
163                self.flush_line();
164                let indent = "  ".repeat(self.list_stack.len().saturating_sub(1));
165                let marker = match self.list_stack.last_mut() {
166                    Some(Some(n)) => {
167                        let marker = format!("{n}. ");
168                        *n += 1;
169                        marker
170                    }
171                    _ => "- ".to_string(),
172                };
173                self.push_styled_text(&format!("{indent}{marker}"), Style::fg(self.theme.muted()));
174            }
175            _ => {}
176        }
177    }
178
179    fn handle_block_end(&mut self, tag_end: TagEnd) {
180        match tag_end {
181            TagEnd::Paragraph => {
182                self.flush_line();
183                self.lines.push(Line::default());
184            }
185            TagEnd::Heading(_) => {
186                self.style_stack.pop();
187                self.flush_line();
188                self.lines.push(Line::default());
189            }
190            TagEnd::BlockQuote(_) => {
191                self.style_stack.pop();
192                self.blockquote_depth -= 1;
193                self.flush_line();
194                if self.blockquote_depth == 0 {
195                    self.lines.push(Line::default());
196                }
197            }
198            TagEnd::List(_) => {
199                self.list_stack.pop();
200                if self.list_stack.is_empty() {
201                    self.flush_line();
202                    self.lines.push(Line::default());
203                }
204            }
205            TagEnd::Item => {
206                self.flush_line();
207            }
208            _ => {}
209        }
210    }
211
212    fn handle_inline_start(&mut self, tag: Tag<'_>) {
213        match tag {
214            Tag::Strong => {
215                self.style_stack.push(Style::default().bold());
216            }
217            Tag::Emphasis => {
218                self.style_stack.push(Style::default().italic());
219            }
220            Tag::Strikethrough => {
221                self.style_stack.push(Style::default().strikethrough());
222            }
223            Tag::Link { dest_url, .. } => {
224                self.style_stack
225                    .push(Style::fg(self.theme.link()).underline());
226                // Store URL to emit after text if desired; for now just style the text
227                let _ = dest_url;
228            }
229            _ => {}
230        }
231    }
232
233    fn handle_inline_end(&mut self, _tag_end: TagEnd) {
234        self.style_stack.pop();
235    }
236
237    fn handle_code_block_start(&mut self, tag: Tag<'_>) {
238        if let Tag::CodeBlock(kind) = tag {
239            self.finish_current_line();
240            self.in_code_block = true;
241            self.code_buffer.clear();
242            self.code_lang = match kind {
243                CodeBlockKind::Fenced(lang) => {
244                    lang.split(',').next().unwrap_or("").trim().to_string()
245                }
246                CodeBlockKind::Indented => String::new(),
247            };
248        }
249    }
250
251    fn handle_code_block_end(&mut self) {
252        self.in_code_block = false;
253        let code = std::mem::take(&mut self.code_buffer);
254        let lang = std::mem::take(&mut self.code_lang);
255        let code_lines = self
256            .context
257            .highlighter()
258            .highlight(&code, &lang, self.theme);
259        self.lines.extend(code_lines);
260        self.lines.push(Line::default());
261    }
262
263    fn handle_table_start(&mut self, tag: Tag<'_>) {
264        match tag {
265            Tag::Table(alignments) => {
266                self.finish_current_line();
267                self.table_state = Some(TableState::new(&alignments));
268            }
269            Tag::TableRow => {
270                if let Some(ref mut table) = self.table_state {
271                    table.start_row();
272                }
273            }
274            Tag::TableCell => {
275                self.active_cell = Some(CellBuilder::default());
276            }
277            _ => {}
278        }
279    }
280
281    fn handle_table_end(&mut self, tag_end: TagEnd) {
282        match tag_end {
283            TagEnd::Table => {
284                if let Some(table) = self.table_state.take() {
285                    let rendered = table.render(self.theme);
286                    self.lines.extend(rendered);
287                    self.lines.push(Line::default());
288                }
289            }
290            TagEnd::TableRow | TagEnd::TableHead => {
291                if let Some(ref mut table) = self.table_state {
292                    table.finish_row();
293                }
294            }
295            TagEnd::TableCell => {
296                if let Some(builder) = self.active_cell.take()
297                    && let Some(ref mut table) = self.table_state
298                {
299                    let col_idx = table.current_row.len();
300                    let alignment = table
301                        .alignments
302                        .get(col_idx)
303                        .copied()
304                        .unwrap_or(Alignment::None);
305                    let lines = builder.finish();
306                    let max_width = lines.iter().map(line_display_width).max().unwrap_or(0);
307                    let cell = TableCell {
308                        lines,
309                        alignment,
310                        max_width,
311                    };
312                    table.add_cell(cell);
313                }
314            }
315            _ => {}
316        }
317    }
318
319    fn current_style(&self) -> Style {
320        self.style_stack
321            .iter()
322            .copied()
323            .fold(Style::default(), Style::merge)
324    }
325
326    fn push_text(&mut self, text: &str) {
327        let style = self.current_style();
328        let prefix = self.blockquote_prefix();
329
330        for (i, chunk) in text.split('\n').enumerate() {
331            if i > 0 {
332                self.flush_line();
333            }
334            if self.current_line.is_empty() && !prefix.is_empty() {
335                self.current_line
336                    .push_with_style(&*prefix, Style::fg(self.theme.blockquote()));
337            }
338            if !chunk.is_empty() {
339                self.current_line.push_span(Span::with_style(chunk, style));
340            }
341        }
342    }
343
344    fn push_styled_text(&mut self, text: &str, style: Style) {
345        self.current_line.push_span(Span::with_style(text, style));
346    }
347
348    fn push_inline_text(&mut self, text: &str) {
349        if self.in_code_block {
350            self.code_buffer.push_str(text);
351            return;
352        }
353
354        let style = self.current_style();
355        if let Some(cell) = self.active_cell.as_mut() {
356            cell.push_text(text, style);
357            return;
358        }
359
360        self.push_text(text);
361    }
362
363    fn push_inline_code(&mut self, code: &str) {
364        if self.in_code_block {
365            self.code_buffer.push_str(code);
366            return;
367        }
368
369        let style = Style::fg(self.theme.code_fg());
370        if let Some(cell) = self.active_cell.as_mut() {
371            cell.push_code(code, style);
372        } else {
373            self.current_line.push_span(Span::with_style(code, style));
374        }
375    }
376
377    fn push_soft_break(&mut self) {
378        if self.in_code_block {
379            self.code_buffer.push('\n');
380            return;
381        }
382
383        let style = self.current_style();
384        if let Some(cell) = self.active_cell.as_mut() {
385            cell.soft_break(style);
386            return;
387        }
388
389        self.push_text(" ");
390    }
391
392    fn push_hard_break(&mut self) {
393        if self.in_code_block {
394            self.code_buffer.push('\n');
395            return;
396        }
397
398        if let Some(cell) = self.active_cell.as_mut() {
399            cell.hard_break();
400            return;
401        }
402
403        self.flush_line();
404    }
405
406    /// Flush the current line only if it has content. Avoids pushing
407    /// empty lines at block-element boundaries.
408    fn finish_current_line(&mut self) {
409        if !self.current_line.is_empty() {
410            self.flush_line();
411        }
412    }
413
414    fn flush_line(&mut self) {
415        let prefix = self.blockquote_prefix();
416        if !prefix.is_empty() && self.current_line.is_empty() {
417            self.current_line
418                .push_with_style(&*prefix, Style::fg(self.theme.blockquote()));
419        }
420        let line = std::mem::take(&mut self.current_line);
421        self.lines.push(line);
422    }
423
424    fn blockquote_prefix(&self) -> Cow<'static, str> {
425        if self.blockquote_depth == 0 {
426            Cow::Borrowed("")
427        } else {
428            Cow::Owned("  ".repeat(self.blockquote_depth))
429        }
430    }
431}