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