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