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