1use crate::markdown_elements::{
2 HeadingLevel, Image, Link, MarkdownParagraph, MarkdownParagraphChunk, ParsedMarkdown,
3 ParsedMarkdownBlockQuote, ParsedMarkdownCodeBlock, ParsedMarkdownElement,
4 ParsedMarkdownHeading, ParsedMarkdownListItem, ParsedMarkdownListItemType, ParsedMarkdownTable,
5 ParsedMarkdownTableAlignment, ParsedMarkdownTableRow,
6};
7use fs::normalize_path;
8use gpui::{
9 AbsoluteLength, AnyElement, App, AppContext as _, ClipboardItem, Context, DefiniteLength, Div,
10 Element, ElementId, Entity, HighlightStyle, Hsla, ImageSource, InteractiveText, IntoElement,
11 Keystroke, Length, Modifiers, ParentElement, Render, Resource, SharedString, Styled,
12 StyledText, TextStyle, WeakEntity, Window, div, img, rems,
13};
14use settings::Settings;
15use std::{
16 ops::{Mul, Range},
17 sync::Arc,
18 vec,
19};
20use theme::{ActiveTheme, SyntaxTheme, ThemeSettings};
21use ui::{
22 ButtonCommon, Clickable, Color, FluentBuilder, IconButton, IconName, IconSize,
23 InteractiveElement, Label, LabelCommon, LabelSize, LinkPreview, Pixels, Rems,
24 StatefulInteractiveElement, StyledExt, StyledImage, ToggleState, Tooltip, VisibleOnHover,
25 h_flex, relative, tooltip_container, v_flex,
26};
27use workspace::{OpenOptions, OpenVisible, Workspace};
28
29pub struct CheckboxClickedEvent {
30 pub checked: bool,
31 pub source_range: Range<usize>,
32}
33
34impl CheckboxClickedEvent {
35 pub fn source_range(&self) -> Range<usize> {
36 self.source_range.clone()
37 }
38
39 pub fn checked(&self) -> bool {
40 self.checked
41 }
42}
43
44type CheckboxClickedCallback = Arc<Box<dyn Fn(&CheckboxClickedEvent, &mut Window, &mut App)>>;
45
46#[derive(Clone)]
47pub struct RenderContext {
48 workspace: Option<WeakEntity<Workspace>>,
49 next_id: usize,
50 buffer_font_family: SharedString,
51 buffer_text_style: TextStyle,
52 text_style: TextStyle,
53 border_color: Hsla,
54 element_background_color: Hsla,
55 text_color: Hsla,
56 window_rem_size: Pixels,
57 text_muted_color: Hsla,
58 code_block_background_color: Hsla,
59 code_span_background_color: Hsla,
60 syntax_theme: Arc<SyntaxTheme>,
61 indent: usize,
62 checkbox_clicked_callback: Option<CheckboxClickedCallback>,
63}
64
65impl RenderContext {
66 pub fn new(
67 workspace: Option<WeakEntity<Workspace>>,
68 window: &mut Window,
69 cx: &mut App,
70 ) -> RenderContext {
71 let theme = cx.theme().clone();
72
73 let settings = ThemeSettings::get_global(cx);
74 let buffer_font_family = settings.buffer_font.family.clone();
75 let mut buffer_text_style = window.text_style();
76 buffer_text_style.font_family = buffer_font_family.clone();
77 buffer_text_style.font_size = AbsoluteLength::from(settings.buffer_font_size(cx));
78
79 RenderContext {
80 workspace,
81 next_id: 0,
82 indent: 0,
83 buffer_font_family,
84 buffer_text_style,
85 text_style: window.text_style(),
86 syntax_theme: theme.syntax().clone(),
87 border_color: theme.colors().border,
88 element_background_color: theme.colors().element_background,
89 text_color: theme.colors().text,
90 window_rem_size: window.rem_size(),
91 text_muted_color: theme.colors().text_muted,
92 code_block_background_color: theme.colors().surface_background,
93 code_span_background_color: theme.colors().editor_document_highlight_read_background,
94 checkbox_clicked_callback: None,
95 }
96 }
97
98 pub fn with_checkbox_clicked_callback(
99 mut self,
100 callback: impl Fn(&CheckboxClickedEvent, &mut Window, &mut App) + 'static,
101 ) -> Self {
102 self.checkbox_clicked_callback = Some(Arc::new(Box::new(callback)));
103 self
104 }
105
106 fn next_id(&mut self, span: &Range<usize>) -> ElementId {
107 let id = format!("markdown-{}-{}-{}", self.next_id, span.start, span.end);
108 self.next_id += 1;
109 ElementId::from(SharedString::from(id))
110 }
111
112 /// HACK: used to have rems relative to buffer font size, so that things scale appropriately as
113 /// buffer font size changes. The callees of this function should be reimplemented to use real
114 /// relative sizing once that is implemented in GPUI
115 pub fn scaled_rems(&self, rems: f32) -> Rems {
116 self.buffer_text_style
117 .font_size
118 .to_rems(self.window_rem_size)
119 .mul(rems)
120 }
121
122 /// This ensures that children inside of block quotes
123 /// have padding between them.
124 ///
125 /// For example, for this markdown:
126 ///
127 /// ```markdown
128 /// > This is a block quote.
129 /// >
130 /// > And this is the next paragraph.
131 /// ```
132 ///
133 /// We give padding between "This is a block quote."
134 /// and "And this is the next paragraph."
135 fn with_common_p(&self, element: Div) -> Div {
136 if self.indent > 0 {
137 element.pb(self.scaled_rems(0.75))
138 } else {
139 element
140 }
141 }
142}
143
144pub fn render_parsed_markdown(
145 parsed: &ParsedMarkdown,
146 workspace: Option<WeakEntity<Workspace>>,
147 window: &mut Window,
148 cx: &mut App,
149) -> Div {
150 let mut cx = RenderContext::new(workspace, window, cx);
151
152 v_flex().gap_3().children(
153 parsed
154 .children
155 .iter()
156 .map(|block| render_markdown_block(block, &mut cx)),
157 )
158}
159pub fn render_markdown_block(block: &ParsedMarkdownElement, cx: &mut RenderContext) -> AnyElement {
160 use ParsedMarkdownElement::*;
161 match block {
162 Paragraph(text) => render_markdown_paragraph(text, cx),
163 Heading(heading) => render_markdown_heading(heading, cx),
164 ListItem(list_item) => render_markdown_list_item(list_item, cx),
165 Table(table) => render_markdown_table(table, cx),
166 BlockQuote(block_quote) => render_markdown_block_quote(block_quote, cx),
167 CodeBlock(code_block) => render_markdown_code_block(code_block, cx),
168 HorizontalRule(_) => render_markdown_rule(cx),
169 Image(image) => render_markdown_image(image, cx),
170 }
171}
172
173fn render_markdown_heading(parsed: &ParsedMarkdownHeading, cx: &mut RenderContext) -> AnyElement {
174 let size = match parsed.level {
175 HeadingLevel::H1 => 2.,
176 HeadingLevel::H2 => 1.5,
177 HeadingLevel::H3 => 1.25,
178 HeadingLevel::H4 => 1.,
179 HeadingLevel::H5 => 0.875,
180 HeadingLevel::H6 => 0.85,
181 };
182
183 let text_size = cx.scaled_rems(size);
184
185 // was `DefiniteLength::from(text_size.mul(1.25))`
186 // let line_height = DefiniteLength::from(text_size.mul(1.25));
187 let line_height = text_size * 1.25;
188
189 // was `rems(0.15)`
190 // let padding_top = cx.scaled_rems(0.15);
191 let padding_top = rems(0.15);
192
193 // was `.pb_1()` = `rems(0.25)`
194 // let padding_bottom = cx.scaled_rems(0.25);
195 let padding_bottom = rems(0.25);
196
197 let color = match parsed.level {
198 HeadingLevel::H6 => cx.text_muted_color,
199 _ => cx.text_color,
200 };
201 div()
202 .line_height(line_height)
203 .text_size(text_size)
204 .text_color(color)
205 .pt(padding_top)
206 .pb(padding_bottom)
207 .children(render_markdown_text(&parsed.contents, cx))
208 .whitespace_normal()
209 .into_any()
210}
211
212fn render_markdown_list_item(
213 parsed: &ParsedMarkdownListItem,
214 cx: &mut RenderContext,
215) -> AnyElement {
216 use ParsedMarkdownListItemType::*;
217
218 let padding = cx.scaled_rems((parsed.depth - 1) as f32);
219
220 let bullet = match &parsed.item_type {
221 Ordered(order) => format!("{}.", order).into_any_element(),
222 Unordered => "•".into_any_element(),
223 Task(checked, range) => div()
224 .id(cx.next_id(range))
225 .mt(cx.scaled_rems(3.0 / 16.0))
226 .child(
227 MarkdownCheckbox::new(
228 "checkbox",
229 if *checked {
230 ToggleState::Selected
231 } else {
232 ToggleState::Unselected
233 },
234 cx.clone(),
235 )
236 .when_some(
237 cx.checkbox_clicked_callback.clone(),
238 |this, callback| {
239 this.on_click({
240 let range = range.clone();
241 move |selection, window, cx| {
242 let checked = match selection {
243 ToggleState::Selected => true,
244 ToggleState::Unselected => false,
245 _ => return,
246 };
247
248 if window.modifiers().secondary() {
249 callback(
250 &CheckboxClickedEvent {
251 checked,
252 source_range: range.clone(),
253 },
254 window,
255 cx,
256 );
257 }
258 }
259 })
260 },
261 ),
262 )
263 .hover(|s| s.cursor_pointer())
264 .tooltip(|_, cx| {
265 InteractiveMarkdownElementTooltip::new(None, "toggle checkbox", cx).into()
266 })
267 .into_any_element(),
268 };
269 let bullet = div().mr(cx.scaled_rems(0.5)).child(bullet);
270
271 let contents: Vec<AnyElement> = parsed
272 .content
273 .iter()
274 .map(|c| render_markdown_block(c, cx))
275 .collect();
276
277 let item = h_flex()
278 .pl(DefiniteLength::Absolute(AbsoluteLength::Rems(padding)))
279 .items_start()
280 .children(vec![
281 bullet,
282 v_flex()
283 .children(contents)
284 .gap(cx.scaled_rems(1.0))
285 .pr(cx.scaled_rems(1.0))
286 .w_full(),
287 ]);
288
289 cx.with_common_p(item).into_any()
290}
291
292/// # MarkdownCheckbox ///
293/// HACK: Copied from `ui/src/components/toggle.rs` to deal with scaling issues in markdown preview
294/// changes should be integrated into `Checkbox` in `toggle.rs` while making sure checkboxes elsewhere in the
295/// app are not visually affected
296#[derive(gpui::IntoElement)]
297struct MarkdownCheckbox {
298 id: ElementId,
299 toggle_state: ToggleState,
300 disabled: bool,
301 placeholder: bool,
302 on_click: Option<Box<dyn Fn(&ToggleState, &mut Window, &mut App) + 'static>>,
303 filled: bool,
304 style: ui::ToggleStyle,
305 tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> gpui::AnyView>>,
306 label: Option<SharedString>,
307 render_cx: RenderContext,
308}
309
310impl MarkdownCheckbox {
311 /// Creates a new [`Checkbox`].
312 fn new(id: impl Into<ElementId>, checked: ToggleState, render_cx: RenderContext) -> Self {
313 Self {
314 id: id.into(),
315 toggle_state: checked,
316 disabled: false,
317 on_click: None,
318 filled: false,
319 style: ui::ToggleStyle::default(),
320 tooltip: None,
321 label: None,
322 placeholder: false,
323 render_cx,
324 }
325 }
326
327 /// Binds a handler to the [`Checkbox`] that will be called when clicked.
328 fn on_click(mut self, handler: impl Fn(&ToggleState, &mut Window, &mut App) + 'static) -> Self {
329 self.on_click = Some(Box::new(handler));
330 self
331 }
332
333 fn bg_color(&self, cx: &App) -> Hsla {
334 let style = self.style.clone();
335 match (style, self.filled) {
336 (ui::ToggleStyle::Ghost, false) => cx.theme().colors().ghost_element_background,
337 (ui::ToggleStyle::Ghost, true) => cx.theme().colors().element_background,
338 (ui::ToggleStyle::ElevationBased(_), false) => gpui::transparent_black(),
339 (ui::ToggleStyle::ElevationBased(elevation), true) => elevation.darker_bg(cx),
340 (ui::ToggleStyle::Custom(_), false) => gpui::transparent_black(),
341 (ui::ToggleStyle::Custom(color), true) => color.opacity(0.2),
342 }
343 }
344
345 fn border_color(&self, cx: &App) -> Hsla {
346 if self.disabled {
347 return cx.theme().colors().border_variant;
348 }
349
350 match self.style.clone() {
351 ui::ToggleStyle::Ghost => cx.theme().colors().border,
352 ui::ToggleStyle::ElevationBased(_) => cx.theme().colors().border,
353 ui::ToggleStyle::Custom(color) => color.opacity(0.3),
354 }
355 }
356}
357
358impl gpui::RenderOnce for MarkdownCheckbox {
359 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
360 let group_id = format!("checkbox_group_{:?}", self.id);
361 let color = if self.disabled {
362 Color::Disabled
363 } else {
364 Color::Selected
365 };
366 let icon_size_small = IconSize::Custom(self.render_cx.scaled_rems(14. / 16.)); // was IconSize::Small
367 let icon = match self.toggle_state {
368 ToggleState::Selected => {
369 if self.placeholder {
370 None
371 } else {
372 Some(
373 ui::Icon::new(IconName::Check)
374 .size(icon_size_small)
375 .color(color),
376 )
377 }
378 }
379 ToggleState::Indeterminate => Some(
380 ui::Icon::new(IconName::Dash)
381 .size(icon_size_small)
382 .color(color),
383 ),
384 ToggleState::Unselected => None,
385 };
386
387 let bg_color = self.bg_color(cx);
388 let border_color = self.border_color(cx);
389 let hover_border_color = border_color.alpha(0.7);
390
391 let size = self.render_cx.scaled_rems(1.25); // was Self::container_size(); (20px)
392
393 let checkbox = h_flex()
394 .id(self.id.clone())
395 .justify_center()
396 .items_center()
397 .size(size)
398 .group(group_id.clone())
399 .child(
400 div()
401 .flex()
402 .flex_none()
403 .justify_center()
404 .items_center()
405 .m(self.render_cx.scaled_rems(0.25)) // was .m_1
406 .size(self.render_cx.scaled_rems(1.0)) // was .size_4
407 .rounded(self.render_cx.scaled_rems(0.125)) // was .rounded_xs
408 .border_1()
409 .bg(bg_color)
410 .border_color(border_color)
411 .when(self.disabled, |this| this.cursor_not_allowed())
412 .when(self.disabled, |this| {
413 this.bg(cx.theme().colors().element_disabled.opacity(0.6))
414 })
415 .when(!self.disabled, |this| {
416 this.group_hover(group_id.clone(), |el| el.border_color(hover_border_color))
417 })
418 .when(self.placeholder, |this| {
419 this.child(
420 div()
421 .flex_none()
422 .rounded_full()
423 .bg(color.color(cx).alpha(0.5))
424 .size(self.render_cx.scaled_rems(0.25)), // was .size_1
425 )
426 })
427 .children(icon),
428 );
429
430 h_flex()
431 .id(self.id)
432 .gap(ui::DynamicSpacing::Base06.rems(cx))
433 .child(checkbox)
434 .when_some(
435 self.on_click.filter(|_| !self.disabled),
436 |this, on_click| {
437 this.on_click(move |_, window, cx| {
438 on_click(&self.toggle_state.inverse(), window, cx)
439 })
440 },
441 )
442 // TODO: Allow label size to be different from default.
443 // TODO: Allow label color to be different from muted.
444 .when_some(self.label, |this, label| {
445 this.child(Label::new(label).color(Color::Muted))
446 })
447 .when_some(self.tooltip, |this, tooltip| {
448 this.tooltip(move |window, cx| tooltip(window, cx))
449 })
450 }
451}
452
453fn paragraph_len(paragraphs: &MarkdownParagraph) -> usize {
454 paragraphs
455 .iter()
456 .map(|paragraph| match paragraph {
457 MarkdownParagraphChunk::Text(text) => text.contents.len(),
458 // TODO: Scale column width based on image size
459 MarkdownParagraphChunk::Image(_) => 1,
460 })
461 .sum()
462}
463
464fn render_markdown_table(parsed: &ParsedMarkdownTable, cx: &mut RenderContext) -> AnyElement {
465 let mut max_lengths: Vec<usize> = vec![0; parsed.header.children.len()];
466
467 for (index, cell) in parsed.header.children.iter().enumerate() {
468 let length = paragraph_len(cell);
469 max_lengths[index] = length;
470 }
471
472 for row in &parsed.body {
473 for (index, cell) in row.children.iter().enumerate() {
474 let length = paragraph_len(cell);
475
476 if length > max_lengths[index] {
477 max_lengths[index] = length;
478 }
479 }
480 }
481
482 let total_max_length: usize = max_lengths.iter().sum();
483 let max_column_widths: Vec<f32> = max_lengths
484 .iter()
485 .map(|&length| length as f32 / total_max_length as f32)
486 .collect();
487
488 let header = render_markdown_table_row(
489 &parsed.header,
490 &parsed.column_alignments,
491 &max_column_widths,
492 true,
493 cx,
494 );
495
496 let body: Vec<AnyElement> = parsed
497 .body
498 .iter()
499 .map(|row| {
500 render_markdown_table_row(
501 row,
502 &parsed.column_alignments,
503 &max_column_widths,
504 false,
505 cx,
506 )
507 })
508 .collect();
509
510 cx.with_common_p(v_flex())
511 .w_full()
512 .child(header)
513 .children(body)
514 .into_any()
515}
516
517fn render_markdown_table_row(
518 parsed: &ParsedMarkdownTableRow,
519 alignments: &Vec<ParsedMarkdownTableAlignment>,
520 max_column_widths: &Vec<f32>,
521 is_header: bool,
522 cx: &mut RenderContext,
523) -> AnyElement {
524 let mut items = vec![];
525 let count = parsed.children.len();
526
527 for (index, cell) in parsed.children.iter().enumerate() {
528 let alignment = alignments
529 .get(index)
530 .copied()
531 .unwrap_or(ParsedMarkdownTableAlignment::None);
532
533 let contents = render_markdown_text(cell, cx);
534
535 let container = match alignment {
536 ParsedMarkdownTableAlignment::Left | ParsedMarkdownTableAlignment::None => div(),
537 ParsedMarkdownTableAlignment::Center => v_flex().items_center(),
538 ParsedMarkdownTableAlignment::Right => v_flex().items_end(),
539 };
540
541 let max_width = max_column_widths.get(index).unwrap_or(&0.0);
542 let mut cell = container
543 .w(Length::Definite(relative(*max_width)))
544 .h_full()
545 .children(contents)
546 .px_2()
547 .py_1()
548 .border_color(cx.border_color)
549 .border_l_1();
550
551 if count == index + 1 {
552 cell = cell.border_r_1();
553 }
554
555 if is_header {
556 cell = cell.bg(cx.element_background_color)
557 }
558
559 items.push(cell);
560 }
561
562 let mut row = h_flex().border_color(cx.border_color);
563
564 if is_header {
565 row = row.border_y_1();
566 } else {
567 row = row.border_b_1();
568 }
569
570 row.children(items).into_any_element()
571}
572
573fn render_markdown_block_quote(
574 parsed: &ParsedMarkdownBlockQuote,
575 cx: &mut RenderContext,
576) -> AnyElement {
577 cx.indent += 1;
578
579 let children: Vec<AnyElement> = parsed
580 .children
581 .iter()
582 .map(|child| render_markdown_block(child, cx))
583 .collect();
584
585 cx.indent -= 1;
586
587 cx.with_common_p(div())
588 .child(
589 div()
590 .border_l_4()
591 .border_color(cx.border_color)
592 .pl_3()
593 .children(children),
594 )
595 .into_any()
596}
597
598fn render_markdown_code_block(
599 parsed: &ParsedMarkdownCodeBlock,
600 cx: &mut RenderContext,
601) -> AnyElement {
602 let body = if let Some(highlights) = parsed.highlights.as_ref() {
603 StyledText::new(parsed.contents.clone()).with_default_highlights(
604 &cx.buffer_text_style,
605 highlights.iter().filter_map(|(range, highlight_id)| {
606 highlight_id
607 .style(cx.syntax_theme.as_ref())
608 .map(|style| (range.clone(), style))
609 }),
610 )
611 } else {
612 StyledText::new(parsed.contents.clone())
613 };
614
615 let copy_block_button = IconButton::new("copy-code", IconName::Copy)
616 .icon_size(IconSize::Small)
617 .on_click({
618 let contents = parsed.contents.clone();
619 move |_, _window, cx| {
620 cx.write_to_clipboard(ClipboardItem::new_string(contents.to_string()));
621 }
622 })
623 .tooltip(Tooltip::text("Copy code block"))
624 .visible_on_hover("markdown-block");
625
626 cx.with_common_p(div())
627 .font_family(cx.buffer_font_family.clone())
628 .px_3()
629 .py_3()
630 .bg(cx.code_block_background_color)
631 .rounded_sm()
632 .child(body)
633 .child(
634 div()
635 .h_flex()
636 .absolute()
637 .right_1()
638 .top_1()
639 .child(copy_block_button),
640 )
641 .into_any()
642}
643
644fn render_markdown_paragraph(parsed: &MarkdownParagraph, cx: &mut RenderContext) -> AnyElement {
645 cx.with_common_p(div())
646 .children(render_markdown_text(parsed, cx))
647 .flex()
648 .flex_col()
649 .into_any_element()
650}
651
652fn render_markdown_text(parsed_new: &MarkdownParagraph, cx: &mut RenderContext) -> Vec<AnyElement> {
653 let mut any_element = vec![];
654 // these values are cloned in-order satisfy borrow checker
655 let syntax_theme = cx.syntax_theme.clone();
656 let workspace_clone = cx.workspace.clone();
657 let code_span_bg_color = cx.code_span_background_color;
658 let text_style = cx.text_style.clone();
659
660 for parsed_region in parsed_new {
661 match parsed_region {
662 MarkdownParagraphChunk::Text(parsed) => {
663 let element_id = cx.next_id(&parsed.source_range);
664
665 let highlights = gpui::combine_highlights(
666 parsed.highlights.iter().filter_map(|(range, highlight)| {
667 highlight
668 .to_highlight_style(&syntax_theme)
669 .map(|style| (range.clone(), style))
670 }),
671 parsed.regions.iter().zip(&parsed.region_ranges).filter_map(
672 |(region, range)| {
673 if region.code {
674 Some((
675 range.clone(),
676 HighlightStyle {
677 background_color: Some(code_span_bg_color),
678 ..Default::default()
679 },
680 ))
681 } else {
682 None
683 }
684 },
685 ),
686 );
687 let mut links = Vec::new();
688 let mut link_ranges = Vec::new();
689 for (range, region) in parsed.region_ranges.iter().zip(&parsed.regions) {
690 if let Some(link) = region.link.clone() {
691 links.push(link);
692 link_ranges.push(range.clone());
693 }
694 }
695 let workspace = workspace_clone.clone();
696 let element = div()
697 .child(
698 InteractiveText::new(
699 element_id,
700 StyledText::new(parsed.contents.clone())
701 .with_default_highlights(&text_style, highlights),
702 )
703 .tooltip({
704 let links = links.clone();
705 let link_ranges = link_ranges.clone();
706 move |idx, _, cx| {
707 for (ix, range) in link_ranges.iter().enumerate() {
708 if range.contains(&idx) {
709 return Some(LinkPreview::new(&links[ix].to_string(), cx));
710 }
711 }
712 None
713 }
714 })
715 .on_click(
716 link_ranges,
717 move |clicked_range_ix, window, cx| match &links[clicked_range_ix] {
718 Link::Web { url } => cx.open_url(url),
719 Link::Path { path, .. } => {
720 if let Some(workspace) = &workspace {
721 _ = workspace.update(cx, |workspace, cx| {
722 workspace
723 .open_abs_path(
724 normalize_path(path.clone().as_path()),
725 OpenOptions {
726 visible: Some(OpenVisible::None),
727 ..Default::default()
728 },
729 window,
730 cx,
731 )
732 .detach();
733 });
734 }
735 }
736 },
737 ),
738 )
739 .into_any();
740 any_element.push(element);
741 }
742
743 MarkdownParagraphChunk::Image(image) => {
744 any_element.push(render_markdown_image(image, cx));
745 }
746 }
747 }
748
749 any_element
750}
751
752fn render_markdown_rule(cx: &mut RenderContext) -> AnyElement {
753 let rule = div().w_full().h(cx.scaled_rems(0.125)).bg(cx.border_color);
754 div().py(cx.scaled_rems(0.5)).child(rule).into_any()
755}
756
757fn render_markdown_image(image: &Image, cx: &mut RenderContext) -> AnyElement {
758 let image_resource = match image.link.clone() {
759 Link::Web { url } => Resource::Uri(url.into()),
760 Link::Path { path, .. } => Resource::Path(Arc::from(path)),
761 };
762
763 let element_id = cx.next_id(&image.source_range);
764 let workspace = cx.workspace.clone();
765
766 div()
767 .id(element_id)
768 .cursor_pointer()
769 .child(
770 img(ImageSource::Resource(image_resource))
771 .max_w_full()
772 .with_fallback({
773 let alt_text = image.alt_text.clone();
774 move || div().children(alt_text.clone()).into_any_element()
775 })
776 .when_some(image.height, |this, height| this.h(height))
777 .when_some(image.width, |this, width| this.w(width)),
778 )
779 .tooltip({
780 let link = image.link.clone();
781 let alt_text = image.alt_text.clone();
782 move |_, cx| {
783 InteractiveMarkdownElementTooltip::new(
784 Some(alt_text.clone().unwrap_or(link.to_string().into())),
785 "open image",
786 cx,
787 )
788 .into()
789 }
790 })
791 .on_click({
792 let link = image.link.clone();
793 move |_, window, cx| {
794 if window.modifiers().secondary() {
795 match &link {
796 Link::Web { url } => cx.open_url(url),
797 Link::Path { path, .. } => {
798 if let Some(workspace) = &workspace {
799 _ = workspace.update(cx, |workspace, cx| {
800 workspace
801 .open_abs_path(
802 path.clone(),
803 OpenOptions {
804 visible: Some(OpenVisible::None),
805 ..Default::default()
806 },
807 window,
808 cx,
809 )
810 .detach();
811 });
812 }
813 }
814 }
815 }
816 }
817 })
818 .into_any()
819}
820
821struct InteractiveMarkdownElementTooltip {
822 tooltip_text: Option<SharedString>,
823 action_text: SharedString,
824}
825
826impl InteractiveMarkdownElementTooltip {
827 pub fn new(
828 tooltip_text: Option<SharedString>,
829 action_text: impl Into<SharedString>,
830 cx: &mut App,
831 ) -> Entity<Self> {
832 let tooltip_text = tooltip_text.map(|t| util::truncate_and_trailoff(&t, 50).into());
833
834 cx.new(|_cx| Self {
835 tooltip_text,
836 action_text: action_text.into(),
837 })
838 }
839}
840
841impl Render for InteractiveMarkdownElementTooltip {
842 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
843 tooltip_container(cx, |el, _| {
844 let secondary_modifier = Keystroke {
845 modifiers: Modifiers::secondary_key(),
846 ..Default::default()
847 };
848
849 el.child(
850 v_flex()
851 .gap_1()
852 .when_some(self.tooltip_text.clone(), |this, text| {
853 this.child(Label::new(text).size(LabelSize::Small))
854 })
855 .child(
856 Label::new(format!(
857 "{}-click to {}",
858 secondary_modifier, self.action_text
859 ))
860 .size(LabelSize::Small)
861 .color(Color::Muted),
862 ),
863 )
864 })
865 }
866}