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