1pub mod html;
2mod mermaid;
3pub mod parser;
4mod path_range;
5
6use base64::Engine as _;
7use futures::FutureExt as _;
8use gpui::EdgesRefinement;
9use gpui::HitboxBehavior;
10use gpui::UnderlineStyle;
11use language::LanguageName;
12
13use log::Level;
14use mermaid::{
15 MermaidState, ParsedMarkdownMermaidDiagram, extract_mermaid_diagrams, render_mermaid_diagram,
16};
17pub use path_range::{LineCol, PathWithRange};
18use settings::Settings as _;
19use theme_settings::ThemeSettings;
20use ui::Checkbox;
21use ui::CopyButton;
22
23use std::borrow::Cow;
24use std::collections::BTreeMap;
25use std::iter;
26use std::mem;
27use std::ops::Range;
28use std::path::Path;
29use std::rc::Rc;
30use std::sync::Arc;
31use std::time::Duration;
32
33use collections::{HashMap, HashSet};
34use gpui::{
35 AnyElement, App, BorderStyle, Bounds, ClipboardItem, CursorStyle, DispatchPhase, Edges, Entity,
36 FocusHandle, Focusable, FontStyle, FontWeight, GlobalElementId, Hitbox, Hsla, Image,
37 ImageFormat, ImageSource, KeyContext, Length, MouseButton, MouseDownEvent, MouseEvent,
38 MouseMoveEvent, MouseUpEvent, Point, ScrollHandle, Stateful, StrikethroughStyle,
39 StyleRefinement, StyledText, Task, TextAlign, TextLayout, TextRun, TextStyle,
40 TextStyleRefinement, actions, img, point, quad,
41};
42use language::{CharClassifier, Language, LanguageRegistry, Rope};
43use parser::CodeBlockMetadata;
44use parser::{
45 MarkdownEvent, MarkdownTag, MarkdownTagEnd, parse_links_only, parse_markdown_with_options,
46};
47use pulldown_cmark::Alignment;
48use sum_tree::TreeMap;
49use theme::SyntaxTheme;
50use ui::{ScrollAxes, Scrollbars, WithScrollbar, prelude::*};
51use util::ResultExt;
52
53use crate::parser::CodeBlockKind;
54
55/// A callback function that can be used to customize the style of links based on the destination URL.
56/// If the callback returns `None`, the default link style will be used.
57type LinkStyleCallback = Rc<dyn Fn(&str, &App) -> Option<TextStyleRefinement>>;
58type SourceClickCallback = Box<dyn Fn(usize, usize, &mut Window, &mut App) -> bool>;
59type CheckboxToggleCallback = Rc<dyn Fn(Range<usize>, bool, &mut Window, &mut App)>;
60/// Defines custom style refinements for each heading level (H1-H6)
61#[derive(Clone, Default)]
62pub struct HeadingLevelStyles {
63 pub h1: Option<TextStyleRefinement>,
64 pub h2: Option<TextStyleRefinement>,
65 pub h3: Option<TextStyleRefinement>,
66 pub h4: Option<TextStyleRefinement>,
67 pub h5: Option<TextStyleRefinement>,
68 pub h6: Option<TextStyleRefinement>,
69}
70
71#[derive(Clone)]
72pub struct MarkdownStyle {
73 pub base_text_style: TextStyle,
74 pub container_style: StyleRefinement,
75 pub code_block: StyleRefinement,
76 pub code_block_overflow_x_scroll: bool,
77 pub inline_code: TextStyleRefinement,
78 pub block_quote: TextStyleRefinement,
79 pub link: TextStyleRefinement,
80 pub link_callback: Option<LinkStyleCallback>,
81 pub rule_color: Hsla,
82 pub block_quote_border_color: Hsla,
83 pub syntax: Arc<SyntaxTheme>,
84 pub selection_background_color: Hsla,
85 pub heading: StyleRefinement,
86 pub heading_level_styles: Option<HeadingLevelStyles>,
87 pub height_is_multiple_of_line_height: bool,
88 pub prevent_mouse_interaction: bool,
89 pub table_columns_min_size: bool,
90}
91
92impl Default for MarkdownStyle {
93 fn default() -> Self {
94 Self {
95 base_text_style: Default::default(),
96 container_style: Default::default(),
97 code_block: Default::default(),
98 code_block_overflow_x_scroll: false,
99 inline_code: Default::default(),
100 block_quote: Default::default(),
101 link: Default::default(),
102 link_callback: None,
103 rule_color: Default::default(),
104 block_quote_border_color: Default::default(),
105 syntax: Arc::new(SyntaxTheme::default()),
106 selection_background_color: Default::default(),
107 heading: Default::default(),
108 heading_level_styles: None,
109 height_is_multiple_of_line_height: false,
110 prevent_mouse_interaction: false,
111 table_columns_min_size: false,
112 }
113 }
114}
115
116pub enum MarkdownFont {
117 Agent,
118 Editor,
119}
120
121impl MarkdownStyle {
122 pub fn themed(font: MarkdownFont, window: &Window, cx: &App) -> Self {
123 let theme_settings = ThemeSettings::get_global(cx);
124 let colors = cx.theme().colors();
125
126 let buffer_font_weight = theme_settings.buffer_font.weight;
127 let (buffer_font_size, ui_font_size) = match font {
128 MarkdownFont::Agent => (
129 theme_settings.agent_buffer_font_size(cx),
130 theme_settings.agent_ui_font_size(cx),
131 ),
132 MarkdownFont::Editor => (
133 theme_settings.buffer_font_size(cx),
134 theme_settings.ui_font_size(cx),
135 ),
136 };
137
138 let text_color = colors.text;
139
140 let mut text_style = window.text_style();
141 let line_height = buffer_font_size * 1.75;
142
143 text_style.refine(&TextStyleRefinement {
144 font_family: Some(theme_settings.ui_font.family.clone()),
145 font_fallbacks: theme_settings.ui_font.fallbacks.clone(),
146 font_features: Some(theme_settings.ui_font.features.clone()),
147 font_size: Some(ui_font_size.into()),
148 line_height: Some(line_height.into()),
149 color: Some(text_color),
150 ..Default::default()
151 });
152
153 MarkdownStyle {
154 base_text_style: text_style.clone(),
155 syntax: cx.theme().syntax().clone(),
156 selection_background_color: colors.element_selection_background,
157 rule_color: colors.border,
158 block_quote_border_color: colors.border,
159 code_block_overflow_x_scroll: true,
160 code_block: StyleRefinement {
161 padding: EdgesRefinement {
162 top: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(8.)))),
163 left: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(8.)))),
164 right: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(8.)))),
165 bottom: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(8.)))),
166 },
167 margin: EdgesRefinement {
168 top: Some(Length::Definite(px(8.).into())),
169 left: Some(Length::Definite(px(0.).into())),
170 right: Some(Length::Definite(px(0.).into())),
171 bottom: Some(Length::Definite(px(12.).into())),
172 },
173 border_style: Some(BorderStyle::Solid),
174 border_widths: EdgesRefinement {
175 top: Some(AbsoluteLength::Pixels(px(1.))),
176 left: Some(AbsoluteLength::Pixels(px(1.))),
177 right: Some(AbsoluteLength::Pixels(px(1.))),
178 bottom: Some(AbsoluteLength::Pixels(px(1.))),
179 },
180 border_color: Some(colors.border_variant),
181 background: Some(colors.editor_background.into()),
182 text: TextStyleRefinement {
183 font_family: Some(theme_settings.buffer_font.family.clone()),
184 font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
185 font_features: Some(theme_settings.buffer_font.features.clone()),
186 font_size: Some(buffer_font_size.into()),
187 font_weight: Some(buffer_font_weight),
188 ..Default::default()
189 },
190 ..Default::default()
191 },
192 inline_code: TextStyleRefinement {
193 font_family: Some(theme_settings.buffer_font.family.clone()),
194 font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
195 font_features: Some(theme_settings.buffer_font.features.clone()),
196 font_size: Some(buffer_font_size.into()),
197 font_weight: Some(buffer_font_weight),
198 background_color: Some(colors.editor_foreground.opacity(0.08)),
199 color: cx
200 .theme()
201 .syntax()
202 .style_for_name("text.literal")
203 .and_then(|style| style.color),
204 ..Default::default()
205 },
206 link: TextStyleRefinement {
207 background_color: Some(colors.editor_foreground.opacity(0.025)),
208 color: Some(colors.text_accent),
209 underline: Some(UnderlineStyle {
210 color: Some(colors.text_accent.opacity(0.5)),
211 thickness: px(1.),
212 ..Default::default()
213 }),
214 ..Default::default()
215 },
216 ..Default::default()
217 }
218 }
219
220 pub fn with_muted_text(mut self, cx: &App) -> Self {
221 let colors = cx.theme().colors();
222 self.base_text_style.color = colors.text_muted;
223 self
224 }
225}
226
227pub struct Markdown {
228 source: SharedString,
229 selection: Selection,
230 pressed_link: Option<RenderedLink>,
231 pressed_footnote_ref: Option<RenderedFootnoteRef>,
232 autoscroll_request: Option<usize>,
233 active_root_block: Option<usize>,
234 parsed_markdown: ParsedMarkdown,
235 images_by_source_offset: HashMap<usize, Arc<Image>>,
236 should_reparse: bool,
237 pending_parse: Option<Task<()>>,
238 focus_handle: FocusHandle,
239 language_registry: Option<Arc<LanguageRegistry>>,
240 fallback_code_block_language: Option<LanguageName>,
241 options: MarkdownOptions,
242 mermaid_state: MermaidState,
243 copied_code_blocks: HashSet<ElementId>,
244 code_block_scroll_handles: BTreeMap<usize, ScrollHandle>,
245 context_menu_link: Option<SharedString>,
246 context_menu_selected_text: Option<String>,
247 search_highlights: Vec<Range<usize>>,
248 active_search_highlight: Option<usize>,
249}
250
251#[derive(Clone, Copy, Default)]
252pub struct MarkdownOptions {
253 pub parse_links_only: bool,
254 pub parse_html: bool,
255 pub render_mermaid_diagrams: bool,
256 pub parse_heading_slugs: bool,
257}
258
259#[derive(Clone, Copy, PartialEq, Eq)]
260pub enum CopyButtonVisibility {
261 Hidden,
262 AlwaysVisible,
263 VisibleOnHover,
264}
265
266pub enum CodeBlockRenderer {
267 Default {
268 copy_button_visibility: CopyButtonVisibility,
269 border: bool,
270 },
271 Custom {
272 render: CodeBlockRenderFn,
273 /// A function that can modify the parent container after the code block
274 /// content has been appended as a child element.
275 transform: Option<CodeBlockTransformFn>,
276 },
277}
278
279pub type CodeBlockRenderFn = Arc<
280 dyn Fn(
281 &CodeBlockKind,
282 &ParsedMarkdown,
283 Range<usize>,
284 CodeBlockMetadata,
285 &mut Window,
286 &App,
287 ) -> Div,
288>;
289
290pub type CodeBlockTransformFn =
291 Arc<dyn Fn(AnyDiv, Range<usize>, CodeBlockMetadata, &mut Window, &App) -> AnyDiv>;
292
293actions!(
294 markdown,
295 [
296 /// Copies the selected text to the clipboard.
297 Copy,
298 /// Copies the selected text as markdown to the clipboard.
299 CopyAsMarkdown
300 ]
301);
302
303enum EscapeAction {
304 PassThrough,
305 Nbsp(usize),
306 DoubleNewline,
307 PrefixBackslash,
308}
309
310impl EscapeAction {
311 fn output_len(&self) -> usize {
312 match self {
313 Self::PassThrough => 1,
314 Self::Nbsp(count) => count * '\u{00A0}'.len_utf8(),
315 Self::DoubleNewline => 2,
316 Self::PrefixBackslash => 2,
317 }
318 }
319
320 fn write_to(&self, c: char, output: &mut String) {
321 match self {
322 Self::PassThrough => output.push(c),
323 Self::Nbsp(count) => {
324 for _ in 0..*count {
325 output.push('\u{00A0}');
326 }
327 }
328 Self::DoubleNewline => {
329 output.push('\n');
330 output.push('\n');
331 }
332 Self::PrefixBackslash => {
333 // '\\' is a single backslash in Rust, e.g. '|' -> '\|'
334 output.push('\\');
335 output.push(c);
336 }
337 }
338 }
339}
340
341// Valid to operate on raw bytes since multi-byte UTF-8
342// sequences never contain ASCII-range bytes.
343struct MarkdownEscaper {
344 in_leading_whitespace: bool,
345}
346
347impl MarkdownEscaper {
348 const TAB_SIZE: usize = 4;
349
350 fn new() -> Self {
351 Self {
352 in_leading_whitespace: true,
353 }
354 }
355
356 fn next(&mut self, byte: u8) -> EscapeAction {
357 let action = if self.in_leading_whitespace && byte == b'\t' {
358 EscapeAction::Nbsp(Self::TAB_SIZE)
359 } else if self.in_leading_whitespace && byte == b' ' {
360 EscapeAction::Nbsp(1)
361 } else if byte == b'\n' {
362 EscapeAction::DoubleNewline
363 } else if byte.is_ascii_punctuation() {
364 EscapeAction::PrefixBackslash
365 } else {
366 EscapeAction::PassThrough
367 };
368
369 self.in_leading_whitespace =
370 byte == b'\n' || (self.in_leading_whitespace && (byte == b' ' || byte == b'\t'));
371 action
372 }
373}
374
375impl Markdown {
376 pub fn new(
377 source: SharedString,
378 language_registry: Option<Arc<LanguageRegistry>>,
379 fallback_code_block_language: Option<LanguageName>,
380 cx: &mut Context<Self>,
381 ) -> Self {
382 Self::new_with_options(
383 source,
384 language_registry,
385 fallback_code_block_language,
386 MarkdownOptions::default(),
387 cx,
388 )
389 }
390
391 pub fn new_with_options(
392 source: SharedString,
393 language_registry: Option<Arc<LanguageRegistry>>,
394 fallback_code_block_language: Option<LanguageName>,
395 options: MarkdownOptions,
396 cx: &mut Context<Self>,
397 ) -> Self {
398 let focus_handle = cx.focus_handle();
399 let mut this = Self {
400 source,
401 selection: Selection::default(),
402 pressed_link: None,
403 pressed_footnote_ref: None,
404 autoscroll_request: None,
405 active_root_block: None,
406 should_reparse: false,
407 images_by_source_offset: Default::default(),
408 parsed_markdown: ParsedMarkdown::default(),
409 pending_parse: None,
410 focus_handle,
411 language_registry,
412 fallback_code_block_language,
413 options,
414 mermaid_state: MermaidState::default(),
415 copied_code_blocks: HashSet::default(),
416 code_block_scroll_handles: BTreeMap::default(),
417 context_menu_link: None,
418 context_menu_selected_text: None,
419 search_highlights: Vec::new(),
420 active_search_highlight: None,
421 };
422 this.parse(cx);
423 this
424 }
425
426 pub fn new_text(source: SharedString, cx: &mut Context<Self>) -> Self {
427 Self::new_with_options(
428 source,
429 None,
430 None,
431 MarkdownOptions {
432 parse_links_only: true,
433 ..Default::default()
434 },
435 cx,
436 )
437 }
438
439 fn code_block_scroll_handle(&mut self, id: usize) -> ScrollHandle {
440 self.code_block_scroll_handles
441 .entry(id)
442 .or_insert_with(ScrollHandle::new)
443 .clone()
444 }
445
446 fn retain_code_block_scroll_handles(&mut self, ids: &HashSet<usize>) {
447 self.code_block_scroll_handles
448 .retain(|id, _| ids.contains(id));
449 }
450
451 fn clear_code_block_scroll_handles(&mut self) {
452 self.code_block_scroll_handles.clear();
453 }
454
455 fn autoscroll_code_block(&self, source_index: usize, cursor_position: Point<Pixels>) {
456 let Some((_, scroll_handle)) = self
457 .code_block_scroll_handles
458 .range(..=source_index)
459 .next_back()
460 else {
461 return;
462 };
463
464 let bounds = scroll_handle.bounds();
465 if cursor_position.y < bounds.top() || cursor_position.y > bounds.bottom() {
466 return;
467 }
468
469 let horizontal_delta = if cursor_position.x < bounds.left() {
470 bounds.left() - cursor_position.x
471 } else if cursor_position.x > bounds.right() {
472 bounds.right() - cursor_position.x
473 } else {
474 return;
475 };
476
477 let offset = scroll_handle.offset();
478 scroll_handle.set_offset(point(offset.x + horizontal_delta, offset.y));
479 }
480
481 pub fn is_parsing(&self) -> bool {
482 self.pending_parse.is_some()
483 }
484
485 pub fn scroll_to_heading(&mut self, slug: &str, cx: &mut Context<Self>) -> Option<usize> {
486 if let Some(source_index) = self.parsed_markdown.heading_slugs.get(slug).copied() {
487 self.autoscroll_request = Some(source_index);
488 cx.notify();
489 Some(source_index)
490 } else {
491 None
492 }
493 }
494
495 pub fn source(&self) -> &str {
496 &self.source
497 }
498
499 pub fn append(&mut self, text: &str, cx: &mut Context<Self>) {
500 self.source = SharedString::new(self.source.to_string() + text);
501 self.parse(cx);
502 }
503
504 pub fn replace(&mut self, source: impl Into<SharedString>, cx: &mut Context<Self>) {
505 self.source = source.into();
506 self.parse(cx);
507 }
508
509 pub fn request_autoscroll_to_source_index(
510 &mut self,
511 source_index: usize,
512 cx: &mut Context<Self>,
513 ) {
514 self.autoscroll_request = Some(source_index);
515 cx.refresh_windows();
516 }
517
518 fn footnote_definition_content_start(&self, label: &SharedString) -> Option<usize> {
519 self.parsed_markdown
520 .footnote_definitions
521 .get(label)
522 .copied()
523 }
524
525 pub fn set_active_root_for_source_index(
526 &mut self,
527 source_index: Option<usize>,
528 cx: &mut Context<Self>,
529 ) {
530 let active_root_block =
531 source_index.and_then(|index| self.parsed_markdown.root_block_for_source_index(index));
532 if self.active_root_block == active_root_block {
533 return;
534 }
535
536 self.active_root_block = active_root_block;
537 cx.notify();
538 }
539
540 pub fn reset(&mut self, source: SharedString, cx: &mut Context<Self>) {
541 if source == self.source() {
542 return;
543 }
544 self.source = source;
545 self.selection = Selection::default();
546 self.autoscroll_request = None;
547 self.pending_parse = None;
548 self.should_reparse = false;
549 self.search_highlights.clear();
550 self.active_search_highlight = None;
551 // Don't clear parsed_markdown here - keep existing content visible until new parse completes
552 self.parse(cx);
553 }
554
555 #[cfg(any(test, feature = "test-support"))]
556 pub fn parsed_markdown(&self) -> &ParsedMarkdown {
557 &self.parsed_markdown
558 }
559
560 pub fn escape(s: &str) -> Cow<'_, str> {
561 let output_len: usize = {
562 let mut escaper = MarkdownEscaper::new();
563 s.bytes().map(|byte| escaper.next(byte).output_len()).sum()
564 };
565
566 if output_len == s.len() {
567 return s.into();
568 }
569
570 let mut escaper = MarkdownEscaper::new();
571 let mut output = String::with_capacity(output_len);
572 for c in s.chars() {
573 escaper.next(c as u8).write_to(c, &mut output);
574 }
575 output.into()
576 }
577
578 pub fn selected_text(&self) -> Option<String> {
579 if self.selection.end <= self.selection.start {
580 None
581 } else {
582 Some(self.source[self.selection.start..self.selection.end].to_string())
583 }
584 }
585
586 pub fn set_search_highlights(
587 &mut self,
588 highlights: Vec<Range<usize>>,
589 active: Option<usize>,
590 cx: &mut Context<Self>,
591 ) {
592 self.search_highlights = highlights;
593 self.active_search_highlight = active;
594 cx.notify();
595 }
596
597 pub fn clear_search_highlights(&mut self, cx: &mut Context<Self>) {
598 if !self.search_highlights.is_empty() || self.active_search_highlight.is_some() {
599 self.search_highlights.clear();
600 self.active_search_highlight = None;
601 cx.notify();
602 }
603 }
604
605 pub fn set_active_search_highlight(&mut self, active: Option<usize>, cx: &mut Context<Self>) {
606 if self.active_search_highlight != active {
607 self.active_search_highlight = active;
608 cx.notify();
609 }
610 }
611
612 pub fn search_highlights(&self) -> &[Range<usize>] {
613 &self.search_highlights
614 }
615
616 pub fn active_search_highlight(&self) -> Option<usize> {
617 self.active_search_highlight
618 }
619
620 fn copy(&self, text: &RenderedText, _: &mut Window, cx: &mut Context<Self>) {
621 if self.selection.end <= self.selection.start {
622 return;
623 }
624 let text = text.text_for_range(self.selection.start..self.selection.end);
625 cx.write_to_clipboard(ClipboardItem::new_string(text));
626 }
627
628 fn copy_as_markdown(&mut self, _: &mut Window, cx: &mut Context<Self>) {
629 if let Some(text) = self.context_menu_selected_text.take() {
630 cx.write_to_clipboard(ClipboardItem::new_string(text));
631 return;
632 }
633 if self.selection.end <= self.selection.start {
634 return;
635 }
636 let text = self.source[self.selection.start..self.selection.end].to_string();
637 cx.write_to_clipboard(ClipboardItem::new_string(text));
638 }
639
640 fn capture_for_context_menu(&mut self, link: Option<SharedString>) {
641 self.context_menu_selected_text = self.selected_text();
642 self.context_menu_link = link;
643 }
644
645 /// Returns the URL of the link that was most recently right-clicked, if any.
646 /// This is set during a right-click mouse-down event and can be read by parent
647 /// views to include a "Copy Link" item in their context menus.
648 pub fn context_menu_link(&self) -> Option<&SharedString> {
649 self.context_menu_link.as_ref()
650 }
651
652 fn parse(&mut self, cx: &mut Context<Self>) {
653 if self.source.is_empty() {
654 self.should_reparse = false;
655 self.pending_parse.take();
656 self.parsed_markdown = ParsedMarkdown {
657 source: self.source.clone(),
658 ..Default::default()
659 };
660 self.active_root_block = None;
661 self.images_by_source_offset.clear();
662 self.mermaid_state.clear();
663 cx.notify();
664 cx.refresh_windows();
665 return;
666 }
667
668 if self.pending_parse.is_some() {
669 self.should_reparse = true;
670 return;
671 }
672 self.should_reparse = false;
673 self.pending_parse = Some(self.start_background_parse(cx));
674 }
675
676 fn start_background_parse(&self, cx: &Context<Self>) -> Task<()> {
677 let source = self.source.clone();
678 let should_parse_links_only = self.options.parse_links_only;
679 let should_parse_html = self.options.parse_html;
680 let should_render_mermaid_diagrams = self.options.render_mermaid_diagrams;
681 let should_parse_heading_slugs = self.options.parse_heading_slugs;
682 let language_registry = self.language_registry.clone();
683 let fallback = self.fallback_code_block_language.clone();
684
685 let parsed = cx.background_spawn(async move {
686 if should_parse_links_only {
687 return (
688 ParsedMarkdown {
689 events: Arc::from(parse_links_only(source.as_ref())),
690 source,
691 languages_by_name: TreeMap::default(),
692 languages_by_path: TreeMap::default(),
693 root_block_starts: Arc::default(),
694 html_blocks: BTreeMap::default(),
695 mermaid_diagrams: BTreeMap::default(),
696 heading_slugs: HashMap::default(),
697 footnote_definitions: HashMap::default(),
698 },
699 Default::default(),
700 );
701 }
702
703 let parsed =
704 parse_markdown_with_options(&source, should_parse_html, should_parse_heading_slugs);
705 let events = parsed.events;
706 let language_names = parsed.language_names;
707 let paths = parsed.language_paths;
708 let root_block_starts = parsed.root_block_starts;
709 let html_blocks = parsed.html_blocks;
710 let heading_slugs = parsed.heading_slugs;
711 let footnote_definitions = parsed.footnote_definitions;
712 let mermaid_diagrams = if should_render_mermaid_diagrams {
713 extract_mermaid_diagrams(&source, &events)
714 } else {
715 BTreeMap::default()
716 };
717 let mut images_by_source_offset = HashMap::default();
718 let mut languages_by_name = TreeMap::default();
719 let mut languages_by_path = TreeMap::default();
720 if let Some(registry) = language_registry.as_ref() {
721 for name in language_names {
722 let language = if !name.is_empty() {
723 registry.language_for_name_or_extension(&name).left_future()
724 } else if let Some(fallback) = &fallback {
725 registry.language_for_name(fallback.as_ref()).right_future()
726 } else {
727 continue;
728 };
729 if let Ok(language) = language.await {
730 languages_by_name.insert(name, language);
731 }
732 }
733
734 for path in paths {
735 if let Ok(language) = registry
736 .load_language_for_file_path(Path::new(path.as_ref()))
737 .await
738 {
739 languages_by_path.insert(path, language);
740 }
741 }
742 }
743
744 for (range, event) in &events {
745 if let MarkdownEvent::Start(MarkdownTag::Image { dest_url, .. }) = event
746 && let Some(data_url) = dest_url.strip_prefix("data:")
747 {
748 let Some((mime_info, data)) = data_url.split_once(',') else {
749 continue;
750 };
751 let Some((mime_type, encoding)) = mime_info.split_once(';') else {
752 continue;
753 };
754 let Some(format) = ImageFormat::from_mime_type(mime_type) else {
755 continue;
756 };
757 let is_base64 = encoding == "base64";
758 if is_base64
759 && let Some(bytes) = base64::prelude::BASE64_STANDARD
760 .decode(data)
761 .log_with_level(Level::Debug)
762 {
763 let image = Arc::new(Image::from_bytes(format, bytes));
764 images_by_source_offset.insert(range.start, image);
765 }
766 }
767 }
768
769 (
770 ParsedMarkdown {
771 source,
772 events: Arc::from(events),
773 languages_by_name,
774 languages_by_path,
775 root_block_starts: Arc::from(root_block_starts),
776 html_blocks,
777 mermaid_diagrams,
778 heading_slugs,
779 footnote_definitions,
780 },
781 images_by_source_offset,
782 )
783 });
784
785 cx.spawn(async move |this, cx| {
786 let (parsed, images_by_source_offset) = parsed.await;
787
788 this.update(cx, |this, cx| {
789 this.parsed_markdown = parsed;
790 this.images_by_source_offset = images_by_source_offset;
791 if this.active_root_block.is_some_and(|block_index| {
792 block_index >= this.parsed_markdown.root_block_starts.len()
793 }) {
794 this.active_root_block = None;
795 }
796 if this.options.render_mermaid_diagrams {
797 let parsed_markdown = this.parsed_markdown.clone();
798 this.mermaid_state.update(&parsed_markdown, cx);
799 } else {
800 this.mermaid_state.clear();
801 }
802 this.pending_parse.take();
803 if this.should_reparse {
804 this.parse(cx);
805 }
806 cx.notify();
807 cx.refresh_windows();
808 })
809 .ok();
810 })
811 }
812}
813
814impl Focusable for Markdown {
815 fn focus_handle(&self, _cx: &App) -> FocusHandle {
816 self.focus_handle.clone()
817 }
818}
819
820#[derive(Debug, Default, Clone)]
821enum SelectMode {
822 #[default]
823 Character,
824 Word(Range<usize>),
825 Line(Range<usize>),
826 All,
827}
828
829#[derive(Clone, Default)]
830struct Selection {
831 start: usize,
832 end: usize,
833 reversed: bool,
834 pending: bool,
835 mode: SelectMode,
836}
837
838impl Selection {
839 fn set_head(&mut self, head: usize, rendered_text: &RenderedText) {
840 match &self.mode {
841 SelectMode::Character => {
842 if head < self.tail() {
843 if !self.reversed {
844 self.end = self.start;
845 self.reversed = true;
846 }
847 self.start = head;
848 } else {
849 if self.reversed {
850 self.start = self.end;
851 self.reversed = false;
852 }
853 self.end = head;
854 }
855 }
856 SelectMode::Word(original_range) | SelectMode::Line(original_range) => {
857 let head_range = if matches!(self.mode, SelectMode::Word(_)) {
858 rendered_text.surrounding_word_range(head)
859 } else {
860 rendered_text.surrounding_line_range(head)
861 };
862
863 if head < original_range.start {
864 self.start = head_range.start;
865 self.end = original_range.end;
866 self.reversed = true;
867 } else if head >= original_range.end {
868 self.start = original_range.start;
869 self.end = head_range.end;
870 self.reversed = false;
871 } else {
872 self.start = original_range.start;
873 self.end = original_range.end;
874 self.reversed = false;
875 }
876 }
877 SelectMode::All => {
878 self.start = 0;
879 self.end = rendered_text
880 .lines
881 .last()
882 .map(|line| line.source_end)
883 .unwrap_or(0);
884 self.reversed = false;
885 }
886 }
887 }
888
889 fn tail(&self) -> usize {
890 if self.reversed { self.end } else { self.start }
891 }
892}
893
894#[derive(Debug, Clone, Default)]
895pub struct ParsedMarkdown {
896 pub source: SharedString,
897 pub events: Arc<[(Range<usize>, MarkdownEvent)]>,
898 pub languages_by_name: TreeMap<SharedString, Arc<Language>>,
899 pub languages_by_path: TreeMap<Arc<str>, Arc<Language>>,
900 pub root_block_starts: Arc<[usize]>,
901 pub(crate) html_blocks: BTreeMap<usize, html::html_parser::ParsedHtmlBlock>,
902 pub(crate) mermaid_diagrams: BTreeMap<usize, ParsedMarkdownMermaidDiagram>,
903 pub heading_slugs: HashMap<SharedString, usize>,
904 pub footnote_definitions: HashMap<SharedString, usize>,
905}
906
907impl ParsedMarkdown {
908 pub fn source(&self) -> &SharedString {
909 &self.source
910 }
911
912 pub fn events(&self) -> &Arc<[(Range<usize>, MarkdownEvent)]> {
913 &self.events
914 }
915
916 pub fn root_block_starts(&self) -> &Arc<[usize]> {
917 &self.root_block_starts
918 }
919
920 pub fn root_block_for_source_index(&self, source_index: usize) -> Option<usize> {
921 if self.root_block_starts.is_empty() {
922 return None;
923 }
924
925 let partition = self
926 .root_block_starts
927 .partition_point(|block_start| *block_start <= source_index);
928
929 Some(partition.saturating_sub(1))
930 }
931}
932
933pub enum AutoscrollBehavior {
934 /// Propagate the request up the element tree for the nearest
935 /// scrollable ancestor (e.g. `List`) to handle.
936 Propagate,
937 /// Directly control a specific scroll handle.
938 Controlled(ScrollHandle),
939}
940
941pub struct MarkdownElement {
942 markdown: Entity<Markdown>,
943 style: MarkdownStyle,
944 code_block_renderer: CodeBlockRenderer,
945 on_url_click: Option<Box<dyn Fn(SharedString, &mut Window, &mut App)>>,
946 on_source_click: Option<SourceClickCallback>,
947 on_checkbox_toggle: Option<CheckboxToggleCallback>,
948 image_resolver: Option<Box<dyn Fn(&str) -> Option<ImageSource>>>,
949 show_root_block_markers: bool,
950 autoscroll: AutoscrollBehavior,
951}
952
953impl MarkdownElement {
954 pub fn new(markdown: Entity<Markdown>, style: MarkdownStyle) -> Self {
955 Self {
956 markdown,
957 style,
958 code_block_renderer: CodeBlockRenderer::Default {
959 copy_button_visibility: CopyButtonVisibility::VisibleOnHover,
960 border: false,
961 },
962 on_url_click: None,
963 on_source_click: None,
964 on_checkbox_toggle: None,
965 image_resolver: None,
966 show_root_block_markers: false,
967 autoscroll: AutoscrollBehavior::Propagate,
968 }
969 }
970
971 #[cfg(any(test, feature = "test-support"))]
972 pub fn rendered_text(
973 markdown: Entity<Markdown>,
974 cx: &mut gpui::VisualTestContext,
975 style: impl FnOnce(&Window, &App) -> MarkdownStyle,
976 ) -> String {
977 use gpui::size;
978
979 let (text, _) = cx.draw(
980 Default::default(),
981 size(px(600.0), px(600.0)),
982 |window, cx| Self::new(markdown, style(window, cx)),
983 );
984 text.text
985 .lines
986 .iter()
987 .map(|line| line.layout.wrapped_text())
988 .collect::<Vec<_>>()
989 .join("\n")
990 }
991
992 pub fn code_block_renderer(mut self, variant: CodeBlockRenderer) -> Self {
993 self.code_block_renderer = variant;
994 self
995 }
996
997 pub fn on_url_click(
998 mut self,
999 handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
1000 ) -> Self {
1001 self.on_url_click = Some(Box::new(handler));
1002 self
1003 }
1004
1005 pub fn on_source_click(
1006 mut self,
1007 handler: impl Fn(usize, usize, &mut Window, &mut App) -> bool + 'static,
1008 ) -> Self {
1009 self.on_source_click = Some(Box::new(handler));
1010 self
1011 }
1012
1013 pub fn on_checkbox_toggle(
1014 mut self,
1015 handler: impl Fn(Range<usize>, bool, &mut Window, &mut App) + 'static,
1016 ) -> Self {
1017 self.on_checkbox_toggle = Some(Rc::new(handler));
1018 self
1019 }
1020
1021 pub fn image_resolver(
1022 mut self,
1023 resolver: impl Fn(&str) -> Option<ImageSource> + 'static,
1024 ) -> Self {
1025 self.image_resolver = Some(Box::new(resolver));
1026 self
1027 }
1028
1029 pub fn show_root_block_markers(mut self) -> Self {
1030 self.show_root_block_markers = true;
1031 self
1032 }
1033
1034 pub fn scroll_handle(mut self, scroll_handle: ScrollHandle) -> Self {
1035 self.autoscroll = AutoscrollBehavior::Controlled(scroll_handle);
1036 self
1037 }
1038
1039 fn push_markdown_image(
1040 &self,
1041 builder: &mut MarkdownElementBuilder,
1042 range: &Range<usize>,
1043 source: ImageSource,
1044 width: Option<DefiniteLength>,
1045 height: Option<DefiniteLength>,
1046 ) {
1047 let align = builder.text_style().text_align;
1048 builder.modify_current_div(|el| {
1049 let mut image_container = el.flex().flex_row().items_center();
1050
1051 image_container = match align {
1052 TextAlign::Left => image_container.justify_start(),
1053 TextAlign::Center => image_container.justify_center(),
1054 TextAlign::Right => image_container.justify_end(),
1055 };
1056
1057 image_container.child(
1058 img(source)
1059 .id(("markdown-image", range.start))
1060 .max_w_full()
1061 .when_some(height, |this, height| this.h(height))
1062 .when_some(width, |this, width| this.w(width)),
1063 )
1064 });
1065 }
1066
1067 fn push_markdown_paragraph(
1068 &self,
1069 builder: &mut MarkdownElementBuilder,
1070 range: &Range<usize>,
1071 markdown_end: usize,
1072 text_align_override: Option<TextAlign>,
1073 ) {
1074 let align = text_align_override.unwrap_or(self.style.base_text_style.text_align);
1075 let mut paragraph = div().when(!self.style.height_is_multiple_of_line_height, |el| {
1076 el.mb_2().line_height(rems(1.3))
1077 });
1078
1079 paragraph = match align {
1080 TextAlign::Center => paragraph.text_center(),
1081 TextAlign::Left => paragraph.text_left(),
1082 TextAlign::Right => paragraph.text_right(),
1083 };
1084
1085 builder.push_text_style(TextStyleRefinement {
1086 text_align: Some(align),
1087 ..Default::default()
1088 });
1089 builder.push_div(paragraph, range, markdown_end);
1090 }
1091
1092 fn pop_markdown_paragraph(&self, builder: &mut MarkdownElementBuilder) {
1093 builder.pop_div();
1094 builder.pop_text_style();
1095 }
1096
1097 fn push_markdown_heading(
1098 &self,
1099 builder: &mut MarkdownElementBuilder,
1100 level: pulldown_cmark::HeadingLevel,
1101 range: &Range<usize>,
1102 markdown_end: usize,
1103 text_align_override: Option<TextAlign>,
1104 ) {
1105 let align = text_align_override.unwrap_or(self.style.base_text_style.text_align);
1106 let mut heading = div().mt_4().mb_2();
1107 heading = apply_heading_style(heading, level, self.style.heading_level_styles.as_ref());
1108
1109 heading = match align {
1110 TextAlign::Center => heading.text_center(),
1111 TextAlign::Left => heading.text_left(),
1112 TextAlign::Right => heading.text_right(),
1113 };
1114
1115 let mut heading_style = self.style.heading.clone();
1116 let heading_text_style = heading_style.text_style().clone();
1117 heading.style().refine(&heading_style);
1118
1119 builder.push_text_style(TextStyleRefinement {
1120 text_align: Some(align),
1121 ..heading_text_style
1122 });
1123 builder.push_div(heading, range, markdown_end);
1124 }
1125
1126 fn pop_markdown_heading(&self, builder: &mut MarkdownElementBuilder) {
1127 builder.pop_div();
1128 builder.pop_text_style();
1129 }
1130
1131 fn push_markdown_block_quote(
1132 &self,
1133 builder: &mut MarkdownElementBuilder,
1134 range: &Range<usize>,
1135 markdown_end: usize,
1136 ) {
1137 builder.push_text_style(self.style.block_quote.clone());
1138 builder.push_div(
1139 div()
1140 .pl_4()
1141 .mb_2()
1142 .border_l_4()
1143 .border_color(self.style.block_quote_border_color),
1144 range,
1145 markdown_end,
1146 );
1147 }
1148
1149 fn pop_markdown_block_quote(&self, builder: &mut MarkdownElementBuilder) {
1150 builder.pop_div();
1151 builder.pop_text_style();
1152 }
1153
1154 fn push_markdown_list_item(
1155 &self,
1156 builder: &mut MarkdownElementBuilder,
1157 bullet: AnyElement,
1158 range: &Range<usize>,
1159 markdown_end: usize,
1160 ) {
1161 builder.push_div(
1162 div()
1163 .when(!self.style.height_is_multiple_of_line_height, |el| {
1164 el.mb_1().gap_1().line_height(rems(1.3))
1165 })
1166 .h_flex()
1167 .items_start()
1168 .child(bullet),
1169 range,
1170 markdown_end,
1171 );
1172 // Without `w_0`, text doesn't wrap to the width of the container.
1173 builder.push_div(div().flex_1().w_0(), range, markdown_end);
1174 }
1175
1176 fn pop_markdown_list_item(&self, builder: &mut MarkdownElementBuilder) {
1177 builder.pop_div();
1178 builder.pop_div();
1179 }
1180
1181 fn paint_highlight_range(
1182 bounds: Bounds<Pixels>,
1183 start: usize,
1184 end: usize,
1185 color: Hsla,
1186 rendered_text: &RenderedText,
1187 window: &mut Window,
1188 ) {
1189 let start_pos = rendered_text.position_for_source_index(start);
1190 let end_pos = rendered_text.position_for_source_index(end);
1191 if let Some(((start_position, start_line_height), (end_position, end_line_height))) =
1192 start_pos.zip(end_pos)
1193 {
1194 if start_position.y == end_position.y {
1195 window.paint_quad(quad(
1196 Bounds::from_corners(
1197 start_position,
1198 point(end_position.x, end_position.y + end_line_height),
1199 ),
1200 Pixels::ZERO,
1201 color,
1202 Edges::default(),
1203 Hsla::transparent_black(),
1204 BorderStyle::default(),
1205 ));
1206 } else {
1207 window.paint_quad(quad(
1208 Bounds::from_corners(
1209 start_position,
1210 point(bounds.right(), start_position.y + start_line_height),
1211 ),
1212 Pixels::ZERO,
1213 color,
1214 Edges::default(),
1215 Hsla::transparent_black(),
1216 BorderStyle::default(),
1217 ));
1218
1219 if end_position.y > start_position.y + start_line_height {
1220 window.paint_quad(quad(
1221 Bounds::from_corners(
1222 point(bounds.left(), start_position.y + start_line_height),
1223 point(bounds.right(), end_position.y),
1224 ),
1225 Pixels::ZERO,
1226 color,
1227 Edges::default(),
1228 Hsla::transparent_black(),
1229 BorderStyle::default(),
1230 ));
1231 }
1232
1233 window.paint_quad(quad(
1234 Bounds::from_corners(
1235 point(bounds.left(), end_position.y),
1236 point(end_position.x, end_position.y + end_line_height),
1237 ),
1238 Pixels::ZERO,
1239 color,
1240 Edges::default(),
1241 Hsla::transparent_black(),
1242 BorderStyle::default(),
1243 ));
1244 }
1245 }
1246 }
1247
1248 fn paint_selection(
1249 &self,
1250 bounds: Bounds<Pixels>,
1251 rendered_text: &RenderedText,
1252 window: &mut Window,
1253 cx: &mut App,
1254 ) {
1255 let selection = self.markdown.read(cx).selection.clone();
1256 Self::paint_highlight_range(
1257 bounds,
1258 selection.start,
1259 selection.end,
1260 self.style.selection_background_color,
1261 rendered_text,
1262 window,
1263 );
1264 }
1265
1266 fn paint_search_highlights(
1267 &self,
1268 bounds: Bounds<Pixels>,
1269 rendered_text: &RenderedText,
1270 window: &mut Window,
1271 cx: &mut App,
1272 ) {
1273 let markdown = self.markdown.read(cx);
1274 let active_index = markdown.active_search_highlight;
1275 let colors = cx.theme().colors();
1276
1277 for (i, highlight_range) in markdown.search_highlights.iter().enumerate() {
1278 let color = if Some(i) == active_index {
1279 colors.search_active_match_background
1280 } else {
1281 colors.search_match_background
1282 };
1283 Self::paint_highlight_range(
1284 bounds,
1285 highlight_range.start,
1286 highlight_range.end,
1287 color,
1288 rendered_text,
1289 window,
1290 );
1291 }
1292 }
1293
1294 fn paint_mouse_listeners(
1295 &mut self,
1296 hitbox: &Hitbox,
1297 rendered_text: &RenderedText,
1298 window: &mut Window,
1299 cx: &mut App,
1300 ) {
1301 if self.style.prevent_mouse_interaction {
1302 return;
1303 }
1304
1305 let is_hovering_clickable = hitbox.is_hovered(window)
1306 && !self.markdown.read(cx).selection.pending
1307 && rendered_text
1308 .source_index_for_position(window.mouse_position())
1309 .ok()
1310 .is_some_and(|source_index| {
1311 rendered_text.link_for_source_index(source_index).is_some()
1312 || rendered_text
1313 .footnote_ref_for_source_index(source_index)
1314 .is_some()
1315 });
1316
1317 if is_hovering_clickable {
1318 window.set_cursor_style(CursorStyle::PointingHand, hitbox);
1319 } else {
1320 window.set_cursor_style(CursorStyle::IBeam, hitbox);
1321 }
1322
1323 let on_open_url = self.on_url_click.take();
1324 let on_source_click = self.on_source_click.take();
1325
1326 self.on_mouse_event(window, cx, {
1327 let hitbox = hitbox.clone();
1328 let rendered_text = rendered_text.clone();
1329 move |markdown, event: &MouseDownEvent, phase, window, _cx| {
1330 if phase.capture()
1331 && event.button == MouseButton::Right
1332 && hitbox.is_hovered(window)
1333 {
1334 let link = rendered_text
1335 .source_index_for_position(event.position)
1336 .ok()
1337 .and_then(|ix| rendered_text.link_for_source_index(ix))
1338 .map(|link| link.destination_url.clone());
1339 markdown.capture_for_context_menu(link);
1340 }
1341 }
1342 });
1343
1344 self.on_mouse_event(window, cx, {
1345 let rendered_text = rendered_text.clone();
1346 let hitbox = hitbox.clone();
1347 move |markdown, event: &MouseDownEvent, phase, window, cx| {
1348 if hitbox.is_hovered(window) {
1349 if phase.bubble() && event.button != MouseButton::Right {
1350 let position_result =
1351 rendered_text.source_index_for_position(event.position);
1352
1353 if let Ok(source_index) = position_result {
1354 if let Some(footnote_ref) =
1355 rendered_text.footnote_ref_for_source_index(source_index)
1356 {
1357 markdown.pressed_footnote_ref = Some(footnote_ref.clone());
1358 } else if let Some(link) =
1359 rendered_text.link_for_source_index(source_index)
1360 {
1361 markdown.pressed_link = Some(link.clone());
1362 }
1363 }
1364
1365 if markdown.pressed_footnote_ref.is_none()
1366 && markdown.pressed_link.is_none()
1367 {
1368 let source_index = match position_result {
1369 Ok(ix) | Err(ix) => ix,
1370 };
1371 if let Some(handler) = on_source_click.as_ref() {
1372 let blocked = handler(source_index, event.click_count, window, cx);
1373 if blocked {
1374 markdown.selection = Selection::default();
1375 markdown.pressed_link = None;
1376 window.prevent_default();
1377 cx.notify();
1378 return;
1379 }
1380 }
1381 let (range, mode) = match event.click_count {
1382 1 => {
1383 let range = source_index..source_index;
1384 (range, SelectMode::Character)
1385 }
1386 2 => {
1387 let range = rendered_text.surrounding_word_range(source_index);
1388 (range.clone(), SelectMode::Word(range))
1389 }
1390 3 => {
1391 let range = rendered_text.surrounding_line_range(source_index);
1392 (range.clone(), SelectMode::Line(range))
1393 }
1394 _ => {
1395 let range = 0..rendered_text
1396 .lines
1397 .last()
1398 .map(|line| line.source_end)
1399 .unwrap_or(0);
1400 (range, SelectMode::All)
1401 }
1402 };
1403 markdown.selection = Selection {
1404 start: range.start,
1405 end: range.end,
1406 reversed: false,
1407 pending: true,
1408 mode,
1409 };
1410 window.focus(&markdown.focus_handle, cx);
1411 }
1412
1413 window.prevent_default();
1414 cx.notify();
1415 }
1416 } else if phase.capture() && event.button == MouseButton::Left {
1417 markdown.selection = Selection::default();
1418 markdown.pressed_link = None;
1419 cx.notify();
1420 }
1421 }
1422 });
1423 self.on_mouse_event(window, cx, {
1424 let rendered_text = rendered_text.clone();
1425 let hitbox = hitbox.clone();
1426 let was_hovering_clickable = is_hovering_clickable;
1427 move |markdown, event: &MouseMoveEvent, phase, window, cx| {
1428 if phase.capture() {
1429 return;
1430 }
1431
1432 if markdown.selection.pending {
1433 let source_index = match rendered_text.source_index_for_position(event.position)
1434 {
1435 Ok(ix) | Err(ix) => ix,
1436 };
1437 markdown.selection.set_head(source_index, &rendered_text);
1438 markdown.autoscroll_code_block(source_index, event.position);
1439 markdown.autoscroll_request = Some(source_index);
1440 cx.notify();
1441 } else {
1442 let is_hovering_clickable = hitbox.is_hovered(window)
1443 && rendered_text
1444 .source_index_for_position(event.position)
1445 .ok()
1446 .is_some_and(|source_index| {
1447 rendered_text.link_for_source_index(source_index).is_some()
1448 || rendered_text
1449 .footnote_ref_for_source_index(source_index)
1450 .is_some()
1451 });
1452 if is_hovering_clickable != was_hovering_clickable {
1453 cx.notify();
1454 }
1455 }
1456 }
1457 });
1458 self.on_mouse_event(window, cx, {
1459 let rendered_text = rendered_text.clone();
1460 move |markdown, event: &MouseUpEvent, phase, window, cx| {
1461 if phase.bubble() {
1462 let source_index = rendered_text.source_index_for_position(event.position).ok();
1463 if let Some(pressed_footnote_ref) = markdown.pressed_footnote_ref.take()
1464 && source_index
1465 .and_then(|ix| rendered_text.footnote_ref_for_source_index(ix))
1466 == Some(&pressed_footnote_ref)
1467 {
1468 if let Some(source_index) =
1469 markdown.footnote_definition_content_start(&pressed_footnote_ref.label)
1470 {
1471 markdown.autoscroll_request = Some(source_index);
1472 cx.notify();
1473 }
1474 } else if let Some(pressed_link) = markdown.pressed_link.take()
1475 && source_index.and_then(|ix| rendered_text.link_for_source_index(ix))
1476 == Some(&pressed_link)
1477 {
1478 if let Some(open_url) = on_open_url.as_ref() {
1479 open_url(pressed_link.destination_url, window, cx);
1480 } else {
1481 cx.open_url(&pressed_link.destination_url);
1482 }
1483 }
1484 } else if markdown.selection.pending {
1485 markdown.selection.pending = false;
1486 #[cfg(any(target_os = "linux", target_os = "freebsd"))]
1487 {
1488 let text = rendered_text
1489 .text_for_range(markdown.selection.start..markdown.selection.end);
1490 cx.write_to_primary(ClipboardItem::new_string(text))
1491 }
1492 cx.notify();
1493 }
1494 }
1495 });
1496 }
1497
1498 fn autoscroll(
1499 &self,
1500 rendered_text: &RenderedText,
1501 window: &mut Window,
1502 cx: &mut App,
1503 ) -> Option<()> {
1504 let autoscroll_index = self
1505 .markdown
1506 .update(cx, |markdown, _| markdown.autoscroll_request.take())?;
1507 let (position, line_height) = rendered_text.position_for_source_index(autoscroll_index)?;
1508
1509 match &self.autoscroll {
1510 AutoscrollBehavior::Controlled(scroll_handle) => {
1511 let viewport = scroll_handle.bounds();
1512 let margin = line_height * 3.;
1513 let top_goal = viewport.top() + margin;
1514 let bottom_goal = viewport.bottom() - margin;
1515 let current_offset = scroll_handle.offset();
1516
1517 let new_offset_y = if position.y < top_goal {
1518 current_offset.y + (top_goal - position.y)
1519 } else if position.y + line_height > bottom_goal {
1520 current_offset.y + (bottom_goal - (position.y + line_height))
1521 } else {
1522 current_offset.y
1523 };
1524
1525 scroll_handle.set_offset(point(
1526 current_offset.x,
1527 new_offset_y.clamp(-scroll_handle.max_offset().y, Pixels::ZERO),
1528 ));
1529 }
1530 AutoscrollBehavior::Propagate => {
1531 let text_style = self.style.base_text_style.clone();
1532 let font_id = window.text_system().resolve_font(&text_style.font());
1533 let font_size = text_style.font_size.to_pixels(window.rem_size());
1534 let em_width = window.text_system().em_width(font_id, font_size).unwrap();
1535 window.request_autoscroll(Bounds::from_corners(
1536 point(position.x - 3. * em_width, position.y - 3. * line_height),
1537 point(position.x + 3. * em_width, position.y + 3. * line_height),
1538 ));
1539 }
1540 }
1541 Some(())
1542 }
1543
1544 fn on_mouse_event<T: MouseEvent>(
1545 &self,
1546 window: &mut Window,
1547 _cx: &mut App,
1548 mut f: impl 'static
1549 + FnMut(&mut Markdown, &T, DispatchPhase, &mut Window, &mut Context<Markdown>),
1550 ) {
1551 window.on_mouse_event({
1552 let markdown = self.markdown.downgrade();
1553 move |event, phase, window, cx| {
1554 markdown
1555 .update(cx, |markdown, cx| f(markdown, event, phase, window, cx))
1556 .log_err();
1557 }
1558 });
1559 }
1560}
1561
1562impl Styled for MarkdownElement {
1563 fn style(&mut self) -> &mut StyleRefinement {
1564 &mut self.style.container_style
1565 }
1566}
1567
1568impl Element for MarkdownElement {
1569 type RequestLayoutState = RenderedMarkdown;
1570 type PrepaintState = Hitbox;
1571
1572 fn id(&self) -> Option<ElementId> {
1573 None
1574 }
1575
1576 fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
1577 None
1578 }
1579
1580 fn request_layout(
1581 &mut self,
1582 _id: Option<&GlobalElementId>,
1583 _inspector_id: Option<&gpui::InspectorElementId>,
1584 window: &mut Window,
1585 cx: &mut App,
1586 ) -> (gpui::LayoutId, Self::RequestLayoutState) {
1587 let mut builder = MarkdownElementBuilder::new(
1588 &self.style.container_style,
1589 self.style.base_text_style.clone(),
1590 self.style.syntax.clone(),
1591 );
1592 let (parsed_markdown, images, active_root_block, render_mermaid_diagrams, mermaid_state) = {
1593 let markdown = self.markdown.read(cx);
1594 (
1595 markdown.parsed_markdown.clone(),
1596 markdown.images_by_source_offset.clone(),
1597 markdown.active_root_block,
1598 markdown.options.render_mermaid_diagrams,
1599 markdown.mermaid_state.clone(),
1600 )
1601 };
1602 let markdown_end = if let Some(last) = parsed_markdown.events.last() {
1603 last.0.end
1604 } else {
1605 0
1606 };
1607 let mut code_block_ids = HashSet::default();
1608
1609 let mut current_img_block_range: Option<Range<usize>> = None;
1610 let mut handled_html_block = false;
1611 let mut rendered_mermaid_block = false;
1612 for (index, (range, event)) in parsed_markdown.events.iter().enumerate() {
1613 // Skip alt text for images that rendered
1614 if let Some(current_img_block_range) = ¤t_img_block_range
1615 && current_img_block_range.end > range.end
1616 {
1617 continue;
1618 }
1619
1620 if handled_html_block {
1621 if let MarkdownEvent::End(MarkdownTagEnd::HtmlBlock) = event {
1622 handled_html_block = false;
1623 } else {
1624 continue;
1625 }
1626 }
1627
1628 if rendered_mermaid_block {
1629 if matches!(event, MarkdownEvent::End(MarkdownTagEnd::CodeBlock)) {
1630 rendered_mermaid_block = false;
1631 }
1632 continue;
1633 }
1634
1635 match event {
1636 MarkdownEvent::RootStart => {
1637 if self.show_root_block_markers {
1638 builder.push_root_block(range, markdown_end);
1639 }
1640 }
1641 MarkdownEvent::RootEnd(root_block_index) => {
1642 if self.show_root_block_markers {
1643 builder.pop_root_block(
1644 active_root_block == Some(*root_block_index),
1645 cx.theme().colors().border,
1646 cx.theme().colors().border_variant,
1647 );
1648 }
1649 }
1650 MarkdownEvent::Start(tag) => {
1651 match tag {
1652 MarkdownTag::Image { dest_url, .. } => {
1653 if let Some(image) = images.get(&range.start) {
1654 current_img_block_range = Some(range.clone());
1655 self.push_markdown_image(
1656 &mut builder,
1657 range,
1658 image.clone().into(),
1659 None,
1660 None,
1661 );
1662 } else if let Some(source) = self
1663 .image_resolver
1664 .as_ref()
1665 .and_then(|resolve| resolve(dest_url.as_ref()))
1666 {
1667 current_img_block_range = Some(range.clone());
1668 self.push_markdown_image(&mut builder, range, source, None, None);
1669 }
1670 }
1671 MarkdownTag::Paragraph => {
1672 self.push_markdown_paragraph(&mut builder, range, markdown_end, None);
1673 }
1674 MarkdownTag::Heading { level, .. } => {
1675 self.push_markdown_heading(
1676 &mut builder,
1677 *level,
1678 range,
1679 markdown_end,
1680 None,
1681 );
1682 }
1683 MarkdownTag::BlockQuote => {
1684 self.push_markdown_block_quote(&mut builder, range, markdown_end);
1685 }
1686 MarkdownTag::CodeBlock { kind, .. } => {
1687 if render_mermaid_diagrams
1688 && let Some(mermaid_diagram) =
1689 parsed_markdown.mermaid_diagrams.get(&range.start)
1690 {
1691 builder.push_sourced_element(
1692 mermaid_diagram.content_range.clone(),
1693 render_mermaid_diagram(
1694 mermaid_diagram,
1695 &mermaid_state,
1696 &self.style,
1697 ),
1698 );
1699 rendered_mermaid_block = true;
1700 continue;
1701 }
1702
1703 let language = match kind {
1704 CodeBlockKind::Fenced => None,
1705 CodeBlockKind::FencedLang(language) => {
1706 parsed_markdown.languages_by_name.get(language).cloned()
1707 }
1708 CodeBlockKind::FencedSrc(path_range) => parsed_markdown
1709 .languages_by_path
1710 .get(&path_range.path)
1711 .cloned(),
1712 _ => None,
1713 };
1714
1715 let is_indented = matches!(kind, CodeBlockKind::Indented);
1716 let scroll_handle = if self.style.code_block_overflow_x_scroll {
1717 code_block_ids.insert(range.start);
1718 Some(self.markdown.update(cx, |markdown, _| {
1719 markdown.code_block_scroll_handle(range.start)
1720 }))
1721 } else {
1722 None
1723 };
1724
1725 match (&self.code_block_renderer, is_indented) {
1726 (CodeBlockRenderer::Default { .. }, _) | (_, true) => {
1727 // This is a parent container that we can position the copy button inside.
1728 let parent_container =
1729 div().group("code_block").relative().w_full();
1730
1731 let mut parent_container: AnyDiv = if let Some(scroll_handle) =
1732 scroll_handle.as_ref()
1733 {
1734 let scrollbars = Scrollbars::new(ScrollAxes::Horizontal)
1735 .id(("markdown-code-block-scrollbar", range.start))
1736 .tracked_scroll_handle(scroll_handle)
1737 .with_track_along(
1738 ScrollAxes::Horizontal,
1739 cx.theme().colors().editor_background,
1740 )
1741 .notify_content();
1742
1743 parent_container
1744 .rounded_lg()
1745 .custom_scrollbars(scrollbars, window, cx)
1746 .into()
1747 } else {
1748 parent_container.into()
1749 };
1750
1751 if let CodeBlockRenderer::Default { border: true, .. } =
1752 &self.code_block_renderer
1753 {
1754 parent_container = parent_container
1755 .rounded_md()
1756 .border_1()
1757 .border_color(cx.theme().colors().border_variant);
1758 }
1759
1760 parent_container.style().refine(&self.style.code_block);
1761 builder.push_div(parent_container, range, markdown_end);
1762
1763 let code_block = div()
1764 .id(("code-block", range.start))
1765 .rounded_lg()
1766 .map(|mut code_block| {
1767 if let Some(scroll_handle) = scroll_handle.as_ref() {
1768 code_block.style().restrict_scroll_to_axis =
1769 Some(true);
1770 code_block
1771 .flex()
1772 .overflow_x_scroll()
1773 .track_scroll(scroll_handle)
1774 } else {
1775 code_block.w_full()
1776 }
1777 });
1778
1779 builder.push_text_style(self.style.code_block.text.to_owned());
1780 builder.push_code_block(language);
1781 builder.push_div(code_block, range, markdown_end);
1782 }
1783 (CodeBlockRenderer::Custom { .. }, _) => {}
1784 }
1785 }
1786 MarkdownTag::HtmlBlock => {
1787 builder.push_div(div(), range, markdown_end);
1788 if let Some(block) = parsed_markdown.html_blocks.get(&range.start) {
1789 self.render_html_block(block, &mut builder, markdown_end, cx);
1790 handled_html_block = true;
1791 }
1792 }
1793 MarkdownTag::List(bullet_index) => {
1794 builder.push_list(*bullet_index);
1795 builder.push_div(div().pl_2p5(), range, markdown_end);
1796 }
1797 MarkdownTag::Item => {
1798 let bullet =
1799 if let Some((task_range, MarkdownEvent::TaskListMarker(checked))) =
1800 parsed_markdown.events.get(index.saturating_add(1))
1801 {
1802 let source = &parsed_markdown.source()[range.clone()];
1803 let checked = *checked;
1804 let toggle_state = if checked {
1805 ToggleState::Selected
1806 } else {
1807 ToggleState::Unselected
1808 };
1809
1810 let checkbox = Checkbox::new(
1811 ElementId::Name(source.to_string().into()),
1812 toggle_state,
1813 )
1814 .fill();
1815
1816 if let Some(on_toggle) = self.on_checkbox_toggle.clone() {
1817 let task_source_range = task_range.clone();
1818 checkbox
1819 .on_click(move |_state, window, cx| {
1820 on_toggle(
1821 task_source_range.clone(),
1822 !checked,
1823 window,
1824 cx,
1825 );
1826 })
1827 .into_any_element()
1828 } else {
1829 checkbox.visualization_only(true).into_any_element()
1830 }
1831 } else if let Some(bullet_index) = builder.next_bullet_index() {
1832 div().child(format!("{}.", bullet_index)).into_any_element()
1833 } else {
1834 div().child("•").into_any_element()
1835 };
1836 self.push_markdown_list_item(&mut builder, bullet, range, markdown_end);
1837 }
1838 MarkdownTag::Emphasis => builder.push_text_style(TextStyleRefinement {
1839 font_style: Some(FontStyle::Italic),
1840 ..Default::default()
1841 }),
1842 MarkdownTag::Strong => builder.push_text_style(TextStyleRefinement {
1843 font_weight: Some(FontWeight::BOLD),
1844 ..Default::default()
1845 }),
1846 MarkdownTag::Strikethrough => {
1847 builder.push_text_style(TextStyleRefinement {
1848 strikethrough: Some(StrikethroughStyle {
1849 thickness: px(1.),
1850 color: None,
1851 }),
1852 ..Default::default()
1853 })
1854 }
1855 MarkdownTag::Link { dest_url, .. } => {
1856 if builder.code_block_stack.is_empty() {
1857 builder.push_link(dest_url.clone(), range.clone());
1858 let style = self
1859 .style
1860 .link_callback
1861 .as_ref()
1862 .and_then(|callback| callback(dest_url, cx))
1863 .unwrap_or_else(|| self.style.link.clone());
1864 builder.push_text_style(style)
1865 }
1866 }
1867 MarkdownTag::FootnoteDefinition(label) => {
1868 if !builder.rendered_footnote_separator {
1869 builder.rendered_footnote_separator = true;
1870 builder.push_div(
1871 div()
1872 .border_t_1()
1873 .mt_2()
1874 .border_color(self.style.rule_color),
1875 range,
1876 markdown_end,
1877 );
1878 builder.pop_div();
1879 }
1880 builder.push_div(
1881 div()
1882 .pt_1()
1883 .mb_1()
1884 .line_height(rems(1.3))
1885 .text_size(rems(0.85))
1886 .h_flex()
1887 .items_start()
1888 .gap_2()
1889 .child(
1890 div().text_size(rems(0.85)).child(format!("{}.", label)),
1891 ),
1892 range,
1893 markdown_end,
1894 );
1895 builder.push_div(div().flex_1().w_0(), range, markdown_end);
1896 }
1897 MarkdownTag::MetadataBlock(_) => {}
1898 MarkdownTag::Table(alignments) => {
1899 builder.table.start(alignments.clone());
1900
1901 let column_count = alignments.len();
1902 builder.push_div(
1903 div()
1904 .id(("table", range.start))
1905 .grid()
1906 .grid_cols(column_count as u16)
1907 .when(self.style.table_columns_min_size, |this| {
1908 this.grid_cols_min_content(column_count as u16)
1909 })
1910 .when(!self.style.table_columns_min_size, |this| {
1911 this.grid_cols(column_count as u16)
1912 })
1913 .w_full()
1914 .mb_2()
1915 .border(px(1.5))
1916 .border_color(cx.theme().colors().border)
1917 .rounded_sm()
1918 .overflow_hidden(),
1919 range,
1920 markdown_end,
1921 );
1922 }
1923 MarkdownTag::TableHead => {
1924 builder.table.start_head();
1925 builder.push_text_style(TextStyleRefinement {
1926 font_weight: Some(FontWeight::SEMIBOLD),
1927 ..Default::default()
1928 });
1929 }
1930 MarkdownTag::TableRow => {
1931 builder.table.start_row();
1932 }
1933 MarkdownTag::TableCell => {
1934 let is_header = builder.table.in_head;
1935 let row_index = builder.table.row_index;
1936 let col_index = builder.table.col_index;
1937
1938 builder.push_div(
1939 div()
1940 .when(col_index > 0, |this| this.border_l_1())
1941 .when(row_index > 0, |this| this.border_t_1())
1942 .border_color(cx.theme().colors().border)
1943 .px_1()
1944 .py_0p5()
1945 .when(is_header, |this| {
1946 this.bg(cx.theme().colors().title_bar_background)
1947 })
1948 .when(!is_header && row_index % 2 == 1, |this| {
1949 this.bg(cx.theme().colors().panel_background)
1950 }),
1951 range,
1952 markdown_end,
1953 );
1954 }
1955 _ => log::debug!("unsupported markdown tag {:?}", tag),
1956 }
1957 }
1958 MarkdownEvent::End(tag) => match tag {
1959 MarkdownTagEnd::Image => {
1960 current_img_block_range.take();
1961 }
1962 MarkdownTagEnd::Paragraph => {
1963 self.pop_markdown_paragraph(&mut builder);
1964 }
1965 MarkdownTagEnd::Heading(_) => {
1966 self.pop_markdown_heading(&mut builder);
1967 }
1968 MarkdownTagEnd::BlockQuote(_kind) => {
1969 self.pop_markdown_block_quote(&mut builder);
1970 }
1971 MarkdownTagEnd::CodeBlock => {
1972 builder.trim_trailing_newline();
1973
1974 builder.pop_div();
1975 builder.pop_code_block();
1976 builder.pop_text_style();
1977
1978 if let CodeBlockRenderer::Default {
1979 copy_button_visibility,
1980 ..
1981 } = &self.code_block_renderer
1982 && *copy_button_visibility != CopyButtonVisibility::Hidden
1983 {
1984 builder.modify_current_div(|el| {
1985 let content_range = parser::extract_code_block_content_range(
1986 &parsed_markdown.source()[range.clone()],
1987 );
1988 let content_range = content_range.start + range.start
1989 ..content_range.end + range.start;
1990
1991 let code = parsed_markdown.source()[content_range].to_string();
1992 let codeblock = render_copy_code_block_button(
1993 range.end,
1994 code,
1995 self.markdown.clone(),
1996 );
1997 el.child(
1998 h_flex()
1999 .w_4()
2000 .absolute()
2001 .justify_end()
2002 .when_else(
2003 *copy_button_visibility
2004 == CopyButtonVisibility::VisibleOnHover,
2005 |this| {
2006 this.top_0()
2007 .right_0()
2008 .visible_on_hover("code_block")
2009 },
2010 |this| this.top_1p5().right_1p5(),
2011 )
2012 .child(codeblock),
2013 )
2014 });
2015 }
2016
2017 // Pop the parent container.
2018 builder.pop_div();
2019 }
2020 MarkdownTagEnd::HtmlBlock => builder.pop_div(),
2021 MarkdownTagEnd::List(_) => {
2022 builder.pop_list();
2023 builder.pop_div();
2024 }
2025 MarkdownTagEnd::Item => {
2026 self.pop_markdown_list_item(&mut builder);
2027 }
2028 MarkdownTagEnd::Emphasis => builder.pop_text_style(),
2029 MarkdownTagEnd::Strong => builder.pop_text_style(),
2030 MarkdownTagEnd::Strikethrough => builder.pop_text_style(),
2031 MarkdownTagEnd::Link => {
2032 if builder.code_block_stack.is_empty() {
2033 builder.pop_text_style()
2034 }
2035 }
2036 MarkdownTagEnd::Table => {
2037 builder.pop_div();
2038 builder.table.end();
2039 }
2040 MarkdownTagEnd::TableHead => {
2041 builder.pop_text_style();
2042 builder.table.end_head();
2043 }
2044 MarkdownTagEnd::TableRow => {
2045 builder.table.end_row();
2046 }
2047 MarkdownTagEnd::TableCell => {
2048 builder.replace_pending_checkbox(range);
2049 builder.pop_div();
2050 builder.table.end_cell();
2051 }
2052 MarkdownTagEnd::FootnoteDefinition => {
2053 builder.pop_div();
2054 builder.pop_div();
2055 }
2056 _ => log::debug!("unsupported markdown tag end: {:?}", tag),
2057 },
2058 MarkdownEvent::Text => {
2059 builder.push_text(&parsed_markdown.source[range.clone()], range.clone());
2060 }
2061 MarkdownEvent::SubstitutedText(text) => {
2062 builder.push_text(text, range.clone());
2063 }
2064 MarkdownEvent::Code => {
2065 builder.push_text_style(self.style.inline_code.clone());
2066 builder.push_text(&parsed_markdown.source[range.clone()], range.clone());
2067 builder.pop_text_style();
2068 }
2069 MarkdownEvent::Html => {
2070 let html = &parsed_markdown.source[range.clone()];
2071 if html.starts_with("<!--") {
2072 builder.html_comment = true;
2073 }
2074 if html.trim_end().ends_with("-->") {
2075 builder.html_comment = false;
2076 continue;
2077 }
2078 if builder.html_comment {
2079 continue;
2080 }
2081 builder.push_text(html, range.clone());
2082 }
2083 MarkdownEvent::InlineHtml => {
2084 let html = &parsed_markdown.source[range.clone()];
2085 if html.starts_with("<code>") {
2086 builder.push_text_style(self.style.inline_code.clone());
2087 continue;
2088 }
2089 if html.trim_end().starts_with("</code>") {
2090 builder.pop_text_style();
2091 continue;
2092 }
2093 builder.push_text(&parsed_markdown.source[range.clone()], range.clone());
2094 }
2095 MarkdownEvent::Rule => {
2096 builder.push_div(
2097 div()
2098 .border_b_1()
2099 .my_2()
2100 .border_color(self.style.rule_color),
2101 range,
2102 markdown_end,
2103 );
2104 builder.pop_div()
2105 }
2106 MarkdownEvent::SoftBreak => builder.push_text(" ", range.clone()),
2107 MarkdownEvent::HardBreak => builder.push_text("\n", range.clone()),
2108 MarkdownEvent::TaskListMarker(_) => {
2109 // handled inside the `MarkdownTag::Item` case
2110 }
2111 MarkdownEvent::FootnoteReference(label) => {
2112 builder.push_footnote_ref(label.clone(), range.clone());
2113 builder.push_text_style(self.style.link.clone());
2114 builder.push_text(&format!("[{label}]"), range.clone());
2115 builder.pop_text_style();
2116 }
2117 }
2118 }
2119 if self.style.code_block_overflow_x_scroll {
2120 let code_block_ids = code_block_ids;
2121 self.markdown.update(cx, move |markdown, _| {
2122 markdown.retain_code_block_scroll_handles(&code_block_ids);
2123 });
2124 } else {
2125 self.markdown
2126 .update(cx, |markdown, _| markdown.clear_code_block_scroll_handles());
2127 }
2128 let mut rendered_markdown = builder.build();
2129 let child_layout_id = rendered_markdown.element.request_layout(window, cx);
2130 let layout_id = window.request_layout(gpui::Style::default(), [child_layout_id], cx);
2131 (layout_id, rendered_markdown)
2132 }
2133
2134 fn prepaint(
2135 &mut self,
2136 _id: Option<&GlobalElementId>,
2137 _inspector_id: Option<&gpui::InspectorElementId>,
2138 bounds: Bounds<Pixels>,
2139 rendered_markdown: &mut Self::RequestLayoutState,
2140 window: &mut Window,
2141 cx: &mut App,
2142 ) -> Self::PrepaintState {
2143 let focus_handle = self.markdown.read(cx).focus_handle.clone();
2144 window.set_focus_handle(&focus_handle, cx);
2145 window.set_view_id(self.markdown.entity_id());
2146
2147 let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal);
2148 rendered_markdown.element.prepaint(window, cx);
2149 self.autoscroll(&rendered_markdown.text, window, cx);
2150 hitbox
2151 }
2152
2153 fn paint(
2154 &mut self,
2155 _id: Option<&GlobalElementId>,
2156 _inspector_id: Option<&gpui::InspectorElementId>,
2157 bounds: Bounds<Pixels>,
2158 rendered_markdown: &mut Self::RequestLayoutState,
2159 hitbox: &mut Self::PrepaintState,
2160 window: &mut Window,
2161 cx: &mut App,
2162 ) {
2163 let mut context = KeyContext::default();
2164 context.add("Markdown");
2165 window.set_key_context(context);
2166 window.on_action(std::any::TypeId::of::<crate::Copy>(), {
2167 let entity = self.markdown.clone();
2168 let text = rendered_markdown.text.clone();
2169 move |_, phase, window, cx| {
2170 let text = text.clone();
2171 if phase == DispatchPhase::Bubble {
2172 entity.update(cx, move |this, cx| this.copy(&text, window, cx))
2173 }
2174 }
2175 });
2176 window.on_action(std::any::TypeId::of::<crate::CopyAsMarkdown>(), {
2177 let entity = self.markdown.clone();
2178 move |_, phase, window, cx| {
2179 if phase == DispatchPhase::Bubble {
2180 entity.update(cx, move |this, cx| this.copy_as_markdown(window, cx))
2181 }
2182 }
2183 });
2184
2185 self.paint_mouse_listeners(hitbox, &rendered_markdown.text, window, cx);
2186 rendered_markdown.element.paint(window, cx);
2187 self.paint_search_highlights(bounds, &rendered_markdown.text, window, cx);
2188 self.paint_selection(bounds, &rendered_markdown.text, window, cx);
2189 }
2190}
2191
2192fn apply_heading_style(
2193 mut heading: Div,
2194 level: pulldown_cmark::HeadingLevel,
2195 custom_styles: Option<&HeadingLevelStyles>,
2196) -> Div {
2197 heading = match level {
2198 pulldown_cmark::HeadingLevel::H1 => heading.text_3xl(),
2199 pulldown_cmark::HeadingLevel::H2 => heading.text_2xl(),
2200 pulldown_cmark::HeadingLevel::H3 => heading.text_xl(),
2201 pulldown_cmark::HeadingLevel::H4 => heading.text_lg(),
2202 pulldown_cmark::HeadingLevel::H5 => heading.text_base(),
2203 pulldown_cmark::HeadingLevel::H6 => heading.text_sm(),
2204 };
2205
2206 if let Some(styles) = custom_styles {
2207 let style_opt = match level {
2208 pulldown_cmark::HeadingLevel::H1 => &styles.h1,
2209 pulldown_cmark::HeadingLevel::H2 => &styles.h2,
2210 pulldown_cmark::HeadingLevel::H3 => &styles.h3,
2211 pulldown_cmark::HeadingLevel::H4 => &styles.h4,
2212 pulldown_cmark::HeadingLevel::H5 => &styles.h5,
2213 pulldown_cmark::HeadingLevel::H6 => &styles.h6,
2214 };
2215
2216 if let Some(style) = style_opt {
2217 heading.style().text = style.clone();
2218 }
2219 }
2220
2221 heading
2222}
2223
2224fn render_copy_code_block_button(
2225 id: usize,
2226 code: String,
2227 markdown: Entity<Markdown>,
2228) -> impl IntoElement {
2229 let id = ElementId::named_usize("copy-markdown-code", id);
2230
2231 CopyButton::new(id.clone(), code.clone()).custom_on_click({
2232 let markdown = markdown;
2233 move |_window, cx| {
2234 let id = id.clone();
2235 markdown.update(cx, |this, cx| {
2236 this.copied_code_blocks.insert(id.clone());
2237
2238 cx.write_to_clipboard(ClipboardItem::new_string(code.clone()));
2239
2240 cx.spawn(async move |this, cx| {
2241 cx.background_executor().timer(Duration::from_secs(2)).await;
2242
2243 cx.update(|cx| {
2244 this.update(cx, |this, cx| {
2245 this.copied_code_blocks.remove(&id);
2246 cx.notify();
2247 })
2248 })
2249 .ok();
2250 })
2251 .detach();
2252 });
2253 }
2254 })
2255}
2256
2257impl IntoElement for MarkdownElement {
2258 type Element = Self;
2259
2260 fn into_element(self) -> Self::Element {
2261 self
2262 }
2263}
2264
2265pub enum AnyDiv {
2266 Div(Div),
2267 Stateful(Stateful<Div>),
2268}
2269
2270impl AnyDiv {
2271 fn into_any_element(self) -> AnyElement {
2272 match self {
2273 Self::Div(div) => div.into_any_element(),
2274 Self::Stateful(div) => div.into_any_element(),
2275 }
2276 }
2277}
2278
2279impl From<Div> for AnyDiv {
2280 fn from(value: Div) -> Self {
2281 Self::Div(value)
2282 }
2283}
2284
2285impl From<Stateful<Div>> for AnyDiv {
2286 fn from(value: Stateful<Div>) -> Self {
2287 Self::Stateful(value)
2288 }
2289}
2290
2291impl Styled for AnyDiv {
2292 fn style(&mut self) -> &mut StyleRefinement {
2293 match self {
2294 Self::Div(div) => div.style(),
2295 Self::Stateful(div) => div.style(),
2296 }
2297 }
2298}
2299
2300impl ParentElement for AnyDiv {
2301 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
2302 match self {
2303 Self::Div(div) => div.extend(elements),
2304 Self::Stateful(div) => div.extend(elements),
2305 }
2306 }
2307}
2308
2309#[derive(Default)]
2310struct TableState {
2311 alignments: Vec<Alignment>,
2312 in_head: bool,
2313 row_index: usize,
2314 col_index: usize,
2315}
2316
2317impl TableState {
2318 fn start(&mut self, alignments: Vec<Alignment>) {
2319 self.alignments = alignments;
2320 self.in_head = false;
2321 self.row_index = 0;
2322 self.col_index = 0;
2323 }
2324
2325 fn end(&mut self) {
2326 self.alignments.clear();
2327 self.in_head = false;
2328 self.row_index = 0;
2329 self.col_index = 0;
2330 }
2331
2332 fn start_head(&mut self) {
2333 self.in_head = true;
2334 }
2335
2336 fn end_head(&mut self) {
2337 self.in_head = false;
2338 }
2339
2340 fn start_row(&mut self) {
2341 self.col_index = 0;
2342 }
2343
2344 fn end_row(&mut self) {
2345 self.row_index += 1;
2346 }
2347
2348 fn end_cell(&mut self) {
2349 self.col_index += 1;
2350 }
2351}
2352
2353struct MarkdownElementBuilder {
2354 div_stack: Vec<AnyDiv>,
2355 rendered_lines: Vec<RenderedLine>,
2356 pending_line: PendingLine,
2357 rendered_links: Vec<RenderedLink>,
2358 rendered_footnote_refs: Vec<RenderedFootnoteRef>,
2359 current_source_index: usize,
2360 html_comment: bool,
2361 rendered_footnote_separator: bool,
2362 base_text_style: TextStyle,
2363 text_style_stack: Vec<TextStyleRefinement>,
2364 code_block_stack: Vec<Option<Arc<Language>>>,
2365 list_stack: Vec<ListStackEntry>,
2366 table: TableState,
2367 syntax_theme: Arc<SyntaxTheme>,
2368}
2369
2370#[derive(Default)]
2371struct PendingLine {
2372 text: String,
2373 runs: Vec<TextRun>,
2374 source_mappings: Vec<SourceMapping>,
2375}
2376
2377struct ListStackEntry {
2378 bullet_index: Option<u64>,
2379}
2380
2381impl MarkdownElementBuilder {
2382 fn new(
2383 container_style: &StyleRefinement,
2384 base_text_style: TextStyle,
2385 syntax_theme: Arc<SyntaxTheme>,
2386 ) -> Self {
2387 Self {
2388 div_stack: vec![{
2389 let mut base_div = div();
2390 base_div.style().refine(container_style);
2391 base_div.debug_selector(|| "inner".into()).into()
2392 }],
2393 rendered_lines: Vec::new(),
2394 pending_line: PendingLine::default(),
2395 rendered_links: Vec::new(),
2396 rendered_footnote_refs: Vec::new(),
2397 current_source_index: 0,
2398 html_comment: false,
2399 rendered_footnote_separator: false,
2400 base_text_style,
2401 text_style_stack: Vec::new(),
2402 code_block_stack: Vec::new(),
2403 list_stack: Vec::new(),
2404 table: TableState::default(),
2405 syntax_theme,
2406 }
2407 }
2408
2409 fn push_text_style(&mut self, style: TextStyleRefinement) {
2410 self.text_style_stack.push(style);
2411 }
2412
2413 fn text_style(&self) -> TextStyle {
2414 let mut style = self.base_text_style.clone();
2415 for refinement in &self.text_style_stack {
2416 style.refine(refinement);
2417 }
2418 style
2419 }
2420
2421 fn pop_text_style(&mut self) {
2422 self.text_style_stack.pop();
2423 }
2424
2425 fn push_div(&mut self, div: impl Into<AnyDiv>, range: &Range<usize>, markdown_end: usize) {
2426 let mut div = div.into();
2427 self.flush_text();
2428
2429 if range.start == 0 {
2430 // Remove the top margin on the first element.
2431 div.style().refine(&StyleRefinement {
2432 margin: gpui::EdgesRefinement {
2433 top: Some(Length::Definite(px(0.).into())),
2434 left: None,
2435 right: None,
2436 bottom: None,
2437 },
2438 ..Default::default()
2439 });
2440 }
2441
2442 if range.end == markdown_end {
2443 div.style().refine(&StyleRefinement {
2444 margin: gpui::EdgesRefinement {
2445 top: None,
2446 left: None,
2447 right: None,
2448 bottom: Some(Length::Definite(rems(0.).into())),
2449 },
2450 ..Default::default()
2451 });
2452 }
2453
2454 self.div_stack.push(div);
2455 }
2456
2457 fn push_root_block(&mut self, range: &Range<usize>, markdown_end: usize) {
2458 self.push_div(
2459 div().group("markdown-root-block").relative(),
2460 range,
2461 markdown_end,
2462 );
2463 self.push_div(div().pl_4(), range, markdown_end);
2464 }
2465
2466 fn modify_current_div(&mut self, f: impl FnOnce(AnyDiv) -> AnyDiv) {
2467 self.flush_text();
2468 if let Some(div) = self.div_stack.pop() {
2469 self.div_stack.push(f(div));
2470 }
2471 }
2472
2473 fn pop_root_block(
2474 &mut self,
2475 is_active: bool,
2476 active_gutter_color: Hsla,
2477 hovered_gutter_color: Hsla,
2478 ) {
2479 self.pop_div();
2480 self.modify_current_div(|el| {
2481 el.child(
2482 div()
2483 .h_full()
2484 .w(px(4.0))
2485 .when(is_active, |this| this.bg(active_gutter_color))
2486 .group_hover("markdown-root-block", |this| {
2487 if is_active {
2488 this
2489 } else {
2490 this.bg(hovered_gutter_color)
2491 }
2492 })
2493 .rounded_xs()
2494 .absolute()
2495 .left_0()
2496 .top_0(),
2497 )
2498 });
2499 self.pop_div();
2500 }
2501
2502 fn pop_div(&mut self) {
2503 self.flush_text();
2504 let div = self.div_stack.pop().unwrap().into_any_element();
2505 self.div_stack.last_mut().unwrap().extend(iter::once(div));
2506 }
2507
2508 fn push_sourced_element(&mut self, source_range: Range<usize>, element: impl Into<AnyElement>) {
2509 self.flush_text();
2510 let anchor = self.render_source_anchor(source_range);
2511 self.div_stack.last_mut().unwrap().extend([{
2512 div()
2513 .relative()
2514 .child(anchor)
2515 .child(element.into())
2516 .into_any_element()
2517 }]);
2518 }
2519
2520 fn push_list(&mut self, bullet_index: Option<u64>) {
2521 self.list_stack.push(ListStackEntry { bullet_index });
2522 }
2523
2524 fn next_bullet_index(&mut self) -> Option<u64> {
2525 self.list_stack.last_mut().and_then(|entry| {
2526 let item_index = entry.bullet_index.as_mut()?;
2527 *item_index += 1;
2528 Some(*item_index - 1)
2529 })
2530 }
2531
2532 fn pop_list(&mut self) {
2533 self.list_stack.pop();
2534 }
2535
2536 fn push_code_block(&mut self, language: Option<Arc<Language>>) {
2537 self.code_block_stack.push(language);
2538 }
2539
2540 fn pop_code_block(&mut self) {
2541 self.code_block_stack.pop();
2542 }
2543
2544 fn push_link(&mut self, destination_url: SharedString, source_range: Range<usize>) {
2545 self.rendered_links.push(RenderedLink {
2546 source_range,
2547 destination_url,
2548 });
2549 }
2550
2551 fn push_footnote_ref(&mut self, label: SharedString, source_range: Range<usize>) {
2552 self.rendered_footnote_refs.push(RenderedFootnoteRef {
2553 source_range,
2554 label,
2555 });
2556 }
2557
2558 fn push_text(&mut self, text: &str, source_range: Range<usize>) {
2559 self.pending_line.source_mappings.push(SourceMapping {
2560 rendered_index: self.pending_line.text.len(),
2561 source_index: source_range.start,
2562 });
2563 self.pending_line.text.push_str(text);
2564 self.current_source_index = source_range.end;
2565
2566 if let Some(Some(language)) = self.code_block_stack.last() {
2567 let mut offset = 0;
2568 for (range, highlight_id) in language.highlight_text(&Rope::from(text), 0..text.len()) {
2569 if range.start > offset {
2570 self.pending_line
2571 .runs
2572 .push(self.text_style().to_run(range.start - offset));
2573 }
2574
2575 let mut run_style = self.text_style();
2576 if let Some(highlight) = self.syntax_theme.get(highlight_id).cloned() {
2577 run_style = run_style.highlight(highlight);
2578 }
2579
2580 self.pending_line.runs.push(run_style.to_run(range.len()));
2581 offset = range.end;
2582 }
2583
2584 if offset < text.len() {
2585 self.pending_line
2586 .runs
2587 .push(self.text_style().to_run(text.len() - offset));
2588 }
2589 } else {
2590 self.pending_line
2591 .runs
2592 .push(self.text_style().to_run(text.len()));
2593 }
2594 }
2595
2596 fn trim_trailing_newline(&mut self) {
2597 if self.pending_line.text.ends_with('\n') {
2598 self.pending_line
2599 .text
2600 .truncate(self.pending_line.text.len() - 1);
2601 self.pending_line.runs.last_mut().unwrap().len -= 1;
2602 self.current_source_index -= 1;
2603 }
2604 }
2605
2606 fn replace_pending_checkbox(&mut self, source_range: &Range<usize>) {
2607 let trimmed = self.pending_line.text.trim();
2608 if trimmed == "[x]" || trimmed == "[X]" || trimmed == "[ ]" {
2609 let checked = trimmed != "[ ]";
2610 self.pending_line = PendingLine::default();
2611 let checkbox = Checkbox::new(
2612 ElementId::Name(
2613 format!("table_checkbox_{}_{}", source_range.start, source_range.end).into(),
2614 ),
2615 if checked {
2616 ToggleState::Selected
2617 } else {
2618 ToggleState::Unselected
2619 },
2620 )
2621 .fill()
2622 .visualization_only(true)
2623 .into_any_element();
2624 self.div_stack.last_mut().unwrap().extend([checkbox]);
2625 }
2626 }
2627
2628 fn render_source_anchor(&mut self, source_range: Range<usize>) -> AnyElement {
2629 let mut text_style = self.base_text_style.clone();
2630 text_style.color = Hsla::transparent_black();
2631 let text = "\u{200B}";
2632 let styled_text = StyledText::new(text).with_runs(vec![text_style.to_run(text.len())]);
2633 self.rendered_lines.push(RenderedLine {
2634 layout: styled_text.layout().clone(),
2635 source_mappings: vec![SourceMapping {
2636 rendered_index: 0,
2637 source_index: source_range.start,
2638 }],
2639 source_end: source_range.end,
2640 language: None,
2641 });
2642 div()
2643 .absolute()
2644 .top_0()
2645 .left_0()
2646 .opacity(0.)
2647 .child(styled_text)
2648 .into_any_element()
2649 }
2650
2651 fn flush_text(&mut self) {
2652 let line = mem::take(&mut self.pending_line);
2653 if line.text.is_empty() {
2654 return;
2655 }
2656
2657 let text = StyledText::new(line.text).with_runs(line.runs);
2658 self.rendered_lines.push(RenderedLine {
2659 layout: text.layout().clone(),
2660 source_mappings: line.source_mappings,
2661 source_end: self.current_source_index,
2662 language: self.code_block_stack.last().cloned().flatten(),
2663 });
2664 self.div_stack.last_mut().unwrap().extend([text.into_any()]);
2665 }
2666
2667 fn build(mut self) -> RenderedMarkdown {
2668 debug_assert_eq!(self.div_stack.len(), 1);
2669 self.flush_text();
2670 RenderedMarkdown {
2671 element: self.div_stack.pop().unwrap().into_any_element(),
2672 text: RenderedText {
2673 lines: self.rendered_lines.into(),
2674 links: self.rendered_links.into(),
2675 footnote_refs: self.rendered_footnote_refs.into(),
2676 },
2677 }
2678 }
2679}
2680
2681struct RenderedLine {
2682 layout: TextLayout,
2683 source_mappings: Vec<SourceMapping>,
2684 source_end: usize,
2685 language: Option<Arc<Language>>,
2686}
2687
2688impl RenderedLine {
2689 fn rendered_index_for_source_index(&self, source_index: usize) -> usize {
2690 if source_index >= self.source_end {
2691 return self.layout.len();
2692 }
2693
2694 let mapping = match self
2695 .source_mappings
2696 .binary_search_by_key(&source_index, |probe| probe.source_index)
2697 {
2698 Ok(ix) => &self.source_mappings[ix],
2699 Err(ix) => &self.source_mappings[ix - 1],
2700 };
2701 (mapping.rendered_index + (source_index - mapping.source_index)).min(self.layout.len())
2702 }
2703
2704 fn source_index_for_rendered_index(&self, rendered_index: usize) -> usize {
2705 if rendered_index >= self.layout.len() {
2706 return self.source_end;
2707 }
2708
2709 let mapping = match self
2710 .source_mappings
2711 .binary_search_by_key(&rendered_index, |probe| probe.rendered_index)
2712 {
2713 Ok(ix) => &self.source_mappings[ix],
2714 Err(ix) => &self.source_mappings[ix - 1],
2715 };
2716 mapping.source_index + (rendered_index - mapping.rendered_index)
2717 }
2718
2719 /// Returns the source index for use as an exclusive range end at a word/selection boundary.
2720 /// When the rendered index is exactly at the start of a segment with a gap from the previous
2721 /// segment (e.g., after stripped markdown syntax like backticks), this returns the end of the
2722 /// previous segment rather than the start of the current one.
2723 fn source_index_for_exclusive_rendered_end(&self, rendered_index: usize) -> usize {
2724 if rendered_index >= self.layout.len() {
2725 return self.source_end;
2726 }
2727
2728 let ix = match self
2729 .source_mappings
2730 .binary_search_by_key(&rendered_index, |probe| probe.rendered_index)
2731 {
2732 Ok(ix) => ix,
2733 Err(ix) => {
2734 return self.source_mappings[ix - 1].source_index
2735 + (rendered_index - self.source_mappings[ix - 1].rendered_index);
2736 }
2737 };
2738
2739 // Exact match at the start of a segment. Check if there's a gap from the previous segment.
2740 if ix > 0 {
2741 let prev_mapping = &self.source_mappings[ix - 1];
2742 let mapping = &self.source_mappings[ix];
2743 let prev_segment_len = mapping.rendered_index - prev_mapping.rendered_index;
2744 let prev_source_end = prev_mapping.source_index + prev_segment_len;
2745 if prev_source_end < mapping.source_index {
2746 return prev_source_end;
2747 }
2748 }
2749
2750 self.source_mappings[ix].source_index
2751 }
2752
2753 fn source_index_for_position(&self, position: Point<Pixels>) -> Result<usize, usize> {
2754 let line_rendered_index;
2755 let out_of_bounds;
2756 match self.layout.index_for_position(position) {
2757 Ok(ix) => {
2758 line_rendered_index = ix;
2759 out_of_bounds = false;
2760 }
2761 Err(ix) => {
2762 line_rendered_index = ix;
2763 out_of_bounds = true;
2764 }
2765 };
2766 let source_index = self.source_index_for_rendered_index(line_rendered_index);
2767 if out_of_bounds {
2768 Err(source_index)
2769 } else {
2770 Ok(source_index)
2771 }
2772 }
2773}
2774
2775#[derive(Copy, Clone, Debug, Default)]
2776struct SourceMapping {
2777 rendered_index: usize,
2778 source_index: usize,
2779}
2780
2781pub struct RenderedMarkdown {
2782 element: AnyElement,
2783 text: RenderedText,
2784}
2785
2786#[derive(Clone)]
2787struct RenderedText {
2788 lines: Rc<[RenderedLine]>,
2789 links: Rc<[RenderedLink]>,
2790 footnote_refs: Rc<[RenderedFootnoteRef]>,
2791}
2792
2793#[derive(Debug, Clone, Eq, PartialEq)]
2794struct RenderedLink {
2795 source_range: Range<usize>,
2796 destination_url: SharedString,
2797}
2798
2799#[derive(Debug, Clone, Eq, PartialEq)]
2800struct RenderedFootnoteRef {
2801 source_range: Range<usize>,
2802 label: SharedString,
2803}
2804
2805impl RenderedText {
2806 fn source_index_for_position(&self, position: Point<Pixels>) -> Result<usize, usize> {
2807 let mut lines = self.lines.iter().peekable();
2808 let mut fallback_line: Option<&RenderedLine> = None;
2809
2810 while let Some(line) = lines.next() {
2811 let line_bounds = line.layout.bounds();
2812
2813 // Exact match: position is within bounds (handles overlapping bounds like table columns)
2814 if line_bounds.contains(&position) {
2815 return line.source_index_for_position(position);
2816 }
2817
2818 // Track fallback for Y-coordinate based matching
2819 if position.y <= line_bounds.bottom() && fallback_line.is_none() {
2820 fallback_line = Some(line);
2821 }
2822
2823 // Handle gap between lines
2824 if position.y > line_bounds.bottom() {
2825 if let Some(next_line) = lines.peek()
2826 && position.y < next_line.layout.bounds().top()
2827 {
2828 return Err(line.source_end);
2829 }
2830 }
2831 }
2832
2833 // Fall back to Y-coordinate matched line
2834 if let Some(line) = fallback_line {
2835 return line.source_index_for_position(position);
2836 }
2837
2838 Err(self.lines.last().map_or(0, |line| line.source_end))
2839 }
2840
2841 fn position_for_source_index(&self, source_index: usize) -> Option<(Point<Pixels>, Pixels)> {
2842 for line in self.lines.iter() {
2843 let line_source_start = line.source_mappings.first().unwrap().source_index;
2844 if source_index < line_source_start {
2845 break;
2846 } else if source_index > line.source_end {
2847 continue;
2848 } else {
2849 let line_height = line.layout.line_height();
2850 let rendered_index_within_line = line.rendered_index_for_source_index(source_index);
2851 let position = line.layout.position_for_index(rendered_index_within_line)?;
2852 return Some((position, line_height));
2853 }
2854 }
2855 None
2856 }
2857
2858 fn surrounding_word_range(&self, source_index: usize) -> Range<usize> {
2859 for line in self.lines.iter() {
2860 if source_index > line.source_end {
2861 continue;
2862 }
2863
2864 let line_rendered_start = line.source_mappings.first().unwrap().rendered_index;
2865 let rendered_index_in_line =
2866 line.rendered_index_for_source_index(source_index) - line_rendered_start;
2867 let text = line.layout.text();
2868
2869 let scope = line.language.as_ref().map(|l| l.default_scope());
2870 let classifier = CharClassifier::new(scope);
2871
2872 let mut prev_chars = text[..rendered_index_in_line].chars().rev().peekable();
2873 let mut next_chars = text[rendered_index_in_line..].chars().peekable();
2874
2875 let word_kind = std::cmp::max(
2876 prev_chars.peek().map(|&c| classifier.kind(c)),
2877 next_chars.peek().map(|&c| classifier.kind(c)),
2878 );
2879
2880 let mut start = rendered_index_in_line;
2881 for c in prev_chars {
2882 if Some(classifier.kind(c)) == word_kind {
2883 start -= c.len_utf8();
2884 } else {
2885 break;
2886 }
2887 }
2888
2889 let mut end = rendered_index_in_line;
2890 for c in next_chars {
2891 if Some(classifier.kind(c)) == word_kind {
2892 end += c.len_utf8();
2893 } else {
2894 break;
2895 }
2896 }
2897
2898 return line.source_index_for_rendered_index(line_rendered_start + start)
2899 ..line.source_index_for_exclusive_rendered_end(line_rendered_start + end);
2900 }
2901
2902 source_index..source_index
2903 }
2904
2905 fn surrounding_line_range(&self, source_index: usize) -> Range<usize> {
2906 for line in self.lines.iter() {
2907 if source_index > line.source_end {
2908 continue;
2909 }
2910 let line_source_start = line.source_mappings.first().unwrap().source_index;
2911 return line_source_start..line.source_end;
2912 }
2913
2914 source_index..source_index
2915 }
2916
2917 fn text_for_range(&self, range: Range<usize>) -> String {
2918 let mut accumulator = String::new();
2919
2920 for line in self.lines.iter() {
2921 if range.start > line.source_end {
2922 continue;
2923 }
2924 let line_source_start = line.source_mappings.first().unwrap().source_index;
2925 if range.end < line_source_start {
2926 break;
2927 }
2928
2929 let text = line.layout.text();
2930
2931 let start = if range.start < line_source_start {
2932 0
2933 } else {
2934 line.rendered_index_for_source_index(range.start)
2935 };
2936 let end = if range.end > line.source_end {
2937 line.rendered_index_for_source_index(line.source_end)
2938 } else {
2939 line.rendered_index_for_source_index(range.end)
2940 }
2941 .min(text.len());
2942
2943 accumulator.push_str(&text[start..end]);
2944 accumulator.push('\n');
2945 }
2946 // Remove trailing newline
2947 accumulator.pop();
2948 accumulator
2949 }
2950
2951 fn link_for_source_index(&self, source_index: usize) -> Option<&RenderedLink> {
2952 self.links
2953 .iter()
2954 .find(|link| link.source_range.contains(&source_index))
2955 }
2956
2957 fn footnote_ref_for_source_index(&self, source_index: usize) -> Option<&RenderedFootnoteRef> {
2958 self.footnote_refs
2959 .iter()
2960 .find(|fref| fref.source_range.contains(&source_index))
2961 }
2962}
2963
2964#[cfg(test)]
2965mod tests {
2966 use super::*;
2967 use gpui::{TestAppContext, size};
2968 use language::{Language, LanguageConfig, LanguageMatcher};
2969 use std::sync::Arc;
2970
2971 fn ensure_theme_initialized(cx: &mut TestAppContext) {
2972 cx.update(|cx| {
2973 if !cx.has_global::<settings::SettingsStore>() {
2974 settings::init(cx);
2975 }
2976 if !cx.has_global::<theme::GlobalTheme>() {
2977 theme_settings::init(theme::LoadThemes::JustBase, cx);
2978 }
2979 });
2980 }
2981
2982 #[gpui::test]
2983 fn test_mappings(cx: &mut TestAppContext) {
2984 // Formatting.
2985 assert_mappings(
2986 &render_markdown("He*l*lo", cx),
2987 vec![vec![(0, 0), (1, 1), (2, 3), (3, 5), (4, 6), (5, 7)]],
2988 );
2989
2990 // Multiple lines.
2991 assert_mappings(
2992 &render_markdown("Hello\n\nWorld", cx),
2993 vec![
2994 vec![(0, 0), (1, 1), (2, 2), (3, 3), (4, 4), (5, 5)],
2995 vec![(0, 7), (1, 8), (2, 9), (3, 10), (4, 11), (5, 12)],
2996 ],
2997 );
2998
2999 // Multi-byte characters.
3000 assert_mappings(
3001 &render_markdown("αβγ\n\nδεζ", cx),
3002 vec![
3003 vec![(0, 0), (2, 2), (4, 4), (6, 6)],
3004 vec![(0, 8), (2, 10), (4, 12), (6, 14)],
3005 ],
3006 );
3007
3008 // Smart quotes.
3009 assert_mappings(&render_markdown("\"", cx), vec![vec![(0, 0), (3, 1)]]);
3010 assert_mappings(
3011 &render_markdown("\"hey\"", cx),
3012 vec![vec![(0, 0), (3, 1), (4, 2), (5, 3), (6, 4), (9, 5)]],
3013 );
3014
3015 // HTML Comments are ignored
3016 assert_mappings(
3017 &render_markdown(
3018 "<!--\nrdoc-file=string.c\n- str.intern -> symbol\n- str.to_sym -> symbol\n-->\nReturns",
3019 cx,
3020 ),
3021 vec![vec![
3022 (0, 78),
3023 (1, 79),
3024 (2, 80),
3025 (3, 81),
3026 (4, 82),
3027 (5, 83),
3028 (6, 84),
3029 ]],
3030 );
3031 }
3032
3033 fn render_markdown(markdown: &str, cx: &mut TestAppContext) -> RenderedText {
3034 render_markdown_with_language_registry(markdown, None, cx)
3035 }
3036
3037 fn render_markdown_with_language_registry(
3038 markdown: &str,
3039 language_registry: Option<Arc<LanguageRegistry>>,
3040 cx: &mut TestAppContext,
3041 ) -> RenderedText {
3042 render_markdown_with_options(markdown, language_registry, MarkdownOptions::default(), cx)
3043 }
3044
3045 fn render_markdown_with_options(
3046 markdown: &str,
3047 language_registry: Option<Arc<LanguageRegistry>>,
3048 options: MarkdownOptions,
3049 cx: &mut TestAppContext,
3050 ) -> RenderedText {
3051 struct TestWindow;
3052
3053 impl Render for TestWindow {
3054 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3055 div()
3056 }
3057 }
3058
3059 ensure_theme_initialized(cx);
3060
3061 let (_, cx) = cx.add_window_view(|_, _| TestWindow);
3062 let markdown = cx.new(|cx| {
3063 Markdown::new_with_options(
3064 markdown.to_string().into(),
3065 language_registry,
3066 None,
3067 options,
3068 cx,
3069 )
3070 });
3071 cx.run_until_parked();
3072 let (rendered, _) = cx.draw(
3073 Default::default(),
3074 size(px(600.0), px(600.0)),
3075 |_window, _cx| {
3076 MarkdownElement::new(markdown, MarkdownStyle::default()).code_block_renderer(
3077 CodeBlockRenderer::Default {
3078 copy_button_visibility: CopyButtonVisibility::Hidden,
3079 border: false,
3080 },
3081 )
3082 },
3083 );
3084 rendered.text
3085 }
3086
3087 #[gpui::test]
3088 fn test_surrounding_word_range(cx: &mut TestAppContext) {
3089 let rendered = render_markdown("Hello world tesεζ", cx);
3090
3091 // Test word selection for "Hello"
3092 let word_range = rendered.surrounding_word_range(2); // Simulate click on 'l' in "Hello"
3093 let selected_text = rendered.text_for_range(word_range);
3094 assert_eq!(selected_text, "Hello");
3095
3096 // Test word selection for "world"
3097 let word_range = rendered.surrounding_word_range(7); // Simulate click on 'o' in "world"
3098 let selected_text = rendered.text_for_range(word_range);
3099 assert_eq!(selected_text, "world");
3100
3101 // Test word selection for "tesεζ"
3102 let word_range = rendered.surrounding_word_range(14); // Simulate click on 's' in "tesεζ"
3103 let selected_text = rendered.text_for_range(word_range);
3104 assert_eq!(selected_text, "tesεζ");
3105
3106 // Test word selection at word boundary (space)
3107 let word_range = rendered.surrounding_word_range(5); // Simulate click on space between "Hello" and "world", expect highlighting word to the left
3108 let selected_text = rendered.text_for_range(word_range);
3109 assert_eq!(selected_text, "Hello");
3110 }
3111
3112 #[gpui::test]
3113 fn test_surrounding_line_range(cx: &mut TestAppContext) {
3114 let rendered = render_markdown("First line\n\nSecond line\n\nThird lineεζ", cx);
3115
3116 // Test getting line range for first line
3117 let line_range = rendered.surrounding_line_range(5); // Simulate click somewhere in first line
3118 let selected_text = rendered.text_for_range(line_range);
3119 assert_eq!(selected_text, "First line");
3120
3121 // Test getting line range for second line
3122 let line_range = rendered.surrounding_line_range(13); // Simulate click at beginning in second line
3123 let selected_text = rendered.text_for_range(line_range);
3124 assert_eq!(selected_text, "Second line");
3125
3126 // Test getting line range for third line
3127 let line_range = rendered.surrounding_line_range(37); // Simulate click at end of third line with multi-byte chars
3128 let selected_text = rendered.text_for_range(line_range);
3129 assert_eq!(selected_text, "Third lineεζ");
3130 }
3131
3132 #[gpui::test]
3133 fn test_selection_head_movement(cx: &mut TestAppContext) {
3134 let rendered = render_markdown("Hello world test", cx);
3135
3136 let mut selection = Selection {
3137 start: 5,
3138 end: 5,
3139 reversed: false,
3140 pending: false,
3141 mode: SelectMode::Character,
3142 };
3143
3144 // Test forward selection
3145 selection.set_head(10, &rendered);
3146 assert_eq!(selection.start, 5);
3147 assert_eq!(selection.end, 10);
3148 assert!(!selection.reversed);
3149 assert_eq!(selection.tail(), 5);
3150
3151 // Test backward selection
3152 selection.set_head(2, &rendered);
3153 assert_eq!(selection.start, 2);
3154 assert_eq!(selection.end, 5);
3155 assert!(selection.reversed);
3156 assert_eq!(selection.tail(), 5);
3157
3158 // Test forward selection again from reversed state
3159 selection.set_head(15, &rendered);
3160 assert_eq!(selection.start, 5);
3161 assert_eq!(selection.end, 15);
3162 assert!(!selection.reversed);
3163 assert_eq!(selection.tail(), 5);
3164 }
3165
3166 #[gpui::test]
3167 fn test_word_selection_drag(cx: &mut TestAppContext) {
3168 let rendered = render_markdown("Hello world test", cx);
3169
3170 // Start with a simulated double-click on "world" (index 6-10)
3171 let word_range = rendered.surrounding_word_range(7); // Click on 'o' in "world"
3172 let mut selection = Selection {
3173 start: word_range.start,
3174 end: word_range.end,
3175 reversed: false,
3176 pending: true,
3177 mode: SelectMode::Word(word_range),
3178 };
3179
3180 // Drag forward to "test" - should expand selection to include "test"
3181 selection.set_head(13, &rendered); // Index in "test"
3182 assert_eq!(selection.start, 6); // Start of "world"
3183 assert_eq!(selection.end, 16); // End of "test"
3184 assert!(!selection.reversed);
3185 let selected_text = rendered.text_for_range(selection.start..selection.end);
3186 assert_eq!(selected_text, "world test");
3187
3188 // Drag backward to "Hello" - should expand selection to include "Hello"
3189 selection.set_head(2, &rendered); // Index in "Hello"
3190 assert_eq!(selection.start, 0); // Start of "Hello"
3191 assert_eq!(selection.end, 11); // End of "world" (original selection)
3192 assert!(selection.reversed);
3193 let selected_text = rendered.text_for_range(selection.start..selection.end);
3194 assert_eq!(selected_text, "Hello world");
3195
3196 // Drag back within original word - should revert to original selection
3197 selection.set_head(8, &rendered); // Back within "world"
3198 assert_eq!(selection.start, 6); // Start of "world"
3199 assert_eq!(selection.end, 11); // End of "world"
3200 assert!(!selection.reversed);
3201 let selected_text = rendered.text_for_range(selection.start..selection.end);
3202 assert_eq!(selected_text, "world");
3203 }
3204
3205 #[gpui::test]
3206 fn test_selection_with_markdown_formatting(cx: &mut TestAppContext) {
3207 let rendered = render_markdown(
3208 "This is **bold** text, this is *italic* text, use `code` here",
3209 cx,
3210 );
3211 let word_range = rendered.surrounding_word_range(10); // Inside "bold"
3212 let selected_text = rendered.text_for_range(word_range);
3213 assert_eq!(selected_text, "bold");
3214
3215 let word_range = rendered.surrounding_word_range(32); // Inside "italic"
3216 let selected_text = rendered.text_for_range(word_range);
3217 assert_eq!(selected_text, "italic");
3218
3219 let word_range = rendered.surrounding_word_range(51); // Inside "code"
3220 let selected_text = rendered.text_for_range(word_range);
3221 assert_eq!(selected_text, "code");
3222 }
3223
3224 #[gpui::test]
3225 fn test_table_column_selection(cx: &mut TestAppContext) {
3226 let rendered = render_markdown("| a | b |\n|---|---|\n| c | d |", cx);
3227
3228 assert!(rendered.lines.len() >= 2);
3229 let first_bounds = rendered.lines[0].layout.bounds();
3230 let second_bounds = rendered.lines[1].layout.bounds();
3231
3232 let first_index = match rendered.source_index_for_position(first_bounds.center()) {
3233 Ok(index) | Err(index) => index,
3234 };
3235 let second_index = match rendered.source_index_for_position(second_bounds.center()) {
3236 Ok(index) | Err(index) => index,
3237 };
3238
3239 let first_word = rendered.text_for_range(rendered.surrounding_word_range(first_index));
3240 let second_word = rendered.text_for_range(rendered.surrounding_word_range(second_index));
3241
3242 assert_eq!(first_word, "a");
3243 assert_eq!(second_word, "b");
3244 }
3245
3246 #[test]
3247 fn test_table_checkbox_detection() {
3248 let md = "| Done |\n|------|\n| [x] |\n| [ ] |";
3249 let events = crate::parser::parse_markdown_with_options(md, false, false).events;
3250
3251 let mut in_table = false;
3252 let mut cell_texts: Vec<String> = Vec::new();
3253 let mut current_cell = String::new();
3254
3255 for (range, event) in &events {
3256 match event {
3257 MarkdownEvent::Start(MarkdownTag::Table(_)) => in_table = true,
3258 MarkdownEvent::End(MarkdownTagEnd::Table) => in_table = false,
3259 MarkdownEvent::Start(MarkdownTag::TableCell) => current_cell.clear(),
3260 MarkdownEvent::End(MarkdownTagEnd::TableCell) => {
3261 if in_table {
3262 cell_texts.push(current_cell.clone());
3263 }
3264 }
3265 MarkdownEvent::Text if in_table => {
3266 current_cell.push_str(&md[range.clone()]);
3267 }
3268 _ => {}
3269 }
3270 }
3271
3272 let checkbox_cells: Vec<&String> = cell_texts
3273 .iter()
3274 .filter(|t| {
3275 let trimmed = t.trim();
3276 trimmed == "[x]" || trimmed == "[X]" || trimmed == "[ ]"
3277 })
3278 .collect();
3279 assert_eq!(
3280 checkbox_cells.len(),
3281 2,
3282 "Expected 2 checkbox cells, got: {cell_texts:?}"
3283 );
3284 assert_eq!(checkbox_cells[0].trim(), "[x]");
3285 assert_eq!(checkbox_cells[1].trim(), "[ ]");
3286 }
3287
3288 #[gpui::test]
3289 fn test_inline_code_word_selection_excludes_backticks(cx: &mut TestAppContext) {
3290 // Test that double-clicking on inline code selects just the code content,
3291 // not the backticks. This verifies the fix for the bug where selecting
3292 // inline code would include the trailing backtick.
3293 let rendered = render_markdown("use `blah` here", cx);
3294
3295 // Source layout: "use `blah` here"
3296 // 0123456789...
3297 // The inline code "blah" is at source positions 5-8 (content range 5..9)
3298
3299 // Click inside "blah" - should select just "blah", not "blah`"
3300 let word_range = rendered.surrounding_word_range(6); // 'l' in "blah"
3301
3302 // text_for_range extracts from the rendered text (without backticks), so it
3303 // would return "blah" even with a wrong source range. We check it anyway.
3304 let selected_text = rendered.text_for_range(word_range.clone());
3305 assert_eq!(selected_text, "blah");
3306
3307 // The source range is what matters for copy_as_markdown and selected_text,
3308 // which extract directly from the source. With the bug, this would be 5..10
3309 // which includes the closing backtick at position 9.
3310 assert_eq!(word_range, 5..9);
3311 }
3312
3313 #[gpui::test]
3314 fn test_surrounding_word_range_respects_word_characters(cx: &mut TestAppContext) {
3315 let rendered = render_markdown("foo.bar() baz", cx);
3316
3317 // Double clicking on 'f' in "foo" - should select just "foo"
3318 let word_range = rendered.surrounding_word_range(0);
3319 let selected_text = rendered.text_for_range(word_range);
3320 assert_eq!(selected_text, "foo");
3321
3322 // Double clicking on 'b' in "bar" - should select just "bar"
3323 let word_range = rendered.surrounding_word_range(4);
3324 let selected_text = rendered.text_for_range(word_range);
3325 assert_eq!(selected_text, "bar");
3326
3327 // Double clicking on 'b' in "baz" - should select "baz"
3328 let word_range = rendered.surrounding_word_range(10);
3329 let selected_text = rendered.text_for_range(word_range);
3330 assert_eq!(selected_text, "baz");
3331
3332 // Double clicking selects word characters in code blocks
3333 let javascript_language = Arc::new(Language::new(
3334 LanguageConfig {
3335 name: "JavaScript".into(),
3336 matcher: LanguageMatcher {
3337 path_suffixes: vec!["js".to_string()],
3338 ..Default::default()
3339 },
3340 word_characters: ['$', '#'].into_iter().collect(),
3341 ..Default::default()
3342 },
3343 None,
3344 ));
3345
3346 let language_registry = Arc::new(LanguageRegistry::test(cx.executor()));
3347 language_registry.add(javascript_language);
3348
3349 let rendered = render_markdown_with_language_registry(
3350 "```javascript\n$foo #bar\n```",
3351 Some(language_registry),
3352 cx,
3353 );
3354
3355 let word_range = rendered.surrounding_word_range(14);
3356 let selected_text = rendered.text_for_range(word_range);
3357 assert_eq!(selected_text, "$foo");
3358
3359 let word_range = rendered.surrounding_word_range(19);
3360 let selected_text = rendered.text_for_range(word_range);
3361 assert_eq!(selected_text, "#bar");
3362 }
3363
3364 #[gpui::test]
3365 fn test_all_selection(cx: &mut TestAppContext) {
3366 let rendered = render_markdown("Hello world\n\nThis is a test\n\nwith multiple lines", cx);
3367
3368 let total_length = rendered
3369 .lines
3370 .last()
3371 .map(|line| line.source_end)
3372 .unwrap_or(0);
3373
3374 let mut selection = Selection {
3375 start: 0,
3376 end: total_length,
3377 reversed: false,
3378 pending: true,
3379 mode: SelectMode::All,
3380 };
3381
3382 selection.set_head(5, &rendered); // Try to set head in middle
3383 assert_eq!(selection.start, 0);
3384 assert_eq!(selection.end, total_length);
3385 assert!(!selection.reversed);
3386
3387 selection.set_head(25, &rendered); // Try to set head near end
3388 assert_eq!(selection.start, 0);
3389 assert_eq!(selection.end, total_length);
3390 assert!(!selection.reversed);
3391
3392 let selected_text = rendered.text_for_range(selection.start..selection.end);
3393 assert_eq!(
3394 selected_text,
3395 "Hello world\nThis is a test\nwith multiple lines"
3396 );
3397 }
3398
3399 fn nbsp(n: usize) -> String {
3400 "\u{00A0}".repeat(n)
3401 }
3402
3403 #[test]
3404 fn test_escape_plain_text() {
3405 assert_eq!(Markdown::escape("hello world"), "hello world");
3406 assert_eq!(Markdown::escape(""), "");
3407 assert_eq!(Markdown::escape("café ☕ naïve"), "café ☕ naïve");
3408 }
3409
3410 #[test]
3411 fn test_escape_punctuation() {
3412 assert_eq!(Markdown::escape("hello `world`"), r"hello \`world\`");
3413 assert_eq!(Markdown::escape("a|b"), r"a\|b");
3414 }
3415
3416 #[test]
3417 fn test_escape_leading_spaces() {
3418 assert_eq!(Markdown::escape(" hello"), [ (4), "hello"].concat());
3419 assert_eq!(
3420 Markdown::escape(" | { a: string }"),
3421 [ (4), r"\| \{ a\: string \}"].concat()
3422 );
3423 assert_eq!(
3424 Markdown::escape(" first\n second"),
3425 [ (2), "first\n\n",  (2), "second"].concat()
3426 );
3427 assert_eq!(Markdown::escape("hello world"), "hello world");
3428 }
3429
3430 #[test]
3431 fn test_escape_leading_tabs() {
3432 assert_eq!(Markdown::escape("\thello"), [ (4), "hello"].concat());
3433 assert_eq!(
3434 Markdown::escape("hello\n\t\tindented"),
3435 ["hello\n\n",  (8), "indented"].concat()
3436 );
3437 assert_eq!(
3438 Markdown::escape(" \t hello"),
3439 [ (1 + 4 + 1), "hello"].concat()
3440 );
3441 assert_eq!(Markdown::escape("hello\tworld"), "hello\tworld");
3442 }
3443
3444 #[test]
3445 fn test_escape_newlines() {
3446 assert_eq!(Markdown::escape("a\nb"), "a\n\nb");
3447 assert_eq!(Markdown::escape("a\n\nb"), "a\n\n\n\nb");
3448 assert_eq!(Markdown::escape("\nhello"), "\n\nhello");
3449 }
3450
3451 #[test]
3452 fn test_escape_multiline_diagnostic() {
3453 assert_eq!(
3454 Markdown::escape(" | { a: string }\n | { b: number }"),
3455 [
3456  (4),
3457 r"\| \{ a\: string \}",
3458 "\n\n",
3459  (4),
3460 r"\| \{ b\: number \}",
3461 ]
3462 .concat()
3463 );
3464 }
3465
3466 fn has_code_block(markdown: &str) -> bool {
3467 let parsed_data = parse_markdown_with_options(markdown, false, false);
3468 parsed_data
3469 .events
3470 .iter()
3471 .any(|(_, event)| matches!(event, MarkdownEvent::Start(MarkdownTag::CodeBlock { .. })))
3472 }
3473
3474 #[test]
3475 fn test_escape_output_len_matches_precomputed() {
3476 let cases = [
3477 "",
3478 "hello world",
3479 "hello `world`",
3480 " hello",
3481 " | { a: string }",
3482 "\thello",
3483 "hello\n\t\tindented",
3484 " \t hello",
3485 "hello\tworld",
3486 "a\nb",
3487 "a\n\nb",
3488 "\nhello",
3489 " | { a: string }\n | { b: number }",
3490 "café ☕ naïve",
3491 ];
3492 for input in cases {
3493 let mut escaper = MarkdownEscaper::new();
3494 let precomputed: usize = input.bytes().map(|b| escaper.next(b).output_len()).sum();
3495
3496 let mut escaper = MarkdownEscaper::new();
3497 let mut output = String::new();
3498 for c in input.chars() {
3499 escaper.next(c as u8).write_to(c, &mut output);
3500 }
3501
3502 assert_eq!(precomputed, output.len(), "length mismatch for {:?}", input);
3503 }
3504 }
3505
3506 #[test]
3507 fn test_escape_prevents_code_block() {
3508 let diagnostic = " | { a: string }";
3509 assert!(has_code_block(diagnostic));
3510 assert!(!has_code_block(&Markdown::escape(diagnostic)));
3511 }
3512
3513 #[gpui::test]
3514 fn test_link_detected_for_source_index(cx: &mut TestAppContext) {
3515 let rendered = render_markdown("[Click here](https://example.com)", cx);
3516
3517 assert_eq!(rendered.links.len(), 1);
3518 assert_eq!(rendered.links[0].destination_url, "https://example.com");
3519
3520 // Source index 1 ('C' in "Click") is inside the link's source range
3521 let link = rendered.link_for_source_index(1);
3522 assert!(link.is_some());
3523 assert_eq!(link.unwrap().destination_url, "https://example.com");
3524
3525 // A source index past the end of the link range returns None
3526 let past_end = rendered.links[0].source_range.end;
3527 assert!(rendered.link_for_source_index(past_end).is_none());
3528 }
3529
3530 #[gpui::test]
3531 fn test_link_for_source_index_ignores_plain_text(cx: &mut TestAppContext) {
3532 let rendered = render_markdown("Hello world", cx);
3533
3534 assert!(rendered.links.is_empty());
3535 assert!(rendered.link_for_source_index(0).is_none());
3536 assert!(rendered.link_for_source_index(5).is_none());
3537 }
3538
3539 #[gpui::test]
3540 fn test_context_menu_link_initial_state(cx: &mut TestAppContext) {
3541 struct TestWindow;
3542 impl Render for TestWindow {
3543 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3544 div()
3545 }
3546 }
3547
3548 ensure_theme_initialized(cx);
3549 let (_, cx) = cx.add_window_view(|_, _| TestWindow);
3550 let markdown =
3551 cx.new(|cx| Markdown::new("Hello [world](https://example.com)".into(), None, None, cx));
3552 cx.run_until_parked();
3553
3554 cx.update(|_window, cx| {
3555 assert!(markdown.read(cx).context_menu_link().is_none());
3556 });
3557 }
3558
3559 #[gpui::test]
3560 fn test_capture_for_context_menu(cx: &mut TestAppContext) {
3561 struct TestWindow;
3562 impl Render for TestWindow {
3563 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3564 div()
3565 }
3566 }
3567
3568 ensure_theme_initialized(cx);
3569 let (_, cx) = cx.add_window_view(|_, _| TestWindow);
3570 let markdown = cx.new(|cx| Markdown::new("text".into(), None, None, cx));
3571 cx.run_until_parked();
3572
3573 // Simulates right-clicking on a link
3574 let url: SharedString = "https://example.com".into();
3575 markdown.update(cx, |md, _cx| {
3576 md.capture_for_context_menu(Some(url.clone()));
3577 });
3578 cx.update(|_window, cx| {
3579 assert_eq!(
3580 markdown
3581 .read(cx)
3582 .context_menu_link()
3583 .map(SharedString::as_ref),
3584 Some("https://example.com")
3585 );
3586 });
3587
3588 // Simulates right-clicking on plain text — link is cleared
3589 markdown.update(cx, |md, _cx| {
3590 md.capture_for_context_menu(None);
3591 });
3592 cx.update(|_window, cx| {
3593 assert!(markdown.read(cx).context_menu_link().is_none());
3594 });
3595 }
3596
3597 #[track_caller]
3598 fn assert_mappings(rendered: &RenderedText, expected: Vec<Vec<(usize, usize)>>) {
3599 assert_eq!(rendered.lines.len(), expected.len(), "line count mismatch");
3600 for (line_ix, line_mappings) in expected.into_iter().enumerate() {
3601 let line = &rendered.lines[line_ix];
3602
3603 assert!(
3604 line.source_mappings.windows(2).all(|mappings| {
3605 mappings[0].source_index < mappings[1].source_index
3606 && mappings[0].rendered_index < mappings[1].rendered_index
3607 }),
3608 "line {} has duplicate mappings: {:?}",
3609 line_ix,
3610 line.source_mappings
3611 );
3612
3613 for (rendered_ix, source_ix) in line_mappings {
3614 assert_eq!(
3615 line.source_index_for_rendered_index(rendered_ix),
3616 source_ix,
3617 "line {}, rendered_ix {}",
3618 line_ix,
3619 rendered_ix
3620 );
3621
3622 assert_eq!(
3623 line.rendered_index_for_source_index(source_ix),
3624 rendered_ix,
3625 "line {}, source_ix {}",
3626 line_ix,
3627 source_ix
3628 );
3629 }
3630 }
3631 }
3632
3633 #[gpui::test]
3634 fn test_heading_font_sizes_are_distinct(cx: &mut TestAppContext) {
3635 let rendered = render_markdown("# H1\n\n## H2\n\n### H3\n\nBody text", cx);
3636
3637 assert!(
3638 rendered.lines.len() >= 4,
3639 "expected at least 4 rendered lines, got {}",
3640 rendered.lines.len()
3641 );
3642
3643 let h1_line_height = rendered.lines[0].layout.line_height();
3644 let h2_line_height = rendered.lines[1].layout.line_height();
3645 let h3_line_height = rendered.lines[2].layout.line_height();
3646 let body_line_height = rendered.lines[3].layout.line_height();
3647
3648 assert!(
3649 h1_line_height > h2_line_height,
3650 "H1 line height ({h1_line_height:?}) should be greater than H2 ({h2_line_height:?})"
3651 );
3652 assert!(
3653 h2_line_height > h3_line_height,
3654 "H2 line height ({h2_line_height:?}) should be greater than H3 ({h3_line_height:?})"
3655 );
3656 assert!(
3657 h3_line_height > body_line_height,
3658 "H3 line height ({h3_line_height:?}) should be greater than body text ({body_line_height:?})"
3659 );
3660 }
3661}