markdown_renderer.rs

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