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("───────────────", Style::fg(self.theme.muted())));
88                self.lines.push(Line::default());
89            }
90            _ => {}
91        }
92    }
93
94    fn handle_start(&mut self, tag: Tag<'_>) {
95        match tag {
96            Tag::Heading { .. } | Tag::BlockQuote(_) | Tag::List(_) | Tag::Item | Tag::Paragraph => {
97                self.handle_block_start(&tag);
98            }
99
100            Tag::Strong | Tag::Emphasis | Tag::Strikethrough | Tag::Link { .. } => {
101                self.handle_inline_start(tag);
102            }
103
104            Tag::CodeBlock(_) => self.handle_code_block_start(tag),
105
106            Tag::Table(_) | Tag::TableRow | Tag::TableCell => self.handle_table_start(tag),
107
108            _ => {}
109        }
110    }
111
112    fn handle_end(&mut self, tag_end: TagEnd) {
113        match tag_end {
114            TagEnd::Paragraph | TagEnd::Heading(_) | TagEnd::BlockQuote(_) | TagEnd::List(_) | TagEnd::Item => {
115                self.handle_block_end(tag_end);
116            }
117
118            TagEnd::Strong | TagEnd::Emphasis | TagEnd::Strikethrough | TagEnd::Link => {
119                self.handle_inline_end(tag_end);
120            }
121
122            TagEnd::CodeBlock => self.handle_code_block_end(),
123
124            TagEnd::Table | TagEnd::TableRow | TagEnd::TableHead | TagEnd::TableCell => {
125                self.handle_table_end(tag_end);
126            }
127
128            _ => {}
129        }
130    }
131
132    fn handle_block_start(&mut self, tag: &Tag<'_>) {
133        match tag {
134            Tag::Heading { level, .. } => {
135                self.finish_current_line();
136                let prefix = "#".repeat(*level as usize);
137                self.push_styled_text(&format!("{prefix} "), Style::fg(self.theme.heading()).bold());
138                self.style_stack.push(Style::fg(self.theme.heading()).bold());
139            }
140            Tag::BlockQuote(_) => {
141                self.finish_current_line();
142                self.blockquote_depth += 1;
143                self.style_stack.push(Style::fg(self.theme.blockquote()));
144            }
145            Tag::List(start) => {
146                if self.list_stack.is_empty() {
147                    self.finish_current_line();
148                }
149                self.list_stack.push(*start);
150            }
151            Tag::Item => {
152                self.flush_line();
153                let indent = "  ".repeat(self.list_stack.len().saturating_sub(1));
154                let marker = match self.list_stack.last_mut() {
155                    Some(Some(n)) => {
156                        let marker = format!("{n}. ");
157                        *n += 1;
158                        marker
159                    }
160                    _ => "- ".to_string(),
161                };
162                self.push_styled_text(&format!("{indent}{marker}"), Style::fg(self.theme.muted()));
163            }
164            _ => {}
165        }
166    }
167
168    fn handle_block_end(&mut self, tag_end: TagEnd) {
169        match tag_end {
170            TagEnd::Paragraph => {
171                self.flush_line();
172                self.lines.push(Line::default());
173            }
174            TagEnd::Heading(_) => {
175                self.style_stack.pop();
176                self.flush_line();
177                self.lines.push(Line::default());
178            }
179            TagEnd::BlockQuote(_) => {
180                self.style_stack.pop();
181                self.blockquote_depth -= 1;
182                self.flush_line();
183                if self.blockquote_depth == 0 {
184                    self.lines.push(Line::default());
185                }
186            }
187            TagEnd::List(_) => {
188                self.list_stack.pop();
189                if self.list_stack.is_empty() {
190                    self.flush_line();
191                    self.lines.push(Line::default());
192                }
193            }
194            TagEnd::Item => {
195                self.flush_line();
196            }
197            _ => {}
198        }
199    }
200
201    fn handle_inline_start(&mut self, tag: Tag<'_>) {
202        match tag {
203            Tag::Strong => {
204                self.style_stack.push(Style::default().bold());
205            }
206            Tag::Emphasis => {
207                self.style_stack.push(Style::default().italic());
208            }
209            Tag::Strikethrough => {
210                self.style_stack.push(Style::default().strikethrough());
211            }
212            Tag::Link { dest_url, .. } => {
213                self.style_stack.push(Style::fg(self.theme.link()).underline());
214                // Store URL to emit after text if desired; for now just style the text
215                let _ = dest_url;
216            }
217            _ => {}
218        }
219    }
220
221    fn handle_inline_end(&mut self, _tag_end: TagEnd) {
222        self.style_stack.pop();
223    }
224
225    fn handle_code_block_start(&mut self, tag: Tag<'_>) {
226        if let Tag::CodeBlock(kind) = tag {
227            self.finish_current_line();
228            self.in_code_block = true;
229            self.code_buffer.clear();
230            self.code_lang = match kind {
231                CodeBlockKind::Fenced(lang) => lang.split(',').next().unwrap_or("").trim().to_string(),
232                CodeBlockKind::Indented => String::new(),
233            };
234        }
235    }
236
237    fn handle_code_block_end(&mut self) {
238        self.in_code_block = false;
239        let code = std::mem::take(&mut self.code_buffer);
240        let lang = std::mem::take(&mut self.code_lang);
241        let code_lines = self.context.highlighter().highlight(&code, &lang, self.theme);
242        self.lines.extend(code_lines);
243        self.lines.push(Line::default());
244    }
245
246    fn handle_table_start(&mut self, tag: Tag<'_>) {
247        match tag {
248            Tag::Table(alignments) => {
249                self.finish_current_line();
250                self.table_state = Some(TableState::new(&alignments));
251            }
252            Tag::TableRow => {
253                if let Some(ref mut table) = self.table_state {
254                    table.start_row();
255                }
256            }
257            Tag::TableCell => {
258                self.active_cell = Some(CellBuilder::default());
259            }
260            _ => {}
261        }
262    }
263
264    fn handle_table_end(&mut self, tag_end: TagEnd) {
265        match tag_end {
266            TagEnd::Table => {
267                if let Some(table) = self.table_state.take() {
268                    let rendered = table.render(self.theme);
269                    self.lines.extend(rendered);
270                    self.lines.push(Line::default());
271                }
272            }
273            TagEnd::TableRow | TagEnd::TableHead => {
274                if let Some(ref mut table) = self.table_state {
275                    table.finish_row();
276                }
277            }
278            TagEnd::TableCell => {
279                if let Some(builder) = self.active_cell.take()
280                    && let Some(ref mut table) = self.table_state
281                {
282                    let col_idx = table.current_row.len();
283                    let alignment = table.alignments.get(col_idx).copied().unwrap_or(Alignment::None);
284                    let lines = builder.finish();
285                    let max_width = lines.iter().map(line_display_width).max().unwrap_or(0);
286                    let cell = TableCell { lines, alignment, max_width };
287                    table.add_cell(cell);
288                }
289            }
290            _ => {}
291        }
292    }
293
294    fn current_style(&self) -> Style {
295        self.style_stack.iter().copied().fold(Style::default(), Style::merge)
296    }
297
298    fn push_text(&mut self, text: &str) {
299        let style = self.current_style();
300        let prefix = self.blockquote_prefix();
301
302        for (i, chunk) in text.split('\n').enumerate() {
303            if i > 0 {
304                self.flush_line();
305            }
306            if self.current_line.is_empty() && !prefix.is_empty() {
307                self.current_line.push_with_style(&*prefix, Style::fg(self.theme.blockquote()));
308            }
309            if !chunk.is_empty() {
310                self.current_line.push_span(Span::with_style(chunk, style));
311            }
312        }
313    }
314
315    fn push_styled_text(&mut self, text: &str, style: Style) {
316        self.current_line.push_span(Span::with_style(text, style));
317    }
318
319    fn push_inline_text(&mut self, text: &str) {
320        if self.in_code_block {
321            self.code_buffer.push_str(text);
322            return;
323        }
324
325        let style = self.current_style();
326        if let Some(cell) = self.active_cell.as_mut() {
327            cell.push_text(text, style);
328            return;
329        }
330
331        self.push_text(text);
332    }
333
334    fn push_inline_code(&mut self, code: &str) {
335        if self.in_code_block {
336            self.code_buffer.push_str(code);
337            return;
338        }
339
340        let style = Style::fg(self.theme.code_fg());
341        if let Some(cell) = self.active_cell.as_mut() {
342            cell.push_code(code, style);
343        } else {
344            self.current_line.push_span(Span::with_style(code, style));
345        }
346    }
347
348    fn push_soft_break(&mut self) {
349        if self.in_code_block {
350            self.code_buffer.push('\n');
351            return;
352        }
353
354        let style = self.current_style();
355        if let Some(cell) = self.active_cell.as_mut() {
356            cell.soft_break(style);
357            return;
358        }
359
360        self.push_text(" ");
361    }
362
363    fn push_hard_break(&mut self) {
364        if self.in_code_block {
365            self.code_buffer.push('\n');
366            return;
367        }
368
369        if let Some(cell) = self.active_cell.as_mut() {
370            cell.hard_break();
371            return;
372        }
373
374        self.flush_line();
375    }
376
377    /// Flush the current line only if it has content. Avoids pushing
378    /// empty lines at block-element boundaries.
379    fn finish_current_line(&mut self) {
380        if !self.current_line.is_empty() {
381            self.flush_line();
382        }
383    }
384
385    fn flush_line(&mut self) {
386        let prefix = self.blockquote_prefix();
387        if !prefix.is_empty() && self.current_line.is_empty() {
388            self.current_line.push_with_style(&*prefix, Style::fg(self.theme.blockquote()));
389        }
390        let line = std::mem::take(&mut self.current_line);
391        self.lines.push(line);
392    }
393
394    fn blockquote_prefix(&self) -> Cow<'static, str> {
395        if self.blockquote_depth == 0 { Cow::Borrowed("") } else { Cow::Owned("  ".repeat(self.blockquote_depth)) }
396    }
397}