markdown_renderer.rs

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