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