1use std::borrow::Cow;
2
3use freya_core::prelude::*;
4use pulldown_cmark::{
5 Event,
6 HeadingLevel,
7 Options,
8 Parser,
9 Tag,
10 TagEnd,
11};
12use torin::prelude::*;
13
14#[cfg(feature = "remote-asset")]
15use crate::Uri;
16#[cfg(feature = "remote-asset")]
17use crate::image_viewer::{
18 ImageSource,
19 ImageViewer,
20};
21use crate::{
22 table::{
23 Table,
24 TableBody,
25 TableCell,
26 TableHead,
27 TableRow,
28 },
29 theming::component_themes::MarkdownViewerTheme,
30};
31
32#[derive(PartialEq)]
54pub struct MarkdownViewer {
55 content: Cow<'static, str>,
56 layout: LayoutData,
57 key: DiffKey,
58 pub(crate) theme: Option<crate::theming::component_themes::MarkdownViewerThemePartial>,
59}
60
61impl MarkdownViewer {
62 pub fn new(content: impl Into<Cow<'static, str>>) -> Self {
63 Self {
64 content: content.into(),
65 layout: LayoutData::default(),
66 key: DiffKey::None,
67 theme: None,
68 }
69 }
70
71 pub fn key(mut self, key: impl Into<DiffKey>) -> Self {
72 self.key = key.into();
73 self
74 }
75}
76
77impl KeyExt for MarkdownViewer {
78 fn write_key(&mut self) -> &mut DiffKey {
79 &mut self.key
80 }
81}
82
83impl LayoutExt for MarkdownViewer {
84 fn get_layout(&mut self) -> &mut LayoutData {
85 &mut self.layout
86 }
87}
88
89impl ContainerExt for MarkdownViewer {}
90
91#[derive(Clone)]
93enum MarkdownElement {
94 Heading {
95 level: HeadingLevel,
96 spans: Vec<TextSpan>,
97 },
98 Paragraph {
99 spans: Vec<TextSpan>,
100 },
101 CodeBlock {
102 code: String,
103 #[allow(dead_code)]
104 language: Option<String>,
105 },
106 UnorderedList {
107 items: Vec<Vec<TextSpan>>,
108 },
109 OrderedList {
110 start: u64,
111 items: Vec<Vec<TextSpan>>,
112 },
113 Image {
114 #[cfg_attr(not(feature = "remote-asset"), allow(dead_code))]
115 url: String,
116 alt: String,
117 },
118 Blockquote {
119 spans: Vec<TextSpan>,
120 },
121 Table {
122 headers: Vec<Vec<TextSpan>>,
123 rows: Vec<Vec<Vec<TextSpan>>>,
124 },
125 HorizontalRule,
126}
127
128#[derive(Clone, Debug)]
130struct TextSpan {
131 text: String,
132 bold: bool,
133 italic: bool,
134 #[allow(dead_code)]
135 strikethrough: bool,
136 code: bool,
137}
138
139impl TextSpan {
140 fn new(text: impl Into<String>) -> Self {
141 Self {
142 text: text.into(),
143 bold: false,
144 italic: false,
145 strikethrough: false,
146 code: false,
147 }
148 }
149}
150
151fn parse_markdown(content: &str) -> Vec<MarkdownElement> {
153 let mut options = Options::empty();
154 options.insert(Options::ENABLE_STRIKETHROUGH);
155 options.insert(Options::ENABLE_TABLES);
156
157 let parser = Parser::new_ext(content, options);
158 let mut elements = Vec::new();
159 let mut current_spans: Vec<TextSpan> = Vec::new();
160 let mut list_items: Vec<Vec<TextSpan>> = Vec::new();
161 let mut current_list_item: Vec<TextSpan> = Vec::new();
162
163 let mut in_heading: Option<HeadingLevel> = None;
164 let mut in_paragraph = false;
165 let mut in_code_block = false;
166 let mut code_block_content = String::new();
167 let mut code_block_language: Option<String> = None;
168 let mut ordered_list_start: Option<u64> = None;
169 let mut in_list_item = false;
170 let mut in_blockquote = false;
171 let mut blockquote_spans: Vec<TextSpan> = Vec::new();
172
173 let mut in_table_cell = false;
174 let mut table_headers: Vec<Vec<TextSpan>> = Vec::new();
175 let mut table_rows: Vec<Vec<Vec<TextSpan>>> = Vec::new();
176 let mut current_table_row: Vec<Vec<TextSpan>> = Vec::new();
177 let mut current_cell_spans: Vec<TextSpan> = Vec::new();
178
179 let mut bold = false;
180 let mut italic = false;
181 let mut strikethrough = false;
182
183 for event in parser {
184 match event {
185 Event::Start(tag) => match tag {
186 Tag::Heading { level, .. } => {
187 in_heading = Some(level);
188 current_spans.clear();
189 }
190 Tag::Paragraph => {
191 if in_blockquote {
192 } else if in_list_item {
194 } else {
196 in_paragraph = true;
197 current_spans.clear();
198 }
199 }
200 Tag::CodeBlock(kind) => {
201 in_code_block = true;
202 code_block_content.clear();
203 code_block_language = match kind {
204 pulldown_cmark::CodeBlockKind::Fenced(lang) => {
205 let lang_str = lang.to_string();
206 if lang_str.is_empty() {
207 None
208 } else {
209 Some(lang_str)
210 }
211 }
212 pulldown_cmark::CodeBlockKind::Indented => None,
213 };
214 }
215 Tag::List(start) => {
216 ordered_list_start = start;
217 list_items.clear();
218 }
219 Tag::Item => {
220 in_list_item = true;
221 current_list_item.clear();
222 }
223 Tag::Strong => bold = true,
224 Tag::Emphasis => italic = true,
225 Tag::Strikethrough => strikethrough = true,
226 Tag::BlockQuote(_) => {
227 in_blockquote = true;
228 blockquote_spans.clear();
229 }
230 Tag::Image {
231 dest_url, title, ..
232 } => {
233 elements.push(MarkdownElement::Image {
234 url: dest_url.to_string(),
235 alt: title.to_string(),
236 });
237 }
238 Tag::Table(_) => {
239 table_headers.clear();
240 table_rows.clear();
241 current_table_row.clear();
242 }
243 Tag::TableHead => {}
244 Tag::TableRow => {
245 current_table_row.clear();
246 }
247 Tag::TableCell => {
248 in_table_cell = true;
249 current_cell_spans.clear();
250 }
251 _ => {}
252 },
253 Event::End(tag_end) => match tag_end {
254 TagEnd::Heading(_) => {
255 if let Some(level) = in_heading.take() {
256 elements.push(MarkdownElement::Heading {
257 level,
258 spans: current_spans.clone(),
259 });
260 }
261 }
262 TagEnd::Paragraph => {
263 if in_blockquote {
264 blockquote_spans.append(&mut current_spans)
265 } else if in_list_item {
266 current_list_item.append(&mut current_spans)
267 } else if in_paragraph {
268 in_paragraph = false;
269 elements.push(MarkdownElement::Paragraph {
270 spans: current_spans.clone(),
271 });
272 }
273 }
274 TagEnd::CodeBlock => {
275 in_code_block = false;
276 elements.push(MarkdownElement::CodeBlock {
277 code: code_block_content.clone(),
278 language: code_block_language.take(),
279 });
280 }
281 TagEnd::List(_) => {
282 if let Some(start) = ordered_list_start.take() {
283 elements.push(MarkdownElement::OrderedList {
284 start,
285 items: list_items.clone(),
286 });
287 } else {
288 elements.push(MarkdownElement::UnorderedList {
289 items: list_items.clone(),
290 });
291 }
292 }
293 TagEnd::Item => {
294 in_list_item = false;
295 list_items.push(current_list_item.clone());
296 }
297 TagEnd::Strong => bold = false,
298 TagEnd::Emphasis => italic = false,
299 TagEnd::Strikethrough => strikethrough = false,
300 TagEnd::BlockQuote(_) => {
301 in_blockquote = false;
302 elements.push(MarkdownElement::Blockquote {
303 spans: blockquote_spans.clone(),
304 });
305 }
306 TagEnd::Table => {
307 elements.push(MarkdownElement::Table {
308 headers: table_headers.clone(),
309 rows: table_rows.clone(),
310 });
311 }
312 TagEnd::TableHead => {
313 table_headers = current_table_row.clone();
315 current_table_row.clear();
316 }
317 TagEnd::TableRow => {
318 table_rows.push(current_table_row.clone());
320 current_table_row.clear();
321 }
322 TagEnd::TableCell => {
323 in_table_cell = false;
324 current_table_row.push(current_cell_spans.clone());
325 }
326 _ => {}
327 },
328 Event::Text(text) => {
329 if in_code_block {
330 code_block_content.push_str(text.trim());
331 } else if in_table_cell {
332 let span = TextSpan {
333 text: text.to_string(),
334 bold,
335 italic,
336 strikethrough,
337 code: false,
338 };
339 current_cell_spans.push(span);
340 } else {
341 let span = TextSpan {
342 text: text.to_string(),
343 bold,
344 italic,
345 strikethrough,
346 code: false,
347 };
348 if in_blockquote && !in_paragraph {
349 blockquote_spans.push(span);
350 } else if in_list_item && !in_paragraph {
351 current_list_item.push(span);
352 } else {
353 current_spans.push(span);
354 }
355 }
356 }
357 Event::Code(code) => {
358 let span = TextSpan {
359 text: code.to_string(),
360 bold,
361 italic,
362 strikethrough,
363 code: true,
364 };
365 if in_table_cell {
366 current_cell_spans.push(span);
367 } else if in_blockquote {
368 blockquote_spans.push(span);
369 } else if in_list_item {
370 current_list_item.push(span);
371 } else {
372 current_spans.push(span);
373 }
374 }
375 Event::SoftBreak | Event::HardBreak => {
376 let span = TextSpan::new(" ");
377 if in_blockquote {
378 blockquote_spans.push(span);
379 } else if in_list_item {
380 current_list_item.push(span);
381 } else {
382 current_spans.push(span);
383 }
384 }
385 Event::Rule => {
386 elements.push(MarkdownElement::HorizontalRule);
387 }
388 _ => {}
389 }
390 }
391
392 elements
393}
394
395fn render_spans(spans: &[TextSpan], base_font_size: f32, code_color: Option<Color>) -> Paragraph {
397 let mut p = paragraph().font_size(base_font_size);
398
399 for span in spans {
400 let mut s = Span::new(span.text.clone());
401
402 if span.bold {
403 s = s.font_weight(FontWeight::BOLD);
404 }
405
406 if span.italic {
407 s = s.font_slant(FontSlant::Italic);
408 }
409
410 if span.code {
411 s = s.font_family("monospace");
412 if let Some(c) = code_color {
413 s = s.color(c);
414 }
415 }
416
417 p = p.span(s);
418 }
419
420 p
421}
422
423impl Component for MarkdownViewer {
424 fn render(&self) -> impl IntoElement {
425 let elements = parse_markdown(&self.content);
426
427 let MarkdownViewerTheme {
428 color,
429 background_code,
430 color_code,
431 background_blockquote,
432 border_blockquote,
433 background_divider,
434 heading_h1,
435 heading_h2,
436 heading_h3,
437 heading_h4,
438 heading_h5,
439 heading_h6,
440 paragraph_size,
441 code_font_size,
442 table_font_size,
443 } = crate::get_theme!(&self.theme, markdown_viewer);
444
445 let mut container = rect().vertical().layout(self.layout.clone()).spacing(12.);
446
447 for (idx, element) in elements.into_iter().enumerate() {
448 let child: Element = match element {
449 MarkdownElement::Heading { level, spans } => {
450 let font_size = match level {
451 HeadingLevel::H1 => heading_h1,
452 HeadingLevel::H2 => heading_h2,
453 HeadingLevel::H3 => heading_h3,
454 HeadingLevel::H4 => heading_h4,
455 HeadingLevel::H5 => heading_h5,
456 HeadingLevel::H6 => heading_h6,
457 };
458 render_spans(&spans, font_size, Some(color))
459 .font_weight(FontWeight::BOLD)
460 .key(idx)
461 .into()
462 }
463 MarkdownElement::Paragraph { spans } => {
464 render_spans(&spans, paragraph_size, Some(color))
465 .key(idx)
466 .into()
467 }
468 MarkdownElement::CodeBlock { code, .. } => rect()
469 .key(idx)
470 .width(Size::fill())
471 .background(background_code)
472 .corner_radius(6.)
473 .padding(Gaps::new_all(12.))
474 .child(
475 label()
476 .text(code)
477 .font_family("monospace")
478 .font_size(code_font_size)
479 .color(color_code),
480 )
481 .into(),
482 MarkdownElement::UnorderedList { items } => {
483 let mut list = rect()
484 .key(idx)
485 .vertical()
486 .spacing(4.)
487 .padding(Gaps::new(0., 0., 0., 20.));
488
489 for (item_idx, item_spans) in items.into_iter().enumerate() {
490 let item_content = rect()
491 .key(item_idx)
492 .horizontal()
493 .cross_align(Alignment::Start)
494 .spacing(8.)
495 .child(label().text("•").font_size(paragraph_size))
496 .child(render_spans(&item_spans, paragraph_size, Some(color_code)));
497
498 list = list.child(item_content);
499 }
500
501 list.into()
502 }
503 MarkdownElement::OrderedList { start, items } => {
504 let mut list = rect()
505 .key(idx)
506 .vertical()
507 .spacing(4.)
508 .padding(Gaps::new(0., 0., 0., 20.));
509
510 for (item_idx, item_spans) in items.into_iter().enumerate() {
511 let number = start + item_idx as u64;
512 let item_content = rect()
513 .key(item_idx)
514 .horizontal()
515 .cross_align(Alignment::Start)
516 .spacing(8.)
517 .child(
518 label()
519 .text(format!("{}.", number))
520 .font_size(paragraph_size),
521 )
522 .child(render_spans(&item_spans, paragraph_size, Some(color_code)));
523
524 list = list.child(item_content);
525 }
526
527 list.into()
528 }
529 #[cfg(feature = "remote-asset")]
530 MarkdownElement::Image { url, alt } => match url.parse::<Uri>() {
531 Ok(uri) => {
532 let source: ImageSource = uri.into();
533 ImageViewer::new(source)
534 .a11y_alt(alt)
535 .key(idx)
536 .width(Size::fill())
537 .height(Size::px(300.))
538 .into()
539 }
540 Err(_) => label()
541 .key(idx)
542 .text(format!("[Invalid image URL: {}]", url))
543 .into(),
544 },
545 #[cfg(not(feature = "remote-asset"))]
546 MarkdownElement::Image { alt, .. } => {
547 label().key(idx).text(format!("[Image: {}]", alt)).into()
548 }
549 MarkdownElement::Blockquote { spans } => rect()
550 .key(idx)
551 .width(Size::fill())
552 .padding(Gaps::new(12., 12., 12., 16.))
553 .border(
554 Border::new()
555 .width(4.)
556 .fill(border_blockquote)
557 .alignment(BorderAlignment::Inner),
558 )
559 .background(background_blockquote)
560 .child(
561 render_spans(&spans, paragraph_size, Some(color_code))
562 .font_slant(FontSlant::Italic),
563 )
564 .into(),
565 MarkdownElement::HorizontalRule => rect()
566 .key(idx)
567 .width(Size::fill())
568 .height(Size::px(1.))
569 .background(background_divider)
570 .into(),
571 MarkdownElement::Table { headers, rows } => {
572 let columns = headers.len();
573
574 let mut head = TableHead::new();
575 let mut header_row = TableRow::new();
576 for (col_idx, header_spans) in headers.into_iter().enumerate() {
577 header_row = header_row.child(
578 TableCell::new().key(col_idx).child(
579 render_spans(&header_spans, table_font_size, Some(color_code))
580 .font_weight(FontWeight::BOLD),
581 ),
582 );
583 }
584 head = head.child(header_row);
585
586 let mut body = TableBody::new();
587 for (row_idx, row) in rows.into_iter().enumerate() {
588 let mut table_row = TableRow::new().key(row_idx);
589 for (col_idx, cell_spans) in row.into_iter().enumerate() {
590 table_row = table_row.child(TableCell::new().key(col_idx).child(
591 render_spans(&cell_spans, table_font_size, Some(color_code)),
592 ));
593 }
594 body = body.child(table_row);
595 }
596
597 Table::new(columns).key(idx).child(head).child(body).into()
598 }
599 };
600
601 container = container.child(child);
602 }
603
604 container
605 }
606
607 fn render_key(&self) -> DiffKey {
608 self.key.clone().or(self.default_key())
609 }
610}