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 =
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 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 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 let md = Markdown::new("中文");
279 let r = md.render(5).unwrap();
280 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}