1use crate::markdown_elements::{
2 HeadingLevel, Image, Link, MarkdownParagraph, MarkdownParagraphChunk, ParsedMarkdown,
3 ParsedMarkdownBlockQuote, ParsedMarkdownCodeBlock, ParsedMarkdownElement,
4 ParsedMarkdownHeading, ParsedMarkdownListItem, ParsedMarkdownListItemType, ParsedMarkdownTable,
5 ParsedMarkdownTableAlignment, ParsedMarkdownTableRow, ParsedMarkdownText,
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, Resource, SharedString, Styled, StyledText, TextStyle, WeakView,
11 WindowContext,
12};
13use settings::Settings;
14use std::{
15 ops::{Mul, Range},
16 path::Path,
17 sync::Arc,
18 vec,
19};
20use theme::{ActiveTheme, SyntaxTheme, ThemeSettings};
21use ui::{
22 h_flex, relative, v_flex, Checkbox, Clickable, FluentBuilder, IconButton, IconName, IconSize,
23 InteractiveElement, LinkPreview, Selection, StatefulInteractiveElement, StyledExt, StyledImage,
24 Tooltip, VisibleOnHover,
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) -> Vec<AnyElement> {
114 let mut cx = RenderContext::new(workspace, cx);
115 let mut elements = Vec::new();
116
117 for child in &parsed.children {
118 elements.push(render_markdown_block(child, &mut cx));
119 }
120
121 elements
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 Selection::Selected
184 } else {
185 Selection::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 Selection::Selected => true,
196 Selection::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 let secondary_modifier = Keystroke {
211 key: "".to_string(),
212 modifiers: Modifiers::secondary_key(),
213 key_char: None,
214 };
215 Tooltip::text(
216 format!("{}-click to toggle the checkbox", secondary_modifier),
217 cx,
218 )
219 })
220 .into_any_element(),
221 };
222 let bullet = div().mr_2().child(bullet);
223
224 let contents: Vec<AnyElement> = parsed
225 .content
226 .iter()
227 .map(|c| render_markdown_block(c, cx))
228 .collect();
229
230 let item = h_flex()
231 .pl(DefiniteLength::Absolute(AbsoluteLength::Rems(padding)))
232 .items_start()
233 .children(vec![bullet, div().children(contents).pr_4().w_full()]);
234
235 cx.with_common_p(item).into_any()
236}
237
238fn paragraph_len(paragraphs: &MarkdownParagraph) -> usize {
239 paragraphs
240 .iter()
241 .map(|paragraph| match paragraph {
242 MarkdownParagraphChunk::Text(text) => text.contents.len(),
243 // TODO: Scale column width based on image size
244 MarkdownParagraphChunk::Image(_) => 1,
245 })
246 .sum()
247}
248
249fn render_markdown_table(parsed: &ParsedMarkdownTable, cx: &mut RenderContext) -> AnyElement {
250 let mut max_lengths: Vec<usize> = vec![0; parsed.header.children.len()];
251
252 for (index, cell) in parsed.header.children.iter().enumerate() {
253 let length = paragraph_len(&cell);
254 max_lengths[index] = length;
255 }
256
257 for row in &parsed.body {
258 for (index, cell) in row.children.iter().enumerate() {
259 let length = paragraph_len(&cell);
260
261 if length > max_lengths[index] {
262 max_lengths[index] = length;
263 }
264 }
265 }
266
267 let total_max_length: usize = max_lengths.iter().sum();
268 let max_column_widths: Vec<f32> = max_lengths
269 .iter()
270 .map(|&length| length as f32 / total_max_length as f32)
271 .collect();
272
273 let header = render_markdown_table_row(
274 &parsed.header,
275 &parsed.column_alignments,
276 &max_column_widths,
277 true,
278 cx,
279 );
280
281 let body: Vec<AnyElement> = parsed
282 .body
283 .iter()
284 .map(|row| {
285 render_markdown_table_row(
286 row,
287 &parsed.column_alignments,
288 &max_column_widths,
289 false,
290 cx,
291 )
292 })
293 .collect();
294
295 cx.with_common_p(v_flex())
296 .w_full()
297 .child(header)
298 .children(body)
299 .into_any()
300}
301
302fn render_markdown_table_row(
303 parsed: &ParsedMarkdownTableRow,
304 alignments: &Vec<ParsedMarkdownTableAlignment>,
305 max_column_widths: &Vec<f32>,
306 is_header: bool,
307 cx: &mut RenderContext,
308) -> AnyElement {
309 let mut items = vec![];
310
311 for (index, cell) in parsed.children.iter().enumerate() {
312 let alignment = alignments
313 .get(index)
314 .copied()
315 .unwrap_or(ParsedMarkdownTableAlignment::None);
316
317 let contents = render_markdown_text(cell, cx);
318
319 let container = match alignment {
320 ParsedMarkdownTableAlignment::Left | ParsedMarkdownTableAlignment::None => div(),
321 ParsedMarkdownTableAlignment::Center => v_flex().items_center(),
322 ParsedMarkdownTableAlignment::Right => v_flex().items_end(),
323 };
324
325 let max_width = max_column_widths.get(index).unwrap_or(&0.0);
326 let mut cell = container
327 .w(Length::Definite(relative(*max_width)))
328 .h_full()
329 .children(contents)
330 .px_2()
331 .py_1()
332 .border_color(cx.border_color);
333
334 if is_header {
335 cell = cell.border_2()
336 } else {
337 cell = cell.border_1()
338 }
339
340 items.push(cell);
341 }
342
343 h_flex().children(items).into_any_element()
344}
345
346fn render_markdown_block_quote(
347 parsed: &ParsedMarkdownBlockQuote,
348 cx: &mut RenderContext,
349) -> AnyElement {
350 cx.indent += 1;
351
352 let children: Vec<AnyElement> = parsed
353 .children
354 .iter()
355 .map(|child| render_markdown_block(child, cx))
356 .collect();
357
358 cx.indent -= 1;
359
360 cx.with_common_p(div())
361 .child(
362 div()
363 .border_l_4()
364 .border_color(cx.border_color)
365 .pl_3()
366 .children(children),
367 )
368 .into_any()
369}
370
371fn render_markdown_code_block(
372 parsed: &ParsedMarkdownCodeBlock,
373 cx: &mut RenderContext,
374) -> AnyElement {
375 let body = if let Some(highlights) = parsed.highlights.as_ref() {
376 StyledText::new(parsed.contents.clone()).with_highlights(
377 &cx.buffer_text_style,
378 highlights.iter().filter_map(|(range, highlight_id)| {
379 highlight_id
380 .style(cx.syntax_theme.as_ref())
381 .map(|style| (range.clone(), style))
382 }),
383 )
384 } else {
385 StyledText::new(parsed.contents.clone())
386 };
387
388 let copy_block_button = IconButton::new("copy-code", IconName::Copy)
389 .icon_size(IconSize::Small)
390 .on_click({
391 let contents = parsed.contents.clone();
392 move |_, cx| {
393 cx.write_to_clipboard(ClipboardItem::new_string(contents.to_string()));
394 }
395 })
396 .visible_on_hover("markdown-block");
397
398 cx.with_common_p(div())
399 .font_family(cx.buffer_font_family.clone())
400 .px_3()
401 .py_3()
402 .bg(cx.code_block_background_color)
403 .rounded_md()
404 .child(body)
405 .child(
406 div()
407 .h_flex()
408 .absolute()
409 .right_1()
410 .top_1()
411 .child(copy_block_button),
412 )
413 .into_any()
414}
415
416fn render_markdown_paragraph(parsed: &MarkdownParagraph, cx: &mut RenderContext) -> AnyElement {
417 cx.with_common_p(div())
418 .children(render_markdown_text(parsed, cx))
419 .flex()
420 .flex_col()
421 .into_any_element()
422}
423
424fn render_markdown_text(parsed_new: &MarkdownParagraph, cx: &mut RenderContext) -> Vec<AnyElement> {
425 let mut any_element = vec![];
426 // these values are cloned in-order satisfy borrow checker
427 let syntax_theme = cx.syntax_theme.clone();
428 let workspace_clone = cx.workspace.clone();
429 let code_span_bg_color = cx.code_span_background_color;
430 let text_style = cx.text_style.clone();
431
432 for parsed_region in parsed_new {
433 match parsed_region {
434 MarkdownParagraphChunk::Text(parsed) => {
435 let element_id = cx.next_id(&parsed.source_range);
436
437 let highlights = gpui::combine_highlights(
438 parsed.highlights.iter().filter_map(|(range, highlight)| {
439 highlight
440 .to_highlight_style(&syntax_theme)
441 .map(|style| (range.clone(), style))
442 }),
443 parsed.regions.iter().zip(&parsed.region_ranges).filter_map(
444 |(region, range)| {
445 if region.code {
446 Some((
447 range.clone(),
448 HighlightStyle {
449 background_color: Some(code_span_bg_color),
450 ..Default::default()
451 },
452 ))
453 } else {
454 None
455 }
456 },
457 ),
458 );
459 let mut links = Vec::new();
460 let mut link_ranges = Vec::new();
461 for (range, region) in parsed.region_ranges.iter().zip(&parsed.regions) {
462 if let Some(link) = region.link.clone() {
463 links.push(link);
464 link_ranges.push(range.clone());
465 }
466 }
467 let workspace = workspace_clone.clone();
468 let element = div()
469 .child(
470 InteractiveText::new(
471 element_id,
472 StyledText::new(parsed.contents.clone())
473 .with_highlights(&text_style, highlights),
474 )
475 .tooltip({
476 let links = links.clone();
477 let link_ranges = link_ranges.clone();
478 move |idx, cx| {
479 for (ix, range) in link_ranges.iter().enumerate() {
480 if range.contains(&idx) {
481 return Some(LinkPreview::new(&links[ix].to_string(), cx));
482 }
483 }
484 None
485 }
486 })
487 .on_click(
488 link_ranges,
489 move |clicked_range_ix, window_cx| match &links[clicked_range_ix] {
490 Link::Web { url } => window_cx.open_url(url),
491 Link::Path { path, .. } => {
492 if let Some(workspace) = &workspace {
493 _ = workspace.update(window_cx, |workspace, cx| {
494 workspace
495 .open_abs_path(path.clone(), false, cx)
496 .detach();
497 });
498 }
499 }
500 },
501 ),
502 )
503 .into_any();
504 any_element.push(element);
505 }
506
507 MarkdownParagraphChunk::Image(image) => {
508 let (link, source_range, image_source, alt_text) = match image {
509 Image::Web {
510 link,
511 source_range,
512 url,
513 alt_text,
514 } => (
515 link,
516 source_range,
517 Resource::Uri(url.clone().into()),
518 alt_text,
519 ),
520 Image::Path {
521 link,
522 source_range,
523 path,
524 alt_text,
525 ..
526 } => {
527 let image_path = Path::new(path.to_str().unwrap());
528 (
529 link,
530 source_range,
531 Resource::Path(Arc::from(image_path)),
532 alt_text,
533 )
534 }
535 };
536
537 let element_id = cx.next_id(source_range);
538
539 match link {
540 None => {
541 let fallback_workspace = workspace_clone.clone();
542 let fallback_syntax_theme = syntax_theme.clone();
543 let fallback_text_style = text_style.clone();
544 let fallback_alt_text = alt_text.clone();
545 let element_id_new = element_id.clone();
546 let element = div()
547 .child(img(ImageSource::Resource(image_source)).with_fallback({
548 move || {
549 fallback_text(
550 fallback_alt_text.clone().unwrap(),
551 element_id.clone(),
552 &fallback_syntax_theme,
553 code_span_bg_color,
554 fallback_workspace.clone(),
555 &fallback_text_style,
556 )
557 }
558 }))
559 .id(element_id_new)
560 .into_any();
561 any_element.push(element);
562 }
563 Some(link) => {
564 let link_click = link.clone();
565 let link_tooltip = link.clone();
566 let fallback_workspace = workspace_clone.clone();
567 let fallback_syntax_theme = syntax_theme.clone();
568 let fallback_text_style = text_style.clone();
569 let fallback_alt_text = alt_text.clone();
570 let element_id_new = element_id.clone();
571 let image_element = div()
572 .child(img(ImageSource::Resource(image_source)).with_fallback({
573 move || {
574 fallback_text(
575 fallback_alt_text.clone().unwrap(),
576 element_id.clone(),
577 &fallback_syntax_theme,
578 code_span_bg_color,
579 fallback_workspace.clone(),
580 &fallback_text_style,
581 )
582 }
583 }))
584 .id(element_id_new)
585 .tooltip(move |cx| LinkPreview::new(&link_tooltip.to_string(), cx))
586 .on_click({
587 let workspace = workspace_clone.clone();
588 move |_event, window_cx| match &link_click {
589 Link::Web { url } => window_cx.open_url(url),
590 Link::Path { path, .. } => {
591 if let Some(workspace) = &workspace {
592 _ = workspace.update(window_cx, |workspace, cx| {
593 workspace
594 .open_abs_path(path.clone(), false, cx)
595 .detach();
596 });
597 }
598 }
599 }
600 })
601 .into_any();
602 any_element.push(image_element);
603 }
604 }
605 }
606 }
607 }
608
609 any_element
610}
611
612fn render_markdown_rule(cx: &mut RenderContext) -> AnyElement {
613 let rule = div().w_full().h(px(2.)).bg(cx.border_color);
614 div().pt_3().pb_3().child(rule).into_any()
615}
616
617fn fallback_text(
618 parsed: ParsedMarkdownText,
619 source_range: ElementId,
620 syntax_theme: &theme::SyntaxTheme,
621 code_span_bg_color: Hsla,
622 workspace: Option<WeakView<Workspace>>,
623 text_style: &TextStyle,
624) -> AnyElement {
625 let element_id = source_range;
626
627 let highlights = gpui::combine_highlights(
628 parsed.highlights.iter().filter_map(|(range, highlight)| {
629 let highlight = highlight.to_highlight_style(syntax_theme)?;
630 Some((range.clone(), highlight))
631 }),
632 parsed
633 .regions
634 .iter()
635 .zip(&parsed.region_ranges)
636 .filter_map(|(region, range)| {
637 if region.code {
638 Some((
639 range.clone(),
640 HighlightStyle {
641 background_color: Some(code_span_bg_color),
642 ..Default::default()
643 },
644 ))
645 } else {
646 None
647 }
648 }),
649 );
650 let mut links = Vec::new();
651 let mut link_ranges = Vec::new();
652 for (range, region) in parsed.region_ranges.iter().zip(&parsed.regions) {
653 if let Some(link) = region.link.clone() {
654 links.push(link);
655 link_ranges.push(range.clone());
656 }
657 }
658 let element = div()
659 .child(
660 InteractiveText::new(
661 element_id,
662 StyledText::new(parsed.contents.clone()).with_highlights(text_style, highlights),
663 )
664 .tooltip({
665 let links = links.clone();
666 let link_ranges = link_ranges.clone();
667 move |idx, cx| {
668 for (ix, range) in link_ranges.iter().enumerate() {
669 if range.contains(&idx) {
670 return Some(LinkPreview::new(&links[ix].to_string(), cx));
671 }
672 }
673 None
674 }
675 })
676 .on_click(
677 link_ranges,
678 move |clicked_range_ix, window_cx| match &links[clicked_range_ix] {
679 Link::Web { url } => window_cx.open_url(url),
680 Link::Path { path, .. } => {
681 if let Some(workspace) = &workspace {
682 _ = workspace.update(window_cx, |workspace, cx| {
683 workspace.open_abs_path(path.clone(), false, cx).detach();
684 });
685 }
686 }
687 },
688 ),
689 )
690 .into_any();
691 return element;
692}