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