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 =
93                                    format!("\x1b[1m\x1b[4m{}\x1b[22m\x1b[0m", current_line);
94                                if pending_bullet {
95                                    lines.push(format!("- {}", styled));
96                                    pending_bullet = false;
97                                } else {
98                                    lines.push(styled);
99                                }
100                                current_line.clear();
101                            }
102                        },
103                        | TagEnd::Paragraph => {
104                            push_line(&mut current_line, &mut pending_bullet, &mut lines);
105                            lines.push("".to_string());
106                        },
107                        | TagEnd::Item => {
108                            push_line(&mut current_line, &mut pending_bullet, &mut lines);
109                        },
110                        | TagEnd::Strong => in_bold = false,
111                        | TagEnd::Emphasis => in_italic = false,
112                        | _ => {},
113                    }
114                },
115                | MdEvent::Text(text) => {
116                    let mut styled = text.to_string();
117                    if in_bold {
118                        // Bold: bright white for visibility
119                        styled = format!("\x1b[1m\x1b[97m{}\x1b[22m\x1b[0m", styled);
120                    }
121                    if in_italic {
122                        styled = format!("\x1b[3m{}\x1b[23m", styled);
123                    }
124                    current_line.push_str(&styled);
125                },
126                | MdEvent::Code(code) => {
127                    let styled = if let Some(style) = self.code_style {
128                        style(&code)
129                    } else {
130                        format!("\x1b[36m{}\x1b[0m", code)
131                    };
132                    current_line.push_str(&styled);
133                },
134                | MdEvent::SoftBreak | MdEvent::HardBreak => {
135                    push_line(&mut current_line, &mut pending_bullet, &mut lines);
136                },
137                | MdEvent::Html(html) => {
138                    current_line.push_str(&html);
139                },
140                | _ => {},
141            }
142        }
143
144        if !current_line.is_empty() {
145            if pending_bullet {
146                current_line = format!("- {}", current_line);
147            }
148            lines.push(current_line);
149        }
150
151        let mut wrapped = Vec::new();
152        for line in lines {
153            if crate::utils::visible_width(&line) > width as usize {
154                wrapped.extend(crate::utils::wrap_text_with_ansi(&line, width));
155            } else {
156                wrapped.push(line);
157            }
158        }
159
160        Ok(Rendered {
161            lines: wrapped,
162            cursor: None,
163            images: Vec::new(),
164        })
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    #[test]
173    fn markdown_bold() {
174        let md = Markdown::new("**bold**");
175        let r = md.render(80).unwrap();
176        assert!(r.lines[0].contains("\x1b[1m"));
177        assert!(r.lines[0].contains("\x1b[97m"));
178    }
179
180    #[test]
181    fn markdown_italic() {
182        let md = Markdown::new("*italic*");
183        let r = md.render(80).unwrap();
184        assert!(r.lines[0].contains("\x1b[3m"));
185    }
186
187    #[test]
188    fn markdown_inline_code() {
189        let md = Markdown::new("`code`");
190        let r = md.render(80).unwrap();
191        // Default: cyan foreground, no backticks
192        assert!(r.lines[0].contains("\x1b[36m"));
193        assert!(!r.lines[0].contains("`code`"));
194        assert!(r.lines[0].contains("code"));
195    }
196
197    #[test]
198    fn markdown_inline_code_custom_style() {
199        let md = Markdown::new("`code`").with_code_style(|s| format!(">{}<", s));
200        let r = md.render(80).unwrap();
201        assert!(r.lines[0].contains(">code<"));
202    }
203
204    #[test]
205    fn markdown_heading_no_hash() {
206        let md = Markdown::new("# Hello");
207        let r = md.render(80).unwrap();
208        assert!(!r.lines[0].contains("# Hello"));
209        assert!(r.lines[0].contains("Hello"));
210        assert!(r.lines[0].contains("\x1b[1m"));
211        assert!(r.lines[0].contains("\x1b[4m"));
212    }
213
214    #[test]
215    fn markdown_soft_break() {
216        let md = Markdown::new("line1\nline2");
217        let r = md.render(80).unwrap();
218        assert!(r.lines.iter().any(|l| l.contains("line1")));
219    }
220
221    #[test]
222    fn markdown_html_passthrough() {
223        let md = Markdown::new("<div>text</div>\n\nmore");
224        let r = md.render(80).unwrap();
225        assert!(!r.lines.is_empty());
226    }
227
228    #[test]
229    fn markdown_list_items_separate_lines() {
230        let md = Markdown::new("- item one\n- item two\n- item three");
231        let r = md.render(80).unwrap();
232        let item_lines: Vec<&String> = r.lines.iter().filter(|l| l.contains("item")).collect();
233        assert_eq!(
234            item_lines.len(),
235            3,
236            "each list item should be on its own line: {:?}",
237            r.lines
238        );
239    }
240
241    #[test]
242    fn markdown_list_has_bullets() {
243        let md = Markdown::new("- first\n- second");
244        let r = md.render(80).unwrap();
245        assert!(
246            r.lines.iter().any(|l| l.contains("- first")),
247            "expected bullets: {:?}",
248            r.lines
249        );
250        assert!(
251            r.lines.iter().any(|l| l.contains("- second")),
252            "expected bullets: {:?}",
253            r.lines
254        );
255    }
256
257    #[test]
258    fn markdown_list_with_styling() {
259        let md = Markdown::new("- *italic* item\n- **bold** item");
260        let r = md.render(80).unwrap();
261        let italic_line = r.lines.iter().find(|l| l.contains("italic")).unwrap();
262        assert!(
263            italic_line.contains("- "),
264            "expected bullet: {}",
265            italic_line
266        );
267        assert!(italic_line.contains("\x1b[3m"));
268
269        let bold_line = r.lines.iter().find(|l| l.contains("bold")).unwrap();
270        assert!(bold_line.contains("- "), "expected bullet: {}", bold_line);
271        assert!(bold_line.contains("\x1b[1m"));
272    }
273
274    #[test]
275    fn markdown_no_unnecessary_wrapping_for_wide_chars() {
276        // "中文" is 6 bytes but only 4 visible columns wide.
277        // A byte-length check would trigger unnecessary wrapping at width 5.
278        let md = Markdown::new("中文");
279        let r = md.render(5).unwrap();
280        // Markdown paragraphs produce a text line followed by an empty line.
281        let text_lines: Vec<&String> = r.lines.iter().filter(|l| !l.is_empty()).collect();
282        assert_eq!(
283            text_lines.len(),
284            1,
285            "CJK text with visible_width 4 should fit in width 5: {:?}",
286            r.lines
287        );
288        assert!(
289            text_lines[0].contains("中文"),
290            "text should be intact: {:?}",
291            text_lines
292        );
293    }
294}