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