Skip to main content

photon_ui/components/
markdown.rs

1use pulldown_cmark::{
2    Event as MdEvent,
3    Parser,
4    Tag,
5    TagEnd,
6};
7
8use crate::{
9    Component,
10    RenderError,
11    Rendered,
12};
13
14/// Renders CommonMark / Markdown text as styled terminal output.
15///
16/// Supports headings (bold + underline, no `#` prefix), bold, italic,
17/// inline code (configurable style, no backticks), lists with bullet markers,
18/// soft/hard breaks, and raw HTML passthrough. Text is automatically wrapped
19/// to the requested width via
20/// [`wrap_text_with_ansi`](crate::utils::wrap_text_with_ansi).
21pub struct Markdown {
22    text: String,
23    code_style: Option<fn(&str) -> String>,
24}
25
26impl Markdown {
27    /// Create a new Markdown component from the given text.
28    ///
29    /// Defaults: headings are bold+underlined, inline code is cyan, bold is
30    /// bright white, italic is slanted.
31    pub fn new(text: impl Into<String>) -> Self {
32        Self {
33            text: text.into(),
34            code_style: None,
35        }
36    }
37
38    /// Override the inline code styling.
39    ///
40    /// The function receives the raw code text and should return the styled
41    /// string (including any ANSI reset). Defaults to cyan foreground.
42    ///
43    /// # Example
44    ///
45    /// ```
46    /// use photon_ui::components::Markdown;
47    ///
48    /// let md = Markdown::new("`hello`").with_code_style(|s| format!("\x1b[48;5;240m{}\x1b[0m", s));
49    /// ```
50    pub fn with_code_style(mut self, style: fn(&str) -> String) -> Self {
51        self.code_style = Some(style);
52        self
53    }
54}
55
56impl Component for Markdown {
57    fn render(&self, width: u16) -> Result<Rendered, RenderError> {
58        let mut lines = Vec::new();
59        let parser = Parser::new(&self.text);
60        let mut current_line = String::new();
61        let mut in_bold = false;
62        let mut in_italic = false;
63        let mut pending_bullet = false;
64
65        // Helper: prepend a bullet to `current_line` if this is the first
66        // line of a list item, then push it to `lines` and clear.
67        let push_line = |line: &mut String, bullet: &mut bool, dest: &mut Vec<String>| {
68            if !line.is_empty() {
69                if *bullet {
70                    *line = format!("- {}", line);
71                    *bullet = false;
72                }
73                dest.push(line.clone());
74                line.clear();
75            }
76        };
77
78        for event in parser {
79            match event {
80                | MdEvent::Start(tag) => match tag {
81                    | Tag::Heading { .. } => {},
82                    | Tag::Strong => in_bold = true,
83                    | Tag::Emphasis => in_italic = true,
84                    | Tag::Item => pending_bullet = true,
85                    | _ => {},
86                },
87                | MdEvent::End(tag_end) => {
88                    match tag_end {
89                        | TagEnd::Heading(_) => {
90                            if !current_line.is_empty() {
91                                // Headings: bold + underline, no Markdown # prefix
92                                let styled = format!("\x1b[1m\x1b[4m{}\x1b[0m", current_line);
93                                if pending_bullet {
94                                    lines.push(format!("- {}", styled));
95                                    pending_bullet = false;
96                                } else {
97                                    lines.push(styled);
98                                }
99                                current_line.clear();
100                            }
101                        },
102                        | TagEnd::Paragraph => {
103                            push_line(&mut current_line, &mut pending_bullet, &mut lines);
104                            lines.push("".to_string());
105                        },
106                        | TagEnd::Item => {
107                            push_line(&mut current_line, &mut pending_bullet, &mut lines);
108                        },
109                        | TagEnd::Strong => in_bold = false,
110                        | TagEnd::Emphasis => in_italic = false,
111                        | _ => {},
112                    }
113                },
114                | MdEvent::Text(text) => {
115                    let mut styled = text.to_string();
116                    if in_bold {
117                        // Bold: bright white for visibility
118                        styled = format!("\x1b[1m\x1b[97m{}\x1b[0m", styled);
119                    }
120                    if in_italic {
121                        styled = format!("\x1b[3m{}\x1b[23m", styled);
122                    }
123                    current_line.push_str(&styled);
124                },
125                | MdEvent::Code(code) => {
126                    let styled = if let Some(style) = self.code_style {
127                        style(&code)
128                    } else {
129                        format!("\x1b[36m{}\x1b[0m", code)
130                    };
131                    current_line.push_str(&styled);
132                },
133                | MdEvent::SoftBreak | MdEvent::HardBreak => {
134                    push_line(&mut current_line, &mut pending_bullet, &mut lines);
135                },
136                | MdEvent::Html(html) => {
137                    current_line.push_str(&html);
138                },
139                | _ => {},
140            }
141        }
142
143        if !current_line.is_empty() {
144            if pending_bullet {
145                current_line = format!("- {}", current_line);
146            }
147            lines.push(current_line);
148        }
149
150        let mut wrapped = Vec::new();
151        for line in lines {
152            if crate::utils::visible_width(&line) > width as usize {
153                wrapped.extend(crate::utils::wrap_text_with_ansi(&line, width));
154            } else {
155                wrapped.push(line);
156            }
157        }
158
159        Ok(Rendered {
160            lines: wrapped,
161            cursor: None,
162            images: Vec::new(),
163        })
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn markdown_bold() {
173        let md = Markdown::new("**bold**");
174        let r = md.render(80).unwrap();
175        assert!(r.lines[0].contains("\x1b[1m"));
176        assert!(r.lines[0].contains("\x1b[97m"));
177    }
178
179    #[test]
180    fn markdown_italic() {
181        let md = Markdown::new("*italic*");
182        let r = md.render(80).unwrap();
183        assert!(r.lines[0].contains("\x1b[3m"));
184    }
185
186    #[test]
187    fn markdown_inline_code() {
188        let md = Markdown::new("`code`");
189        let r = md.render(80).unwrap();
190        // Default: cyan foreground, no backticks
191        assert!(r.lines[0].contains("\x1b[36m"));
192        assert!(!r.lines[0].contains("`code`"));
193        assert!(r.lines[0].contains("code"));
194    }
195
196    #[test]
197    fn markdown_inline_code_custom_style() {
198        let md = Markdown::new("`code`").with_code_style(|s| format!(">{}<", s));
199        let r = md.render(80).unwrap();
200        assert!(r.lines[0].contains(">code<"));
201    }
202
203    #[test]
204    fn markdown_heading_no_hash() {
205        let md = Markdown::new("# Hello");
206        let r = md.render(80).unwrap();
207        assert!(!r.lines[0].contains("# Hello"));
208        assert!(r.lines[0].contains("Hello"));
209        assert!(r.lines[0].contains("\x1b[1m"));
210        assert!(r.lines[0].contains("\x1b[4m"));
211    }
212
213    #[test]
214    fn markdown_soft_break() {
215        let md = Markdown::new("line1\nline2");
216        let r = md.render(80).unwrap();
217        assert!(r.lines.iter().any(|l| l.contains("line1")));
218    }
219
220    #[test]
221    fn markdown_html_passthrough() {
222        let md = Markdown::new("<div>text</div>\n\nmore");
223        let r = md.render(80).unwrap();
224        assert!(!r.lines.is_empty());
225    }
226
227    #[test]
228    fn markdown_list_items_separate_lines() {
229        let md = Markdown::new("- item one\n- item two\n- item three");
230        let r = md.render(80).unwrap();
231        let item_lines: Vec<&String> = r.lines.iter().filter(|l| l.contains("item")).collect();
232        assert_eq!(
233            item_lines.len(),
234            3,
235            "each list item should be on its own line: {:?}",
236            r.lines
237        );
238    }
239
240    #[test]
241    fn markdown_list_has_bullets() {
242        let md = Markdown::new("- first\n- second");
243        let r = md.render(80).unwrap();
244        assert!(
245            r.lines.iter().any(|l| l.contains("- first")),
246            "expected bullets: {:?}",
247            r.lines
248        );
249        assert!(
250            r.lines.iter().any(|l| l.contains("- second")),
251            "expected bullets: {:?}",
252            r.lines
253        );
254    }
255
256    #[test]
257    fn markdown_list_with_styling() {
258        let md = Markdown::new("- *italic* item\n- **bold** item");
259        let r = md.render(80).unwrap();
260        let italic_line = r.lines.iter().find(|l| l.contains("italic")).unwrap();
261        assert!(
262            italic_line.contains("- "),
263            "expected bullet: {}",
264            italic_line
265        );
266        assert!(italic_line.contains("\x1b[3m"));
267
268        let bold_line = r.lines.iter().find(|l| l.contains("bold")).unwrap();
269        assert!(bold_line.contains("- "), "expected bullet: {}", bold_line);
270        assert!(bold_line.contains("\x1b[1m"));
271    }
272
273    #[test]
274    fn markdown_no_unnecessary_wrapping_for_wide_chars() {
275        // "中文" is 6 bytes but only 4 visible columns wide.
276        // A byte-length check would trigger unnecessary wrapping at width 5.
277        let md = Markdown::new("中文");
278        let r = md.render(5).unwrap();
279        // Markdown paragraphs produce a text line followed by an empty line.
280        let text_lines: Vec<&String> = r.lines.iter().filter(|l| !l.is_empty()).collect();
281        assert_eq!(
282            text_lines.len(),
283            1,
284            "CJK text with visible_width 4 should fit in width 5: {:?}",
285            r.lines
286        );
287        assert!(
288            text_lines[0].contains("中文"),
289            "text should be intact: {:?}",
290            text_lines
291        );
292    }
293}