1pub mod parser;
2mod path_range;
3
4use base64::Engine as _;
5use futures::FutureExt as _;
6use gpui::HitboxBehavior;
7use language::LanguageName;
8use log::Level;
9pub use path_range::{LineCol, PathWithRange};
10use ui::Checkbox;
11use ui::CopyButton;
12
13use std::borrow::Cow;
14use std::iter;
15use std::mem;
16use std::ops::Range;
17use std::path::Path;
18use std::rc::Rc;
19use std::sync::Arc;
20use std::time::Duration;
21
22use collections::{HashMap, HashSet};
23use gpui::{
24 AnyElement, App, BorderStyle, Bounds, ClipboardItem, CursorStyle, DispatchPhase, Edges, Entity,
25 FocusHandle, Focusable, FontStyle, FontWeight, GlobalElementId, Hitbox, Hsla, Image,
26 ImageFormat, KeyContext, Length, MouseButton, MouseDownEvent, MouseEvent, MouseMoveEvent,
27 MouseUpEvent, Point, ScrollHandle, Stateful, StrikethroughStyle, StyleRefinement, StyledText,
28 Task, TextLayout, TextRun, TextStyle, TextStyleRefinement, actions, img, point, quad,
29};
30use language::{Language, LanguageRegistry, Rope};
31use parser::CodeBlockMetadata;
32use parser::{MarkdownEvent, MarkdownTag, MarkdownTagEnd, parse_links_only, parse_markdown};
33use pulldown_cmark::Alignment;
34use sum_tree::TreeMap;
35use theme::SyntaxTheme;
36use ui::{ScrollAxes, Scrollbars, WithScrollbar, prelude::*};
37use util::ResultExt;
38
39use crate::parser::CodeBlockKind;
40
41/// A callback function that can be used to customize the style of links based on the destination URL.
42/// If the callback returns `None`, the default link style will be used.
43type LinkStyleCallback = Rc<dyn Fn(&str, &App) -> Option<TextStyleRefinement>>;
44
45/// Defines custom style refinements for each heading level (H1-H6)
46#[derive(Clone, Default)]
47pub struct HeadingLevelStyles {
48 pub h1: Option<TextStyleRefinement>,
49 pub h2: Option<TextStyleRefinement>,
50 pub h3: Option<TextStyleRefinement>,
51 pub h4: Option<TextStyleRefinement>,
52 pub h5: Option<TextStyleRefinement>,
53 pub h6: Option<TextStyleRefinement>,
54}
55
56#[derive(Clone)]
57pub struct MarkdownStyle {
58 pub base_text_style: TextStyle,
59 pub container_style: StyleRefinement,
60 pub code_block: StyleRefinement,
61 pub code_block_overflow_x_scroll: bool,
62 pub inline_code: TextStyleRefinement,
63 pub block_quote: TextStyleRefinement,
64 pub link: TextStyleRefinement,
65 pub link_callback: Option<LinkStyleCallback>,
66 pub rule_color: Hsla,
67 pub block_quote_border_color: Hsla,
68 pub syntax: Arc<SyntaxTheme>,
69 pub selection_background_color: Hsla,
70 pub heading: StyleRefinement,
71 pub heading_level_styles: Option<HeadingLevelStyles>,
72 pub height_is_multiple_of_line_height: bool,
73 pub prevent_mouse_interaction: bool,
74 pub table_columns_min_size: bool,
75}
76
77impl Default for MarkdownStyle {
78 fn default() -> Self {
79 Self {
80 base_text_style: Default::default(),
81 container_style: Default::default(),
82 code_block: Default::default(),
83 code_block_overflow_x_scroll: false,
84 inline_code: Default::default(),
85 block_quote: Default::default(),
86 link: Default::default(),
87 link_callback: None,
88 rule_color: Default::default(),
89 block_quote_border_color: Default::default(),
90 syntax: Arc::new(SyntaxTheme::default()),
91 selection_background_color: Default::default(),
92 heading: Default::default(),
93 heading_level_styles: None,
94 height_is_multiple_of_line_height: false,
95 prevent_mouse_interaction: false,
96 table_columns_min_size: false,
97 }
98 }
99}
100
101pub struct Markdown {
102 source: SharedString,
103 selection: Selection,
104 pressed_link: Option<RenderedLink>,
105 autoscroll_request: Option<usize>,
106 parsed_markdown: ParsedMarkdown,
107 images_by_source_offset: HashMap<usize, Arc<Image>>,
108 should_reparse: bool,
109 pending_parse: Option<Task<()>>,
110 focus_handle: FocusHandle,
111 language_registry: Option<Arc<LanguageRegistry>>,
112 fallback_code_block_language: Option<LanguageName>,
113 options: Options,
114 copied_code_blocks: HashSet<ElementId>,
115 code_block_scroll_handles: HashMap<usize, ScrollHandle>,
116 context_menu_selected_text: Option<String>,
117}
118
119struct Options {
120 parse_links_only: bool,
121}
122
123pub enum CodeBlockRenderer {
124 Default {
125 copy_button: bool,
126 copy_button_on_hover: bool,
127 border: bool,
128 },
129 Custom {
130 render: CodeBlockRenderFn,
131 /// A function that can modify the parent container after the code block
132 /// content has been appended as a child element.
133 transform: Option<CodeBlockTransformFn>,
134 },
135}
136
137pub type CodeBlockRenderFn = Arc<
138 dyn Fn(
139 &CodeBlockKind,
140 &ParsedMarkdown,
141 Range<usize>,
142 CodeBlockMetadata,
143 &mut Window,
144 &App,
145 ) -> Div,
146>;
147
148pub type CodeBlockTransformFn =
149 Arc<dyn Fn(AnyDiv, Range<usize>, CodeBlockMetadata, &mut Window, &App) -> AnyDiv>;
150
151actions!(
152 markdown,
153 [
154 /// Copies the selected text to the clipboard.
155 Copy,
156 /// Copies the selected text as markdown to the clipboard.
157 CopyAsMarkdown
158 ]
159);
160
161impl Markdown {
162 pub fn new(
163 source: SharedString,
164 language_registry: Option<Arc<LanguageRegistry>>,
165 fallback_code_block_language: Option<LanguageName>,
166 cx: &mut Context<Self>,
167 ) -> Self {
168 let focus_handle = cx.focus_handle();
169 let mut this = Self {
170 source,
171 selection: Selection::default(),
172 pressed_link: None,
173 autoscroll_request: None,
174 should_reparse: false,
175 images_by_source_offset: Default::default(),
176 parsed_markdown: ParsedMarkdown::default(),
177 pending_parse: None,
178 focus_handle,
179 language_registry,
180 fallback_code_block_language,
181 options: Options {
182 parse_links_only: false,
183 },
184 copied_code_blocks: HashSet::default(),
185 code_block_scroll_handles: HashMap::default(),
186 context_menu_selected_text: None,
187 };
188 this.parse(cx);
189 this
190 }
191
192 pub fn new_text(source: SharedString, cx: &mut Context<Self>) -> Self {
193 let focus_handle = cx.focus_handle();
194 let mut this = Self {
195 source,
196 selection: Selection::default(),
197 pressed_link: None,
198 autoscroll_request: None,
199 should_reparse: false,
200 parsed_markdown: ParsedMarkdown::default(),
201 images_by_source_offset: Default::default(),
202 pending_parse: None,
203 focus_handle,
204 language_registry: None,
205 fallback_code_block_language: None,
206 options: Options {
207 parse_links_only: true,
208 },
209 copied_code_blocks: HashSet::default(),
210 code_block_scroll_handles: HashMap::default(),
211 context_menu_selected_text: None,
212 };
213 this.parse(cx);
214 this
215 }
216
217 fn code_block_scroll_handle(&mut self, id: usize) -> ScrollHandle {
218 self.code_block_scroll_handles
219 .entry(id)
220 .or_insert_with(ScrollHandle::new)
221 .clone()
222 }
223
224 fn retain_code_block_scroll_handles(&mut self, ids: &HashSet<usize>) {
225 self.code_block_scroll_handles
226 .retain(|id, _| ids.contains(id));
227 }
228
229 fn clear_code_block_scroll_handles(&mut self) {
230 self.code_block_scroll_handles.clear();
231 }
232
233 pub fn is_parsing(&self) -> bool {
234 self.pending_parse.is_some()
235 }
236
237 pub fn source(&self) -> &str {
238 &self.source
239 }
240
241 pub fn append(&mut self, text: &str, cx: &mut Context<Self>) {
242 self.source = SharedString::new(self.source.to_string() + text);
243 self.parse(cx);
244 }
245
246 pub fn replace(&mut self, source: impl Into<SharedString>, cx: &mut Context<Self>) {
247 self.source = source.into();
248 self.parse(cx);
249 }
250
251 pub fn reset(&mut self, source: SharedString, cx: &mut Context<Self>) {
252 if source == self.source() {
253 return;
254 }
255 self.source = source;
256 self.selection = Selection::default();
257 self.autoscroll_request = None;
258 self.pending_parse = None;
259 self.should_reparse = false;
260 // Don't clear parsed_markdown here - keep existing content visible until new parse completes
261 self.parse(cx);
262 }
263
264 #[cfg(any(test, feature = "test-support"))]
265 pub fn parsed_markdown(&self) -> &ParsedMarkdown {
266 &self.parsed_markdown
267 }
268
269 pub fn escape(s: &str) -> Cow<'_, str> {
270 // Valid to use bytes since multi-byte UTF-8 doesn't use ASCII chars.
271 let count = s
272 .bytes()
273 .filter(|c| *c == b'\n' || c.is_ascii_punctuation())
274 .count();
275 if count > 0 {
276 let mut output = String::with_capacity(s.len() + count);
277 let mut is_newline = false;
278 for c in s.chars() {
279 if is_newline && c == ' ' {
280 continue;
281 }
282 is_newline = c == '\n';
283 if c == '\n' {
284 output.push('\n')
285 } else if c.is_ascii_punctuation() {
286 output.push('\\')
287 }
288 output.push(c)
289 }
290 output.into()
291 } else {
292 s.into()
293 }
294 }
295
296 pub fn selected_text(&self) -> Option<String> {
297 if self.selection.end <= self.selection.start {
298 None
299 } else {
300 Some(self.source[self.selection.start..self.selection.end].to_string())
301 }
302 }
303
304 fn copy(&self, text: &RenderedText, _: &mut Window, cx: &mut Context<Self>) {
305 if self.selection.end <= self.selection.start {
306 return;
307 }
308 let text = text.text_for_range(self.selection.start..self.selection.end);
309 cx.write_to_clipboard(ClipboardItem::new_string(text));
310 }
311
312 fn copy_as_markdown(&mut self, _: &mut Window, cx: &mut Context<Self>) {
313 if let Some(text) = self.context_menu_selected_text.take() {
314 cx.write_to_clipboard(ClipboardItem::new_string(text));
315 return;
316 }
317 if self.selection.end <= self.selection.start {
318 return;
319 }
320 let text = self.source[self.selection.start..self.selection.end].to_string();
321 cx.write_to_clipboard(ClipboardItem::new_string(text));
322 }
323
324 fn capture_selection_for_context_menu(&mut self) {
325 self.context_menu_selected_text = self.selected_text();
326 }
327
328 fn parse(&mut self, cx: &mut Context<Self>) {
329 if self.source.is_empty() {
330 return;
331 }
332
333 if self.pending_parse.is_some() {
334 self.should_reparse = true;
335 return;
336 }
337 self.should_reparse = false;
338 self.pending_parse = Some(self.start_background_parse(cx));
339 }
340
341 fn start_background_parse(&self, cx: &Context<Self>) -> Task<()> {
342 let source = self.source.clone();
343 let should_parse_links_only = self.options.parse_links_only;
344 let language_registry = self.language_registry.clone();
345 let fallback = self.fallback_code_block_language.clone();
346
347 let parsed = cx.background_spawn(async move {
348 if should_parse_links_only {
349 return (
350 ParsedMarkdown {
351 events: Arc::from(parse_links_only(source.as_ref())),
352 source,
353 languages_by_name: TreeMap::default(),
354 languages_by_path: TreeMap::default(),
355 },
356 Default::default(),
357 );
358 }
359
360 let (events, language_names, paths) = parse_markdown(&source);
361 let mut images_by_source_offset = HashMap::default();
362 let mut languages_by_name = TreeMap::default();
363 let mut languages_by_path = TreeMap::default();
364 if let Some(registry) = language_registry.as_ref() {
365 for name in language_names {
366 let language = if !name.is_empty() {
367 registry.language_for_name_or_extension(&name).left_future()
368 } else if let Some(fallback) = &fallback {
369 registry.language_for_name(fallback.as_ref()).right_future()
370 } else {
371 continue;
372 };
373 if let Ok(language) = language.await {
374 languages_by_name.insert(name, language);
375 }
376 }
377
378 for path in paths {
379 if let Ok(language) = registry
380 .load_language_for_file_path(Path::new(path.as_ref()))
381 .await
382 {
383 languages_by_path.insert(path, language);
384 }
385 }
386 }
387
388 for (range, event) in &events {
389 if let MarkdownEvent::Start(MarkdownTag::Image { dest_url, .. }) = event
390 && let Some(data_url) = dest_url.strip_prefix("data:")
391 {
392 let Some((mime_info, data)) = data_url.split_once(',') else {
393 continue;
394 };
395 let Some((mime_type, encoding)) = mime_info.split_once(';') else {
396 continue;
397 };
398 let Some(format) = ImageFormat::from_mime_type(mime_type) else {
399 continue;
400 };
401 let is_base64 = encoding == "base64";
402 if is_base64
403 && let Some(bytes) = base64::prelude::BASE64_STANDARD
404 .decode(data)
405 .log_with_level(Level::Debug)
406 {
407 let image = Arc::new(Image::from_bytes(format, bytes));
408 images_by_source_offset.insert(range.start, image);
409 }
410 }
411 }
412
413 (
414 ParsedMarkdown {
415 source,
416 events: Arc::from(events),
417 languages_by_name,
418 languages_by_path,
419 },
420 images_by_source_offset,
421 )
422 });
423
424 cx.spawn(async move |this, cx| {
425 let (parsed, images_by_source_offset) = parsed.await;
426
427 this.update(cx, |this, cx| {
428 this.parsed_markdown = parsed;
429 this.images_by_source_offset = images_by_source_offset;
430 this.pending_parse.take();
431 if this.should_reparse {
432 this.parse(cx);
433 }
434 cx.refresh_windows();
435 })
436 .ok();
437 })
438 }
439}
440
441impl Focusable for Markdown {
442 fn focus_handle(&self, _cx: &App) -> FocusHandle {
443 self.focus_handle.clone()
444 }
445}
446
447#[derive(Debug, Default, Clone)]
448enum SelectMode {
449 #[default]
450 Character,
451 Word(Range<usize>),
452 Line(Range<usize>),
453 All,
454}
455
456#[derive(Clone, Default)]
457struct Selection {
458 start: usize,
459 end: usize,
460 reversed: bool,
461 pending: bool,
462 mode: SelectMode,
463}
464
465impl Selection {
466 fn set_head(&mut self, head: usize, rendered_text: &RenderedText) {
467 match &self.mode {
468 SelectMode::Character => {
469 if head < self.tail() {
470 if !self.reversed {
471 self.end = self.start;
472 self.reversed = true;
473 }
474 self.start = head;
475 } else {
476 if self.reversed {
477 self.start = self.end;
478 self.reversed = false;
479 }
480 self.end = head;
481 }
482 }
483 SelectMode::Word(original_range) | SelectMode::Line(original_range) => {
484 let head_range = if matches!(self.mode, SelectMode::Word(_)) {
485 rendered_text.surrounding_word_range(head)
486 } else {
487 rendered_text.surrounding_line_range(head)
488 };
489
490 if head < original_range.start {
491 self.start = head_range.start;
492 self.end = original_range.end;
493 self.reversed = true;
494 } else if head >= original_range.end {
495 self.start = original_range.start;
496 self.end = head_range.end;
497 self.reversed = false;
498 } else {
499 self.start = original_range.start;
500 self.end = original_range.end;
501 self.reversed = false;
502 }
503 }
504 SelectMode::All => {
505 self.start = 0;
506 self.end = rendered_text
507 .lines
508 .last()
509 .map(|line| line.source_end)
510 .unwrap_or(0);
511 self.reversed = false;
512 }
513 }
514 }
515
516 fn tail(&self) -> usize {
517 if self.reversed { self.end } else { self.start }
518 }
519}
520
521#[derive(Debug, Clone, Default)]
522pub struct ParsedMarkdown {
523 pub source: SharedString,
524 pub events: Arc<[(Range<usize>, MarkdownEvent)]>,
525 pub languages_by_name: TreeMap<SharedString, Arc<Language>>,
526 pub languages_by_path: TreeMap<Arc<str>, Arc<Language>>,
527}
528
529impl ParsedMarkdown {
530 pub fn source(&self) -> &SharedString {
531 &self.source
532 }
533
534 pub fn events(&self) -> &Arc<[(Range<usize>, MarkdownEvent)]> {
535 &self.events
536 }
537}
538
539pub struct MarkdownElement {
540 markdown: Entity<Markdown>,
541 style: MarkdownStyle,
542 code_block_renderer: CodeBlockRenderer,
543 on_url_click: Option<Box<dyn Fn(SharedString, &mut Window, &mut App)>>,
544}
545
546impl MarkdownElement {
547 pub fn new(markdown: Entity<Markdown>, style: MarkdownStyle) -> Self {
548 Self {
549 markdown,
550 style,
551 code_block_renderer: CodeBlockRenderer::Default {
552 copy_button: true,
553 copy_button_on_hover: false,
554 border: false,
555 },
556 on_url_click: None,
557 }
558 }
559
560 #[cfg(any(test, feature = "test-support"))]
561 pub fn rendered_text(
562 markdown: Entity<Markdown>,
563 cx: &mut gpui::VisualTestContext,
564 style: impl FnOnce(&Window, &App) -> MarkdownStyle,
565 ) -> String {
566 use gpui::size;
567
568 let (text, _) = cx.draw(
569 Default::default(),
570 size(px(600.0), px(600.0)),
571 |window, cx| Self::new(markdown, style(window, cx)),
572 );
573 text.text
574 .lines
575 .iter()
576 .map(|line| line.layout.wrapped_text())
577 .collect::<Vec<_>>()
578 .join("\n")
579 }
580
581 pub fn code_block_renderer(mut self, variant: CodeBlockRenderer) -> Self {
582 self.code_block_renderer = variant;
583 self
584 }
585
586 pub fn on_url_click(
587 mut self,
588 handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
589 ) -> Self {
590 self.on_url_click = Some(Box::new(handler));
591 self
592 }
593
594 fn paint_selection(
595 &self,
596 bounds: Bounds<Pixels>,
597 rendered_text: &RenderedText,
598 window: &mut Window,
599 cx: &mut App,
600 ) {
601 let selection = self.markdown.read(cx).selection.clone();
602 let selection_start = rendered_text.position_for_source_index(selection.start);
603 let selection_end = rendered_text.position_for_source_index(selection.end);
604 if let Some(((start_position, start_line_height), (end_position, end_line_height))) =
605 selection_start.zip(selection_end)
606 {
607 if start_position.y == end_position.y {
608 window.paint_quad(quad(
609 Bounds::from_corners(
610 start_position,
611 point(end_position.x, end_position.y + end_line_height),
612 ),
613 Pixels::ZERO,
614 self.style.selection_background_color,
615 Edges::default(),
616 Hsla::transparent_black(),
617 BorderStyle::default(),
618 ));
619 } else {
620 window.paint_quad(quad(
621 Bounds::from_corners(
622 start_position,
623 point(bounds.right(), start_position.y + start_line_height),
624 ),
625 Pixels::ZERO,
626 self.style.selection_background_color,
627 Edges::default(),
628 Hsla::transparent_black(),
629 BorderStyle::default(),
630 ));
631
632 if end_position.y > start_position.y + start_line_height {
633 window.paint_quad(quad(
634 Bounds::from_corners(
635 point(bounds.left(), start_position.y + start_line_height),
636 point(bounds.right(), end_position.y),
637 ),
638 Pixels::ZERO,
639 self.style.selection_background_color,
640 Edges::default(),
641 Hsla::transparent_black(),
642 BorderStyle::default(),
643 ));
644 }
645
646 window.paint_quad(quad(
647 Bounds::from_corners(
648 point(bounds.left(), end_position.y),
649 point(end_position.x, end_position.y + end_line_height),
650 ),
651 Pixels::ZERO,
652 self.style.selection_background_color,
653 Edges::default(),
654 Hsla::transparent_black(),
655 BorderStyle::default(),
656 ));
657 }
658 }
659 }
660
661 fn paint_mouse_listeners(
662 &mut self,
663 hitbox: &Hitbox,
664 rendered_text: &RenderedText,
665 window: &mut Window,
666 cx: &mut App,
667 ) {
668 if self.style.prevent_mouse_interaction {
669 return;
670 }
671
672 let is_hovering_link = hitbox.is_hovered(window)
673 && !self.markdown.read(cx).selection.pending
674 && rendered_text
675 .link_for_position(window.mouse_position())
676 .is_some();
677
678 if !self.style.prevent_mouse_interaction {
679 if is_hovering_link {
680 window.set_cursor_style(CursorStyle::PointingHand, hitbox);
681 } else {
682 window.set_cursor_style(CursorStyle::IBeam, hitbox);
683 }
684 }
685
686 let on_open_url = self.on_url_click.take();
687
688 self.on_mouse_event(window, cx, {
689 let hitbox = hitbox.clone();
690 move |markdown, event: &MouseDownEvent, phase, window, _| {
691 if phase.capture()
692 && event.button == MouseButton::Right
693 && hitbox.is_hovered(window)
694 {
695 // Capture selected text so it survives until menu item is clicked
696 markdown.capture_selection_for_context_menu();
697 }
698 }
699 });
700
701 self.on_mouse_event(window, cx, {
702 let rendered_text = rendered_text.clone();
703 let hitbox = hitbox.clone();
704 move |markdown, event: &MouseDownEvent, phase, window, cx| {
705 if hitbox.is_hovered(window) {
706 if phase.bubble() {
707 if let Some(link) = rendered_text.link_for_position(event.position) {
708 markdown.pressed_link = Some(link.clone());
709 } else {
710 let source_index =
711 match rendered_text.source_index_for_position(event.position) {
712 Ok(ix) | Err(ix) => ix,
713 };
714 let (range, mode) = match event.click_count {
715 1 => {
716 let range = source_index..source_index;
717 (range, SelectMode::Character)
718 }
719 2 => {
720 let range = rendered_text.surrounding_word_range(source_index);
721 (range.clone(), SelectMode::Word(range))
722 }
723 3 => {
724 let range = rendered_text.surrounding_line_range(source_index);
725 (range.clone(), SelectMode::Line(range))
726 }
727 _ => {
728 let range = 0..rendered_text
729 .lines
730 .last()
731 .map(|line| line.source_end)
732 .unwrap_or(0);
733 (range, SelectMode::All)
734 }
735 };
736 markdown.selection = Selection {
737 start: range.start,
738 end: range.end,
739 reversed: false,
740 pending: true,
741 mode,
742 };
743 window.focus(&markdown.focus_handle, cx);
744 }
745
746 window.prevent_default();
747 cx.notify();
748 }
749 } else if phase.capture() && event.button == MouseButton::Left {
750 markdown.selection = Selection::default();
751 markdown.pressed_link = None;
752 cx.notify();
753 }
754 }
755 });
756 self.on_mouse_event(window, cx, {
757 let rendered_text = rendered_text.clone();
758 let hitbox = hitbox.clone();
759 let was_hovering_link = is_hovering_link;
760 move |markdown, event: &MouseMoveEvent, phase, window, cx| {
761 if phase.capture() {
762 return;
763 }
764
765 if markdown.selection.pending {
766 let source_index = match rendered_text.source_index_for_position(event.position)
767 {
768 Ok(ix) | Err(ix) => ix,
769 };
770 markdown.selection.set_head(source_index, &rendered_text);
771 markdown.autoscroll_request = Some(source_index);
772 cx.notify();
773 } else {
774 let is_hovering_link = hitbox.is_hovered(window)
775 && rendered_text.link_for_position(event.position).is_some();
776 if is_hovering_link != was_hovering_link {
777 cx.notify();
778 }
779 }
780 }
781 });
782 self.on_mouse_event(window, cx, {
783 let rendered_text = rendered_text.clone();
784 move |markdown, event: &MouseUpEvent, phase, window, cx| {
785 if phase.bubble() {
786 if let Some(pressed_link) = markdown.pressed_link.take()
787 && Some(&pressed_link) == rendered_text.link_for_position(event.position)
788 {
789 if let Some(open_url) = on_open_url.as_ref() {
790 open_url(pressed_link.destination_url, window, cx);
791 } else {
792 cx.open_url(&pressed_link.destination_url);
793 }
794 }
795 } else if markdown.selection.pending {
796 markdown.selection.pending = false;
797 #[cfg(any(target_os = "linux", target_os = "freebsd"))]
798 {
799 let text = rendered_text
800 .text_for_range(markdown.selection.start..markdown.selection.end);
801 cx.write_to_primary(ClipboardItem::new_string(text))
802 }
803 cx.notify();
804 }
805 }
806 });
807 }
808
809 fn autoscroll(
810 &self,
811 rendered_text: &RenderedText,
812 window: &mut Window,
813 cx: &mut App,
814 ) -> Option<()> {
815 let autoscroll_index = self
816 .markdown
817 .update(cx, |markdown, _| markdown.autoscroll_request.take())?;
818 let (position, line_height) = rendered_text.position_for_source_index(autoscroll_index)?;
819
820 let text_style = self.style.base_text_style.clone();
821 let font_id = window.text_system().resolve_font(&text_style.font());
822 let font_size = text_style.font_size.to_pixels(window.rem_size());
823 let em_width = window.text_system().em_width(font_id, font_size).unwrap();
824 window.request_autoscroll(Bounds::from_corners(
825 point(position.x - 3. * em_width, position.y - 3. * line_height),
826 point(position.x + 3. * em_width, position.y + 3. * line_height),
827 ));
828 Some(())
829 }
830
831 fn on_mouse_event<T: MouseEvent>(
832 &self,
833 window: &mut Window,
834 _cx: &mut App,
835 mut f: impl 'static
836 + FnMut(&mut Markdown, &T, DispatchPhase, &mut Window, &mut Context<Markdown>),
837 ) {
838 window.on_mouse_event({
839 let markdown = self.markdown.downgrade();
840 move |event, phase, window, cx| {
841 markdown
842 .update(cx, |markdown, cx| f(markdown, event, phase, window, cx))
843 .log_err();
844 }
845 });
846 }
847}
848
849impl Styled for MarkdownElement {
850 fn style(&mut self) -> &mut StyleRefinement {
851 &mut self.style.container_style
852 }
853}
854
855impl Element for MarkdownElement {
856 type RequestLayoutState = RenderedMarkdown;
857 type PrepaintState = Hitbox;
858
859 fn id(&self) -> Option<ElementId> {
860 None
861 }
862
863 fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
864 None
865 }
866
867 fn request_layout(
868 &mut self,
869 _id: Option<&GlobalElementId>,
870 _inspector_id: Option<&gpui::InspectorElementId>,
871 window: &mut Window,
872 cx: &mut App,
873 ) -> (gpui::LayoutId, Self::RequestLayoutState) {
874 let mut builder = MarkdownElementBuilder::new(
875 &self.style.container_style,
876 self.style.base_text_style.clone(),
877 self.style.syntax.clone(),
878 );
879 let (parsed_markdown, images) = {
880 let markdown = self.markdown.read(cx);
881 (
882 markdown.parsed_markdown.clone(),
883 markdown.images_by_source_offset.clone(),
884 )
885 };
886 let markdown_end = if let Some(last) = parsed_markdown.events.last() {
887 last.0.end
888 } else {
889 0
890 };
891 let mut code_block_ids = HashSet::default();
892
893 let mut current_img_block_range: Option<Range<usize>> = None;
894 for (index, (range, event)) in parsed_markdown.events.iter().enumerate() {
895 // Skip alt text for images that rendered
896 if let Some(current_img_block_range) = ¤t_img_block_range
897 && current_img_block_range.end > range.end
898 {
899 continue;
900 }
901
902 match event {
903 MarkdownEvent::Start(tag) => {
904 match tag {
905 MarkdownTag::Image { .. } => {
906 if let Some(image) = images.get(&range.start) {
907 current_img_block_range = Some(range.clone());
908 builder.modify_current_div(|el| {
909 el.items_center()
910 .flex()
911 .flex_row()
912 .child(img(image.clone()))
913 });
914 }
915 }
916 MarkdownTag::Paragraph => {
917 builder.push_div(
918 div().when(!self.style.height_is_multiple_of_line_height, |el| {
919 el.mb_2().line_height(rems(1.3))
920 }),
921 range,
922 markdown_end,
923 );
924 }
925 MarkdownTag::Heading { level, .. } => {
926 let mut heading = div().mb_2();
927
928 heading = apply_heading_style(
929 heading,
930 *level,
931 self.style.heading_level_styles.as_ref(),
932 );
933
934 heading.style().refine(&self.style.heading);
935
936 let text_style = self.style.heading.text_style().clone();
937
938 builder.push_text_style(text_style);
939 builder.push_div(heading, range, markdown_end);
940 }
941 MarkdownTag::BlockQuote => {
942 builder.push_text_style(self.style.block_quote.clone());
943 builder.push_div(
944 div()
945 .pl_4()
946 .mb_2()
947 .border_l_4()
948 .border_color(self.style.block_quote_border_color),
949 range,
950 markdown_end,
951 );
952 }
953 MarkdownTag::CodeBlock { kind, .. } => {
954 let language = match kind {
955 CodeBlockKind::Fenced => None,
956 CodeBlockKind::FencedLang(language) => {
957 parsed_markdown.languages_by_name.get(language).cloned()
958 }
959 CodeBlockKind::FencedSrc(path_range) => parsed_markdown
960 .languages_by_path
961 .get(&path_range.path)
962 .cloned(),
963 _ => None,
964 };
965
966 let is_indented = matches!(kind, CodeBlockKind::Indented);
967 let scroll_handle = if self.style.code_block_overflow_x_scroll {
968 code_block_ids.insert(range.start);
969 Some(self.markdown.update(cx, |markdown, _| {
970 markdown.code_block_scroll_handle(range.start)
971 }))
972 } else {
973 None
974 };
975
976 match (&self.code_block_renderer, is_indented) {
977 (CodeBlockRenderer::Default { .. }, _) | (_, true) => {
978 // This is a parent container that we can position the copy button inside.
979 let parent_container =
980 div().group("code_block").relative().w_full();
981
982 let mut parent_container: AnyDiv = if let Some(scroll_handle) =
983 scroll_handle.as_ref()
984 {
985 let scrollbars = Scrollbars::new(ScrollAxes::Horizontal)
986 .id(("markdown-code-block-scrollbar", range.start))
987 .tracked_scroll_handle(scroll_handle)
988 .with_track_along(
989 ScrollAxes::Horizontal,
990 cx.theme().colors().editor_background,
991 )
992 .notify_content();
993
994 parent_container
995 .rounded_lg()
996 .custom_scrollbars(scrollbars, window, cx)
997 .into()
998 } else {
999 parent_container.into()
1000 };
1001
1002 if let CodeBlockRenderer::Default { border: true, .. } =
1003 &self.code_block_renderer
1004 {
1005 parent_container = parent_container
1006 .rounded_md()
1007 .border_1()
1008 .border_color(cx.theme().colors().border_variant);
1009 }
1010
1011 parent_container.style().refine(&self.style.code_block);
1012 builder.push_div(parent_container, range, markdown_end);
1013
1014 let code_block = div()
1015 .id(("code-block", range.start))
1016 .rounded_lg()
1017 .map(|mut code_block| {
1018 if let Some(scroll_handle) = scroll_handle.as_ref() {
1019 code_block.style().restrict_scroll_to_axis =
1020 Some(true);
1021 code_block
1022 .flex()
1023 .overflow_x_scroll()
1024 .track_scroll(scroll_handle)
1025 } else {
1026 code_block.w_full()
1027 }
1028 });
1029
1030 builder.push_text_style(self.style.code_block.text.to_owned());
1031 builder.push_code_block(language);
1032 builder.push_div(code_block, range, markdown_end);
1033 }
1034 (CodeBlockRenderer::Custom { .. }, _) => {}
1035 }
1036 }
1037 MarkdownTag::HtmlBlock => builder.push_div(div(), range, markdown_end),
1038 MarkdownTag::List(bullet_index) => {
1039 builder.push_list(*bullet_index);
1040 builder.push_div(div().pl_2p5(), range, markdown_end);
1041 }
1042 MarkdownTag::Item => {
1043 let bullet = if let Some((_, MarkdownEvent::TaskListMarker(checked))) =
1044 parsed_markdown.events.get(index.saturating_add(1))
1045 {
1046 let source = &parsed_markdown.source()[range.clone()];
1047
1048 Checkbox::new(
1049 ElementId::Name(source.to_string().into()),
1050 if *checked {
1051 ToggleState::Selected
1052 } else {
1053 ToggleState::Unselected
1054 },
1055 )
1056 .fill()
1057 .visualization_only(true)
1058 .into_any_element()
1059 } else if let Some(bullet_index) = builder.next_bullet_index() {
1060 div().child(format!("{}.", bullet_index)).into_any_element()
1061 } else {
1062 div().child("•").into_any_element()
1063 };
1064 builder.push_div(
1065 div()
1066 .when(!self.style.height_is_multiple_of_line_height, |el| {
1067 el.mb_1().gap_1().line_height(rems(1.3))
1068 })
1069 .h_flex()
1070 .items_start()
1071 .child(bullet),
1072 range,
1073 markdown_end,
1074 );
1075 // Without `w_0`, text doesn't wrap to the width of the container.
1076 builder.push_div(div().flex_1().w_0(), range, markdown_end);
1077 }
1078 MarkdownTag::Emphasis => builder.push_text_style(TextStyleRefinement {
1079 font_style: Some(FontStyle::Italic),
1080 ..Default::default()
1081 }),
1082 MarkdownTag::Strong => builder.push_text_style(TextStyleRefinement {
1083 font_weight: Some(FontWeight::BOLD),
1084 ..Default::default()
1085 }),
1086 MarkdownTag::Strikethrough => {
1087 builder.push_text_style(TextStyleRefinement {
1088 strikethrough: Some(StrikethroughStyle {
1089 thickness: px(1.),
1090 color: None,
1091 }),
1092 ..Default::default()
1093 })
1094 }
1095 MarkdownTag::Link { dest_url, .. } => {
1096 if builder.code_block_stack.is_empty() {
1097 builder.push_link(dest_url.clone(), range.clone());
1098 let style = self
1099 .style
1100 .link_callback
1101 .as_ref()
1102 .and_then(|callback| callback(dest_url, cx))
1103 .unwrap_or_else(|| self.style.link.clone());
1104 builder.push_text_style(style)
1105 }
1106 }
1107 MarkdownTag::MetadataBlock(_) => {}
1108 MarkdownTag::Table(alignments) => {
1109 builder.table.start(alignments.clone());
1110
1111 let column_count = alignments.len();
1112 builder.push_div(
1113 div()
1114 .id(("table", range.start))
1115 .grid()
1116 .grid_cols(column_count as u16)
1117 .when(self.style.table_columns_min_size, |this| {
1118 this.grid_cols_min_content(column_count as u16)
1119 })
1120 .when(!self.style.table_columns_min_size, |this| {
1121 this.grid_cols(column_count as u16)
1122 })
1123 .size_full()
1124 .mb_2()
1125 .border(px(1.5))
1126 .border_color(cx.theme().colors().border)
1127 .rounded_sm()
1128 .overflow_hidden(),
1129 range,
1130 markdown_end,
1131 );
1132 }
1133 MarkdownTag::TableHead => {
1134 builder.table.start_head();
1135 builder.push_text_style(TextStyleRefinement {
1136 font_weight: Some(FontWeight::SEMIBOLD),
1137 ..Default::default()
1138 });
1139 }
1140 MarkdownTag::TableRow => {
1141 builder.table.start_row();
1142 }
1143 MarkdownTag::TableCell => {
1144 let is_header = builder.table.in_head;
1145 let row_index = builder.table.row_index;
1146 let col_index = builder.table.col_index;
1147
1148 builder.push_div(
1149 div()
1150 .when(col_index > 0, |this| this.border_l_1())
1151 .when(row_index > 0, |this| this.border_t_1())
1152 .border_color(cx.theme().colors().border)
1153 .px_1()
1154 .py_0p5()
1155 .when(is_header, |this| {
1156 this.bg(cx.theme().colors().title_bar_background)
1157 })
1158 .when(!is_header && row_index % 2 == 1, |this| {
1159 this.bg(cx.theme().colors().panel_background)
1160 }),
1161 range,
1162 markdown_end,
1163 );
1164 }
1165 _ => log::debug!("unsupported markdown tag {:?}", tag),
1166 }
1167 }
1168 MarkdownEvent::End(tag) => match tag {
1169 MarkdownTagEnd::Image => {
1170 current_img_block_range.take();
1171 }
1172 MarkdownTagEnd::Paragraph => {
1173 builder.pop_div();
1174 }
1175 MarkdownTagEnd::Heading(_) => {
1176 builder.pop_div();
1177 builder.pop_text_style()
1178 }
1179 MarkdownTagEnd::BlockQuote(_kind) => {
1180 builder.pop_text_style();
1181 builder.pop_div()
1182 }
1183 MarkdownTagEnd::CodeBlock => {
1184 builder.trim_trailing_newline();
1185
1186 builder.pop_div();
1187 builder.pop_code_block();
1188 builder.pop_text_style();
1189
1190 if let CodeBlockRenderer::Default {
1191 copy_button: true, ..
1192 } = &self.code_block_renderer
1193 {
1194 builder.modify_current_div(|el| {
1195 let content_range = parser::extract_code_block_content_range(
1196 &parsed_markdown.source()[range.clone()],
1197 );
1198 let content_range = content_range.start + range.start
1199 ..content_range.end + range.start;
1200
1201 let code = parsed_markdown.source()[content_range].to_string();
1202 let codeblock = render_copy_code_block_button(
1203 range.end,
1204 code,
1205 self.markdown.clone(),
1206 );
1207 el.child(
1208 h_flex()
1209 .w_4()
1210 .absolute()
1211 .top_1p5()
1212 .right_1p5()
1213 .justify_end()
1214 .child(codeblock),
1215 )
1216 });
1217 }
1218
1219 if let CodeBlockRenderer::Default {
1220 copy_button_on_hover: true,
1221 ..
1222 } = &self.code_block_renderer
1223 {
1224 builder.modify_current_div(|el| {
1225 let content_range = parser::extract_code_block_content_range(
1226 &parsed_markdown.source()[range.clone()],
1227 );
1228 let content_range = content_range.start + range.start
1229 ..content_range.end + range.start;
1230
1231 let code = parsed_markdown.source()[content_range].to_string();
1232 let codeblock = render_copy_code_block_button(
1233 range.end,
1234 code,
1235 self.markdown.clone(),
1236 );
1237 el.child(
1238 h_flex()
1239 .w_4()
1240 .absolute()
1241 .top_0()
1242 .right_0()
1243 .justify_end()
1244 .visible_on_hover("code_block")
1245 .child(codeblock),
1246 )
1247 });
1248 }
1249
1250 // Pop the parent container.
1251 builder.pop_div();
1252 }
1253 MarkdownTagEnd::HtmlBlock => builder.pop_div(),
1254 MarkdownTagEnd::List(_) => {
1255 builder.pop_list();
1256 builder.pop_div();
1257 }
1258 MarkdownTagEnd::Item => {
1259 builder.pop_div();
1260 builder.pop_div();
1261 }
1262 MarkdownTagEnd::Emphasis => builder.pop_text_style(),
1263 MarkdownTagEnd::Strong => builder.pop_text_style(),
1264 MarkdownTagEnd::Strikethrough => builder.pop_text_style(),
1265 MarkdownTagEnd::Link => {
1266 if builder.code_block_stack.is_empty() {
1267 builder.pop_text_style()
1268 }
1269 }
1270 MarkdownTagEnd::Table => {
1271 builder.pop_div();
1272 builder.table.end();
1273 }
1274 MarkdownTagEnd::TableHead => {
1275 builder.pop_text_style();
1276 builder.table.end_head();
1277 }
1278 MarkdownTagEnd::TableRow => {
1279 builder.table.end_row();
1280 }
1281 MarkdownTagEnd::TableCell => {
1282 builder.pop_div();
1283 builder.table.end_cell();
1284 }
1285 _ => log::debug!("unsupported markdown tag end: {:?}", tag),
1286 },
1287 MarkdownEvent::Text => {
1288 builder.push_text(&parsed_markdown.source[range.clone()], range.clone());
1289 }
1290 MarkdownEvent::SubstitutedText(text) => {
1291 builder.push_text(text, range.clone());
1292 }
1293 MarkdownEvent::Code => {
1294 builder.push_text_style(self.style.inline_code.clone());
1295 builder.push_text(&parsed_markdown.source[range.clone()], range.clone());
1296 builder.pop_text_style();
1297 }
1298 MarkdownEvent::Html => {
1299 let html = &parsed_markdown.source[range.clone()];
1300 if html.starts_with("<!--") {
1301 builder.html_comment = true;
1302 }
1303 if html.trim_end().ends_with("-->") {
1304 builder.html_comment = false;
1305 continue;
1306 }
1307 if builder.html_comment {
1308 continue;
1309 }
1310 builder.push_text(html, range.clone());
1311 }
1312 MarkdownEvent::InlineHtml => {
1313 let html = &parsed_markdown.source[range.clone()];
1314 if html.starts_with("<code>") {
1315 builder.push_text_style(self.style.inline_code.clone());
1316 continue;
1317 }
1318 if html.trim_end().starts_with("</code>") {
1319 builder.pop_text_style();
1320 continue;
1321 }
1322 builder.push_text(&parsed_markdown.source[range.clone()], range.clone());
1323 }
1324 MarkdownEvent::Rule => {
1325 builder.push_div(
1326 div()
1327 .border_b_1()
1328 .my_2()
1329 .border_color(self.style.rule_color),
1330 range,
1331 markdown_end,
1332 );
1333 builder.pop_div()
1334 }
1335 MarkdownEvent::SoftBreak => builder.push_text(" ", range.clone()),
1336 MarkdownEvent::HardBreak => builder.push_text("\n", range.clone()),
1337 MarkdownEvent::TaskListMarker(_) => {
1338 // handled inside the `MarkdownTag::Item` case
1339 }
1340 _ => log::debug!("unsupported markdown event {:?}", event),
1341 }
1342 }
1343 if self.style.code_block_overflow_x_scroll {
1344 let code_block_ids = code_block_ids;
1345 self.markdown.update(cx, move |markdown, _| {
1346 markdown.retain_code_block_scroll_handles(&code_block_ids);
1347 });
1348 } else {
1349 self.markdown
1350 .update(cx, |markdown, _| markdown.clear_code_block_scroll_handles());
1351 }
1352 let mut rendered_markdown = builder.build();
1353 let child_layout_id = rendered_markdown.element.request_layout(window, cx);
1354 let layout_id = window.request_layout(gpui::Style::default(), [child_layout_id], cx);
1355 (layout_id, rendered_markdown)
1356 }
1357
1358 fn prepaint(
1359 &mut self,
1360 _id: Option<&GlobalElementId>,
1361 _inspector_id: Option<&gpui::InspectorElementId>,
1362 bounds: Bounds<Pixels>,
1363 rendered_markdown: &mut Self::RequestLayoutState,
1364 window: &mut Window,
1365 cx: &mut App,
1366 ) -> Self::PrepaintState {
1367 let focus_handle = self.markdown.read(cx).focus_handle.clone();
1368 window.set_focus_handle(&focus_handle, cx);
1369 window.set_view_id(self.markdown.entity_id());
1370
1371 let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal);
1372 rendered_markdown.element.prepaint(window, cx);
1373 self.autoscroll(&rendered_markdown.text, window, cx);
1374 hitbox
1375 }
1376
1377 fn paint(
1378 &mut self,
1379 _id: Option<&GlobalElementId>,
1380 _inspector_id: Option<&gpui::InspectorElementId>,
1381 bounds: Bounds<Pixels>,
1382 rendered_markdown: &mut Self::RequestLayoutState,
1383 hitbox: &mut Self::PrepaintState,
1384 window: &mut Window,
1385 cx: &mut App,
1386 ) {
1387 let mut context = KeyContext::default();
1388 context.add("Markdown");
1389 window.set_key_context(context);
1390 window.on_action(std::any::TypeId::of::<crate::Copy>(), {
1391 let entity = self.markdown.clone();
1392 let text = rendered_markdown.text.clone();
1393 move |_, phase, window, cx| {
1394 let text = text.clone();
1395 if phase == DispatchPhase::Bubble {
1396 entity.update(cx, move |this, cx| this.copy(&text, window, cx))
1397 }
1398 }
1399 });
1400 window.on_action(std::any::TypeId::of::<crate::CopyAsMarkdown>(), {
1401 let entity = self.markdown.clone();
1402 move |_, phase, window, cx| {
1403 if phase == DispatchPhase::Bubble {
1404 entity.update(cx, move |this, cx| this.copy_as_markdown(window, cx))
1405 }
1406 }
1407 });
1408
1409 self.paint_mouse_listeners(hitbox, &rendered_markdown.text, window, cx);
1410 rendered_markdown.element.paint(window, cx);
1411 self.paint_selection(bounds, &rendered_markdown.text, window, cx);
1412 }
1413}
1414
1415fn apply_heading_style(
1416 mut heading: Div,
1417 level: pulldown_cmark::HeadingLevel,
1418 custom_styles: Option<&HeadingLevelStyles>,
1419) -> Div {
1420 heading = match level {
1421 pulldown_cmark::HeadingLevel::H1 => heading.text_3xl(),
1422 pulldown_cmark::HeadingLevel::H2 => heading.text_2xl(),
1423 pulldown_cmark::HeadingLevel::H3 => heading.text_xl(),
1424 pulldown_cmark::HeadingLevel::H4 => heading.text_lg(),
1425 pulldown_cmark::HeadingLevel::H5 => heading.text_base(),
1426 pulldown_cmark::HeadingLevel::H6 => heading.text_sm(),
1427 };
1428
1429 if let Some(styles) = custom_styles {
1430 let style_opt = match level {
1431 pulldown_cmark::HeadingLevel::H1 => &styles.h1,
1432 pulldown_cmark::HeadingLevel::H2 => &styles.h2,
1433 pulldown_cmark::HeadingLevel::H3 => &styles.h3,
1434 pulldown_cmark::HeadingLevel::H4 => &styles.h4,
1435 pulldown_cmark::HeadingLevel::H5 => &styles.h5,
1436 pulldown_cmark::HeadingLevel::H6 => &styles.h6,
1437 };
1438
1439 if let Some(style) = style_opt {
1440 heading.style().text = style.clone();
1441 }
1442 }
1443
1444 heading
1445}
1446
1447fn render_copy_code_block_button(
1448 id: usize,
1449 code: String,
1450 markdown: Entity<Markdown>,
1451) -> impl IntoElement {
1452 let id = ElementId::named_usize("copy-markdown-code", id);
1453
1454 CopyButton::new(code.clone()).custom_on_click({
1455 let markdown = markdown;
1456 move |_window, cx| {
1457 let id = id.clone();
1458 markdown.update(cx, |this, cx| {
1459 this.copied_code_blocks.insert(id.clone());
1460
1461 cx.write_to_clipboard(ClipboardItem::new_string(code.clone()));
1462
1463 cx.spawn(async move |this, cx| {
1464 cx.background_executor().timer(Duration::from_secs(2)).await;
1465
1466 cx.update(|cx| {
1467 this.update(cx, |this, cx| {
1468 this.copied_code_blocks.remove(&id);
1469 cx.notify();
1470 })
1471 })
1472 .ok();
1473 })
1474 .detach();
1475 });
1476 }
1477 })
1478}
1479
1480impl IntoElement for MarkdownElement {
1481 type Element = Self;
1482
1483 fn into_element(self) -> Self::Element {
1484 self
1485 }
1486}
1487
1488pub enum AnyDiv {
1489 Div(Div),
1490 Stateful(Stateful<Div>),
1491}
1492
1493impl AnyDiv {
1494 fn into_any_element(self) -> AnyElement {
1495 match self {
1496 Self::Div(div) => div.into_any_element(),
1497 Self::Stateful(div) => div.into_any_element(),
1498 }
1499 }
1500}
1501
1502impl From<Div> for AnyDiv {
1503 fn from(value: Div) -> Self {
1504 Self::Div(value)
1505 }
1506}
1507
1508impl From<Stateful<Div>> for AnyDiv {
1509 fn from(value: Stateful<Div>) -> Self {
1510 Self::Stateful(value)
1511 }
1512}
1513
1514impl Styled for AnyDiv {
1515 fn style(&mut self) -> &mut StyleRefinement {
1516 match self {
1517 Self::Div(div) => div.style(),
1518 Self::Stateful(div) => div.style(),
1519 }
1520 }
1521}
1522
1523impl ParentElement for AnyDiv {
1524 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
1525 match self {
1526 Self::Div(div) => div.extend(elements),
1527 Self::Stateful(div) => div.extend(elements),
1528 }
1529 }
1530}
1531
1532#[derive(Default)]
1533struct TableState {
1534 alignments: Vec<Alignment>,
1535 in_head: bool,
1536 row_index: usize,
1537 col_index: usize,
1538}
1539
1540impl TableState {
1541 fn start(&mut self, alignments: Vec<Alignment>) {
1542 self.alignments = alignments;
1543 self.in_head = false;
1544 self.row_index = 0;
1545 self.col_index = 0;
1546 }
1547
1548 fn end(&mut self) {
1549 self.alignments.clear();
1550 self.in_head = false;
1551 self.row_index = 0;
1552 self.col_index = 0;
1553 }
1554
1555 fn start_head(&mut self) {
1556 self.in_head = true;
1557 }
1558
1559 fn end_head(&mut self) {
1560 self.in_head = false;
1561 }
1562
1563 fn start_row(&mut self) {
1564 self.col_index = 0;
1565 }
1566
1567 fn end_row(&mut self) {
1568 self.row_index += 1;
1569 }
1570
1571 fn end_cell(&mut self) {
1572 self.col_index += 1;
1573 }
1574}
1575
1576struct MarkdownElementBuilder {
1577 div_stack: Vec<AnyDiv>,
1578 rendered_lines: Vec<RenderedLine>,
1579 pending_line: PendingLine,
1580 rendered_links: Vec<RenderedLink>,
1581 current_source_index: usize,
1582 html_comment: bool,
1583 base_text_style: TextStyle,
1584 text_style_stack: Vec<TextStyleRefinement>,
1585 code_block_stack: Vec<Option<Arc<Language>>>,
1586 list_stack: Vec<ListStackEntry>,
1587 table: TableState,
1588 syntax_theme: Arc<SyntaxTheme>,
1589}
1590
1591#[derive(Default)]
1592struct PendingLine {
1593 text: String,
1594 runs: Vec<TextRun>,
1595 source_mappings: Vec<SourceMapping>,
1596}
1597
1598struct ListStackEntry {
1599 bullet_index: Option<u64>,
1600}
1601
1602impl MarkdownElementBuilder {
1603 fn new(
1604 container_style: &StyleRefinement,
1605 base_text_style: TextStyle,
1606 syntax_theme: Arc<SyntaxTheme>,
1607 ) -> Self {
1608 Self {
1609 div_stack: vec![{
1610 let mut base_div = div();
1611 base_div.style().refine(container_style);
1612 base_div.debug_selector(|| "inner".into()).into()
1613 }],
1614 rendered_lines: Vec::new(),
1615 pending_line: PendingLine::default(),
1616 rendered_links: Vec::new(),
1617 current_source_index: 0,
1618 html_comment: false,
1619 base_text_style,
1620 text_style_stack: Vec::new(),
1621 code_block_stack: Vec::new(),
1622 list_stack: Vec::new(),
1623 table: TableState::default(),
1624 syntax_theme,
1625 }
1626 }
1627
1628 fn push_text_style(&mut self, style: TextStyleRefinement) {
1629 self.text_style_stack.push(style);
1630 }
1631
1632 fn text_style(&self) -> TextStyle {
1633 let mut style = self.base_text_style.clone();
1634 for refinement in &self.text_style_stack {
1635 style.refine(refinement);
1636 }
1637 style
1638 }
1639
1640 fn pop_text_style(&mut self) {
1641 self.text_style_stack.pop();
1642 }
1643
1644 fn push_div(&mut self, div: impl Into<AnyDiv>, range: &Range<usize>, markdown_end: usize) {
1645 let mut div = div.into();
1646 self.flush_text();
1647
1648 if range.start == 0 {
1649 // Remove the top margin on the first element.
1650 div.style().refine(&StyleRefinement {
1651 margin: gpui::EdgesRefinement {
1652 top: Some(Length::Definite(px(0.).into())),
1653 left: None,
1654 right: None,
1655 bottom: None,
1656 },
1657 ..Default::default()
1658 });
1659 }
1660
1661 if range.end == markdown_end {
1662 div.style().refine(&StyleRefinement {
1663 margin: gpui::EdgesRefinement {
1664 top: None,
1665 left: None,
1666 right: None,
1667 bottom: Some(Length::Definite(rems(0.).into())),
1668 },
1669 ..Default::default()
1670 });
1671 }
1672
1673 self.div_stack.push(div);
1674 }
1675
1676 fn modify_current_div(&mut self, f: impl FnOnce(AnyDiv) -> AnyDiv) {
1677 self.flush_text();
1678 if let Some(div) = self.div_stack.pop() {
1679 self.div_stack.push(f(div));
1680 }
1681 }
1682
1683 fn pop_div(&mut self) {
1684 self.flush_text();
1685 let div = self.div_stack.pop().unwrap().into_any_element();
1686 self.div_stack.last_mut().unwrap().extend(iter::once(div));
1687 }
1688
1689 fn push_list(&mut self, bullet_index: Option<u64>) {
1690 self.list_stack.push(ListStackEntry { bullet_index });
1691 }
1692
1693 fn next_bullet_index(&mut self) -> Option<u64> {
1694 self.list_stack.last_mut().and_then(|entry| {
1695 let item_index = entry.bullet_index.as_mut()?;
1696 *item_index += 1;
1697 Some(*item_index - 1)
1698 })
1699 }
1700
1701 fn pop_list(&mut self) {
1702 self.list_stack.pop();
1703 }
1704
1705 fn push_code_block(&mut self, language: Option<Arc<Language>>) {
1706 self.code_block_stack.push(language);
1707 }
1708
1709 fn pop_code_block(&mut self) {
1710 self.code_block_stack.pop();
1711 }
1712
1713 fn push_link(&mut self, destination_url: SharedString, source_range: Range<usize>) {
1714 self.rendered_links.push(RenderedLink {
1715 source_range,
1716 destination_url,
1717 });
1718 }
1719
1720 fn push_text(&mut self, text: &str, source_range: Range<usize>) {
1721 self.pending_line.source_mappings.push(SourceMapping {
1722 rendered_index: self.pending_line.text.len(),
1723 source_index: source_range.start,
1724 });
1725 self.pending_line.text.push_str(text);
1726 self.current_source_index = source_range.end;
1727
1728 if let Some(Some(language)) = self.code_block_stack.last() {
1729 let mut offset = 0;
1730 for (range, highlight_id) in language.highlight_text(&Rope::from(text), 0..text.len()) {
1731 if range.start > offset {
1732 self.pending_line
1733 .runs
1734 .push(self.text_style().to_run(range.start - offset));
1735 }
1736
1737 let mut run_style = self.text_style();
1738 if let Some(highlight) = highlight_id.style(&self.syntax_theme) {
1739 run_style = run_style.highlight(highlight);
1740 }
1741 self.pending_line.runs.push(run_style.to_run(range.len()));
1742 offset = range.end;
1743 }
1744
1745 if offset < text.len() {
1746 self.pending_line
1747 .runs
1748 .push(self.text_style().to_run(text.len() - offset));
1749 }
1750 } else {
1751 self.pending_line
1752 .runs
1753 .push(self.text_style().to_run(text.len()));
1754 }
1755 }
1756
1757 fn trim_trailing_newline(&mut self) {
1758 if self.pending_line.text.ends_with('\n') {
1759 self.pending_line
1760 .text
1761 .truncate(self.pending_line.text.len() - 1);
1762 self.pending_line.runs.last_mut().unwrap().len -= 1;
1763 self.current_source_index -= 1;
1764 }
1765 }
1766
1767 fn flush_text(&mut self) {
1768 let line = mem::take(&mut self.pending_line);
1769 if line.text.is_empty() {
1770 return;
1771 }
1772
1773 let text = StyledText::new(line.text).with_runs(line.runs);
1774 self.rendered_lines.push(RenderedLine {
1775 layout: text.layout().clone(),
1776 source_mappings: line.source_mappings,
1777 source_end: self.current_source_index,
1778 });
1779 self.div_stack.last_mut().unwrap().extend([text.into_any()]);
1780 }
1781
1782 fn build(mut self) -> RenderedMarkdown {
1783 debug_assert_eq!(self.div_stack.len(), 1);
1784 self.flush_text();
1785 RenderedMarkdown {
1786 element: self.div_stack.pop().unwrap().into_any_element(),
1787 text: RenderedText {
1788 lines: self.rendered_lines.into(),
1789 links: self.rendered_links.into(),
1790 },
1791 }
1792 }
1793}
1794
1795struct RenderedLine {
1796 layout: TextLayout,
1797 source_mappings: Vec<SourceMapping>,
1798 source_end: usize,
1799}
1800
1801impl RenderedLine {
1802 fn rendered_index_for_source_index(&self, source_index: usize) -> usize {
1803 if source_index >= self.source_end {
1804 return self.layout.len();
1805 }
1806
1807 let mapping = match self
1808 .source_mappings
1809 .binary_search_by_key(&source_index, |probe| probe.source_index)
1810 {
1811 Ok(ix) => &self.source_mappings[ix],
1812 Err(ix) => &self.source_mappings[ix - 1],
1813 };
1814 mapping.rendered_index + (source_index - mapping.source_index)
1815 }
1816
1817 fn source_index_for_rendered_index(&self, rendered_index: usize) -> usize {
1818 if rendered_index >= self.layout.len() {
1819 return self.source_end;
1820 }
1821
1822 let mapping = match self
1823 .source_mappings
1824 .binary_search_by_key(&rendered_index, |probe| probe.rendered_index)
1825 {
1826 Ok(ix) => &self.source_mappings[ix],
1827 Err(ix) => &self.source_mappings[ix - 1],
1828 };
1829 mapping.source_index + (rendered_index - mapping.rendered_index)
1830 }
1831
1832 fn source_index_for_position(&self, position: Point<Pixels>) -> Result<usize, usize> {
1833 let line_rendered_index;
1834 let out_of_bounds;
1835 match self.layout.index_for_position(position) {
1836 Ok(ix) => {
1837 line_rendered_index = ix;
1838 out_of_bounds = false;
1839 }
1840 Err(ix) => {
1841 line_rendered_index = ix;
1842 out_of_bounds = true;
1843 }
1844 };
1845 let source_index = self.source_index_for_rendered_index(line_rendered_index);
1846 if out_of_bounds {
1847 Err(source_index)
1848 } else {
1849 Ok(source_index)
1850 }
1851 }
1852}
1853
1854#[derive(Copy, Clone, Debug, Default)]
1855struct SourceMapping {
1856 rendered_index: usize,
1857 source_index: usize,
1858}
1859
1860pub struct RenderedMarkdown {
1861 element: AnyElement,
1862 text: RenderedText,
1863}
1864
1865#[derive(Clone)]
1866struct RenderedText {
1867 lines: Rc<[RenderedLine]>,
1868 links: Rc<[RenderedLink]>,
1869}
1870
1871#[derive(Debug, Clone, Eq, PartialEq)]
1872struct RenderedLink {
1873 source_range: Range<usize>,
1874 destination_url: SharedString,
1875}
1876
1877impl RenderedText {
1878 fn source_index_for_position(&self, position: Point<Pixels>) -> Result<usize, usize> {
1879 let mut lines = self.lines.iter().peekable();
1880
1881 while let Some(line) = lines.next() {
1882 let line_bounds = line.layout.bounds();
1883 if position.y > line_bounds.bottom() {
1884 if let Some(next_line) = lines.peek()
1885 && position.y < next_line.layout.bounds().top()
1886 {
1887 return Err(line.source_end);
1888 }
1889
1890 continue;
1891 }
1892
1893 return line.source_index_for_position(position);
1894 }
1895
1896 Err(self.lines.last().map_or(0, |line| line.source_end))
1897 }
1898
1899 fn position_for_source_index(&self, source_index: usize) -> Option<(Point<Pixels>, Pixels)> {
1900 for line in self.lines.iter() {
1901 let line_source_start = line.source_mappings.first().unwrap().source_index;
1902 if source_index < line_source_start {
1903 break;
1904 } else if source_index > line.source_end {
1905 continue;
1906 } else {
1907 let line_height = line.layout.line_height();
1908 let rendered_index_within_line = line.rendered_index_for_source_index(source_index);
1909 let position = line.layout.position_for_index(rendered_index_within_line)?;
1910 return Some((position, line_height));
1911 }
1912 }
1913 None
1914 }
1915
1916 fn surrounding_word_range(&self, source_index: usize) -> Range<usize> {
1917 for line in self.lines.iter() {
1918 if source_index > line.source_end {
1919 continue;
1920 }
1921
1922 let line_rendered_start = line.source_mappings.first().unwrap().rendered_index;
1923 let rendered_index_in_line =
1924 line.rendered_index_for_source_index(source_index) - line_rendered_start;
1925 let text = line.layout.text();
1926 let previous_space = if let Some(idx) = text[0..rendered_index_in_line].rfind(' ') {
1927 idx + ' '.len_utf8()
1928 } else {
1929 0
1930 };
1931 let next_space = if let Some(idx) = text[rendered_index_in_line..].find(' ') {
1932 rendered_index_in_line + idx
1933 } else {
1934 text.len()
1935 };
1936
1937 return line.source_index_for_rendered_index(line_rendered_start + previous_space)
1938 ..line.source_index_for_rendered_index(line_rendered_start + next_space);
1939 }
1940
1941 source_index..source_index
1942 }
1943
1944 fn surrounding_line_range(&self, source_index: usize) -> Range<usize> {
1945 for line in self.lines.iter() {
1946 if source_index > line.source_end {
1947 continue;
1948 }
1949 let line_source_start = line.source_mappings.first().unwrap().source_index;
1950 return line_source_start..line.source_end;
1951 }
1952
1953 source_index..source_index
1954 }
1955
1956 fn text_for_range(&self, range: Range<usize>) -> String {
1957 let mut accumulator = String::new();
1958
1959 for line in self.lines.iter() {
1960 if range.start > line.source_end {
1961 continue;
1962 }
1963 let line_source_start = line.source_mappings.first().unwrap().source_index;
1964 if range.end < line_source_start {
1965 break;
1966 }
1967
1968 let text = line.layout.text();
1969
1970 let start = if range.start < line_source_start {
1971 0
1972 } else {
1973 line.rendered_index_for_source_index(range.start)
1974 };
1975 let end = if range.end > line.source_end {
1976 line.rendered_index_for_source_index(line.source_end)
1977 } else {
1978 line.rendered_index_for_source_index(range.end)
1979 }
1980 .min(text.len());
1981
1982 accumulator.push_str(&text[start..end]);
1983 accumulator.push('\n');
1984 }
1985 // Remove trailing newline
1986 accumulator.pop();
1987 accumulator
1988 }
1989
1990 fn link_for_position(&self, position: Point<Pixels>) -> Option<&RenderedLink> {
1991 let source_index = self.source_index_for_position(position).ok()?;
1992 self.links
1993 .iter()
1994 .find(|link| link.source_range.contains(&source_index))
1995 }
1996}
1997
1998#[cfg(test)]
1999mod tests {
2000 use super::*;
2001 use gpui::{TestAppContext, size};
2002
2003 #[gpui::test]
2004 fn test_mappings(cx: &mut TestAppContext) {
2005 // Formatting.
2006 assert_mappings(
2007 &render_markdown("He*l*lo", cx),
2008 vec![vec![(0, 0), (1, 1), (2, 3), (3, 5), (4, 6), (5, 7)]],
2009 );
2010
2011 // Multiple lines.
2012 assert_mappings(
2013 &render_markdown("Hello\n\nWorld", cx),
2014 vec![
2015 vec![(0, 0), (1, 1), (2, 2), (3, 3), (4, 4), (5, 5)],
2016 vec![(0, 7), (1, 8), (2, 9), (3, 10), (4, 11), (5, 12)],
2017 ],
2018 );
2019
2020 // Multi-byte characters.
2021 assert_mappings(
2022 &render_markdown("αβγ\n\nδεζ", cx),
2023 vec![
2024 vec![(0, 0), (2, 2), (4, 4), (6, 6)],
2025 vec![(0, 8), (2, 10), (4, 12), (6, 14)],
2026 ],
2027 );
2028
2029 // Smart quotes.
2030 assert_mappings(&render_markdown("\"", cx), vec![vec![(0, 0), (3, 1)]]);
2031 assert_mappings(
2032 &render_markdown("\"hey\"", cx),
2033 vec![vec![(0, 0), (3, 1), (4, 2), (5, 3), (6, 4), (9, 5)]],
2034 );
2035
2036 // HTML Comments are ignored
2037 assert_mappings(
2038 &render_markdown(
2039 "<!--\nrdoc-file=string.c\n- str.intern -> symbol\n- str.to_sym -> symbol\n-->\nReturns",
2040 cx,
2041 ),
2042 vec![vec![
2043 (0, 78),
2044 (1, 79),
2045 (2, 80),
2046 (3, 81),
2047 (4, 82),
2048 (5, 83),
2049 (6, 84),
2050 ]],
2051 );
2052 }
2053
2054 fn render_markdown(markdown: &str, cx: &mut TestAppContext) -> RenderedText {
2055 struct TestWindow;
2056
2057 impl Render for TestWindow {
2058 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2059 div()
2060 }
2061 }
2062
2063 let (_, cx) = cx.add_window_view(|_, _| TestWindow);
2064 let markdown = cx.new(|cx| Markdown::new(markdown.to_string().into(), None, None, cx));
2065 cx.run_until_parked();
2066 let (rendered, _) = cx.draw(
2067 Default::default(),
2068 size(px(600.0), px(600.0)),
2069 |_window, _cx| MarkdownElement::new(markdown, MarkdownStyle::default()),
2070 );
2071 rendered.text
2072 }
2073
2074 #[gpui::test]
2075 fn test_surrounding_word_range(cx: &mut TestAppContext) {
2076 let rendered = render_markdown("Hello world tesεζ", cx);
2077
2078 // Test word selection for "Hello"
2079 let word_range = rendered.surrounding_word_range(2); // Simulate click on 'l' in "Hello"
2080 let selected_text = rendered.text_for_range(word_range);
2081 assert_eq!(selected_text, "Hello");
2082
2083 // Test word selection for "world"
2084 let word_range = rendered.surrounding_word_range(7); // Simulate click on 'o' in "world"
2085 let selected_text = rendered.text_for_range(word_range);
2086 assert_eq!(selected_text, "world");
2087
2088 // Test word selection for "tesεζ"
2089 let word_range = rendered.surrounding_word_range(14); // Simulate click on 's' in "tesεζ"
2090 let selected_text = rendered.text_for_range(word_range);
2091 assert_eq!(selected_text, "tesεζ");
2092
2093 // Test word selection at word boundary (space)
2094 let word_range = rendered.surrounding_word_range(5); // Simulate click on space between "Hello" and "world", expect highlighting word to the left
2095 let selected_text = rendered.text_for_range(word_range);
2096 assert_eq!(selected_text, "Hello");
2097 }
2098
2099 #[gpui::test]
2100 fn test_surrounding_line_range(cx: &mut TestAppContext) {
2101 let rendered = render_markdown("First line\n\nSecond line\n\nThird lineεζ", cx);
2102
2103 // Test getting line range for first line
2104 let line_range = rendered.surrounding_line_range(5); // Simulate click somewhere in first line
2105 let selected_text = rendered.text_for_range(line_range);
2106 assert_eq!(selected_text, "First line");
2107
2108 // Test getting line range for second line
2109 let line_range = rendered.surrounding_line_range(13); // Simulate click at beginning in second line
2110 let selected_text = rendered.text_for_range(line_range);
2111 assert_eq!(selected_text, "Second line");
2112
2113 // Test getting line range for third line
2114 let line_range = rendered.surrounding_line_range(37); // Simulate click at end of third line with multi-byte chars
2115 let selected_text = rendered.text_for_range(line_range);
2116 assert_eq!(selected_text, "Third lineεζ");
2117 }
2118
2119 #[gpui::test]
2120 fn test_selection_head_movement(cx: &mut TestAppContext) {
2121 let rendered = render_markdown("Hello world test", cx);
2122
2123 let mut selection = Selection {
2124 start: 5,
2125 end: 5,
2126 reversed: false,
2127 pending: false,
2128 mode: SelectMode::Character,
2129 };
2130
2131 // Test forward selection
2132 selection.set_head(10, &rendered);
2133 assert_eq!(selection.start, 5);
2134 assert_eq!(selection.end, 10);
2135 assert!(!selection.reversed);
2136 assert_eq!(selection.tail(), 5);
2137
2138 // Test backward selection
2139 selection.set_head(2, &rendered);
2140 assert_eq!(selection.start, 2);
2141 assert_eq!(selection.end, 5);
2142 assert!(selection.reversed);
2143 assert_eq!(selection.tail(), 5);
2144
2145 // Test forward selection again from reversed state
2146 selection.set_head(15, &rendered);
2147 assert_eq!(selection.start, 5);
2148 assert_eq!(selection.end, 15);
2149 assert!(!selection.reversed);
2150 assert_eq!(selection.tail(), 5);
2151 }
2152
2153 #[gpui::test]
2154 fn test_word_selection_drag(cx: &mut TestAppContext) {
2155 let rendered = render_markdown("Hello world test", cx);
2156
2157 // Start with a simulated double-click on "world" (index 6-10)
2158 let word_range = rendered.surrounding_word_range(7); // Click on 'o' in "world"
2159 let mut selection = Selection {
2160 start: word_range.start,
2161 end: word_range.end,
2162 reversed: false,
2163 pending: true,
2164 mode: SelectMode::Word(word_range),
2165 };
2166
2167 // Drag forward to "test" - should expand selection to include "test"
2168 selection.set_head(13, &rendered); // Index in "test"
2169 assert_eq!(selection.start, 6); // Start of "world"
2170 assert_eq!(selection.end, 16); // End of "test"
2171 assert!(!selection.reversed);
2172 let selected_text = rendered.text_for_range(selection.start..selection.end);
2173 assert_eq!(selected_text, "world test");
2174
2175 // Drag backward to "Hello" - should expand selection to include "Hello"
2176 selection.set_head(2, &rendered); // Index in "Hello"
2177 assert_eq!(selection.start, 0); // Start of "Hello"
2178 assert_eq!(selection.end, 11); // End of "world" (original selection)
2179 assert!(selection.reversed);
2180 let selected_text = rendered.text_for_range(selection.start..selection.end);
2181 assert_eq!(selected_text, "Hello world");
2182
2183 // Drag back within original word - should revert to original selection
2184 selection.set_head(8, &rendered); // Back within "world"
2185 assert_eq!(selection.start, 6); // Start of "world"
2186 assert_eq!(selection.end, 11); // End of "world"
2187 assert!(!selection.reversed);
2188 let selected_text = rendered.text_for_range(selection.start..selection.end);
2189 assert_eq!(selected_text, "world");
2190 }
2191
2192 #[gpui::test]
2193 fn test_selection_with_markdown_formatting(cx: &mut TestAppContext) {
2194 let rendered = render_markdown(
2195 "This is **bold** text, this is *italic* text, use `code` here",
2196 cx,
2197 );
2198 let word_range = rendered.surrounding_word_range(10); // Inside "bold"
2199 let selected_text = rendered.text_for_range(word_range);
2200 assert_eq!(selected_text, "bold");
2201
2202 let word_range = rendered.surrounding_word_range(32); // Inside "italic"
2203 let selected_text = rendered.text_for_range(word_range);
2204 assert_eq!(selected_text, "italic");
2205
2206 let word_range = rendered.surrounding_word_range(51); // Inside "code"
2207 let selected_text = rendered.text_for_range(word_range);
2208 assert_eq!(selected_text, "code");
2209 }
2210
2211 #[gpui::test]
2212 fn test_all_selection(cx: &mut TestAppContext) {
2213 let rendered = render_markdown("Hello world\n\nThis is a test\n\nwith multiple lines", cx);
2214
2215 let total_length = rendered
2216 .lines
2217 .last()
2218 .map(|line| line.source_end)
2219 .unwrap_or(0);
2220
2221 let mut selection = Selection {
2222 start: 0,
2223 end: total_length,
2224 reversed: false,
2225 pending: true,
2226 mode: SelectMode::All,
2227 };
2228
2229 selection.set_head(5, &rendered); // Try to set head in middle
2230 assert_eq!(selection.start, 0);
2231 assert_eq!(selection.end, total_length);
2232 assert!(!selection.reversed);
2233
2234 selection.set_head(25, &rendered); // Try to set head near end
2235 assert_eq!(selection.start, 0);
2236 assert_eq!(selection.end, total_length);
2237 assert!(!selection.reversed);
2238
2239 let selected_text = rendered.text_for_range(selection.start..selection.end);
2240 assert_eq!(
2241 selected_text,
2242 "Hello world\nThis is a test\nwith multiple lines"
2243 );
2244 }
2245
2246 #[test]
2247 fn test_escape() {
2248 assert_eq!(Markdown::escape("hello `world`"), "hello \\`world\\`");
2249 assert_eq!(
2250 Markdown::escape("hello\n cool world"),
2251 "hello\n\ncool world"
2252 );
2253 }
2254
2255 #[track_caller]
2256 fn assert_mappings(rendered: &RenderedText, expected: Vec<Vec<(usize, usize)>>) {
2257 assert_eq!(rendered.lines.len(), expected.len(), "line count mismatch");
2258 for (line_ix, line_mappings) in expected.into_iter().enumerate() {
2259 let line = &rendered.lines[line_ix];
2260
2261 assert!(
2262 line.source_mappings.windows(2).all(|mappings| {
2263 mappings[0].source_index < mappings[1].source_index
2264 && mappings[0].rendered_index < mappings[1].rendered_index
2265 }),
2266 "line {} has duplicate mappings: {:?}",
2267 line_ix,
2268 line.source_mappings
2269 );
2270
2271 for (rendered_ix, source_ix) in line_mappings {
2272 assert_eq!(
2273 line.source_index_for_rendered_index(rendered_ix),
2274 source_ix,
2275 "line {}, rendered_ix {}",
2276 line_ix,
2277 rendered_ix
2278 );
2279
2280 assert_eq!(
2281 line.rendered_index_for_source_index(source_ix),
2282 rendered_ix,
2283 "line {}, source_ix {}",
2284 line_ix,
2285 source_ix
2286 );
2287 }
2288 }
2289 }
2290}