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,
  6};
  7use fs::normalize_path;
  8use gpui::{
  9    AbsoluteLength, AnyElement, App, AppContext as _, ClipboardItem, Context, DefiniteLength, Div,
 10    Element, ElementId, Entity, HighlightStyle, Hsla, ImageSource, InteractiveText, IntoElement,
 11    Keystroke, Length, Modifiers, ParentElement, Render, Resource, SharedString, Styled,
 12    StyledText, TextStyle, WeakEntity, Window, div, img, rems,
 13};
 14use settings::Settings;
 15use std::{
 16    ops::{Mul, Range},
 17    sync::Arc,
 18    vec,
 19};
 20use theme::{ActiveTheme, SyntaxTheme, ThemeSettings};
 21use ui::{
 22    ButtonCommon, Clickable, Color, FluentBuilder, IconButton, IconName, IconSize,
 23    InteractiveElement, Label, LabelCommon, LabelSize, LinkPreview, Pixels, Rems,
 24    StatefulInteractiveElement, StyledExt, StyledImage, ToggleState, Tooltip, VisibleOnHover,
 25    h_flex, relative, tooltip_container, v_flex,
 26};
 27use workspace::{OpenOptions, OpenVisible, Workspace};
 28
 29pub struct CheckboxClickedEvent {
 30    pub checked: bool,
 31    pub source_range: Range<usize>,
 32}
 33
 34impl CheckboxClickedEvent {
 35    pub fn source_range(&self) -> Range<usize> {
 36        self.source_range.clone()
 37    }
 38
 39    pub fn checked(&self) -> bool {
 40        self.checked
 41    }
 42}
 43
 44type CheckboxClickedCallback = Arc<Box<dyn Fn(&CheckboxClickedEvent, &mut Window, &mut App)>>;
 45
 46#[derive(Clone)]
 47pub struct RenderContext {
 48    workspace: Option<WeakEntity<Workspace>>,
 49    next_id: usize,
 50    buffer_font_family: SharedString,
 51    buffer_text_style: TextStyle,
 52    text_style: TextStyle,
 53    border_color: Hsla,
 54    element_background_color: Hsla,
 55    text_color: Hsla,
 56    window_rem_size: Pixels,
 57    text_muted_color: Hsla,
 58    code_block_background_color: Hsla,
 59    code_span_background_color: Hsla,
 60    syntax_theme: Arc<SyntaxTheme>,
 61    indent: usize,
 62    checkbox_clicked_callback: Option<CheckboxClickedCallback>,
 63}
 64
 65impl RenderContext {
 66    pub fn new(
 67        workspace: Option<WeakEntity<Workspace>>,
 68        window: &mut Window,
 69        cx: &mut App,
 70    ) -> RenderContext {
 71        let theme = cx.theme().clone();
 72
 73        let settings = ThemeSettings::get_global(cx);
 74        let buffer_font_family = settings.buffer_font.family.clone();
 75        let mut buffer_text_style = window.text_style();
 76        buffer_text_style.font_family = buffer_font_family.clone();
 77        buffer_text_style.font_size = AbsoluteLength::from(settings.buffer_font_size(cx));
 78
 79        RenderContext {
 80            workspace,
 81            next_id: 0,
 82            indent: 0,
 83            buffer_font_family,
 84            buffer_text_style,
 85            text_style: window.text_style(),
 86            syntax_theme: theme.syntax().clone(),
 87            border_color: theme.colors().border,
 88            element_background_color: theme.colors().element_background,
 89            text_color: theme.colors().text,
 90            window_rem_size: window.rem_size(),
 91            text_muted_color: theme.colors().text_muted,
 92            code_block_background_color: theme.colors().surface_background,
 93            code_span_background_color: theme.colors().editor_document_highlight_read_background,
 94            checkbox_clicked_callback: None,
 95        }
 96    }
 97
 98    pub fn with_checkbox_clicked_callback(
 99        mut self,
100        callback: impl Fn(&CheckboxClickedEvent, &mut Window, &mut App) + 'static,
101    ) -> Self {
102        self.checkbox_clicked_callback = Some(Arc::new(Box::new(callback)));
103        self
104    }
105
106    fn next_id(&mut self, span: &Range<usize>) -> ElementId {
107        let id = format!("markdown-{}-{}-{}", self.next_id, span.start, span.end);
108        self.next_id += 1;
109        ElementId::from(SharedString::from(id))
110    }
111
112    /// HACK: used to have rems relative to buffer font size, so that things scale appropriately as
113    /// buffer font size changes. The callees of this function should be reimplemented to use real
114    /// relative sizing once that is implemented in GPUI
115    pub fn scaled_rems(&self, rems: f32) -> Rems {
116        self.buffer_text_style
117            .font_size
118            .to_rems(self.window_rem_size)
119            .mul(rems)
120    }
121
122    /// This ensures that children inside of block quotes
123    /// have padding between them.
124    ///
125    /// For example, for this markdown:
126    ///
127    /// ```markdown
128    /// > This is a block quote.
129    /// >
130    /// > And this is the next paragraph.
131    /// ```
132    ///
133    /// We give padding between "This is a block quote."
134    /// and "And this is the next paragraph."
135    fn with_common_p(&self, element: Div) -> Div {
136        if self.indent > 0 {
137            element.pb(self.scaled_rems(0.75))
138        } else {
139            element
140        }
141    }
142}
143
144pub fn render_parsed_markdown(
145    parsed: &ParsedMarkdown,
146    workspace: Option<WeakEntity<Workspace>>,
147    window: &mut Window,
148    cx: &mut App,
149) -> Div {
150    let mut cx = RenderContext::new(workspace, window, cx);
151
152    v_flex().gap_3().children(
153        parsed
154            .children
155            .iter()
156            .map(|block| render_markdown_block(block, &mut cx)),
157    )
158}
159pub fn render_markdown_block(block: &ParsedMarkdownElement, cx: &mut RenderContext) -> AnyElement {
160    use ParsedMarkdownElement::*;
161    match block {
162        Paragraph(text) => render_markdown_paragraph(text, cx),
163        Heading(heading) => render_markdown_heading(heading, cx),
164        ListItem(list_item) => render_markdown_list_item(list_item, cx),
165        Table(table) => render_markdown_table(table, cx),
166        BlockQuote(block_quote) => render_markdown_block_quote(block_quote, cx),
167        CodeBlock(code_block) => render_markdown_code_block(code_block, cx),
168        HorizontalRule(_) => render_markdown_rule(cx),
169        Image(image) => render_markdown_image(image, cx),
170    }
171}
172
173fn render_markdown_heading(parsed: &ParsedMarkdownHeading, cx: &mut RenderContext) -> AnyElement {
174    let size = match parsed.level {
175        HeadingLevel::H1 => 2.,
176        HeadingLevel::H2 => 1.5,
177        HeadingLevel::H3 => 1.25,
178        HeadingLevel::H4 => 1.,
179        HeadingLevel::H5 => 0.875,
180        HeadingLevel::H6 => 0.85,
181    };
182
183    let text_size = cx.scaled_rems(size);
184
185    // was `DefiniteLength::from(text_size.mul(1.25))`
186    // let line_height = DefiniteLength::from(text_size.mul(1.25));
187    let line_height = text_size * 1.25;
188
189    // was `rems(0.15)`
190    // let padding_top = cx.scaled_rems(0.15);
191    let padding_top = rems(0.15);
192
193    // was `.pb_1()` = `rems(0.25)`
194    // let padding_bottom = cx.scaled_rems(0.25);
195    let padding_bottom = rems(0.25);
196
197    let color = match parsed.level {
198        HeadingLevel::H6 => cx.text_muted_color,
199        _ => cx.text_color,
200    };
201    div()
202        .line_height(line_height)
203        .text_size(text_size)
204        .text_color(color)
205        .pt(padding_top)
206        .pb(padding_bottom)
207        .children(render_markdown_text(&parsed.contents, cx))
208        .whitespace_normal()
209        .into_any()
210}
211
212fn render_markdown_list_item(
213    parsed: &ParsedMarkdownListItem,
214    cx: &mut RenderContext,
215) -> AnyElement {
216    use ParsedMarkdownListItemType::*;
217
218    let padding = cx.scaled_rems((parsed.depth - 1) as f32);
219
220    let bullet = match &parsed.item_type {
221        Ordered(order) => format!("{}.", order).into_any_element(),
222        Unordered => "".into_any_element(),
223        Task(checked, range) => div()
224            .id(cx.next_id(range))
225            .mt(cx.scaled_rems(3.0 / 16.0))
226            .child(
227                MarkdownCheckbox::new(
228                    "checkbox",
229                    if *checked {
230                        ToggleState::Selected
231                    } else {
232                        ToggleState::Unselected
233                    },
234                    cx.clone(),
235                )
236                .when_some(
237                    cx.checkbox_clicked_callback.clone(),
238                    |this, callback| {
239                        this.on_click({
240                            let range = range.clone();
241                            move |selection, window, cx| {
242                                let checked = match selection {
243                                    ToggleState::Selected => true,
244                                    ToggleState::Unselected => false,
245                                    _ => return,
246                                };
247
248                                if window.modifiers().secondary() {
249                                    callback(
250                                        &CheckboxClickedEvent {
251                                            checked,
252                                            source_range: range.clone(),
253                                        },
254                                        window,
255                                        cx,
256                                    );
257                                }
258                            }
259                        })
260                    },
261                ),
262            )
263            .hover(|s| s.cursor_pointer())
264            .tooltip(|_, cx| {
265                InteractiveMarkdownElementTooltip::new(None, "toggle checkbox", cx).into()
266            })
267            .into_any_element(),
268    };
269    let bullet = div().mr(cx.scaled_rems(0.5)).child(bullet);
270
271    let contents: Vec<AnyElement> = parsed
272        .content
273        .iter()
274        .map(|c| render_markdown_block(c, cx))
275        .collect();
276
277    let item = h_flex()
278        .pl(DefiniteLength::Absolute(AbsoluteLength::Rems(padding)))
279        .items_start()
280        .children(vec![
281            bullet,
282            v_flex()
283                .children(contents)
284                .gap(cx.scaled_rems(1.0))
285                .pr(cx.scaled_rems(1.0))
286                .w_full(),
287        ]);
288
289    cx.with_common_p(item).into_any()
290}
291
292/// # MarkdownCheckbox ///
293/// HACK: Copied from `ui/src/components/toggle.rs` to deal with scaling issues in markdown preview
294/// changes should be integrated into `Checkbox` in `toggle.rs` while making sure checkboxes elsewhere in the
295/// app are not visually affected
296#[derive(gpui::IntoElement)]
297struct MarkdownCheckbox {
298    id: ElementId,
299    toggle_state: ToggleState,
300    disabled: bool,
301    placeholder: bool,
302    on_click: Option<Box<dyn Fn(&ToggleState, &mut Window, &mut App) + 'static>>,
303    filled: bool,
304    style: ui::ToggleStyle,
305    tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> gpui::AnyView>>,
306    label: Option<SharedString>,
307    render_cx: RenderContext,
308}
309
310impl MarkdownCheckbox {
311    /// Creates a new [`Checkbox`].
312    fn new(id: impl Into<ElementId>, checked: ToggleState, render_cx: RenderContext) -> Self {
313        Self {
314            id: id.into(),
315            toggle_state: checked,
316            disabled: false,
317            on_click: None,
318            filled: false,
319            style: ui::ToggleStyle::default(),
320            tooltip: None,
321            label: None,
322            placeholder: false,
323            render_cx,
324        }
325    }
326
327    /// Binds a handler to the [`Checkbox`] that will be called when clicked.
328    fn on_click(mut self, handler: impl Fn(&ToggleState, &mut Window, &mut App) + 'static) -> Self {
329        self.on_click = Some(Box::new(handler));
330        self
331    }
332
333    fn bg_color(&self, cx: &App) -> Hsla {
334        let style = self.style.clone();
335        match (style, self.filled) {
336            (ui::ToggleStyle::Ghost, false) => cx.theme().colors().ghost_element_background,
337            (ui::ToggleStyle::Ghost, true) => cx.theme().colors().element_background,
338            (ui::ToggleStyle::ElevationBased(_), false) => gpui::transparent_black(),
339            (ui::ToggleStyle::ElevationBased(elevation), true) => elevation.darker_bg(cx),
340            (ui::ToggleStyle::Custom(_), false) => gpui::transparent_black(),
341            (ui::ToggleStyle::Custom(color), true) => color.opacity(0.2),
342        }
343    }
344
345    fn border_color(&self, cx: &App) -> Hsla {
346        if self.disabled {
347            return cx.theme().colors().border_variant;
348        }
349
350        match self.style.clone() {
351            ui::ToggleStyle::Ghost => cx.theme().colors().border,
352            ui::ToggleStyle::ElevationBased(_) => cx.theme().colors().border,
353            ui::ToggleStyle::Custom(color) => color.opacity(0.3),
354        }
355    }
356}
357
358impl gpui::RenderOnce for MarkdownCheckbox {
359    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
360        let group_id = format!("checkbox_group_{:?}", self.id);
361        let color = if self.disabled {
362            Color::Disabled
363        } else {
364            Color::Selected
365        };
366        let icon_size_small = IconSize::Custom(self.render_cx.scaled_rems(14. / 16.)); // was IconSize::Small
367        let icon = match self.toggle_state {
368            ToggleState::Selected => {
369                if self.placeholder {
370                    None
371                } else {
372                    Some(
373                        ui::Icon::new(IconName::Check)
374                            .size(icon_size_small)
375                            .color(color),
376                    )
377                }
378            }
379            ToggleState::Indeterminate => Some(
380                ui::Icon::new(IconName::Dash)
381                    .size(icon_size_small)
382                    .color(color),
383            ),
384            ToggleState::Unselected => None,
385        };
386
387        let bg_color = self.bg_color(cx);
388        let border_color = self.border_color(cx);
389        let hover_border_color = border_color.alpha(0.7);
390
391        let size = self.render_cx.scaled_rems(1.25); // was Self::container_size(); (20px)
392
393        let checkbox = h_flex()
394            .id(self.id.clone())
395            .justify_center()
396            .items_center()
397            .size(size)
398            .group(group_id.clone())
399            .child(
400                div()
401                    .flex()
402                    .flex_none()
403                    .justify_center()
404                    .items_center()
405                    .m(self.render_cx.scaled_rems(0.25)) // was .m_1
406                    .size(self.render_cx.scaled_rems(1.0)) // was .size_4
407                    .rounded(self.render_cx.scaled_rems(0.125)) // was .rounded_xs
408                    .border_1()
409                    .bg(bg_color)
410                    .border_color(border_color)
411                    .when(self.disabled, |this| this.cursor_not_allowed())
412                    .when(self.disabled, |this| {
413                        this.bg(cx.theme().colors().element_disabled.opacity(0.6))
414                    })
415                    .when(!self.disabled, |this| {
416                        this.group_hover(group_id.clone(), |el| el.border_color(hover_border_color))
417                    })
418                    .when(self.placeholder, |this| {
419                        this.child(
420                            div()
421                                .flex_none()
422                                .rounded_full()
423                                .bg(color.color(cx).alpha(0.5))
424                                .size(self.render_cx.scaled_rems(0.25)), // was .size_1
425                        )
426                    })
427                    .children(icon),
428            );
429
430        h_flex()
431            .id(self.id)
432            .gap(ui::DynamicSpacing::Base06.rems(cx))
433            .child(checkbox)
434            .when_some(
435                self.on_click.filter(|_| !self.disabled),
436                |this, on_click| {
437                    this.on_click(move |_, window, cx| {
438                        on_click(&self.toggle_state.inverse(), window, cx)
439                    })
440                },
441            )
442            // TODO: Allow label size to be different from default.
443            // TODO: Allow label color to be different from muted.
444            .when_some(self.label, |this, label| {
445                this.child(Label::new(label).color(Color::Muted))
446            })
447            .when_some(self.tooltip, |this, tooltip| {
448                this.tooltip(move |window, cx| tooltip(window, cx))
449            })
450    }
451}
452
453fn paragraph_len(paragraphs: &MarkdownParagraph) -> usize {
454    paragraphs
455        .iter()
456        .map(|paragraph| match paragraph {
457            MarkdownParagraphChunk::Text(text) => text.contents.len(),
458            // TODO: Scale column width based on image size
459            MarkdownParagraphChunk::Image(_) => 1,
460        })
461        .sum()
462}
463
464fn render_markdown_table(parsed: &ParsedMarkdownTable, cx: &mut RenderContext) -> AnyElement {
465    let mut max_lengths: Vec<usize> = vec![0; parsed.header.children.len()];
466
467    for (index, cell) in parsed.header.children.iter().enumerate() {
468        let length = paragraph_len(cell);
469        max_lengths[index] = length;
470    }
471
472    for row in &parsed.body {
473        for (index, cell) in row.children.iter().enumerate() {
474            let length = paragraph_len(cell);
475
476            if length > max_lengths[index] {
477                max_lengths[index] = length;
478            }
479        }
480    }
481
482    let total_max_length: usize = max_lengths.iter().sum();
483    let max_column_widths: Vec<f32> = max_lengths
484        .iter()
485        .map(|&length| length as f32 / total_max_length as f32)
486        .collect();
487
488    let header = render_markdown_table_row(
489        &parsed.header,
490        &parsed.column_alignments,
491        &max_column_widths,
492        true,
493        cx,
494    );
495
496    let body: Vec<AnyElement> = parsed
497        .body
498        .iter()
499        .map(|row| {
500            render_markdown_table_row(
501                row,
502                &parsed.column_alignments,
503                &max_column_widths,
504                false,
505                cx,
506            )
507        })
508        .collect();
509
510    cx.with_common_p(v_flex())
511        .w_full()
512        .child(header)
513        .children(body)
514        .into_any()
515}
516
517fn render_markdown_table_row(
518    parsed: &ParsedMarkdownTableRow,
519    alignments: &Vec<ParsedMarkdownTableAlignment>,
520    max_column_widths: &Vec<f32>,
521    is_header: bool,
522    cx: &mut RenderContext,
523) -> AnyElement {
524    let mut items = vec![];
525    let count = parsed.children.len();
526
527    for (index, cell) in parsed.children.iter().enumerate() {
528        let alignment = alignments
529            .get(index)
530            .copied()
531            .unwrap_or(ParsedMarkdownTableAlignment::None);
532
533        let contents = render_markdown_text(cell, cx);
534
535        let container = match alignment {
536            ParsedMarkdownTableAlignment::Left | ParsedMarkdownTableAlignment::None => div(),
537            ParsedMarkdownTableAlignment::Center => v_flex().items_center(),
538            ParsedMarkdownTableAlignment::Right => v_flex().items_end(),
539        };
540
541        let max_width = max_column_widths.get(index).unwrap_or(&0.0);
542        let mut cell = container
543            .w(Length::Definite(relative(*max_width)))
544            .h_full()
545            .children(contents)
546            .px_2()
547            .py_1()
548            .border_color(cx.border_color)
549            .border_l_1();
550
551        if count == index + 1 {
552            cell = cell.border_r_1();
553        }
554
555        if is_header {
556            cell = cell.bg(cx.element_background_color)
557        }
558
559        items.push(cell);
560    }
561
562    let mut row = h_flex().border_color(cx.border_color);
563
564    if is_header {
565        row = row.border_y_1();
566    } else {
567        row = row.border_b_1();
568    }
569
570    row.children(items).into_any_element()
571}
572
573fn render_markdown_block_quote(
574    parsed: &ParsedMarkdownBlockQuote,
575    cx: &mut RenderContext,
576) -> AnyElement {
577    cx.indent += 1;
578
579    let children: Vec<AnyElement> = parsed
580        .children
581        .iter()
582        .map(|child| render_markdown_block(child, cx))
583        .collect();
584
585    cx.indent -= 1;
586
587    cx.with_common_p(div())
588        .child(
589            div()
590                .border_l_4()
591                .border_color(cx.border_color)
592                .pl_3()
593                .children(children),
594        )
595        .into_any()
596}
597
598fn render_markdown_code_block(
599    parsed: &ParsedMarkdownCodeBlock,
600    cx: &mut RenderContext,
601) -> AnyElement {
602    let body = if let Some(highlights) = parsed.highlights.as_ref() {
603        StyledText::new(parsed.contents.clone()).with_default_highlights(
604            &cx.buffer_text_style,
605            highlights.iter().filter_map(|(range, highlight_id)| {
606                highlight_id
607                    .style(cx.syntax_theme.as_ref())
608                    .map(|style| (range.clone(), style))
609            }),
610        )
611    } else {
612        StyledText::new(parsed.contents.clone())
613    };
614
615    let copy_block_button = IconButton::new("copy-code", IconName::Copy)
616        .icon_size(IconSize::Small)
617        .on_click({
618            let contents = parsed.contents.clone();
619            move |_, _window, cx| {
620                cx.write_to_clipboard(ClipboardItem::new_string(contents.to_string()));
621            }
622        })
623        .tooltip(Tooltip::text("Copy code block"))
624        .visible_on_hover("markdown-block");
625
626    cx.with_common_p(div())
627        .font_family(cx.buffer_font_family.clone())
628        .px_3()
629        .py_3()
630        .bg(cx.code_block_background_color)
631        .rounded_sm()
632        .child(body)
633        .child(
634            div()
635                .h_flex()
636                .absolute()
637                .right_1()
638                .top_1()
639                .child(copy_block_button),
640        )
641        .into_any()
642}
643
644fn render_markdown_paragraph(parsed: &MarkdownParagraph, cx: &mut RenderContext) -> AnyElement {
645    cx.with_common_p(div())
646        .children(render_markdown_text(parsed, cx))
647        .flex()
648        .flex_col()
649        .into_any_element()
650}
651
652fn render_markdown_text(parsed_new: &MarkdownParagraph, cx: &mut RenderContext) -> Vec<AnyElement> {
653    let mut any_element = vec![];
654    // these values are cloned in-order satisfy borrow checker
655    let syntax_theme = cx.syntax_theme.clone();
656    let workspace_clone = cx.workspace.clone();
657    let code_span_bg_color = cx.code_span_background_color;
658    let text_style = cx.text_style.clone();
659
660    for parsed_region in parsed_new {
661        match parsed_region {
662            MarkdownParagraphChunk::Text(parsed) => {
663                let element_id = cx.next_id(&parsed.source_range);
664
665                let highlights = gpui::combine_highlights(
666                    parsed.highlights.iter().filter_map(|(range, highlight)| {
667                        highlight
668                            .to_highlight_style(&syntax_theme)
669                            .map(|style| (range.clone(), style))
670                    }),
671                    parsed.regions.iter().zip(&parsed.region_ranges).filter_map(
672                        |(region, range)| {
673                            if region.code {
674                                Some((
675                                    range.clone(),
676                                    HighlightStyle {
677                                        background_color: Some(code_span_bg_color),
678                                        ..Default::default()
679                                    },
680                                ))
681                            } else {
682                                None
683                            }
684                        },
685                    ),
686                );
687                let mut links = Vec::new();
688                let mut link_ranges = Vec::new();
689                for (range, region) in parsed.region_ranges.iter().zip(&parsed.regions) {
690                    if let Some(link) = region.link.clone() {
691                        links.push(link);
692                        link_ranges.push(range.clone());
693                    }
694                }
695                let workspace = workspace_clone.clone();
696                let element = div()
697                    .child(
698                        InteractiveText::new(
699                            element_id,
700                            StyledText::new(parsed.contents.clone())
701                                .with_default_highlights(&text_style, highlights),
702                        )
703                        .tooltip({
704                            let links = links.clone();
705                            let link_ranges = link_ranges.clone();
706                            move |idx, _, cx| {
707                                for (ix, range) in link_ranges.iter().enumerate() {
708                                    if range.contains(&idx) {
709                                        return Some(LinkPreview::new(&links[ix].to_string(), cx));
710                                    }
711                                }
712                                None
713                            }
714                        })
715                        .on_click(
716                            link_ranges,
717                            move |clicked_range_ix, window, cx| match &links[clicked_range_ix] {
718                                Link::Web { url } => cx.open_url(url),
719                                Link::Path { path, .. } => {
720                                    if let Some(workspace) = &workspace {
721                                        _ = workspace.update(cx, |workspace, cx| {
722                                            workspace
723                                                .open_abs_path(
724                                                    normalize_path(path.clone().as_path()),
725                                                    OpenOptions {
726                                                        visible: Some(OpenVisible::None),
727                                                        ..Default::default()
728                                                    },
729                                                    window,
730                                                    cx,
731                                                )
732                                                .detach();
733                                        });
734                                    }
735                                }
736                            },
737                        ),
738                    )
739                    .into_any();
740                any_element.push(element);
741            }
742
743            MarkdownParagraphChunk::Image(image) => {
744                any_element.push(render_markdown_image(image, cx));
745            }
746        }
747    }
748
749    any_element
750}
751
752fn render_markdown_rule(cx: &mut RenderContext) -> AnyElement {
753    let rule = div().w_full().h(cx.scaled_rems(0.125)).bg(cx.border_color);
754    div().py(cx.scaled_rems(0.5)).child(rule).into_any()
755}
756
757fn render_markdown_image(image: &Image, cx: &mut RenderContext) -> AnyElement {
758    let image_resource = match image.link.clone() {
759        Link::Web { url } => Resource::Uri(url.into()),
760        Link::Path { path, .. } => Resource::Path(Arc::from(path)),
761    };
762
763    let element_id = cx.next_id(&image.source_range);
764    let workspace = cx.workspace.clone();
765
766    div()
767        .id(element_id)
768        .cursor_pointer()
769        .child(
770            img(ImageSource::Resource(image_resource))
771                .max_w_full()
772                .with_fallback({
773                    let alt_text = image.alt_text.clone();
774                    move || div().children(alt_text.clone()).into_any_element()
775                })
776                .when_some(image.height, |this, height| this.h(height))
777                .when_some(image.width, |this, width| this.w(width)),
778        )
779        .tooltip({
780            let link = image.link.clone();
781            let alt_text = image.alt_text.clone();
782            move |_, cx| {
783                InteractiveMarkdownElementTooltip::new(
784                    Some(alt_text.clone().unwrap_or(link.to_string().into())),
785                    "open image",
786                    cx,
787                )
788                .into()
789            }
790        })
791        .on_click({
792            let link = image.link.clone();
793            move |_, window, cx| {
794                if window.modifiers().secondary() {
795                    match &link {
796                        Link::Web { url } => cx.open_url(url),
797                        Link::Path { path, .. } => {
798                            if let Some(workspace) = &workspace {
799                                _ = workspace.update(cx, |workspace, cx| {
800                                    workspace
801                                        .open_abs_path(
802                                            path.clone(),
803                                            OpenOptions {
804                                                visible: Some(OpenVisible::None),
805                                                ..Default::default()
806                                            },
807                                            window,
808                                            cx,
809                                        )
810                                        .detach();
811                                });
812                            }
813                        }
814                    }
815                }
816            }
817        })
818        .into_any()
819}
820
821struct InteractiveMarkdownElementTooltip {
822    tooltip_text: Option<SharedString>,
823    action_text: SharedString,
824}
825
826impl InteractiveMarkdownElementTooltip {
827    pub fn new(
828        tooltip_text: Option<SharedString>,
829        action_text: impl Into<SharedString>,
830        cx: &mut App,
831    ) -> Entity<Self> {
832        let tooltip_text = tooltip_text.map(|t| util::truncate_and_trailoff(&t, 50).into());
833
834        cx.new(|_cx| Self {
835            tooltip_text,
836            action_text: action_text.into(),
837        })
838    }
839}
840
841impl Render for InteractiveMarkdownElementTooltip {
842    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
843        tooltip_container(cx, |el, _| {
844            let secondary_modifier = Keystroke {
845                modifiers: Modifiers::secondary_key(),
846                ..Default::default()
847            };
848
849            el.child(
850                v_flex()
851                    .gap_1()
852                    .when_some(self.tooltip_text.clone(), |this, text| {
853                        this.child(Label::new(text).size(LabelSize::Small))
854                    })
855                    .child(
856                        Label::new(format!(
857                            "{}-click to {}",
858                            secondary_modifier, self.action_text
859                        ))
860                        .size(LabelSize::Small)
861                        .color(Color::Muted),
862                    ),
863            )
864        })
865    }
866}