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