Skip to main content

telex/
markdown.rs

1//! Markdown rendering for Telex.
2//!
3//! Converts markdown text into View nodes for display.
4
5use crate::theme::current_theme;
6use crate::view::{Align, BoxNode, HStackNode, Justify, LayoutMode, TextNode, VStackNode, View};
7use pulldown_cmark::{CodeBlockKind, Event, Parser, Tag, TagEnd};
8
9/// Render markdown text into a View.
10///
11/// Handles common markdown elements:
12/// - Paragraphs
13/// - Headers (h1-h6)
14/// - Code blocks (fenced and indented)
15/// - Inline code
16/// - Bold and italic text
17/// - Lists (ordered and unordered)
18/// - Blockquotes
19///
20/// # Example
21/// ```ignore
22/// let view = telex::markdown::render("# Hello\n\nThis is **bold** text.");
23/// ```
24pub fn render(markdown: &str) -> View {
25    let parser = Parser::new(markdown);
26    let renderer = MarkdownRenderer::new();
27    renderer.render(parser)
28}
29
30/// Internal state for markdown rendering.
31struct MarkdownRenderer {
32    /// Stack of views being built (for nested structures).
33    view_stack: Vec<Vec<View>>,
34    /// Current inline text being accumulated.
35    current_text: String,
36    /// Current inline styles.
37    bold: bool,
38    italic: bool,
39    /// Are we inside a code block?
40    in_code_block: bool,
41    /// Code block content accumulator.
42    code_block_content: String,
43    /// Code block language (if specified).
44    code_block_lang: Option<String>,
45    /// Current list item content.
46    in_list_item: bool,
47    /// List markers for nested lists.
48    list_markers: Vec<ListMarker>,
49    /// Blockquote depth.
50    blockquote_depth: usize,
51}
52
53#[derive(Clone)]
54enum ListMarker {
55    Unordered,
56    Ordered(u64),
57}
58
59impl MarkdownRenderer {
60    fn new() -> Self {
61        Self {
62            view_stack: vec![vec![]],
63            current_text: String::new(),
64            bold: false,
65            italic: false,
66            in_code_block: false,
67            code_block_content: String::new(),
68            code_block_lang: None,
69            in_list_item: false,
70            list_markers: vec![],
71            blockquote_depth: 0,
72        }
73    }
74
75    fn render(mut self, parser: Parser) -> View {
76        for event in parser {
77            self.handle_event(event);
78        }
79
80        // Flush any remaining text
81        self.flush_text();
82
83        // Build final view from top-level children
84        let children = self.view_stack.pop().unwrap_or_default();
85        if children.is_empty() {
86            View::Empty
87        } else if children.len() == 1 {
88            children.into_iter().next().unwrap()
89        } else {
90            View::VStack(VStackNode {
91                children,
92                spacing: 0,
93                justify: Justify::Start,
94                align: Align::Stretch,
95                layout_mode: LayoutMode::Flex,
96            })
97        }
98    }
99
100    fn handle_event(&mut self, event: Event) {
101        match event {
102            // Block-level start tags
103            Event::Start(Tag::Paragraph) => {
104                self.flush_text();
105            }
106            Event::Start(Tag::Heading { level, .. }) => {
107                self.flush_text();
108                // Headers are bold by default
109                self.bold = true;
110                // Could also set color based on level
111                let _ = level; // TODO: vary styling by level
112            }
113            Event::Start(Tag::CodeBlock(kind)) => {
114                self.flush_text();
115                self.in_code_block = true;
116                self.code_block_content.clear();
117                self.code_block_lang = match kind {
118                    CodeBlockKind::Fenced(lang) => {
119                        let lang = lang.to_string();
120                        if lang.is_empty() {
121                            None
122                        } else {
123                            Some(lang)
124                        }
125                    }
126                    CodeBlockKind::Indented => None,
127                };
128            }
129            Event::Start(Tag::List(start)) => {
130                self.flush_text();
131                let marker = match start {
132                    Some(n) => ListMarker::Ordered(n),
133                    None => ListMarker::Unordered,
134                };
135                self.list_markers.push(marker);
136            }
137            Event::Start(Tag::Item) => {
138                self.flush_text();
139                self.in_list_item = true;
140            }
141            Event::Start(Tag::BlockQuote) => {
142                self.flush_text();
143                self.blockquote_depth += 1;
144            }
145
146            // Inline style tags
147            Event::Start(Tag::Strong) => {
148                // TODO: nested inline styles - currently we just set the flag
149                // If we're already in another style, this will override
150                self.bold = true;
151            }
152            Event::Start(Tag::Emphasis) => {
153                // TODO: nested inline styles
154                self.italic = true;
155            }
156            Event::Code(text) => {
157                // Inline code - flush current text, add code styled, continue
158                self.flush_text();
159                self.push_inline_code(&text);
160            }
161
162            // Text content
163            Event::Text(text) => {
164                if self.in_code_block {
165                    self.code_block_content.push_str(&text);
166                } else {
167                    self.current_text.push_str(&text);
168                }
169            }
170            Event::SoftBreak => {
171                if self.in_code_block {
172                    self.code_block_content.push('\n');
173                } else {
174                    // Preserve newlines in TUI - unlike web markdown which collapses to space
175                    self.current_text.push('\n');
176                }
177            }
178            Event::HardBreak => {
179                if self.in_code_block {
180                    self.code_block_content.push('\n');
181                } else {
182                    self.flush_text();
183                }
184            }
185
186            // Block-level end tags
187            Event::End(TagEnd::Paragraph) => {
188                self.flush_text();
189                self.push_spacing();
190            }
191            Event::End(TagEnd::Heading(_)) => {
192                self.flush_text();
193                self.bold = false;
194                self.push_spacing();
195            }
196            Event::End(TagEnd::CodeBlock) => {
197                self.push_code_block();
198                self.in_code_block = false;
199                self.code_block_content.clear();
200                self.code_block_lang = None;
201                self.push_spacing();
202            }
203            Event::End(TagEnd::List(_)) => {
204                self.list_markers.pop();
205            }
206            Event::End(TagEnd::Item) => {
207                self.flush_list_item();
208                self.in_list_item = false;
209            }
210            Event::End(TagEnd::BlockQuote) => {
211                self.blockquote_depth = self.blockquote_depth.saturating_sub(1);
212            }
213
214            // Inline style end tags
215            Event::End(TagEnd::Strong) => {
216                // TODO: nested inline styles - proper stack-based approach
217                self.bold = false;
218            }
219            Event::End(TagEnd::Emphasis) => {
220                // TODO: nested inline styles
221                self.italic = false;
222            }
223
224            // Ignored events (for now)
225            Event::Start(Tag::Link { .. })
226            | Event::End(TagEnd::Link)
227            | Event::Start(Tag::Image { .. })
228            | Event::End(TagEnd::Image) => {
229                // TODO: links and images
230            }
231
232            _ => {}
233        }
234    }
235
236    /// Flush accumulated text as a styled text node.
237    fn flush_text(&mut self) {
238        if self.current_text.is_empty() {
239            return;
240        }
241
242        let text = std::mem::take(&mut self.current_text);
243        let theme = current_theme();
244
245        let view = View::Text(TextNode {
246            content: text,
247            color: Some(theme.foreground),
248            bg_color: None,
249            bold: self.bold,
250            italic: self.italic,
251            underline: false,
252            dim: false,
253        });
254
255        self.push_view(view);
256    }
257
258    /// Push inline code as a styled segment.
259    fn push_inline_code(&mut self, text: &str) {
260        let theme = current_theme();
261
262        // Inline code: different color, maybe background
263        let view = View::Text(TextNode {
264            content: format!("`{}`", text),
265            color: Some(theme.primary),
266            bg_color: None,
267            bold: false,
268            italic: false,
269            underline: false,
270            dim: false,
271        });
272
273        self.push_view(view);
274    }
275
276    /// Push a code block.
277    fn push_code_block(&mut self) {
278        let content = self.code_block_content.trim_end().to_string();
279        let theme = current_theme();
280
281        // Code block: boxed with border
282        let text_view = View::Text(TextNode {
283            content,
284            color: Some(theme.foreground),
285            bg_color: None,
286            bold: false,
287            italic: false,
288            underline: false,
289            dim: false,
290        });
291
292        let code_box = View::Box(BoxNode {
293            child: Some(Box::new(text_view)),
294            border: true,
295            padding: 1,
296            flex: 0,
297            scroll: false,
298            auto_scroll_bottom: false,
299            focusable: false,
300            min_width: None,
301            max_width: None,
302            min_height: None,
303            max_height: None,
304        });
305
306        self.push_view(code_box);
307    }
308
309    /// Flush a list item with its marker.
310    fn flush_list_item(&mut self) {
311        self.flush_text();
312
313        // Get the appropriate marker
314        let marker = match self.list_markers.last_mut() {
315            Some(ListMarker::Unordered) => "  • ".to_string(),
316            Some(ListMarker::Ordered(n)) => {
317                let marker = format!("{:>2}. ", n);
318                *n += 1;
319                marker
320            }
321            None => "  • ".to_string(),
322        };
323
324        // Indent based on list depth
325        let indent = "    ".repeat(self.list_markers.len().saturating_sub(1));
326
327        // Get the content that was just pushed
328        if let Some(views) = self.view_stack.last_mut() {
329            if let Some(last_view) = views.pop() {
330                // Wrap content in a flex box so it takes remaining width after marker
331                let content_box = View::Box(BoxNode {
332                    child: Some(Box::new(last_view)),
333                    border: false,
334                    padding: 0,
335                    flex: 1,
336                    scroll: false,
337                    auto_scroll_bottom: false,
338                    focusable: false,
339                    min_width: None,
340                    max_width: None,
341                    min_height: None,
342                    max_height: None,
343                });
344
345                // Wrap with marker
346                let marked_view = View::HStack(HStackNode {
347                    children: vec![View::text(format!("{}{}", indent, marker)), content_box],
348                    spacing: 0,
349                    justify: Justify::Start,
350                    align: Align::Start,
351                    layout_mode: LayoutMode::Flex,
352                });
353                views.push(marked_view);
354            }
355        }
356    }
357
358    /// Push an empty line for spacing between blocks.
359    fn push_spacing(&mut self) {
360        // Add a blank line between blocks
361        self.push_view(View::text(""));
362    }
363
364    /// Push a view onto the current stack level.
365    fn push_view(&mut self, view: View) {
366        if let Some(views) = self.view_stack.last_mut() {
367            views.push(view);
368        }
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375
376    #[test]
377    fn test_plain_text() {
378        let view = render("Hello world");
379        assert!(matches!(view, View::VStack(_) | View::Text(_)));
380    }
381
382    #[test]
383    fn test_code_block() {
384        let view = render("```rust\nfn main() {}\n```");
385        // Should produce a view containing a Box (code block)
386        assert!(matches!(view, View::VStack(_)));
387    }
388
389    #[test]
390    fn test_bold_text() {
391        let view = render("This is **bold** text");
392        assert!(matches!(view, View::VStack(_)));
393    }
394}