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