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, Render, Resource, SharedString, Styled, StyledText, TextStyle, View,
 11    WeakView, 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, tooltip_container, v_flex, ButtonCommon, Checkbox, Clickable, Color,
 22    FluentBuilder, IconButton, IconName, IconSize, InteractiveElement, Label, LabelCommon,
 23    LabelSize, LinkPreview, StatefulInteractiveElement, StyledExt, StyledImage, ToggleState,
 24    Tooltip, ViewContext, VisibleOnHover, VisualContext as _,
 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) -> Div {
114    let mut cx = RenderContext::new(workspace, cx);
115
116    v_flex().gap_3().children(
117        parsed
118            .children
119            .iter()
120            .map(|block| render_markdown_block(block, &mut cx)),
121    )
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                        ToggleState::Selected
184                    } else {
185                        ToggleState::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                                    ToggleState::Selected => true,
196                                    ToggleState::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                InteractiveMarkdownElementTooltip::new(None, "toggle checkbox", cx).into()
211            })
212            .into_any_element(),
213    };
214    let bullet = div().mr_2().child(bullet);
215
216    let contents: Vec<AnyElement> = parsed
217        .content
218        .iter()
219        .map(|c| render_markdown_block(c, cx))
220        .collect();
221
222    let item = h_flex()
223        .pl(DefiniteLength::Absolute(AbsoluteLength::Rems(padding)))
224        .items_start()
225        .children(vec![bullet, div().children(contents).pr_4().w_full()]);
226
227    cx.with_common_p(item).into_any()
228}
229
230fn paragraph_len(paragraphs: &MarkdownParagraph) -> usize {
231    paragraphs
232        .iter()
233        .map(|paragraph| match paragraph {
234            MarkdownParagraphChunk::Text(text) => text.contents.len(),
235            // TODO: Scale column width based on image size
236            MarkdownParagraphChunk::Image(_) => 1,
237        })
238        .sum()
239}
240
241fn render_markdown_table(parsed: &ParsedMarkdownTable, cx: &mut RenderContext) -> AnyElement {
242    let mut max_lengths: Vec<usize> = vec![0; parsed.header.children.len()];
243
244    for (index, cell) in parsed.header.children.iter().enumerate() {
245        let length = paragraph_len(&cell);
246        max_lengths[index] = length;
247    }
248
249    for row in &parsed.body {
250        for (index, cell) in row.children.iter().enumerate() {
251            let length = paragraph_len(&cell);
252
253            if length > max_lengths[index] {
254                max_lengths[index] = length;
255            }
256        }
257    }
258
259    let total_max_length: usize = max_lengths.iter().sum();
260    let max_column_widths: Vec<f32> = max_lengths
261        .iter()
262        .map(|&length| length as f32 / total_max_length as f32)
263        .collect();
264
265    let header = render_markdown_table_row(
266        &parsed.header,
267        &parsed.column_alignments,
268        &max_column_widths,
269        true,
270        cx,
271    );
272
273    let body: Vec<AnyElement> = parsed
274        .body
275        .iter()
276        .map(|row| {
277            render_markdown_table_row(
278                row,
279                &parsed.column_alignments,
280                &max_column_widths,
281                false,
282                cx,
283            )
284        })
285        .collect();
286
287    cx.with_common_p(v_flex())
288        .w_full()
289        .child(header)
290        .children(body)
291        .into_any()
292}
293
294fn render_markdown_table_row(
295    parsed: &ParsedMarkdownTableRow,
296    alignments: &Vec<ParsedMarkdownTableAlignment>,
297    max_column_widths: &Vec<f32>,
298    is_header: bool,
299    cx: &mut RenderContext,
300) -> AnyElement {
301    let mut items = vec![];
302
303    for (index, cell) in parsed.children.iter().enumerate() {
304        let alignment = alignments
305            .get(index)
306            .copied()
307            .unwrap_or(ParsedMarkdownTableAlignment::None);
308
309        let contents = render_markdown_text(cell, cx);
310
311        let container = match alignment {
312            ParsedMarkdownTableAlignment::Left | ParsedMarkdownTableAlignment::None => div(),
313            ParsedMarkdownTableAlignment::Center => v_flex().items_center(),
314            ParsedMarkdownTableAlignment::Right => v_flex().items_end(),
315        };
316
317        let max_width = max_column_widths.get(index).unwrap_or(&0.0);
318        let mut cell = container
319            .w(Length::Definite(relative(*max_width)))
320            .h_full()
321            .children(contents)
322            .px_2()
323            .py_1()
324            .border_color(cx.border_color);
325
326        if is_header {
327            cell = cell.border_2()
328        } else {
329            cell = cell.border_1()
330        }
331
332        items.push(cell);
333    }
334
335    h_flex().children(items).into_any_element()
336}
337
338fn render_markdown_block_quote(
339    parsed: &ParsedMarkdownBlockQuote,
340    cx: &mut RenderContext,
341) -> AnyElement {
342    cx.indent += 1;
343
344    let children: Vec<AnyElement> = parsed
345        .children
346        .iter()
347        .map(|child| render_markdown_block(child, cx))
348        .collect();
349
350    cx.indent -= 1;
351
352    cx.with_common_p(div())
353        .child(
354            div()
355                .border_l_4()
356                .border_color(cx.border_color)
357                .pl_3()
358                .children(children),
359        )
360        .into_any()
361}
362
363fn render_markdown_code_block(
364    parsed: &ParsedMarkdownCodeBlock,
365    cx: &mut RenderContext,
366) -> AnyElement {
367    let body = if let Some(highlights) = parsed.highlights.as_ref() {
368        StyledText::new(parsed.contents.clone()).with_highlights(
369            &cx.buffer_text_style,
370            highlights.iter().filter_map(|(range, highlight_id)| {
371                highlight_id
372                    .style(cx.syntax_theme.as_ref())
373                    .map(|style| (range.clone(), style))
374            }),
375        )
376    } else {
377        StyledText::new(parsed.contents.clone())
378    };
379
380    let copy_block_button = IconButton::new("copy-code", IconName::Copy)
381        .icon_size(IconSize::Small)
382        .on_click({
383            let contents = parsed.contents.clone();
384            move |_, cx| {
385                cx.write_to_clipboard(ClipboardItem::new_string(contents.to_string()));
386            }
387        })
388        .tooltip(|cx| Tooltip::text("Copy code block", cx))
389        .visible_on_hover("markdown-block");
390
391    cx.with_common_p(div())
392        .font_family(cx.buffer_font_family.clone())
393        .px_3()
394        .py_3()
395        .bg(cx.code_block_background_color)
396        .rounded_md()
397        .child(body)
398        .child(
399            div()
400                .h_flex()
401                .absolute()
402                .right_1()
403                .top_1()
404                .child(copy_block_button),
405        )
406        .into_any()
407}
408
409fn render_markdown_paragraph(parsed: &MarkdownParagraph, cx: &mut RenderContext) -> AnyElement {
410    cx.with_common_p(div())
411        .children(render_markdown_text(parsed, cx))
412        .flex()
413        .flex_col()
414        .into_any_element()
415}
416
417fn render_markdown_text(parsed_new: &MarkdownParagraph, cx: &mut RenderContext) -> Vec<AnyElement> {
418    let mut any_element = vec![];
419    // these values are cloned in-order satisfy borrow checker
420    let syntax_theme = cx.syntax_theme.clone();
421    let workspace_clone = cx.workspace.clone();
422    let code_span_bg_color = cx.code_span_background_color;
423    let text_style = cx.text_style.clone();
424
425    for parsed_region in parsed_new {
426        match parsed_region {
427            MarkdownParagraphChunk::Text(parsed) => {
428                let element_id = cx.next_id(&parsed.source_range);
429
430                let highlights = gpui::combine_highlights(
431                    parsed.highlights.iter().filter_map(|(range, highlight)| {
432                        highlight
433                            .to_highlight_style(&syntax_theme)
434                            .map(|style| (range.clone(), style))
435                    }),
436                    parsed.regions.iter().zip(&parsed.region_ranges).filter_map(
437                        |(region, range)| {
438                            if region.code {
439                                Some((
440                                    range.clone(),
441                                    HighlightStyle {
442                                        background_color: Some(code_span_bg_color),
443                                        ..Default::default()
444                                    },
445                                ))
446                            } else {
447                                None
448                            }
449                        },
450                    ),
451                );
452                let mut links = Vec::new();
453                let mut link_ranges = Vec::new();
454                for (range, region) in parsed.region_ranges.iter().zip(&parsed.regions) {
455                    if let Some(link) = region.link.clone() {
456                        links.push(link);
457                        link_ranges.push(range.clone());
458                    }
459                }
460                let workspace = workspace_clone.clone();
461                let element = div()
462                    .child(
463                        InteractiveText::new(
464                            element_id,
465                            StyledText::new(parsed.contents.clone())
466                                .with_highlights(&text_style, highlights),
467                        )
468                        .tooltip({
469                            let links = links.clone();
470                            let link_ranges = link_ranges.clone();
471                            move |idx, cx| {
472                                for (ix, range) in link_ranges.iter().enumerate() {
473                                    if range.contains(&idx) {
474                                        return Some(LinkPreview::new(&links[ix].to_string(), cx));
475                                    }
476                                }
477                                None
478                            }
479                        })
480                        .on_click(
481                            link_ranges,
482                            move |clicked_range_ix, window_cx| match &links[clicked_range_ix] {
483                                Link::Web { url } => window_cx.open_url(url),
484                                Link::Path { path, .. } => {
485                                    if let Some(workspace) = &workspace {
486                                        _ = workspace.update(window_cx, |workspace, cx| {
487                                            workspace
488                                                .open_abs_path(path.clone(), false, cx)
489                                                .detach();
490                                        });
491                                    }
492                                }
493                            },
494                        ),
495                    )
496                    .into_any();
497                any_element.push(element);
498            }
499
500            MarkdownParagraphChunk::Image(image) => {
501                let image_resource = match image.link.clone() {
502                    Link::Web { url } => Resource::Uri(url.into()),
503                    Link::Path { path, .. } => Resource::Path(Arc::from(path)),
504                };
505
506                let element_id = cx.next_id(&image.source_range);
507
508                let image_element = div()
509                    .id(element_id)
510                    .cursor_pointer()
511                    .child(img(ImageSource::Resource(image_resource)).with_fallback({
512                        let alt_text = image.alt_text.clone();
513                        {
514                            move || div().children(alt_text.clone()).into_any_element()
515                        }
516                    }))
517                    .tooltip({
518                        let link = image.link.clone();
519                        move |cx| {
520                            InteractiveMarkdownElementTooltip::new(
521                                Some(link.to_string()),
522                                "open image",
523                                cx,
524                            )
525                            .into()
526                        }
527                    })
528                    .on_click({
529                        let workspace = workspace_clone.clone();
530                        let link = image.link.clone();
531                        move |_, cx| {
532                            if cx.modifiers().secondary() {
533                                match &link {
534                                    Link::Web { url } => cx.open_url(url),
535                                    Link::Path { path, .. } => {
536                                        if let Some(workspace) = &workspace {
537                                            _ = workspace.update(cx, |workspace, cx| {
538                                                workspace
539                                                    .open_abs_path(path.clone(), false, cx)
540                                                    .detach();
541                                            });
542                                        }
543                                    }
544                                }
545                            }
546                        }
547                    })
548                    .into_any();
549                any_element.push(image_element);
550            }
551        }
552    }
553
554    any_element
555}
556
557fn render_markdown_rule(cx: &mut RenderContext) -> AnyElement {
558    let rule = div().w_full().h(px(2.)).bg(cx.border_color);
559    div().pt_3().pb_3().child(rule).into_any()
560}
561
562struct InteractiveMarkdownElementTooltip {
563    tooltip_text: Option<SharedString>,
564    action_text: String,
565}
566
567impl InteractiveMarkdownElementTooltip {
568    pub fn new(
569        tooltip_text: Option<String>,
570        action_text: &str,
571        cx: &mut WindowContext,
572    ) -> View<Self> {
573        let tooltip_text = tooltip_text.map(|t| util::truncate_and_trailoff(&t, 50).into());
574
575        cx.new_view(|_| Self {
576            tooltip_text,
577            action_text: action_text.to_string(),
578        })
579    }
580}
581
582impl Render for InteractiveMarkdownElementTooltip {
583    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
584        tooltip_container(cx, |el, _| {
585            let secondary_modifier = Keystroke {
586                modifiers: Modifiers::secondary_key(),
587                ..Default::default()
588            };
589
590            el.child(
591                v_flex()
592                    .gap_1()
593                    .when_some(self.tooltip_text.clone(), |this, text| {
594                        this.child(Label::new(text).size(LabelSize::Small))
595                    })
596                    .child(
597                        Label::new(format!(
598                            "{}-click to {}",
599                            secondary_modifier, self.action_text
600                        ))
601                        .size(LabelSize::Small)
602                        .color(Color::Muted),
603                    ),
604            )
605        })
606    }
607}