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