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 AbsoluteLength, AnyElement, App, AppContext as _, ClipboardItem, Context, DefiniteLength, Div,
9 Element, ElementId, Entity, HighlightStyle, Hsla, ImageSource, InteractiveText, IntoElement,
10 Keystroke, Length, Modifiers, ParentElement, Render, Resource, SharedString, Styled,
11 StyledText, TextStyle, WeakEntity, Window, div, img, px, rems,
12};
13use settings::Settings;
14use std::{
15 ops::{Mul, Range},
16 sync::Arc,
17 vec,
18};
19use theme::{ActiveTheme, SyntaxTheme, ThemeSettings};
20use ui::{
21 ButtonCommon, Checkbox, Clickable, Color, FluentBuilder, IconButton, IconName, IconSize,
22 InteractiveElement, Label, LabelCommon, LabelSize, LinkPreview, StatefulInteractiveElement,
23 StyledExt, StyledImage, ToggleState, Tooltip, VisibleOnHover, h_flex, relative,
24 tooltip_container, v_flex,
25};
26use workspace::{OpenOptions, OpenVisible, Workspace};
27
28type CheckboxClickedCallback = Arc<Box<dyn Fn(bool, Range<usize>, &mut Window, &mut App)>>;
29
30#[derive(Clone)]
31pub struct RenderContext {
32 workspace: Option<WeakEntity<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(
49 workspace: Option<WeakEntity<Workspace>>,
50 window: &mut Window,
51 cx: &mut App,
52 ) -> RenderContext {
53 let theme = cx.theme().clone();
54
55 let settings = ThemeSettings::get_global(cx);
56 let buffer_font_family = settings.buffer_font.family.clone();
57 let mut buffer_text_style = window.text_style();
58 buffer_text_style.font_family = buffer_font_family.clone();
59
60 RenderContext {
61 workspace,
62 next_id: 0,
63 indent: 0,
64 buffer_font_family,
65 buffer_text_style,
66 text_style: window.text_style(),
67 syntax_theme: theme.syntax().clone(),
68 border_color: theme.colors().border,
69 text_color: theme.colors().text,
70 text_muted_color: theme.colors().text_muted,
71 code_block_background_color: theme.colors().surface_background,
72 code_span_background_color: theme.colors().editor_document_highlight_read_background,
73 checkbox_clicked_callback: None,
74 }
75 }
76
77 pub fn with_checkbox_clicked_callback(
78 mut self,
79 callback: impl Fn(bool, Range<usize>, &mut Window, &mut App) + 'static,
80 ) -> Self {
81 self.checkbox_clicked_callback = Some(Arc::new(Box::new(callback)));
82 self
83 }
84
85 fn next_id(&mut self, span: &Range<usize>) -> ElementId {
86 let id = format!("markdown-{}-{}-{}", self.next_id, span.start, span.end);
87 self.next_id += 1;
88 ElementId::from(SharedString::from(id))
89 }
90
91 /// This ensures that children inside of block quotes
92 /// have padding between them.
93 ///
94 /// For example, for this markdown:
95 ///
96 /// ```markdown
97 /// > This is a block quote.
98 /// >
99 /// > And this is the next paragraph.
100 /// ```
101 ///
102 /// We give padding between "This is a block quote."
103 /// and "And this is the next paragraph."
104 fn with_common_p(&self, element: Div) -> Div {
105 if self.indent > 0 {
106 element.pb_3()
107 } else {
108 element
109 }
110 }
111}
112
113pub fn render_parsed_markdown(
114 parsed: &ParsedMarkdown,
115 workspace: Option<WeakEntity<Workspace>>,
116 window: &mut Window,
117 cx: &mut App,
118) -> Div {
119 let mut cx = RenderContext::new(workspace, window, cx);
120
121 v_flex().gap_3().children(
122 parsed
123 .children
124 .iter()
125 .map(|block| render_markdown_block(block, &mut cx)),
126 )
127}
128
129pub fn render_markdown_block(block: &ParsedMarkdownElement, cx: &mut RenderContext) -> AnyElement {
130 use ParsedMarkdownElement::*;
131 match block {
132 Paragraph(text) => render_markdown_paragraph(text, cx),
133 Heading(heading) => render_markdown_heading(heading, cx),
134 ListItem(list_item) => render_markdown_list_item(list_item, cx),
135 Table(table) => render_markdown_table(table, cx),
136 BlockQuote(block_quote) => render_markdown_block_quote(block_quote, cx),
137 CodeBlock(code_block) => render_markdown_code_block(code_block, cx),
138 HorizontalRule(_) => render_markdown_rule(cx),
139 }
140}
141
142fn render_markdown_heading(parsed: &ParsedMarkdownHeading, cx: &mut RenderContext) -> AnyElement {
143 let size = match parsed.level {
144 HeadingLevel::H1 => rems(2.),
145 HeadingLevel::H2 => rems(1.5),
146 HeadingLevel::H3 => rems(1.25),
147 HeadingLevel::H4 => rems(1.),
148 HeadingLevel::H5 => rems(0.875),
149 HeadingLevel::H6 => rems(0.85),
150 };
151
152 let color = match parsed.level {
153 HeadingLevel::H6 => cx.text_muted_color,
154 _ => cx.text_color,
155 };
156
157 let line_height = DefiniteLength::from(size.mul(1.25));
158
159 div()
160 .line_height(line_height)
161 .text_size(size)
162 .text_color(color)
163 .pt(rems(0.15))
164 .pb_1()
165 .children(render_markdown_text(&parsed.contents, cx))
166 .whitespace_normal()
167 .into_any()
168}
169
170fn render_markdown_list_item(
171 parsed: &ParsedMarkdownListItem,
172 cx: &mut RenderContext,
173) -> AnyElement {
174 use ParsedMarkdownListItemType::*;
175
176 let padding = rems((parsed.depth - 1) as f32);
177
178 let bullet = match &parsed.item_type {
179 Ordered(order) => format!("{}.", order).into_any_element(),
180 Unordered => "•".into_any_element(),
181 Task(checked, range) => div()
182 .id(cx.next_id(range))
183 .mt(px(3.))
184 .child(
185 Checkbox::new(
186 "checkbox",
187 if *checked {
188 ToggleState::Selected
189 } else {
190 ToggleState::Unselected
191 },
192 )
193 .when_some(
194 cx.checkbox_clicked_callback.clone(),
195 |this, callback| {
196 this.on_click({
197 let range = range.clone();
198 move |selection, window, cx| {
199 let checked = match selection {
200 ToggleState::Selected => true,
201 ToggleState::Unselected => false,
202 _ => return,
203 };
204
205 if window.modifiers().secondary() {
206 callback(checked, range.clone(), window, cx);
207 }
208 }
209 })
210 },
211 ),
212 )
213 .hover(|s| s.cursor_pointer())
214 .tooltip(|_, cx| {
215 InteractiveMarkdownElementTooltip::new(None, "toggle checkbox", cx).into()
216 })
217 .into_any_element(),
218 };
219 let bullet = div().mr_2().child(bullet);
220
221 let contents: Vec<AnyElement> = parsed
222 .content
223 .iter()
224 .map(|c| render_markdown_block(c, cx))
225 .collect();
226
227 let item = h_flex()
228 .pl(DefiniteLength::Absolute(AbsoluteLength::Rems(padding)))
229 .items_start()
230 .children(vec![bullet, div().children(contents).pr_4().w_full()]);
231
232 cx.with_common_p(item).into_any()
233}
234
235fn paragraph_len(paragraphs: &MarkdownParagraph) -> usize {
236 paragraphs
237 .iter()
238 .map(|paragraph| match paragraph {
239 MarkdownParagraphChunk::Text(text) => text.contents.len(),
240 // TODO: Scale column width based on image size
241 MarkdownParagraphChunk::Image(_) => 1,
242 })
243 .sum()
244}
245
246fn render_markdown_table(parsed: &ParsedMarkdownTable, cx: &mut RenderContext) -> AnyElement {
247 let mut max_lengths: Vec<usize> = vec![0; parsed.header.children.len()];
248
249 for (index, cell) in parsed.header.children.iter().enumerate() {
250 let length = paragraph_len(&cell);
251 max_lengths[index] = length;
252 }
253
254 for row in &parsed.body {
255 for (index, cell) in row.children.iter().enumerate() {
256 let length = paragraph_len(&cell);
257
258 if length > max_lengths[index] {
259 max_lengths[index] = length;
260 }
261 }
262 }
263
264 let total_max_length: usize = max_lengths.iter().sum();
265 let max_column_widths: Vec<f32> = max_lengths
266 .iter()
267 .map(|&length| length as f32 / total_max_length as f32)
268 .collect();
269
270 let header = render_markdown_table_row(
271 &parsed.header,
272 &parsed.column_alignments,
273 &max_column_widths,
274 true,
275 cx,
276 );
277
278 let body: Vec<AnyElement> = parsed
279 .body
280 .iter()
281 .map(|row| {
282 render_markdown_table_row(
283 row,
284 &parsed.column_alignments,
285 &max_column_widths,
286 false,
287 cx,
288 )
289 })
290 .collect();
291
292 cx.with_common_p(v_flex())
293 .w_full()
294 .child(header)
295 .children(body)
296 .into_any()
297}
298
299fn render_markdown_table_row(
300 parsed: &ParsedMarkdownTableRow,
301 alignments: &Vec<ParsedMarkdownTableAlignment>,
302 max_column_widths: &Vec<f32>,
303 is_header: bool,
304 cx: &mut RenderContext,
305) -> AnyElement {
306 let mut items = vec![];
307
308 for (index, cell) in parsed.children.iter().enumerate() {
309 let alignment = alignments
310 .get(index)
311 .copied()
312 .unwrap_or(ParsedMarkdownTableAlignment::None);
313
314 let contents = render_markdown_text(cell, cx);
315
316 let container = match alignment {
317 ParsedMarkdownTableAlignment::Left | ParsedMarkdownTableAlignment::None => div(),
318 ParsedMarkdownTableAlignment::Center => v_flex().items_center(),
319 ParsedMarkdownTableAlignment::Right => v_flex().items_end(),
320 };
321
322 let max_width = max_column_widths.get(index).unwrap_or(&0.0);
323 let mut cell = container
324 .w(Length::Definite(relative(*max_width)))
325 .h_full()
326 .children(contents)
327 .px_2()
328 .py_1()
329 .border_color(cx.border_color);
330
331 if is_header {
332 cell = cell.border_2()
333 } else {
334 cell = cell.border_1()
335 }
336
337 items.push(cell);
338 }
339
340 h_flex().children(items).into_any_element()
341}
342
343fn render_markdown_block_quote(
344 parsed: &ParsedMarkdownBlockQuote,
345 cx: &mut RenderContext,
346) -> AnyElement {
347 cx.indent += 1;
348
349 let children: Vec<AnyElement> = parsed
350 .children
351 .iter()
352 .map(|child| render_markdown_block(child, cx))
353 .collect();
354
355 cx.indent -= 1;
356
357 cx.with_common_p(div())
358 .child(
359 div()
360 .border_l_4()
361 .border_color(cx.border_color)
362 .pl_3()
363 .children(children),
364 )
365 .into_any()
366}
367
368fn render_markdown_code_block(
369 parsed: &ParsedMarkdownCodeBlock,
370 cx: &mut RenderContext,
371) -> AnyElement {
372 let body = if let Some(highlights) = parsed.highlights.as_ref() {
373 StyledText::new(parsed.contents.clone()).with_default_highlights(
374 &cx.buffer_text_style,
375 highlights.iter().filter_map(|(range, highlight_id)| {
376 highlight_id
377 .style(cx.syntax_theme.as_ref())
378 .map(|style| (range.clone(), style))
379 }),
380 )
381 } else {
382 StyledText::new(parsed.contents.clone())
383 };
384
385 let copy_block_button = IconButton::new("copy-code", IconName::Copy)
386 .icon_size(IconSize::Small)
387 .on_click({
388 let contents = parsed.contents.clone();
389 move |_, _window, cx| {
390 cx.write_to_clipboard(ClipboardItem::new_string(contents.to_string()));
391 }
392 })
393 .tooltip(Tooltip::text("Copy code block"))
394 .visible_on_hover("markdown-block");
395
396 cx.with_common_p(div())
397 .font_family(cx.buffer_font_family.clone())
398 .px_3()
399 .py_3()
400 .bg(cx.code_block_background_color)
401 .rounded_sm()
402 .child(body)
403 .child(
404 div()
405 .h_flex()
406 .absolute()
407 .right_1()
408 .top_1()
409 .child(copy_block_button),
410 )
411 .into_any()
412}
413
414fn render_markdown_paragraph(parsed: &MarkdownParagraph, cx: &mut RenderContext) -> AnyElement {
415 cx.with_common_p(div())
416 .children(render_markdown_text(parsed, cx))
417 .flex()
418 .flex_col()
419 .into_any_element()
420}
421
422fn render_markdown_text(parsed_new: &MarkdownParagraph, cx: &mut RenderContext) -> Vec<AnyElement> {
423 let mut any_element = vec![];
424 // these values are cloned in-order satisfy borrow checker
425 let syntax_theme = cx.syntax_theme.clone();
426 let workspace_clone = cx.workspace.clone();
427 let code_span_bg_color = cx.code_span_background_color;
428 let text_style = cx.text_style.clone();
429
430 for parsed_region in parsed_new {
431 match parsed_region {
432 MarkdownParagraphChunk::Text(parsed) => {
433 let element_id = cx.next_id(&parsed.source_range);
434
435 let highlights = gpui::combine_highlights(
436 parsed.highlights.iter().filter_map(|(range, highlight)| {
437 highlight
438 .to_highlight_style(&syntax_theme)
439 .map(|style| (range.clone(), style))
440 }),
441 parsed.regions.iter().zip(&parsed.region_ranges).filter_map(
442 |(region, range)| {
443 if region.code {
444 Some((
445 range.clone(),
446 HighlightStyle {
447 background_color: Some(code_span_bg_color),
448 ..Default::default()
449 },
450 ))
451 } else {
452 None
453 }
454 },
455 ),
456 );
457 let mut links = Vec::new();
458 let mut link_ranges = Vec::new();
459 for (range, region) in parsed.region_ranges.iter().zip(&parsed.regions) {
460 if let Some(link) = region.link.clone() {
461 links.push(link);
462 link_ranges.push(range.clone());
463 }
464 }
465 let workspace = workspace_clone.clone();
466 let element = div()
467 .child(
468 InteractiveText::new(
469 element_id,
470 StyledText::new(parsed.contents.clone())
471 .with_default_highlights(&text_style, highlights),
472 )
473 .tooltip({
474 let links = links.clone();
475 let link_ranges = link_ranges.clone();
476 move |idx, _, cx| {
477 for (ix, range) in link_ranges.iter().enumerate() {
478 if range.contains(&idx) {
479 return Some(LinkPreview::new(&links[ix].to_string(), cx));
480 }
481 }
482 None
483 }
484 })
485 .on_click(
486 link_ranges,
487 move |clicked_range_ix, window, cx| match &links[clicked_range_ix] {
488 Link::Web { url } => cx.open_url(url),
489 Link::Path { path, .. } => {
490 if let Some(workspace) = &workspace {
491 _ = workspace.update(cx, |workspace, cx| {
492 workspace
493 .open_abs_path(
494 path.clone(),
495 OpenOptions {
496 visible: Some(OpenVisible::None),
497 ..Default::default()
498 },
499 window,
500 cx,
501 )
502 .detach();
503 });
504 }
505 }
506 },
507 ),
508 )
509 .into_any();
510 any_element.push(element);
511 }
512
513 MarkdownParagraphChunk::Image(image) => {
514 let image_resource = match image.link.clone() {
515 Link::Web { url } => Resource::Uri(url.into()),
516 Link::Path { path, .. } => Resource::Path(Arc::from(path)),
517 };
518
519 let element_id = cx.next_id(&image.source_range);
520
521 let image_element = div()
522 .id(element_id)
523 .cursor_pointer()
524 .child(
525 img(ImageSource::Resource(image_resource))
526 .max_w_full()
527 .with_fallback({
528 let alt_text = image.alt_text.clone();
529 move || div().children(alt_text.clone()).into_any_element()
530 }),
531 )
532 .tooltip({
533 let link = image.link.clone();
534 move |_, cx| {
535 InteractiveMarkdownElementTooltip::new(
536 Some(link.to_string()),
537 "open image",
538 cx,
539 )
540 .into()
541 }
542 })
543 .on_click({
544 let workspace = workspace_clone.clone();
545 let link = image.link.clone();
546 move |_, window, cx| {
547 if window.modifiers().secondary() {
548 match &link {
549 Link::Web { url } => cx.open_url(url),
550 Link::Path { path, .. } => {
551 if let Some(workspace) = &workspace {
552 _ = workspace.update(cx, |workspace, cx| {
553 workspace
554 .open_abs_path(
555 path.clone(),
556 OpenOptions {
557 visible: Some(OpenVisible::None),
558 ..Default::default()
559 },
560 window,
561 cx,
562 )
563 .detach();
564 });
565 }
566 }
567 }
568 }
569 }
570 })
571 .into_any();
572 any_element.push(image_element);
573 }
574 }
575 }
576
577 any_element
578}
579
580fn render_markdown_rule(cx: &mut RenderContext) -> AnyElement {
581 let rule = div().w_full().h(px(2.)).bg(cx.border_color);
582 div().pt_3().pb_3().child(rule).into_any()
583}
584
585struct InteractiveMarkdownElementTooltip {
586 tooltip_text: Option<SharedString>,
587 action_text: String,
588}
589
590impl InteractiveMarkdownElementTooltip {
591 pub fn new(tooltip_text: Option<String>, action_text: &str, cx: &mut App) -> Entity<Self> {
592 let tooltip_text = tooltip_text.map(|t| util::truncate_and_trailoff(&t, 50).into());
593
594 cx.new(|_cx| Self {
595 tooltip_text,
596 action_text: action_text.to_string(),
597 })
598 }
599}
600
601impl Render for InteractiveMarkdownElementTooltip {
602 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
603 tooltip_container(window, cx, |el, _, _| {
604 let secondary_modifier = Keystroke {
605 modifiers: Modifiers::secondary_key(),
606 ..Default::default()
607 };
608
609 el.child(
610 v_flex()
611 .gap_1()
612 .when_some(self.tooltip_text.clone(), |this, text| {
613 this.child(Label::new(text).size(LabelSize::Small))
614 })
615 .child(
616 Label::new(format!(
617 "{}-click to {}",
618 secondary_modifier, self.action_text
619 ))
620 .size(LabelSize::Small)
621 .color(Color::Muted),
622 ),
623 )
624 })
625 }
626}