photon_ui/components/
markdown.rs1use pulldown_cmark::{
2 Event as MdEvent,
3 Parser,
4 Tag,
5 TagEnd,
6};
7
8use crate::{
9 Component,
10 RenderError,
11 Rendered,
12};
13
14pub struct Markdown {
22 text: String,
23 code_style: Option<fn(&str) -> String>,
24}
25
26impl Markdown {
27 pub fn new(text: impl Into<String>) -> Self {
32 Self {
33 text: text.into(),
34 code_style: None,
35 }
36 }
37
38 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 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 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 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 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 let md = Markdown::new("中文");
278 let r = md.render(5).unwrap();
279 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}