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