markdown_renderer.rs

  1use crate::markdown_elements::{
  2    HeadingLevel, Image, Link, MarkdownParagraph, MarkdownParagraphChunk, ParsedMarkdown,
  3    ParsedMarkdownBlockQuote, ParsedMarkdownCodeBlock, ParsedMarkdownElement,
  4    ParsedMarkdownHeading, ParsedMarkdownListItem, ParsedMarkdownListItemType, ParsedMarkdownTable,
  5    ParsedMarkdownTableAlignment, ParsedMarkdownTableRow, ParsedMarkdownText,
  6};
  7use gpui::{
  8    div, img, px, rems, AbsoluteLength, AnyElement, ClipboardItem, DefiniteLength, Div, Element,
  9    ElementId, HighlightStyle, Hsla, ImageSource, InteractiveText, IntoElement, Keystroke, Length,
 10    Modifiers, ParentElement, Resource, SharedString, Styled, StyledText, TextStyle, WeakView,
 11    WindowContext,
 12};
 13use settings::Settings;
 14use std::{
 15    ops::{Mul, Range},
 16    path::Path,
 17    sync::Arc,
 18    vec,
 19};
 20use theme::{ActiveTheme, SyntaxTheme, ThemeSettings};
 21use ui::{
 22    h_flex, relative, v_flex, Checkbox, Clickable, FluentBuilder, IconButton, IconName, IconSize,
 23    InteractiveElement, LinkPreview, Selection, StatefulInteractiveElement, StyledExt, StyledImage,
 24    Tooltip, VisibleOnHover,
 25};
 26use workspace::Workspace;
 27
 28type CheckboxClickedCallback = Arc<Box<dyn Fn(bool, Range<usize>, &mut WindowContext)>>;
 29
 30#[derive(Clone)]
 31pub struct RenderContext {
 32    workspace: Option<WeakView<Workspace>>,
 33    next_id: usize,
 34    buffer_font_family: SharedString,
 35    buffer_text_style: TextStyle,
 36    text_style: TextStyle,
 37    border_color: Hsla,
 38    text_color: Hsla,
 39    text_muted_color: Hsla,
 40    code_block_background_color: Hsla,
 41    code_span_background_color: Hsla,
 42    syntax_theme: Arc<SyntaxTheme>,
 43    indent: usize,
 44    checkbox_clicked_callback: Option<CheckboxClickedCallback>,
 45}
 46
 47impl RenderContext {
 48    pub fn new(workspace: Option<WeakView<Workspace>>, cx: &WindowContext) -> RenderContext {
 49        let theme = cx.theme().clone();
 50
 51        let settings = ThemeSettings::get_global(cx);
 52        let buffer_font_family = settings.buffer_font.family.clone();
 53        let mut buffer_text_style = cx.text_style();
 54        buffer_text_style.font_family = buffer_font_family.clone();
 55
 56        RenderContext {
 57            workspace,
 58            next_id: 0,
 59            indent: 0,
 60            buffer_font_family,
 61            buffer_text_style,
 62            text_style: cx.text_style(),
 63            syntax_theme: theme.syntax().clone(),
 64            border_color: theme.colors().border,
 65            text_color: theme.colors().text,
 66            text_muted_color: theme.colors().text_muted,
 67            code_block_background_color: theme.colors().surface_background,
 68            code_span_background_color: theme.colors().editor_document_highlight_read_background,
 69            checkbox_clicked_callback: None,
 70        }
 71    }
 72
 73    pub fn with_checkbox_clicked_callback(
 74        mut self,
 75        callback: impl Fn(bool, Range<usize>, &mut WindowContext) + 'static,
 76    ) -> Self {
 77        self.checkbox_clicked_callback = Some(Arc::new(Box::new(callback)));
 78        self
 79    }
 80
 81    fn next_id(&mut self, span: &Range<usize>) -> ElementId {
 82        let id = format!("markdown-{}-{}-{}", self.next_id, span.start, span.end);
 83        self.next_id += 1;
 84        ElementId::from(SharedString::from(id))
 85    }
 86
 87    /// This ensures that children inside of block quotes
 88    /// have padding between them.
 89    ///
 90    /// For example, for this markdown:
 91    ///
 92    /// ```markdown
 93    /// > This is a block quote.
 94    /// >
 95    /// > And this is the next paragraph.
 96    /// ```
 97    ///
 98    /// We give padding between "This is a block quote."
 99    /// and "And this is the next paragraph."
100    fn with_common_p(&self, element: Div) -> Div {
101        if self.indent > 0 {
102            element.pb_3()
103        } else {
104            element
105        }
106    }
107}
108
109pub fn render_parsed_markdown(
110    parsed: &ParsedMarkdown,
111    workspace: Option<WeakView<Workspace>>,
112    cx: &WindowContext,
113) -> Vec<AnyElement> {
114    let mut cx = RenderContext::new(workspace, cx);
115    let mut elements = Vec::new();
116
117    for child in &parsed.children {
118        elements.push(render_markdown_block(child, &mut cx));
119    }
120
121    elements
122}
123
124pub fn render_markdown_block(block: &ParsedMarkdownElement, cx: &mut RenderContext) -> AnyElement {
125    use ParsedMarkdownElement::*;
126    match block {
127        Paragraph(text) => render_markdown_paragraph(text, cx),
128        Heading(heading) => render_markdown_heading(heading, cx),
129        ListItem(list_item) => render_markdown_list_item(list_item, cx),
130        Table(table) => render_markdown_table(table, cx),
131        BlockQuote(block_quote) => render_markdown_block_quote(block_quote, cx),
132        CodeBlock(code_block) => render_markdown_code_block(code_block, cx),
133        HorizontalRule(_) => render_markdown_rule(cx),
134    }
135}
136
137fn render_markdown_heading(parsed: &ParsedMarkdownHeading, cx: &mut RenderContext) -> AnyElement {
138    let size = match parsed.level {
139        HeadingLevel::H1 => rems(2.),
140        HeadingLevel::H2 => rems(1.5),
141        HeadingLevel::H3 => rems(1.25),
142        HeadingLevel::H4 => rems(1.),
143        HeadingLevel::H5 => rems(0.875),
144        HeadingLevel::H6 => rems(0.85),
145    };
146
147    let color = match parsed.level {
148        HeadingLevel::H6 => cx.text_muted_color,
149        _ => cx.text_color,
150    };
151
152    let line_height = DefiniteLength::from(size.mul(1.25));
153
154    div()
155        .line_height(line_height)
156        .text_size(size)
157        .text_color(color)
158        .pt(rems(0.15))
159        .pb_1()
160        .children(render_markdown_text(&parsed.contents, cx))
161        .whitespace_normal()
162        .into_any()
163}
164
165fn render_markdown_list_item(
166    parsed: &ParsedMarkdownListItem,
167    cx: &mut RenderContext,
168) -> AnyElement {
169    use ParsedMarkdownListItemType::*;
170
171    let padding = rems((parsed.depth - 1) as f32);
172
173    let bullet = match &parsed.item_type {
174        Ordered(order) => format!("{}.", order).into_any_element(),
175        Unordered => "".into_any_element(),
176        Task(checked, range) => div()
177            .id(cx.next_id(range))
178            .mt(px(3.))
179            .child(
180                Checkbox::new(
181                    "checkbox",
182                    if *checked {
183                        Selection::Selected
184                    } else {
185                        Selection::Unselected
186                    },
187                )
188                .when_some(
189                    cx.checkbox_clicked_callback.clone(),
190                    |this, callback| {
191                        this.on_click({
192                            let range = range.clone();
193                            move |selection, cx| {
194                                let checked = match selection {
195                                    Selection::Selected => true,
196                                    Selection::Unselected => false,
197                                    _ => return,
198                                };
199
200                                if cx.modifiers().secondary() {
201                                    callback(checked, range.clone(), cx);
202                                }
203                            }
204                        })
205                    },
206                ),
207            )
208            .hover(|s| s.cursor_pointer())
209            .tooltip(|cx| {
210                let secondary_modifier = Keystroke {
211                    key: "".to_string(),
212                    modifiers: Modifiers::secondary_key(),
213                    key_char: None,
214                };
215                Tooltip::text(
216                    format!("{}-click to toggle the checkbox", secondary_modifier),
217                    cx,
218                )
219            })
220            .into_any_element(),
221    };
222    let bullet = div().mr_2().child(bullet);
223
224    let contents: Vec<AnyElement> = parsed
225        .content
226        .iter()
227        .map(|c| render_markdown_block(c, cx))
228        .collect();
229
230    let item = h_flex()
231        .pl(DefiniteLength::Absolute(AbsoluteLength::Rems(padding)))
232        .items_start()
233        .children(vec![bullet, div().children(contents).pr_4().w_full()]);
234
235    cx.with_common_p(item).into_any()
236}
237
238fn paragraph_len(paragraphs: &MarkdownParagraph) -> usize {
239    paragraphs
240        .iter()
241        .map(|paragraph| match paragraph {
242            MarkdownParagraphChunk::Text(text) => text.contents.len(),
243            // TODO: Scale column width based on image size
244            MarkdownParagraphChunk::Image(_) => 1,
245        })
246        .sum()
247}
248
249fn render_markdown_table(parsed: &ParsedMarkdownTable, cx: &mut RenderContext) -> AnyElement {
250    let mut max_lengths: Vec<usize> = vec![0; parsed.header.children.len()];
251
252    for (index, cell) in parsed.header.children.iter().enumerate() {
253        let length = paragraph_len(&cell);
254        max_lengths[index] = length;
255    }
256
257    for row in &parsed.body {
258        for (index, cell) in row.children.iter().enumerate() {
259            let length = paragraph_len(&cell);
260
261            if length > max_lengths[index] {
262                max_lengths[index] = length;
263            }
264        }
265    }
266
267    let total_max_length: usize = max_lengths.iter().sum();
268    let max_column_widths: Vec<f32> = max_lengths
269        .iter()
270        .map(|&length| length as f32 / total_max_length as f32)
271        .collect();
272
273    let header = render_markdown_table_row(
274        &parsed.header,
275        &parsed.column_alignments,
276        &max_column_widths,
277        true,
278        cx,
279    );
280
281    let body: Vec<AnyElement> = parsed
282        .body
283        .iter()
284        .map(|row| {
285            render_markdown_table_row(
286                row,
287                &parsed.column_alignments,
288                &max_column_widths,
289                false,
290                cx,
291            )
292        })
293        .collect();
294
295    cx.with_common_p(v_flex())
296        .w_full()
297        .child(header)
298        .children(body)
299        .into_any()
300}
301
302fn render_markdown_table_row(
303    parsed: &ParsedMarkdownTableRow,
304    alignments: &Vec<ParsedMarkdownTableAlignment>,
305    max_column_widths: &Vec<f32>,
306    is_header: bool,
307    cx: &mut RenderContext,
308) -> AnyElement {
309    let mut items = vec![];
310
311    for (index, cell) in parsed.children.iter().enumerate() {
312        let alignment = alignments
313            .get(index)
314            .copied()
315            .unwrap_or(ParsedMarkdownTableAlignment::None);
316
317        let contents = render_markdown_text(cell, cx);
318
319        let container = match alignment {
320            ParsedMarkdownTableAlignment::Left | ParsedMarkdownTableAlignment::None => div(),
321            ParsedMarkdownTableAlignment::Center => v_flex().items_center(),
322            ParsedMarkdownTableAlignment::Right => v_flex().items_end(),
323        };
324
325        let max_width = max_column_widths.get(index).unwrap_or(&0.0);
326        let mut cell = container
327            .w(Length::Definite(relative(*max_width)))
328            .h_full()
329            .children(contents)
330            .px_2()
331            .py_1()
332            .border_color(cx.border_color);
333
334        if is_header {
335            cell = cell.border_2()
336        } else {
337            cell = cell.border_1()
338        }
339
340        items.push(cell);
341    }
342
343    h_flex().children(items).into_any_element()
344}
345
346fn render_markdown_block_quote(
347    parsed: &ParsedMarkdownBlockQuote,
348    cx: &mut RenderContext,
349) -> AnyElement {
350    cx.indent += 1;
351
352    let children: Vec<AnyElement> = parsed
353        .children
354        .iter()
355        .map(|child| render_markdown_block(child, cx))
356        .collect();
357
358    cx.indent -= 1;
359
360    cx.with_common_p(div())
361        .child(
362            div()
363                .border_l_4()
364                .border_color(cx.border_color)
365                .pl_3()
366                .children(children),
367        )
368        .into_any()
369}
370
371fn render_markdown_code_block(
372    parsed: &ParsedMarkdownCodeBlock,
373    cx: &mut RenderContext,
374) -> AnyElement {
375    let body = if let Some(highlights) = parsed.highlights.as_ref() {
376        StyledText::new(parsed.contents.clone()).with_highlights(
377            &cx.buffer_text_style,
378            highlights.iter().filter_map(|(range, highlight_id)| {
379                highlight_id
380                    .style(cx.syntax_theme.as_ref())
381                    .map(|style| (range.clone(), style))
382            }),
383        )
384    } else {
385        StyledText::new(parsed.contents.clone())
386    };
387
388    let copy_block_button = IconButton::new("copy-code", IconName::Copy)
389        .icon_size(IconSize::Small)
390        .on_click({
391            let contents = parsed.contents.clone();
392            move |_, cx| {
393                cx.write_to_clipboard(ClipboardItem::new_string(contents.to_string()));
394            }
395        })
396        .visible_on_hover("markdown-block");
397
398    cx.with_common_p(div())
399        .font_family(cx.buffer_font_family.clone())
400        .px_3()
401        .py_3()
402        .bg(cx.code_block_background_color)
403        .rounded_md()
404        .child(body)
405        .child(
406            div()
407                .h_flex()
408                .absolute()
409                .right_1()
410                .top_1()
411                .child(copy_block_button),
412        )
413        .into_any()
414}
415
416fn render_markdown_paragraph(parsed: &MarkdownParagraph, cx: &mut RenderContext) -> AnyElement {
417    cx.with_common_p(div())
418        .children(render_markdown_text(parsed, cx))
419        .flex()
420        .flex_col()
421        .into_any_element()
422}
423
424fn render_markdown_text(parsed_new: &MarkdownParagraph, cx: &mut RenderContext) -> Vec<AnyElement> {
425    let mut any_element = vec![];
426    // these values are cloned in-order satisfy borrow checker
427    let syntax_theme = cx.syntax_theme.clone();
428    let workspace_clone = cx.workspace.clone();
429    let code_span_bg_color = cx.code_span_background_color;
430    let text_style = cx.text_style.clone();
431
432    for parsed_region in parsed_new {
433        match parsed_region {
434            MarkdownParagraphChunk::Text(parsed) => {
435                let element_id = cx.next_id(&parsed.source_range);
436
437                let highlights = gpui::combine_highlights(
438                    parsed.highlights.iter().filter_map(|(range, highlight)| {
439                        highlight
440                            .to_highlight_style(&syntax_theme)
441                            .map(|style| (range.clone(), style))
442                    }),
443                    parsed.regions.iter().zip(&parsed.region_ranges).filter_map(
444                        |(region, range)| {
445                            if region.code {
446                                Some((
447                                    range.clone(),
448                                    HighlightStyle {
449                                        background_color: Some(code_span_bg_color),
450                                        ..Default::default()
451                                    },
452                                ))
453                            } else {
454                                None
455                            }
456                        },
457                    ),
458                );
459                let mut links = Vec::new();
460                let mut link_ranges = Vec::new();
461                for (range, region) in parsed.region_ranges.iter().zip(&parsed.regions) {
462                    if let Some(link) = region.link.clone() {
463                        links.push(link);
464                        link_ranges.push(range.clone());
465                    }
466                }
467                let workspace = workspace_clone.clone();
468                let element = div()
469                    .child(
470                        InteractiveText::new(
471                            element_id,
472                            StyledText::new(parsed.contents.clone())
473                                .with_highlights(&text_style, highlights),
474                        )
475                        .tooltip({
476                            let links = links.clone();
477                            let link_ranges = link_ranges.clone();
478                            move |idx, cx| {
479                                for (ix, range) in link_ranges.iter().enumerate() {
480                                    if range.contains(&idx) {
481                                        return Some(LinkPreview::new(&links[ix].to_string(), cx));
482                                    }
483                                }
484                                None
485                            }
486                        })
487                        .on_click(
488                            link_ranges,
489                            move |clicked_range_ix, window_cx| match &links[clicked_range_ix] {
490                                Link::Web { url } => window_cx.open_url(url),
491                                Link::Path { path, .. } => {
492                                    if let Some(workspace) = &workspace {
493                                        _ = workspace.update(window_cx, |workspace, cx| {
494                                            workspace
495                                                .open_abs_path(path.clone(), false, cx)
496                                                .detach();
497                                        });
498                                    }
499                                }
500                            },
501                        ),
502                    )
503                    .into_any();
504                any_element.push(element);
505            }
506
507            MarkdownParagraphChunk::Image(image) => {
508                let (link, source_range, image_source, alt_text) = match image {
509                    Image::Web {
510                        link,
511                        source_range,
512                        url,
513                        alt_text,
514                    } => (
515                        link,
516                        source_range,
517                        Resource::Uri(url.clone().into()),
518                        alt_text,
519                    ),
520                    Image::Path {
521                        link,
522                        source_range,
523                        path,
524                        alt_text,
525                        ..
526                    } => {
527                        let image_path = Path::new(path.to_str().unwrap());
528                        (
529                            link,
530                            source_range,
531                            Resource::Path(Arc::from(image_path)),
532                            alt_text,
533                        )
534                    }
535                };
536
537                let element_id = cx.next_id(source_range);
538
539                match link {
540                    None => {
541                        let fallback_workspace = workspace_clone.clone();
542                        let fallback_syntax_theme = syntax_theme.clone();
543                        let fallback_text_style = text_style.clone();
544                        let fallback_alt_text = alt_text.clone();
545                        let element_id_new = element_id.clone();
546                        let element = div()
547                            .child(img(ImageSource::Resource(image_source)).with_fallback({
548                                move || {
549                                    fallback_text(
550                                        fallback_alt_text.clone().unwrap(),
551                                        element_id.clone(),
552                                        &fallback_syntax_theme,
553                                        code_span_bg_color,
554                                        fallback_workspace.clone(),
555                                        &fallback_text_style,
556                                    )
557                                }
558                            }))
559                            .id(element_id_new)
560                            .into_any();
561                        any_element.push(element);
562                    }
563                    Some(link) => {
564                        let link_click = link.clone();
565                        let link_tooltip = link.clone();
566                        let fallback_workspace = workspace_clone.clone();
567                        let fallback_syntax_theme = syntax_theme.clone();
568                        let fallback_text_style = text_style.clone();
569                        let fallback_alt_text = alt_text.clone();
570                        let element_id_new = element_id.clone();
571                        let image_element = div()
572                            .child(img(ImageSource::Resource(image_source)).with_fallback({
573                                move || {
574                                    fallback_text(
575                                        fallback_alt_text.clone().unwrap(),
576                                        element_id.clone(),
577                                        &fallback_syntax_theme,
578                                        code_span_bg_color,
579                                        fallback_workspace.clone(),
580                                        &fallback_text_style,
581                                    )
582                                }
583                            }))
584                            .id(element_id_new)
585                            .tooltip(move |cx| LinkPreview::new(&link_tooltip.to_string(), cx))
586                            .on_click({
587                                let workspace = workspace_clone.clone();
588                                move |_event, window_cx| match &link_click {
589                                    Link::Web { url } => window_cx.open_url(url),
590                                    Link::Path { path, .. } => {
591                                        if let Some(workspace) = &workspace {
592                                            _ = workspace.update(window_cx, |workspace, cx| {
593                                                workspace
594                                                    .open_abs_path(path.clone(), false, cx)
595                                                    .detach();
596                                            });
597                                        }
598                                    }
599                                }
600                            })
601                            .into_any();
602                        any_element.push(image_element);
603                    }
604                }
605            }
606        }
607    }
608
609    any_element
610}
611
612fn render_markdown_rule(cx: &mut RenderContext) -> AnyElement {
613    let rule = div().w_full().h(px(2.)).bg(cx.border_color);
614    div().pt_3().pb_3().child(rule).into_any()
615}
616
617fn fallback_text(
618    parsed: ParsedMarkdownText,
619    source_range: ElementId,
620    syntax_theme: &theme::SyntaxTheme,
621    code_span_bg_color: Hsla,
622    workspace: Option<WeakView<Workspace>>,
623    text_style: &TextStyle,
624) -> AnyElement {
625    let element_id = source_range;
626
627    let highlights = gpui::combine_highlights(
628        parsed.highlights.iter().filter_map(|(range, highlight)| {
629            let highlight = highlight.to_highlight_style(syntax_theme)?;
630            Some((range.clone(), highlight))
631        }),
632        parsed
633            .regions
634            .iter()
635            .zip(&parsed.region_ranges)
636            .filter_map(|(region, range)| {
637                if region.code {
638                    Some((
639                        range.clone(),
640                        HighlightStyle {
641                            background_color: Some(code_span_bg_color),
642                            ..Default::default()
643                        },
644                    ))
645                } else {
646                    None
647                }
648            }),
649    );
650    let mut links = Vec::new();
651    let mut link_ranges = Vec::new();
652    for (range, region) in parsed.region_ranges.iter().zip(&parsed.regions) {
653        if let Some(link) = region.link.clone() {
654            links.push(link);
655            link_ranges.push(range.clone());
656        }
657    }
658    let element = div()
659        .child(
660            InteractiveText::new(
661                element_id,
662                StyledText::new(parsed.contents.clone()).with_highlights(text_style, highlights),
663            )
664            .tooltip({
665                let links = links.clone();
666                let link_ranges = link_ranges.clone();
667                move |idx, cx| {
668                    for (ix, range) in link_ranges.iter().enumerate() {
669                        if range.contains(&idx) {
670                            return Some(LinkPreview::new(&links[ix].to_string(), cx));
671                        }
672                    }
673                    None
674                }
675            })
676            .on_click(
677                link_ranges,
678                move |clicked_range_ix, window_cx| match &links[clicked_range_ix] {
679                    Link::Web { url } => window_cx.open_url(url),
680                    Link::Path { path, .. } => {
681                        if let Some(workspace) = &workspace {
682                            _ = workspace.update(window_cx, |workspace, cx| {
683                                workspace.open_abs_path(path.clone(), false, cx).detach();
684                            });
685                        }
686                    }
687                },
688            ),
689        )
690        .into_any();
691    return element;
692}