Skip to repository content5799 lines · 216.9 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T02:54:20.107Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
markdown.rs
1pub mod html;
2mod mermaid;
3pub mod parser;
4mod path_range;
5mod selection;
6
7use base64::Engine as _;
8use futures::FutureExt as _;
9use gpui::EdgesRefinement;
10use gpui::HitboxBehavior;
11use gpui::UnderlineStyle;
12use language::LanguageName;
13
14use log::Level;
15use mermaid::{
16 MermaidState, ParsedMarkdownMermaidDiagram, extract_mermaid_diagrams, render_mermaid_diagram,
17};
18pub use path_range::{LineCol, PathWithRange};
19use settings::Settings as _;
20use smallvec::SmallVec;
21use theme_settings::ThemeSettings;
22use util::maybe;
23
24use std::borrow::Cow;
25use std::cell::Cell;
26use std::collections::BTreeMap;
27use std::mem;
28use std::ops::Range;
29use std::path::Path;
30use std::rc::Rc;
31use std::sync::Arc;
32use std::time::Duration;
33
34use collections::{HashMap, HashSet};
35use gpui::{
36 AnyElement, App, BorderStyle, Bounds, ClipboardItem, CursorStyle, DispatchPhase, Edges, Entity,
37 FocusHandle, Focusable, FontStyle, FontWeight, GlobalElementId, Hitbox, Hsla, Image,
38 ImageFormat, ImageSource, KeyContext, Length, MouseButton, MouseDownEvent, MouseEvent,
39 MouseMoveEvent, MouseUpEvent, Point, ScrollHandle, Stateful, StrikethroughStyle,
40 StyleRefinement, StyledImage, StyledText, Subscription, Task, TextAlign, TextLayout, TextRun,
41 TextStyle, TextStyleRefinement, WrappedLineLayout, actions, canvas, img, point, quad,
42};
43use language::{CharClassifier, Language, LanguageRegistry, Rope};
44use parser::CodeBlockMetadata;
45use parser::{
46 MarkdownEvent, MarkdownTag, MarkdownTagEnd, ParsedMetadataBlock, parse_links_only,
47 parse_markdown_with_options,
48};
49use pulldown_cmark::{Alignment, BlockQuoteKind};
50use sum_tree::TreeMap;
51use theme::SyntaxTheme;
52use ui::{Checkbox, CopyButton, ScrollAxes, Scrollbars, Tooltip, WithScrollbar, prelude::*};
53use util::ResultExt;
54
55use crate::parser::CodeBlockKind;
56
57/// A callback function that can be used to customize the style of links based on the destination URL.
58/// If the callback returns `None`, the default link style will be used.
59type LinkStyleCallback = Rc<dyn Fn(&str, &App) -> Option<TextStyleRefinement>>;
60pub type CodeSpanLinkCallback = Arc<dyn Fn(&str, &App) -> Option<SharedString> + 'static>;
61type UrlHoverCallback = Rc<dyn Fn(Option<SharedString>, &mut Window, &mut App)>;
62type SourceClickCallback = Box<dyn Fn(usize, usize, &mut Window, &mut App) -> bool>;
63type CheckboxToggleCallback = Rc<dyn Fn(Range<usize>, bool, &mut Window, &mut App)>;
64
65#[derive(Clone, Copy, Default)]
66pub struct BlockQuoteKindColors {
67 pub note: Hsla,
68 pub tip: Hsla,
69 pub important: Hsla,
70 pub warning: Hsla,
71 pub caution: Hsla,
72}
73
74impl BlockQuoteKindColors {
75 fn for_kind(&self, kind: Option<BlockQuoteKind>, default: Hsla) -> Hsla {
76 match kind {
77 Some(BlockQuoteKind::Note) => self.note,
78 Some(BlockQuoteKind::Tip) => self.tip,
79 Some(BlockQuoteKind::Important) => self.important,
80 Some(BlockQuoteKind::Warning) => self.warning,
81 Some(BlockQuoteKind::Caution) => self.caution,
82 None => default,
83 }
84 }
85}
86
87#[derive(Clone, Default)]
88pub struct HeadingLevelStyles {
89 pub h1: Option<TextStyleRefinement>,
90 pub h2: Option<TextStyleRefinement>,
91 pub h3: Option<TextStyleRefinement>,
92 pub h4: Option<TextStyleRefinement>,
93 pub h5: Option<TextStyleRefinement>,
94 pub h6: Option<TextStyleRefinement>,
95}
96
97#[derive(Clone)]
98pub struct MarkdownStyle {
99 pub base_text_style: TextStyle,
100 pub container_style: StyleRefinement,
101 pub code_block: StyleRefinement,
102 pub code_block_overflow_x_scroll: bool,
103 pub inline_code: TextStyleRefinement,
104 pub block_quote: TextStyleRefinement,
105 pub link: TextStyleRefinement,
106 pub link_callback: Option<LinkStyleCallback>,
107 pub rule_color: Hsla,
108 pub block_quote_border_color: Hsla,
109 pub block_quote_kind_colors: BlockQuoteKindColors,
110 pub syntax: Arc<SyntaxTheme>,
111 pub selection_background_color: Hsla,
112 pub heading: StyleRefinement,
113 pub heading_level_styles: Option<HeadingLevelStyles>,
114 pub heading_border_color: Option<Hsla>,
115 pub height_is_multiple_of_line_height: bool,
116 pub prevent_mouse_interaction: bool,
117 pub table_columns_min_size: bool,
118 pub soft_break_as_hard_break: bool,
119}
120
121impl Default for MarkdownStyle {
122 fn default() -> Self {
123 Self {
124 base_text_style: Default::default(),
125 container_style: Default::default(),
126 code_block: Default::default(),
127 code_block_overflow_x_scroll: false,
128 inline_code: Default::default(),
129 block_quote: Default::default(),
130 link: Default::default(),
131 link_callback: None,
132 rule_color: Default::default(),
133 block_quote_border_color: Default::default(),
134 block_quote_kind_colors: Default::default(),
135 syntax: Arc::new(SyntaxTheme::default()),
136 selection_background_color: Default::default(),
137 heading: Default::default(),
138 heading_level_styles: None,
139 heading_border_color: None,
140 height_is_multiple_of_line_height: false,
141 prevent_mouse_interaction: false,
142 table_columns_min_size: false,
143 soft_break_as_hard_break: false,
144 }
145 }
146}
147
148#[derive(Clone, Copy)]
149pub enum MarkdownFont {
150 Agent,
151 Editor,
152 Preview,
153}
154
155impl MarkdownStyle {
156 pub fn themed(font: MarkdownFont, window: &Window, cx: &App) -> Self {
157 let colors = cx.theme().colors();
158 let syntax = cx.theme().syntax().clone();
159 Self::themed_with_overrides(font, colors, &syntax, window, cx)
160 }
161
162 /// Like [`Self::themed`], but takes explicit [`ThemeColors`] and
163 /// [`SyntaxTheme`] so callers (e.g. the markdown preview) can render the
164 /// markdown using a theme other than the active editor theme.
165 pub fn themed_with_overrides(
166 font: MarkdownFont,
167 colors: &theme::ThemeColors,
168 syntax: &Arc<SyntaxTheme>,
169 window: &Window,
170 cx: &App,
171 ) -> Self {
172 let theme_settings = ThemeSettings::get_global(cx);
173 let is_preview = matches!(font, MarkdownFont::Preview);
174
175 let buffer_font_weight = theme_settings.buffer_font.weight;
176 let (buffer_font_size, ui_font_size) = match font {
177 MarkdownFont::Agent => (
178 theme_settings.agent_buffer_font_size(cx),
179 theme_settings.agent_ui_font_size(cx),
180 ),
181 MarkdownFont::Editor => (
182 theme_settings.buffer_font_size(cx),
183 theme_settings.ui_font_size(cx),
184 ),
185 MarkdownFont::Preview => (
186 theme_settings.markdown_preview_font_size(cx),
187 theme_settings.ui_font_size(cx),
188 ),
189 };
190
191 let body_font_family = if is_preview {
192 theme_settings.markdown_preview_font_family().clone()
193 } else {
194 theme_settings.ui_font.family.clone()
195 };
196 let code_font_family = if is_preview {
197 theme_settings.markdown_preview_code_font_family().clone()
198 } else {
199 theme_settings.buffer_font.family.clone()
200 };
201
202 let mut text_style = window.text_style();
203 let line_height = buffer_font_size * 1.75;
204
205 text_style.refine(&TextStyleRefinement {
206 font_family: Some(body_font_family),
207 font_fallbacks: theme_settings.ui_font.fallbacks.clone(),
208 font_features: Some(theme_settings.ui_font.features.clone()),
209 font_size: Some(if is_preview {
210 rems(1.0).into()
211 } else {
212 ui_font_size.into()
213 }),
214 line_height: Some(line_height.into()),
215 color: Some(colors.text),
216 ..Default::default()
217 });
218
219 let style = MarkdownStyle {
220 base_text_style: text_style.clone(),
221 syntax: syntax.clone(),
222 selection_background_color: colors.element_selection_background,
223 rule_color: colors.border,
224 block_quote_border_color: colors.border,
225 block_quote_kind_colors: {
226 let status = cx.theme().status();
227 BlockQuoteKindColors {
228 note: status.info,
229 tip: status.success,
230 important: status.info,
231 warning: status.warning,
232 caution: status.error,
233 }
234 },
235 code_block_overflow_x_scroll: true,
236 code_block: StyleRefinement {
237 padding: EdgesRefinement {
238 top: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(8.)))),
239 left: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(8.)))),
240 right: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(8.)))),
241 bottom: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(8.)))),
242 },
243 margin: EdgesRefinement {
244 top: Some(Length::Definite(px(8.).into())),
245 left: Some(Length::Definite(px(0.).into())),
246 right: Some(Length::Definite(px(0.).into())),
247 bottom: Some(Length::Definite(px(12.).into())),
248 },
249 border_style: Some(BorderStyle::Solid),
250 border_widths: EdgesRefinement {
251 top: Some(AbsoluteLength::Pixels(px(1.))),
252 left: Some(AbsoluteLength::Pixels(px(1.))),
253 right: Some(AbsoluteLength::Pixels(px(1.))),
254 bottom: Some(AbsoluteLength::Pixels(px(1.))),
255 },
256 border_color: Some(colors.border_variant),
257 background: Some(colors.editor_background.into()),
258 text: TextStyleRefinement {
259 font_family: Some(code_font_family.clone()),
260 font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
261 font_features: Some(theme_settings.buffer_font.features.clone()),
262 font_size: Some(buffer_font_size.into()),
263 font_weight: Some(buffer_font_weight),
264 ..Default::default()
265 },
266 ..Default::default()
267 },
268 inline_code: TextStyleRefinement {
269 font_family: Some(code_font_family),
270 font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
271 font_features: Some(theme_settings.buffer_font.features.clone()),
272 font_size: Some(buffer_font_size.into()),
273 font_weight: Some(buffer_font_weight),
274 background_color: Some(colors.editor_foreground.opacity(0.08)),
275 ..Default::default()
276 },
277 link: TextStyleRefinement {
278 background_color: Some(colors.editor_foreground.opacity(0.025)),
279 color: Some(colors.text_accent),
280 underline: Some(UnderlineStyle {
281 color: Some(colors.text_accent.opacity(0.5)),
282 thickness: px(1.),
283 ..Default::default()
284 }),
285 ..Default::default()
286 },
287 soft_break_as_hard_break: matches!(font, MarkdownFont::Agent),
288 heading_level_styles: matches!(font, MarkdownFont::Agent).then_some(
289 HeadingLevelStyles {
290 h1: Some(TextStyleRefinement {
291 font_size: Some(rems(1.15).into()),
292 ..Default::default()
293 }),
294 h2: Some(TextStyleRefinement {
295 font_size: Some(rems(1.1).into()),
296 ..Default::default()
297 }),
298 h3: Some(TextStyleRefinement {
299 font_size: Some(rems(1.05).into()),
300 ..Default::default()
301 }),
302 h4: Some(TextStyleRefinement {
303 font_size: Some(rems(1.).into()),
304 ..Default::default()
305 }),
306 h5: Some(TextStyleRefinement {
307 font_size: Some(rems(0.95).into()),
308 ..Default::default()
309 }),
310 h6: Some(TextStyleRefinement {
311 font_size: Some(rems(0.875).into()),
312 ..Default::default()
313 }),
314 },
315 ),
316 ..Default::default()
317 };
318
319 if is_preview {
320 style.with_preview_overrides(colors)
321 } else {
322 style
323 }
324 }
325
326 fn with_preview_overrides(mut self, colors: &theme::ThemeColors) -> Self {
327 let body_font_size = rems(0.92);
328 self.base_text_style.font_size = body_font_size.into();
329 self.container_style.text.font_size = Some(body_font_size.into());
330
331 self.base_text_style.color = colors.text_muted.blend(colors.text.opacity(0.25));
332 self.inline_code.color = Some(colors.text);
333 self.heading.text.color = Some(colors.text);
334
335 self.heading_level_styles = Some(HeadingLevelStyles {
336 h1: Some(TextStyleRefinement {
337 font_size: Some(rems(1.45).into()),
338 ..Default::default()
339 }),
340 h2: Some(TextStyleRefinement {
341 font_size: Some(rems(1.3).into()),
342 ..Default::default()
343 }),
344 h3: Some(TextStyleRefinement {
345 font_size: Some(rems(1.1).into()),
346 ..Default::default()
347 }),
348 h4: Some(TextStyleRefinement {
349 font_size: Some(rems(1.01).into()),
350 ..Default::default()
351 }),
352 h5: Some(TextStyleRefinement {
353 font_size: Some(rems(0.95).into()),
354 ..Default::default()
355 }),
356 h6: Some(TextStyleRefinement {
357 font_size: Some(rems(0.85).into()),
358 ..Default::default()
359 }),
360 });
361
362 self.heading_border_color = Some(colors.border_variant);
363
364 self
365 }
366
367 pub fn with_buffer_font(mut self, cx: &App) -> Self {
368 let theme_settings = ThemeSettings::get_global(cx);
369 self.base_text_style.font_family = theme_settings.buffer_font.family.clone();
370 self.base_text_style.font_fallbacks = theme_settings.buffer_font.fallbacks.clone();
371 self.base_text_style.font_features = theme_settings.buffer_font.features.clone();
372 self.base_text_style.font_weight = theme_settings.buffer_font.weight;
373 self
374 }
375
376 pub fn with_muted_text(mut self, cx: &App) -> Self {
377 let colors = cx.theme().colors();
378 self.base_text_style.color = colors.text_muted;
379 self
380 }
381}
382
383pub struct Markdown {
384 source: SharedString,
385 selection: Selection,
386 pressed_link: Option<RenderedLink>,
387 pressed_footnote_ref: Option<RenderedFootnoteRef>,
388 autoscroll_request: Option<usize>,
389 active_root_block: Option<usize>,
390 parsed_markdown: ParsedMarkdown,
391 images_by_source_offset: HashMap<usize, Arc<Image>>,
392 should_reparse: bool,
393 pending_parse: Option<Task<()>>,
394 focus_handle: FocusHandle,
395 language_registry: Option<Arc<LanguageRegistry>>,
396 fallback_code_block_language: Option<LanguageName>,
397 options: MarkdownOptions,
398 mermaid_state: MermaidState,
399 _mermaid_theme_subscription: Option<Subscription>,
400 mermaid_showing_code: HashSet<usize>,
401 copied_code_blocks: HashSet<ElementId>,
402 wrapped_code_blocks: HashSet<usize>,
403 code_block_scroll_handles: BTreeMap<usize, ScrollHandle>,
404 context_menu_link: Option<SharedString>,
405 context_menu_selected_text: Option<SharedString>,
406 context_menu_selected_markdown: Option<SharedString>,
407 search_highlights: Vec<Range<usize>>,
408 active_search_highlight: Option<usize>,
409}
410
411#[derive(Clone, Copy, Default)]
412pub struct MarkdownOptions {
413 pub parse_links_only: bool,
414 pub parse_html: bool,
415 pub render_mermaid_diagrams: bool,
416 pub parse_heading_slugs: bool,
417 pub render_metadata_blocks: bool,
418}
419
420#[derive(Clone, Copy, PartialEq, Eq)]
421pub enum CopyButtonVisibility {
422 Hidden,
423 AlwaysVisible,
424 VisibleOnHover,
425}
426
427#[derive(Debug, Clone, Copy, PartialEq, Eq)]
428pub enum WrapButtonVisibility {
429 Hidden,
430 AlwaysVisible,
431 VisibleOnHover,
432}
433
434pub enum CodeBlockRenderer {
435 Default {
436 copy_button_visibility: CopyButtonVisibility,
437 wrap_button_visibility: WrapButtonVisibility,
438 border: bool,
439 },
440 Custom {
441 render: CodeBlockRenderFn,
442 /// A function that can modify the parent container after the code block
443 /// content has been appended as a child element.
444 transform: Option<CodeBlockTransformFn>,
445 },
446}
447
448pub type CodeBlockRenderFn = Arc<
449 dyn Fn(
450 &CodeBlockKind,
451 &ParsedMarkdown,
452 Range<usize>,
453 CodeBlockMetadata,
454 &mut Window,
455 &App,
456 ) -> Div,
457>;
458
459pub type CodeBlockTransformFn =
460 Arc<dyn Fn(AnyDiv, Range<usize>, CodeBlockMetadata, &mut Window, &App) -> AnyDiv>;
461
462actions!(
463 markdown,
464 [
465 /// Copies the selected text to the clipboard.
466 Copy,
467 /// Copies the selected text as markdown to the clipboard.
468 CopyAsMarkdown
469 ]
470);
471
472enum EscapeAction {
473 PassThrough,
474 Nbsp(usize),
475 DoubleNewline,
476 PrefixBackslash,
477}
478
479impl EscapeAction {
480 fn output_len(&self, c: char) -> usize {
481 match self {
482 Self::PassThrough => c.len_utf8(),
483 Self::Nbsp(count) => count * '\u{00A0}'.len_utf8(),
484 Self::DoubleNewline => 2,
485 Self::PrefixBackslash => '\\'.len_utf8() + c.len_utf8(),
486 }
487 }
488
489 fn write_to(&self, c: char, output: &mut String) {
490 match self {
491 Self::PassThrough => output.push(c),
492 Self::Nbsp(count) => {
493 for _ in 0..*count {
494 output.push('\u{00A0}');
495 }
496 }
497 Self::DoubleNewline => {
498 output.push('\n');
499 output.push('\n');
500 }
501 Self::PrefixBackslash => {
502 // '\\' is a single backslash in Rust, e.g. '|' -> '\|'
503 output.push('\\');
504 output.push(c);
505 }
506 }
507 }
508}
509
510struct MarkdownEscaper {
511 in_leading_whitespace: bool,
512}
513
514impl MarkdownEscaper {
515 const TAB_SIZE: usize = 4;
516
517 fn new() -> Self {
518 Self {
519 in_leading_whitespace: true,
520 }
521 }
522
523 fn next(&mut self, c: char) -> EscapeAction {
524 let action = if self.in_leading_whitespace && c == '\t' {
525 EscapeAction::Nbsp(Self::TAB_SIZE)
526 } else if self.in_leading_whitespace && c == ' ' {
527 EscapeAction::Nbsp(1)
528 } else if c == '\n' {
529 EscapeAction::DoubleNewline
530 } else if c.is_ascii_punctuation() {
531 EscapeAction::PrefixBackslash
532 } else {
533 EscapeAction::PassThrough
534 };
535
536 self.in_leading_whitespace =
537 c == '\n' || (self.in_leading_whitespace && (c == ' ' || c == '\t'));
538 action
539 }
540}
541
542impl Markdown {
543 pub fn new(
544 source: SharedString,
545 language_registry: Option<Arc<LanguageRegistry>>,
546 fallback_code_block_language: Option<LanguageName>,
547 cx: &mut Context<Self>,
548 ) -> Self {
549 Self::new_with_options(
550 source,
551 language_registry,
552 fallback_code_block_language,
553 MarkdownOptions::default(),
554 cx,
555 )
556 }
557
558 pub fn new_with_options(
559 source: SharedString,
560 language_registry: Option<Arc<LanguageRegistry>>,
561 fallback_code_block_language: Option<LanguageName>,
562 options: MarkdownOptions,
563 cx: &mut Context<Self>,
564 ) -> Self {
565 let focus_handle = cx.focus_handle();
566
567 let theme_subscription = if options.render_mermaid_diagrams {
568 Some(
569 cx.observe_global::<theme::GlobalTheme>(|this: &mut Self, cx| {
570 this.invalidate_mermaid_cache(cx);
571 }),
572 )
573 } else {
574 None
575 };
576 let mut this = Self {
577 source,
578 selection: Selection::default(),
579 pressed_link: None,
580 pressed_footnote_ref: None,
581 autoscroll_request: None,
582 active_root_block: None,
583 should_reparse: false,
584 images_by_source_offset: Default::default(),
585 parsed_markdown: ParsedMarkdown::default(),
586 pending_parse: None,
587 focus_handle,
588 language_registry,
589 fallback_code_block_language,
590 options,
591 mermaid_state: MermaidState::default(),
592 _mermaid_theme_subscription: theme_subscription,
593 mermaid_showing_code: HashSet::default(),
594 copied_code_blocks: HashSet::default(),
595 wrapped_code_blocks: HashSet::default(),
596 code_block_scroll_handles: BTreeMap::default(),
597 context_menu_link: None,
598 context_menu_selected_text: None,
599 context_menu_selected_markdown: None,
600 search_highlights: Vec::new(),
601 active_search_highlight: None,
602 };
603 this.parse(cx);
604 this
605 }
606
607 pub fn new_text(source: SharedString, cx: &mut Context<Self>) -> Self {
608 Self::new_with_options(
609 source,
610 None,
611 None,
612 MarkdownOptions {
613 parse_links_only: true,
614 ..Default::default()
615 },
616 cx,
617 )
618 }
619
620 fn is_code_block_wrapped(&self, id: usize) -> bool {
621 self.wrapped_code_blocks.contains(&id)
622 }
623
624 fn toggle_code_block_wrap(&mut self, id: usize) {
625 if !self.wrapped_code_blocks.remove(&id) {
626 self.wrapped_code_blocks.insert(id);
627 }
628 }
629
630 fn code_block_scroll_handle(&mut self, id: usize) -> Option<ScrollHandle> {
631 (!self.is_code_block_wrapped(id)).then(|| {
632 self.code_block_scroll_handles
633 .entry(id)
634 .or_insert_with(ScrollHandle::new)
635 .clone()
636 })
637 }
638
639 fn retain_code_block_scroll_handles(&mut self, ids: &HashSet<usize>) {
640 self.code_block_scroll_handles
641 .retain(|id, _| ids.contains(id));
642 }
643
644 pub fn invalidate_mermaid_cache(&mut self, cx: &mut Context<Self>) {
645 if !self.options.render_mermaid_diagrams || self.parsed_markdown.mermaid_diagrams.is_empty()
646 {
647 return;
648 }
649
650 self.mermaid_state.clear();
651 self.mermaid_state.update(&self.parsed_markdown, cx);
652 cx.notify();
653 }
654
655 pub(crate) fn is_mermaid_showing_code(&self, source_offset: usize) -> bool {
656 self.mermaid_showing_code.contains(&source_offset)
657 }
658
659 pub(crate) fn toggle_mermaid_tab(&mut self, source_offset: usize) {
660 if !self.mermaid_showing_code.remove(&source_offset) {
661 self.mermaid_showing_code.insert(source_offset);
662 }
663 }
664
665 fn clear_code_block_scroll_handles(&mut self) {
666 self.code_block_scroll_handles.clear();
667 }
668
669 fn autoscroll_code_block(&self, source_index: usize, cursor_position: Point<Pixels>) {
670 let Some((_, scroll_handle)) = self
671 .code_block_scroll_handles
672 .range(..=source_index)
673 .next_back()
674 else {
675 return;
676 };
677
678 let bounds = scroll_handle.bounds();
679 if cursor_position.y < bounds.top() || cursor_position.y > bounds.bottom() {
680 return;
681 }
682
683 let horizontal_delta = if cursor_position.x < bounds.left() {
684 bounds.left() - cursor_position.x
685 } else if cursor_position.x > bounds.right() {
686 bounds.right() - cursor_position.x
687 } else {
688 return;
689 };
690
691 let offset = scroll_handle.offset();
692 scroll_handle.set_offset(point(offset.x + horizontal_delta, offset.y));
693 }
694
695 pub fn is_parsing(&self) -> bool {
696 self.pending_parse.is_some()
697 }
698
699 pub fn scroll_to_heading(&mut self, slug: &str, cx: &mut Context<Self>) -> Option<usize> {
700 if let Some(source_index) = self.parsed_markdown.heading_slugs.get(slug).copied() {
701 self.autoscroll_request = Some(source_index);
702 cx.notify();
703 Some(source_index)
704 } else {
705 None
706 }
707 }
708
709 pub fn source(&self) -> &SharedString {
710 &self.source
711 }
712
713 pub fn first_code_block_language(&self) -> Option<Arc<Language>> {
714 self.parsed_markdown.events.iter().find_map(|(_, event)| {
715 let MarkdownEvent::Start(MarkdownTag::CodeBlock { kind, .. }) = event else {
716 return None;
717 };
718
719 match kind {
720 CodeBlockKind::FencedLang(language) => self
721 .parsed_markdown
722 .languages_by_name
723 .get(language)
724 .cloned(),
725 CodeBlockKind::FencedSrc(path_range) => self
726 .parsed_markdown
727 .languages_by_path
728 .get(&path_range.path)
729 .cloned(),
730 CodeBlockKind::Fenced | CodeBlockKind::Indented => None,
731 }
732 })
733 }
734
735 pub fn append(&mut self, text: &str, cx: &mut Context<Self>) {
736 self.source = SharedString::new(self.source.to_string() + text);
737 self.parse(cx);
738 }
739
740 pub fn replace(&mut self, source: impl Into<SharedString>, cx: &mut Context<Self>) {
741 self.source = source.into();
742 self.parse(cx);
743 }
744
745 pub fn request_autoscroll_to_source_index(
746 &mut self,
747 source_index: usize,
748 cx: &mut Context<Self>,
749 ) {
750 self.autoscroll_request = Some(source_index);
751 cx.refresh_windows();
752 }
753
754 fn footnote_definition_content_start(&self, label: &SharedString) -> Option<usize> {
755 self.parsed_markdown
756 .footnote_definitions
757 .get(label)
758 .copied()
759 }
760
761 pub fn set_active_root_for_source_index(
762 &mut self,
763 source_index: Option<usize>,
764 cx: &mut Context<Self>,
765 ) {
766 let active_root_block =
767 source_index.and_then(|index| self.parsed_markdown.root_block_for_source_index(index));
768 if self.active_root_block == active_root_block {
769 return;
770 }
771
772 self.active_root_block = active_root_block;
773 cx.notify();
774 }
775
776 pub fn reset(&mut self, source: SharedString, cx: &mut Context<Self>) {
777 if &source == self.source() {
778 return;
779 }
780 self.source = source;
781 self.selection = Selection::default();
782 self.autoscroll_request = None;
783 self.pending_parse = None;
784 self.should_reparse = false;
785 self.search_highlights.clear();
786 self.active_search_highlight = None;
787 // Don't clear parsed_markdown here - keep existing content visible until new parse completes
788 self.parse(cx);
789 }
790
791 #[cfg(any(test, feature = "test-support"))]
792 pub fn parsed_markdown(&self) -> &ParsedMarkdown {
793 &self.parsed_markdown
794 }
795
796 pub fn escape(s: &str) -> Cow<'_, str> {
797 let output_len: usize = {
798 let mut escaper = MarkdownEscaper::new();
799 s.chars().map(|c| escaper.next(c).output_len(c)).sum()
800 };
801
802 if output_len == s.len() {
803 return s.into();
804 }
805
806 let mut escaper = MarkdownEscaper::new();
807 let mut output = String::with_capacity(output_len);
808 for c in s.chars() {
809 escaper.next(c).write_to(c, &mut output);
810 }
811 output.into()
812 }
813
814 pub fn has_selection(&self) -> bool {
815 self.selection.end > self.selection.start
816 }
817
818 pub fn selected_source(&self) -> Option<&str> {
819 if self.selection.end <= self.selection.start {
820 return None;
821 }
822 self.source.get(self.selection.start..self.selection.end)
823 }
824
825 pub fn set_search_highlights(
826 &mut self,
827 highlights: Vec<Range<usize>>,
828 active: Option<usize>,
829 cx: &mut Context<Self>,
830 ) {
831 debug_assert!(
832 highlights
833 .windows(2)
834 .all(|ranges| (ranges[0].start, ranges[0].end) <= (ranges[1].start, ranges[1].end))
835 );
836 self.search_highlights = highlights;
837 self.active_search_highlight =
838 active.filter(|active| *active < self.search_highlights.len());
839 cx.notify();
840 }
841
842 pub fn clear_search_highlights(&mut self, cx: &mut Context<Self>) {
843 if !self.search_highlights.is_empty() || self.active_search_highlight.is_some() {
844 self.search_highlights.clear();
845 self.active_search_highlight = None;
846 cx.notify();
847 }
848 }
849
850 pub fn set_active_search_highlight(&mut self, active: Option<usize>, cx: &mut Context<Self>) {
851 let active = active.filter(|active| *active < self.search_highlights.len());
852 if self.active_search_highlight != active {
853 self.active_search_highlight = active;
854 cx.notify();
855 }
856 }
857
858 pub fn search_highlights(&self) -> &[Range<usize>] {
859 &self.search_highlights
860 }
861
862 pub fn active_search_highlight(&self) -> Option<usize> {
863 self.active_search_highlight
864 }
865
866 fn copy(&self, text: &RenderedText, _: &mut Window, cx: &mut Context<Self>) {
867 if self.selection.end <= self.selection.start {
868 return;
869 }
870 let text = text.text_for_range(self.selection.start..self.selection.end);
871 cx.write_to_clipboard(ClipboardItem::new_string(text));
872 }
873
874 fn copy_as_markdown(&mut self, _: &mut Window, cx: &mut Context<Self>) {
875 if let Some(text) = self.context_menu_selected_markdown.take() {
876 cx.write_to_clipboard(ClipboardItem::new_string(text.to_string()));
877 return;
878 }
879 if self.selection.end <= self.selection.start {
880 return;
881 }
882 let text = self
883 .parsed_markdown
884 .rebalanced_markdown_for_selection(self.selection.start..self.selection.end);
885 cx.write_to_clipboard(ClipboardItem::new_string(text));
886 }
887
888 fn capture_for_context_menu(
889 &mut self,
890 link: Option<SharedString>,
891 rendered_text: Option<&RenderedText>,
892 ) {
893 let range = self.selection.start..self.selection.end;
894 if range.end > range.start {
895 self.context_menu_selected_markdown = Some(SharedString::new(
896 self.parsed_markdown
897 .rebalanced_markdown_for_selection(range.clone()),
898 ));
899 self.context_menu_selected_text = rendered_text
900 .map(|text| text.text_for_range(range))
901 .map(SharedString::new)
902 .or_else(|| self.context_menu_selected_markdown.clone());
903 } else {
904 self.context_menu_selected_markdown = None;
905 self.context_menu_selected_text = None;
906 }
907 self.context_menu_link = link;
908 }
909
910 /// Returns the URL of the link that was most recently right-clicked, if any.
911 /// This is set during a right-click mouse-down event and can be read by parent
912 /// views to include a "Copy Link" item in their context menus.
913 pub fn context_menu_link(&self) -> Option<&SharedString> {
914 self.context_menu_link.as_ref()
915 }
916
917 /// Returns the rendered (plain) text that was selected when the most recent
918 /// context menu invocation happened.
919 pub fn context_menu_selected_text(&self) -> Option<&SharedString> {
920 self.context_menu_selected_text.as_ref()
921 }
922
923 /// Returns the markdown that was selected when the most recent context
924 /// menu invocation happened, rebalanced via
925 /// [`ParsedMarkdown::rebalanced_markdown_for_selection`].
926 pub fn context_menu_selected_markdown(&self) -> Option<&SharedString> {
927 self.context_menu_selected_markdown.as_ref()
928 }
929
930 fn parse(&mut self, cx: &mut Context<Self>) {
931 if self.source.is_empty() {
932 self.should_reparse = false;
933 self.pending_parse.take();
934 self.parsed_markdown = ParsedMarkdown {
935 source: self.source.clone(),
936 ..Default::default()
937 };
938 self.active_root_block = None;
939 self.images_by_source_offset.clear();
940 self.mermaid_state.clear();
941 cx.notify();
942 cx.refresh_windows();
943 return;
944 }
945
946 if self.pending_parse.is_some() {
947 self.should_reparse = true;
948 return;
949 }
950 self.should_reparse = false;
951 self.pending_parse = Some(self.start_background_parse(cx));
952 }
953
954 fn start_background_parse(&self, cx: &Context<Self>) -> Task<()> {
955 let source = self.source.clone();
956 let should_parse_links_only = self.options.parse_links_only;
957 let should_parse_html = self.options.parse_html;
958 let should_render_mermaid_diagrams = self.options.render_mermaid_diagrams;
959 let should_parse_heading_slugs = self.options.parse_heading_slugs;
960 let should_parse_metadata_blocks = self.options.render_metadata_blocks;
961 let language_registry = self.language_registry.clone();
962 let fallback = self.fallback_code_block_language.clone();
963
964 let parsed = cx.background_spawn(async move {
965 if should_parse_links_only {
966 return (
967 ParsedMarkdown {
968 events: Arc::from(parse_links_only(source.as_ref())),
969 source,
970 languages_by_name: TreeMap::default(),
971 languages_by_path: TreeMap::default(),
972 root_block_starts: Arc::default(),
973 html_blocks: BTreeMap::default(),
974 metadata_blocks: BTreeMap::default(),
975 mermaid_diagrams: BTreeMap::default(),
976 heading_slugs: HashMap::default(),
977 footnote_definitions: HashMap::default(),
978 },
979 Default::default(),
980 );
981 }
982
983 let parsed = parse_markdown_with_options(
984 &source,
985 should_parse_html,
986 should_parse_heading_slugs,
987 should_parse_metadata_blocks,
988 );
989 let events = parsed.events;
990 let language_names = parsed.language_names;
991 let paths = parsed.language_paths;
992 let root_block_starts = parsed.root_block_starts;
993 let html_blocks = parsed.html_blocks;
994 let metadata_blocks = parsed.metadata_blocks;
995 let heading_slugs = parsed.heading_slugs;
996 let footnote_definitions = parsed.footnote_definitions;
997 let mermaid_diagrams = if should_render_mermaid_diagrams {
998 extract_mermaid_diagrams(&source, &events)
999 } else {
1000 BTreeMap::default()
1001 };
1002 let mut images_by_source_offset = HashMap::default();
1003 let mut languages_by_name = TreeMap::default();
1004 let mut languages_by_path = TreeMap::default();
1005 if let Some(registry) = language_registry.as_ref() {
1006 for name in language_names {
1007 let language = if !name.is_empty() {
1008 registry.language_for_name_or_extension(&name).left_future()
1009 } else if let Some(fallback) = &fallback {
1010 registry.language_for_name(fallback.as_ref()).right_future()
1011 } else {
1012 continue;
1013 };
1014 if let Ok(language) = language.await {
1015 languages_by_name.insert(name, language);
1016 }
1017 }
1018
1019 for path in paths {
1020 if let Ok(language) = registry
1021 .load_language_for_file_path(Path::new(path.as_ref()))
1022 .await
1023 {
1024 languages_by_path.insert(path, language);
1025 }
1026 }
1027 }
1028
1029 for (range, event) in &events {
1030 if let MarkdownEvent::Start(MarkdownTag::Image { dest_url, .. }) = event
1031 && let Some(data_url) = dest_url.strip_prefix("data:")
1032 {
1033 let Some((mime_info, data)) = data_url.split_once(',') else {
1034 continue;
1035 };
1036 let Some((mime_type, encoding)) = mime_info.split_once(';') else {
1037 continue;
1038 };
1039 let Some(format) = ImageFormat::from_mime_type(mime_type) else {
1040 continue;
1041 };
1042 let is_base64 = encoding == "base64";
1043 if is_base64
1044 && let Some(bytes) = base64::prelude::BASE64_STANDARD
1045 .decode(data)
1046 .log_with_level(Level::Debug)
1047 {
1048 let image = Arc::new(Image::from_bytes(format, bytes));
1049 images_by_source_offset.insert(range.start, image);
1050 }
1051 }
1052 }
1053
1054 (
1055 ParsedMarkdown {
1056 source,
1057 events: Arc::from(events),
1058 languages_by_name,
1059 languages_by_path,
1060 root_block_starts: Arc::from(root_block_starts),
1061 html_blocks,
1062 metadata_blocks,
1063 mermaid_diagrams,
1064 heading_slugs,
1065 footnote_definitions,
1066 },
1067 images_by_source_offset,
1068 )
1069 });
1070
1071 cx.spawn(async move |this, cx| {
1072 let (parsed, images_by_source_offset) = parsed.await;
1073
1074 this.update(cx, |this, cx| {
1075 this.parsed_markdown = parsed;
1076 this.images_by_source_offset = images_by_source_offset;
1077 if this.active_root_block.is_some_and(|block_index| {
1078 block_index >= this.parsed_markdown.root_block_starts.len()
1079 }) {
1080 this.active_root_block = None;
1081 }
1082 if this.options.render_mermaid_diagrams {
1083 let parsed_markdown = this.parsed_markdown.clone();
1084 this.mermaid_state.update(&parsed_markdown, cx);
1085 this.mermaid_showing_code
1086 .retain(|offset| parsed_markdown.mermaid_diagrams.contains_key(offset));
1087 } else {
1088 this.mermaid_state.clear();
1089 this.mermaid_showing_code.clear();
1090 }
1091 this.pending_parse.take();
1092 if this.should_reparse {
1093 this.parse(cx);
1094 }
1095 cx.notify();
1096 cx.refresh_windows();
1097 })
1098 .ok();
1099 })
1100 }
1101}
1102
1103impl Focusable for Markdown {
1104 fn focus_handle(&self, _cx: &App) -> FocusHandle {
1105 self.focus_handle.clone()
1106 }
1107}
1108
1109#[derive(Debug, Default, Clone)]
1110enum SelectMode {
1111 #[default]
1112 Character,
1113 Word(Range<usize>),
1114 Line(Range<usize>),
1115 All,
1116}
1117
1118#[derive(Clone, Default)]
1119struct Selection {
1120 start: usize,
1121 end: usize,
1122 reversed: bool,
1123 pending: bool,
1124 mode: SelectMode,
1125}
1126
1127impl Selection {
1128 fn set_head(&mut self, head: usize, rendered_text: &RenderedText) {
1129 match &self.mode {
1130 SelectMode::Character => {
1131 if head < self.tail() {
1132 if !self.reversed {
1133 self.end = self.start;
1134 self.reversed = true;
1135 }
1136 self.start = head;
1137 } else {
1138 if self.reversed {
1139 self.start = self.end;
1140 self.reversed = false;
1141 }
1142 self.end = head;
1143 }
1144 }
1145 SelectMode::Word(original_range) | SelectMode::Line(original_range) => {
1146 let head_range = if matches!(self.mode, SelectMode::Word(_)) {
1147 rendered_text.surrounding_word_range(head)
1148 } else {
1149 rendered_text.surrounding_line_range(head)
1150 };
1151
1152 if head < original_range.start {
1153 self.start = head_range.start;
1154 self.end = original_range.end;
1155 self.reversed = true;
1156 } else if head >= original_range.end {
1157 self.start = original_range.start;
1158 self.end = head_range.end;
1159 self.reversed = false;
1160 } else {
1161 self.start = original_range.start;
1162 self.end = original_range.end;
1163 self.reversed = false;
1164 }
1165 }
1166 SelectMode::All => {
1167 self.start = 0;
1168 self.end = rendered_text
1169 .lines
1170 .last()
1171 .map(|line| line.source_end)
1172 .unwrap_or(0);
1173 self.reversed = false;
1174 }
1175 }
1176 }
1177
1178 fn tail(&self) -> usize {
1179 if self.reversed { self.end } else { self.start }
1180 }
1181}
1182
1183#[derive(Debug, Clone, Default)]
1184pub struct ParsedMarkdown {
1185 pub source: SharedString,
1186 pub events: Arc<[(Range<usize>, MarkdownEvent)]>,
1187 pub languages_by_name: TreeMap<SharedString, Arc<Language>>,
1188 pub languages_by_path: TreeMap<Arc<str>, Arc<Language>>,
1189 pub root_block_starts: Arc<[usize]>,
1190 pub(crate) html_blocks: BTreeMap<usize, html::html_parser::ParsedHtmlBlock>,
1191 pub(crate) metadata_blocks: BTreeMap<usize, ParsedMetadataBlock>,
1192 pub(crate) mermaid_diagrams: BTreeMap<usize, ParsedMarkdownMermaidDiagram>,
1193 pub heading_slugs: HashMap<SharedString, usize>,
1194 pub footnote_definitions: HashMap<SharedString, usize>,
1195}
1196
1197impl ParsedMarkdown {
1198 pub fn source(&self) -> &SharedString {
1199 &self.source
1200 }
1201
1202 pub fn events(&self) -> &Arc<[(Range<usize>, MarkdownEvent)]> {
1203 &self.events
1204 }
1205
1206 pub fn root_block_starts(&self) -> &Arc<[usize]> {
1207 &self.root_block_starts
1208 }
1209
1210 pub fn root_block_for_source_index(&self, source_index: usize) -> Option<usize> {
1211 if self.root_block_starts.is_empty() {
1212 return None;
1213 }
1214
1215 let partition = self
1216 .root_block_starts
1217 .partition_point(|block_start| *block_start <= source_index);
1218
1219 Some(partition.saturating_sub(1))
1220 }
1221
1222 /// Extracts the markdown source for a selection, rebalancing inline
1223 /// delimiters (`**`, backticks, link syntax, etc.) so partial selections of
1224 /// styled spans stay well-formed.
1225 ///
1226 /// With an exception of a single inline code span, which is returned as plain
1227 /// text, since copying a command or identifier is the dominant use case there.
1228 pub fn rebalanced_markdown_for_selection(&self, selection: Range<usize>) -> String {
1229 selection::rebalanced_markdown_for_selection(
1230 &self.source,
1231 &self.events,
1232 &self.root_block_starts,
1233 selection,
1234 )
1235 }
1236}
1237
1238pub enum AutoscrollBehavior {
1239 /// Propagate the request up the element tree for the nearest
1240 /// scrollable ancestor (e.g. `List`) to handle.
1241 Propagate,
1242 /// Directly control a specific scroll handle.
1243 Controlled(ScrollHandle),
1244}
1245
1246pub struct MarkdownElement {
1247 markdown: Entity<Markdown>,
1248 style: MarkdownStyle,
1249 code_block_renderer: CodeBlockRenderer,
1250 on_url_click: Option<Rc<dyn Fn(SharedString, &mut Window, &mut App)>>,
1251 on_url_hover: Option<UrlHoverCallback>,
1252 code_span_link: Option<CodeSpanLinkCallback>,
1253 on_source_click: Option<SourceClickCallback>,
1254 on_checkbox_toggle: Option<CheckboxToggleCallback>,
1255 image_resolver: Option<Box<dyn Fn(&str) -> Option<ImageSource>>>,
1256 show_root_block_markers: bool,
1257 autoscroll: AutoscrollBehavior,
1258}
1259
1260impl MarkdownElement {
1261 pub fn new(markdown: Entity<Markdown>, style: MarkdownStyle) -> Self {
1262 Self {
1263 markdown,
1264 style,
1265 code_block_renderer: CodeBlockRenderer::Default {
1266 copy_button_visibility: CopyButtonVisibility::VisibleOnHover,
1267 wrap_button_visibility: WrapButtonVisibility::Hidden,
1268 border: false,
1269 },
1270 on_url_click: None,
1271 on_url_hover: None,
1272 code_span_link: None,
1273 on_source_click: None,
1274 on_checkbox_toggle: None,
1275 image_resolver: None,
1276 show_root_block_markers: false,
1277 autoscroll: AutoscrollBehavior::Propagate,
1278 }
1279 }
1280
1281 #[cfg(any(test, feature = "test-support"))]
1282 pub fn rendered_text(
1283 markdown: Entity<Markdown>,
1284 cx: &mut gpui::VisualTestContext,
1285 style: impl FnOnce(&Window, &App) -> MarkdownStyle,
1286 ) -> String {
1287 use gpui::size;
1288
1289 let (text, _) = cx.draw(
1290 Default::default(),
1291 size(px(600.0), px(600.0)),
1292 |window, cx| Self::new(markdown, style(window, cx)),
1293 );
1294 text.text
1295 .lines
1296 .iter()
1297 .map(|line| line.layout.wrapped_text())
1298 .collect::<Vec<_>>()
1299 .join("\n")
1300 }
1301
1302 pub fn code_block_renderer(mut self, variant: CodeBlockRenderer) -> Self {
1303 self.code_block_renderer = variant;
1304 self
1305 }
1306
1307 pub fn on_url_click(
1308 mut self,
1309 handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
1310 ) -> Self {
1311 self.on_url_click = Some(Rc::new(handler));
1312 self
1313 }
1314
1315 pub fn on_url_hover(
1316 mut self,
1317 handler: impl Fn(Option<SharedString>, &mut Window, &mut App) + 'static,
1318 ) -> Self {
1319 self.on_url_hover = Some(Rc::new(handler));
1320 self
1321 }
1322
1323 pub fn on_code_span_link(
1324 mut self,
1325 callback: impl Fn(&str, &App) -> Option<SharedString> + 'static,
1326 ) -> Self {
1327 self.code_span_link = Some(Arc::new(callback));
1328 self
1329 }
1330
1331 pub fn on_source_click(
1332 mut self,
1333 handler: impl Fn(usize, usize, &mut Window, &mut App) -> bool + 'static,
1334 ) -> Self {
1335 self.on_source_click = Some(Box::new(handler));
1336 self
1337 }
1338
1339 pub fn on_checkbox_toggle(
1340 mut self,
1341 handler: impl Fn(Range<usize>, bool, &mut Window, &mut App) + 'static,
1342 ) -> Self {
1343 self.on_checkbox_toggle = Some(Rc::new(handler));
1344 self
1345 }
1346
1347 pub fn image_resolver(
1348 mut self,
1349 resolver: impl Fn(&str) -> Option<ImageSource> + 'static,
1350 ) -> Self {
1351 self.image_resolver = Some(Box::new(resolver));
1352 self
1353 }
1354
1355 pub fn show_root_block_markers(mut self) -> Self {
1356 self.show_root_block_markers = true;
1357 self
1358 }
1359
1360 pub fn scroll_handle(mut self, scroll_handle: ScrollHandle) -> Self {
1361 self.autoscroll = AutoscrollBehavior::Controlled(scroll_handle);
1362 self
1363 }
1364
1365 fn push_markdown_code_span(
1366 &self,
1367 builder: &mut MarkdownElementBuilder,
1368 text: &str,
1369 range: Range<usize>,
1370 cx: &App,
1371 ) {
1372 let link_url = if builder.code_block_stack.is_empty()
1373 && builder.link_depth == 0
1374 && !self.style.prevent_mouse_interaction
1375 {
1376 self.code_span_link
1377 .as_ref()
1378 .and_then(|callback| callback(text, cx))
1379 } else {
1380 None
1381 };
1382
1383 if let Some(url) = link_url {
1384 builder.push_link(url.clone(), range.clone());
1385 let link_style = self
1386 .style
1387 .link_callback
1388 .as_ref()
1389 .and_then(|callback| callback(url.as_ref(), cx))
1390 .unwrap_or_else(|| self.style.link.clone());
1391 builder.push_text_style(self.style.inline_code.clone());
1392 builder.push_text_style(link_style);
1393 builder.push_text(text, range);
1394 builder.pop_text_style();
1395 builder.pop_text_style();
1396 } else {
1397 let mut code_style = self.style.inline_code.clone();
1398 if builder.link_depth > 0 {
1399 code_style.color = self.style.link.color.or(code_style.color);
1400 }
1401 builder.push_text_style(code_style);
1402 builder.push_text(text, range);
1403 builder.pop_text_style();
1404 }
1405 }
1406
1407 fn push_markdown_image(
1408 &self,
1409 builder: &mut MarkdownElementBuilder,
1410 range: &Range<usize>,
1411 source: ImageSource,
1412 dest_url: SharedString,
1413 alt_text: Option<SharedString>,
1414 width: Option<DefiniteLength>,
1415 height: Option<DefiniteLength>,
1416 ) {
1417 let enclosing_link_url = (builder.link_depth > 0)
1418 .then(|| builder.rendered_links.last())
1419 .flatten()
1420 .map(|link| link.destination_url.clone());
1421 let fallback_opens_image_url = enclosing_link_url.is_none();
1422
1423 let image_element = {
1424 let wrapper = div().id(("markdown-image-link", range.start)).min_w_0();
1425 let wrapper = if !self.style.prevent_mouse_interaction
1426 && let Some(url) = enclosing_link_url
1427 {
1428 let click_url = url.clone();
1429 let markdown = self.markdown.clone();
1430 let url_click = self.on_url_click.clone();
1431 let bounds = Rc::new(Cell::new(None));
1432 builder.push_image_link(url.clone(), bounds.clone());
1433 wrapper
1434 .relative()
1435 .cursor_pointer()
1436 .child(
1437 canvas(
1438 move |image_bounds, _window, _cx| bounds.set(Some(image_bounds)),
1439 |_, _, _, _| {},
1440 )
1441 .size_full()
1442 .absolute()
1443 .top_0()
1444 .left_0(),
1445 )
1446 .on_click(move |_, window, cx| {
1447 if let Some(ref on_url_click) = url_click {
1448 on_url_click(click_url.clone(), window, cx);
1449 } else {
1450 cx.open_url(&click_url);
1451 }
1452 })
1453 .capture_any_mouse_down(move |event, _window, cx| {
1454 if event.button == MouseButton::Right {
1455 markdown.update(cx, |md, _| {
1456 md.capture_for_context_menu(Some(url.clone()), None)
1457 });
1458 }
1459 })
1460 } else {
1461 wrapper
1462 };
1463 wrapper.child(
1464 img(source)
1465 .id(("markdown-image", range.start))
1466 .min_w_0()
1467 .max_w_full()
1468 .rounded_md()
1469 .mr_1()
1470 .mb_1()
1471 .when_some(height, |this, height| this.h(height))
1472 .when_some(width, |this, width| this.w(width))
1473 .with_fallback(move || {
1474 image_fallback_element(
1475 dest_url.clone(),
1476 alt_text.clone(),
1477 fallback_opens_image_url,
1478 )
1479 }),
1480 )
1481 };
1482
1483 builder.push_image_child(image_element);
1484 }
1485
1486 fn push_markdown_paragraph(
1487 &self,
1488 builder: &mut MarkdownElementBuilder,
1489 range: &Range<usize>,
1490 markdown_end: usize,
1491 text_align_override: Option<TextAlign>,
1492 ) {
1493 let align = text_align_override.unwrap_or(self.style.base_text_style.text_align);
1494 let mut paragraph = div().when(!self.style.height_is_multiple_of_line_height, |el| {
1495 el.mb_2().line_height(rems(1.3))
1496 });
1497
1498 paragraph = match align {
1499 TextAlign::Center => paragraph.text_center(),
1500 TextAlign::Left => paragraph.text_left(),
1501 TextAlign::Right => paragraph.text_right(),
1502 };
1503
1504 builder.push_text_style(TextStyleRefinement {
1505 text_align: Some(align),
1506 ..Default::default()
1507 });
1508 builder.push_div(paragraph, range, markdown_end);
1509 }
1510
1511 fn pop_markdown_paragraph(&self, builder: &mut MarkdownElementBuilder) {
1512 builder.pop_div();
1513 builder.pop_text_style();
1514 }
1515
1516 fn push_markdown_heading(
1517 &self,
1518 builder: &mut MarkdownElementBuilder,
1519 level: pulldown_cmark::HeadingLevel,
1520 range: &Range<usize>,
1521 markdown_end: usize,
1522 text_align_override: Option<TextAlign>,
1523 ) {
1524 let align = text_align_override.unwrap_or(self.style.base_text_style.text_align);
1525 let mut heading = div().mt_4().mb_2();
1526 heading = apply_heading_style(
1527 heading,
1528 level,
1529 self.style.heading_level_styles.as_ref(),
1530 self.style.heading_border_color,
1531 );
1532
1533 heading = match align {
1534 TextAlign::Center => heading.text_center(),
1535 TextAlign::Left => heading.text_left(),
1536 TextAlign::Right => heading.text_right(),
1537 };
1538
1539 let mut heading_style = self.style.heading.clone();
1540 let heading_text_style = heading_style.text_style().clone();
1541 heading.style().refine(&heading_style);
1542
1543 builder.push_text_style(TextStyleRefinement {
1544 text_align: Some(align),
1545 ..heading_text_style
1546 });
1547 builder.push_div(heading, range, markdown_end);
1548 }
1549
1550 fn pop_markdown_heading(&self, builder: &mut MarkdownElementBuilder) {
1551 builder.pop_div();
1552 builder.pop_text_style();
1553 }
1554
1555 fn push_markdown_block_quote(
1556 &self,
1557 builder: &mut MarkdownElementBuilder,
1558 kind: Option<pulldown_cmark::BlockQuoteKind>,
1559 range: &Range<usize>,
1560 markdown_end: usize,
1561 ) {
1562 let border_color = self
1563 .style
1564 .block_quote_kind_colors
1565 .for_kind(kind, self.style.block_quote_border_color);
1566
1567 let header = kind.map(|kind| {
1568 let (icon_name, label) = match kind {
1569 BlockQuoteKind::Note => (IconName::Info, "Note"),
1570 BlockQuoteKind::Tip => (IconName::Sparkle, "Tip"),
1571 BlockQuoteKind::Important => (IconName::Chat, "Important"),
1572 BlockQuoteKind::Warning => (IconName::Warning, "Warning"),
1573 BlockQuoteKind::Caution => (IconName::Stop, "Caution"),
1574 };
1575 h_flex()
1576 .gap_1()
1577 .items_center()
1578 .mb_1()
1579 .child(
1580 Icon::new(icon_name)
1581 .size(IconSize::Small)
1582 .color(Color::Custom(border_color)),
1583 )
1584 .child(
1585 Label::new(label)
1586 .color(Color::Custom(border_color))
1587 .weight(FontWeight::BOLD),
1588 )
1589 .into_any_element()
1590 });
1591
1592 let block_div = div().pl_4().mb_2().border_l_4().border_color(border_color);
1593 let block_div = match header {
1594 Some(header) => block_div.child(header),
1595 None => block_div,
1596 };
1597
1598 builder.push_text_style(self.style.block_quote.clone());
1599 builder.push_div(block_div, range, markdown_end);
1600 }
1601
1602 fn pop_markdown_block_quote(&self, builder: &mut MarkdownElementBuilder) {
1603 builder.pop_div();
1604 builder.pop_text_style();
1605 }
1606
1607 fn push_metadata_block(
1608 &self,
1609 builder: &mut MarkdownElementBuilder,
1610 source: &str,
1611 metadata_block: &ParsedMetadataBlock,
1612 markdown_end: usize,
1613 cx: &App,
1614 ) {
1615 let content_range = &metadata_block.content_range;
1616 if let Some(rows) = metadata_block.rows.as_deref() {
1617 builder.push_div(
1618 div()
1619 .grid()
1620 .grid_cols(2)
1621 .w_full()
1622 .mb_2()
1623 .border_1()
1624 .border_color(cx.theme().colors().border)
1625 .rounded_sm()
1626 .overflow_hidden(),
1627 content_range,
1628 markdown_end,
1629 );
1630
1631 for (row_index, row) in rows.iter().enumerate() {
1632 self.push_metadata_cell(
1633 builder,
1634 source,
1635 row.key.clone(),
1636 content_range,
1637 markdown_end,
1638 MetadataCellStyle {
1639 row_index,
1640 is_key: true,
1641 },
1642 cx,
1643 );
1644 self.push_metadata_cell(
1645 builder,
1646 source,
1647 row.value.clone(),
1648 content_range,
1649 markdown_end,
1650 MetadataCellStyle {
1651 row_index,
1652 is_key: false,
1653 },
1654 cx,
1655 );
1656 }
1657
1658 builder.pop_div();
1659 } else {
1660 let mut metadata_block = div().w_full().rounded_md();
1661 metadata_block.style().refine(&self.style.code_block);
1662 builder.push_text_style(self.style.code_block.text.to_owned());
1663 builder.push_code_block(None);
1664 builder.push_div(metadata_block, content_range, markdown_end);
1665 builder.push_text(&source[content_range.clone()], content_range.clone());
1666 builder.trim_trailing_newline();
1667 builder.pop_div();
1668 builder.pop_code_block();
1669 builder.pop_text_style();
1670 }
1671 }
1672
1673 fn push_metadata_cell(
1674 &self,
1675 builder: &mut MarkdownElementBuilder,
1676 source: &str,
1677 text_range: Range<usize>,
1678 block_range: &Range<usize>,
1679 markdown_end: usize,
1680 cell_style: MetadataCellStyle,
1681 cx: &App,
1682 ) {
1683 builder.push_div(
1684 div()
1685 .flex()
1686 .flex_col()
1687 .min_w_0()
1688 .px_2()
1689 .py_1()
1690 .border_color(cx.theme().colors().border)
1691 .when(cell_style.row_index > 0, |this| this.border_t_1())
1692 .when(!cell_style.is_key, |this| this.border_l_1())
1693 .when(cell_style.is_key, |this| {
1694 this.bg(cx.theme().colors().panel_background)
1695 }),
1696 block_range,
1697 markdown_end,
1698 );
1699
1700 let text_style = if cell_style.is_key {
1701 TextStyleRefinement {
1702 color: Some(cx.theme().colors().text_muted),
1703 font_weight: Some(FontWeight::SEMIBOLD),
1704 ..Default::default()
1705 }
1706 } else {
1707 TextStyleRefinement::default()
1708 };
1709 builder.push_text_style(text_style);
1710 builder.push_text(&source[text_range.clone()], text_range);
1711 builder.pop_text_style();
1712 builder.pop_div();
1713 }
1714
1715 fn push_markdown_list_item(
1716 &self,
1717 builder: &mut MarkdownElementBuilder,
1718 bullet: AnyElement,
1719 range: &Range<usize>,
1720 markdown_end: usize,
1721 ) {
1722 builder.push_div(
1723 div()
1724 .when(!self.style.height_is_multiple_of_line_height, |el| {
1725 el.mb_1().gap_1().line_height(rems(1.3))
1726 })
1727 .h_flex()
1728 .items_start()
1729 .child(bullet),
1730 range,
1731 markdown_end,
1732 );
1733 // Without `w_0`, text doesn't wrap to the width of the container.
1734 builder.push_div(div().flex_1().w_0(), range, markdown_end);
1735 }
1736
1737 fn pop_markdown_list_item(&self, builder: &mut MarkdownElementBuilder) {
1738 builder.pop_div();
1739 builder.pop_div();
1740 }
1741
1742 fn paint_highlight_range(
1743 start: usize,
1744 end: usize,
1745 color: Hsla,
1746 rendered_text: &RenderedText,
1747 window: &mut Window,
1748 ) {
1749 for bounds in rendered_text.bounds_for_source_range(start..end) {
1750 window.paint_quad(quad(
1751 bounds,
1752 Pixels::ZERO,
1753 color,
1754 Edges::default(),
1755 Hsla::transparent_black(),
1756 BorderStyle::default(),
1757 ));
1758 }
1759 }
1760
1761 fn paint_selection(&self, rendered_text: &RenderedText, window: &mut Window, cx: &mut App) {
1762 let selection = self.markdown.read(cx).selection.clone();
1763 Self::paint_highlight_range(
1764 selection.start,
1765 selection.end,
1766 self.style.selection_background_color,
1767 rendered_text,
1768 window,
1769 );
1770 }
1771
1772 fn paint_search_highlights(
1773 &self,
1774 rendered_text: &RenderedText,
1775 window: &mut Window,
1776 cx: &mut App,
1777 ) {
1778 let markdown = self.markdown.read(cx);
1779 let active_index = markdown.active_search_highlight;
1780 let colors = cx.theme().colors();
1781
1782 let highlight_bounds = rendered_text.bounds_for_sorted_source_ranges(
1783 markdown
1784 .search_highlights
1785 .iter()
1786 .enumerate()
1787 .map(|(ix, range)| (ix, range.clone())),
1788 );
1789 for (highlight_ix, bounds) in highlight_bounds {
1790 let color = if Some(highlight_ix) == active_index {
1791 colors.search_active_match_background
1792 } else {
1793 colors.search_match_background
1794 };
1795 window.paint_quad(quad(
1796 bounds,
1797 Pixels::ZERO,
1798 color,
1799 Edges::default(),
1800 Hsla::transparent_black(),
1801 BorderStyle::default(),
1802 ));
1803 }
1804 }
1805
1806 fn paint_mouse_listeners(
1807 &mut self,
1808 hitbox: &Hitbox,
1809 rendered_text: &RenderedText,
1810 window: &mut Window,
1811 cx: &mut App,
1812 ) {
1813 if self.style.prevent_mouse_interaction {
1814 return;
1815 }
1816
1817 let is_hovering_clickable = hitbox.is_hovered(window)
1818 && !self.markdown.read(cx).selection.pending
1819 && (rendered_text
1820 .image_link_for_position(window.mouse_position())
1821 .is_some()
1822 || rendered_text
1823 .source_index_for_position(window.mouse_position())
1824 .ok()
1825 .is_some_and(|source_index| {
1826 rendered_text.link_for_source_index(source_index).is_some()
1827 || rendered_text
1828 .footnote_ref_for_source_index(source_index)
1829 .is_some()
1830 }));
1831
1832 if is_hovering_clickable {
1833 window.set_cursor_style(CursorStyle::PointingHand, hitbox);
1834 } else {
1835 window.set_cursor_style(CursorStyle::IBeam, hitbox);
1836 }
1837
1838 let on_open_url = self.on_url_click.take();
1839 let on_url_hover = self.on_url_hover.take();
1840 let on_source_click = self.on_source_click.take();
1841
1842 self.on_mouse_event(window, cx, {
1843 let hitbox = hitbox.clone();
1844 let rendered_text = rendered_text.clone();
1845 move |markdown, event: &MouseDownEvent, phase, window, _cx| {
1846 if phase.capture()
1847 && event.button == MouseButton::Right
1848 && hitbox.is_hovered(window)
1849 {
1850 let link = rendered_text
1851 .source_index_for_position(event.position)
1852 .ok()
1853 .and_then(|ix| rendered_text.link_for_source_index(ix))
1854 .map(|link| link.destination_url.clone());
1855 markdown.capture_for_context_menu(link, Some(&rendered_text));
1856 }
1857 }
1858 });
1859
1860 self.on_mouse_event(window, cx, {
1861 let rendered_text = rendered_text.clone();
1862 let hitbox = hitbox.clone();
1863 move |markdown, event: &MouseDownEvent, phase, window, cx| {
1864 if hitbox.is_hovered(window) {
1865 if phase.bubble() && event.button != MouseButton::Right {
1866 let position_result =
1867 rendered_text.source_index_for_position(event.position);
1868
1869 if let Ok(source_index) = position_result {
1870 if let Some(footnote_ref) =
1871 rendered_text.footnote_ref_for_source_index(source_index)
1872 {
1873 markdown.pressed_footnote_ref = Some(footnote_ref.clone());
1874 } else if let Some(link) =
1875 rendered_text.link_for_source_index(source_index)
1876 {
1877 markdown.pressed_link = Some(link.clone());
1878 }
1879 }
1880
1881 if markdown.pressed_footnote_ref.is_none()
1882 && markdown.pressed_link.is_none()
1883 {
1884 let source_index = match position_result {
1885 Ok(ix) | Err(ix) => ix,
1886 };
1887 if let Some(handler) = on_source_click.as_ref() {
1888 let blocked = handler(source_index, event.click_count, window, cx);
1889 if blocked {
1890 markdown.selection = Selection::default();
1891 markdown.pressed_link = None;
1892 window.prevent_default();
1893 cx.notify();
1894 return;
1895 }
1896 }
1897 let (range, mode, reversed) = match event.click_count {
1898 1 if event.modifiers.shift => {
1899 let tail = markdown.selection.tail();
1900 let reversed = source_index < tail;
1901 let range = if reversed {
1902 source_index..tail
1903 } else {
1904 tail..source_index
1905 };
1906 (range, SelectMode::Character, reversed)
1907 }
1908 1 => {
1909 let range = source_index..source_index;
1910 (range, SelectMode::Character, false)
1911 }
1912 2 => {
1913 let range = rendered_text.surrounding_word_range(source_index);
1914 (range.clone(), SelectMode::Word(range), false)
1915 }
1916 3 => {
1917 let range = rendered_text.surrounding_line_range(source_index);
1918 (range.clone(), SelectMode::Line(range), false)
1919 }
1920 _ => {
1921 let range = 0..rendered_text
1922 .lines
1923 .last()
1924 .map(|line| line.source_end)
1925 .unwrap_or(0);
1926 (range, SelectMode::All, false)
1927 }
1928 };
1929 markdown.selection = Selection {
1930 start: range.start,
1931 end: range.end,
1932 reversed,
1933 pending: true,
1934 mode,
1935 };
1936 window.focus(&markdown.focus_handle, cx);
1937 }
1938
1939 window.prevent_default();
1940 cx.notify();
1941 }
1942 } else if phase.capture() && event.button == MouseButton::Left {
1943 markdown.selection = Selection::default();
1944 markdown.pressed_link = None;
1945 cx.notify();
1946 }
1947 }
1948 });
1949 self.on_mouse_event(window, cx, {
1950 let rendered_text = rendered_text.clone();
1951 let hitbox = hitbox.clone();
1952 let was_hovering_clickable = is_hovering_clickable;
1953 move |markdown, event: &MouseMoveEvent, phase, window, cx| {
1954 if phase.capture() {
1955 return;
1956 }
1957
1958 if markdown.selection.pending {
1959 let source_index = match rendered_text.source_index_for_position(event.position)
1960 {
1961 Ok(ix) | Err(ix) => ix,
1962 };
1963 markdown.selection.set_head(source_index, &rendered_text);
1964 markdown.autoscroll_code_block(source_index, event.position);
1965 markdown.autoscroll_request = Some(source_index);
1966 cx.notify();
1967 } else {
1968 let is_hitbox_hovered = hitbox.is_hovered(window);
1969 let source_index = is_hitbox_hovered
1970 .then(|| rendered_text.source_index_for_position(event.position).ok())
1971 .flatten();
1972 let hovered_url = is_hitbox_hovered
1973 .then(|| rendered_text.image_link_for_position(event.position))
1974 .flatten()
1975 .map(|image| image.destination_url.clone())
1976 .or_else(|| {
1977 source_index
1978 .and_then(|source_index| {
1979 rendered_text.link_for_source_index(source_index)
1980 })
1981 .map(|link| link.destination_url.clone())
1982 });
1983 let is_hovering_clickable = hovered_url.is_some()
1984 || source_index.is_some_and(|source_index| {
1985 rendered_text
1986 .footnote_ref_for_source_index(source_index)
1987 .is_some()
1988 });
1989 if let Some(on_url_hover) = on_url_hover.as_ref() {
1990 on_url_hover(hovered_url, window, cx);
1991 }
1992 if is_hovering_clickable != was_hovering_clickable {
1993 cx.notify();
1994 }
1995 }
1996 }
1997 });
1998 self.on_mouse_event(window, cx, {
1999 let rendered_text = rendered_text.clone();
2000 move |markdown, event: &MouseUpEvent, phase, window, cx| {
2001 if phase.bubble() {
2002 let source_index = rendered_text.source_index_for_position(event.position).ok();
2003 if let Some(pressed_footnote_ref) = markdown.pressed_footnote_ref.take()
2004 && source_index
2005 .and_then(|ix| rendered_text.footnote_ref_for_source_index(ix))
2006 == Some(&pressed_footnote_ref)
2007 {
2008 if let Some(source_index) =
2009 markdown.footnote_definition_content_start(&pressed_footnote_ref.label)
2010 {
2011 markdown.autoscroll_request = Some(source_index);
2012 cx.notify();
2013 }
2014 } else if let Some(pressed_link) = markdown.pressed_link.take()
2015 && source_index.and_then(|ix| rendered_text.link_for_source_index(ix))
2016 == Some(&pressed_link)
2017 {
2018 if let Some(open_url) = on_open_url.as_ref() {
2019 open_url(pressed_link.destination_url, window, cx);
2020 } else {
2021 cx.open_url(&pressed_link.destination_url);
2022 }
2023 }
2024 } else if markdown.selection.pending {
2025 markdown.selection.pending = false;
2026 #[cfg(any(target_os = "linux", target_os = "freebsd"))]
2027 {
2028 let text = rendered_text
2029 .text_for_range(markdown.selection.start..markdown.selection.end);
2030 cx.write_to_primary(ClipboardItem::new_string(text))
2031 }
2032 cx.notify();
2033 }
2034 }
2035 });
2036 }
2037
2038 fn autoscroll(
2039 &self,
2040 rendered_text: &RenderedText,
2041 window: &mut Window,
2042 cx: &mut App,
2043 ) -> Option<()> {
2044 let autoscroll_index = self
2045 .markdown
2046 .update(cx, |markdown, _| markdown.autoscroll_request.take())?;
2047 let (position, line_height) = rendered_text.position_for_source_index(autoscroll_index)?;
2048
2049 match &self.autoscroll {
2050 AutoscrollBehavior::Controlled(scroll_handle) => {
2051 let viewport = scroll_handle.bounds();
2052 let margin = line_height * 3.;
2053 let top_goal = viewport.top() + margin;
2054 let bottom_goal = viewport.bottom() - margin;
2055 let current_offset = scroll_handle.offset();
2056
2057 let new_offset_y = if position.y < top_goal {
2058 current_offset.y + (top_goal - position.y)
2059 } else if position.y + line_height > bottom_goal {
2060 current_offset.y + (bottom_goal - (position.y + line_height))
2061 } else {
2062 current_offset.y
2063 };
2064
2065 scroll_handle.set_offset(point(
2066 current_offset.x,
2067 new_offset_y.clamp(-scroll_handle.max_offset().y, Pixels::ZERO),
2068 ));
2069 }
2070 AutoscrollBehavior::Propagate => {
2071 let text_style = self.style.base_text_style.clone();
2072 let font_id = window.text_system().resolve_font(&text_style.font());
2073 let font_size = text_style.font_size.to_pixels(window.rem_size());
2074 let em_width = window.text_system().em_width(font_id, font_size).unwrap();
2075 window.request_autoscroll(Bounds::from_corners(
2076 point(position.x - 3. * em_width, position.y - 3. * line_height),
2077 point(position.x + 3. * em_width, position.y + 3. * line_height),
2078 ));
2079 }
2080 }
2081 Some(())
2082 }
2083
2084 fn on_mouse_event<T: MouseEvent>(
2085 &self,
2086 window: &mut Window,
2087 _cx: &mut App,
2088 mut f: impl 'static
2089 + FnMut(&mut Markdown, &T, DispatchPhase, &mut Window, &mut Context<Markdown>),
2090 ) {
2091 window.on_mouse_event({
2092 let markdown = self.markdown.downgrade();
2093 move |event, phase, window, cx| {
2094 markdown
2095 .update(cx, |markdown, cx| f(markdown, event, phase, window, cx))
2096 .log_err();
2097 }
2098 });
2099 }
2100}
2101
2102impl Styled for MarkdownElement {
2103 fn style(&mut self) -> &mut StyleRefinement {
2104 &mut self.style.container_style
2105 }
2106}
2107
2108impl Element for MarkdownElement {
2109 type RequestLayoutState = RenderedMarkdown;
2110 type PrepaintState = Hitbox;
2111
2112 fn id(&self) -> Option<ElementId> {
2113 None
2114 }
2115
2116 fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
2117 None
2118 }
2119
2120 fn request_layout(
2121 &mut self,
2122 _id: Option<&GlobalElementId>,
2123 _inspector_id: Option<&gpui::InspectorElementId>,
2124 window: &mut Window,
2125 cx: &mut App,
2126 ) -> (gpui::LayoutId, Self::RequestLayoutState) {
2127 let mut builder = MarkdownElementBuilder::new(
2128 &self.style.container_style,
2129 self.style.base_text_style.clone(),
2130 self.style.syntax.clone(),
2131 );
2132 let (parsed_markdown, images, active_root_block, render_mermaid_diagrams, mermaid_state) = {
2133 let markdown = self.markdown.read(cx);
2134 (
2135 markdown.parsed_markdown.clone(),
2136 markdown.images_by_source_offset.clone(),
2137 markdown.active_root_block,
2138 markdown.options.render_mermaid_diagrams,
2139 markdown.mermaid_state.clone(),
2140 )
2141 };
2142 let markdown_end = if let Some(last) = parsed_markdown.events.last() {
2143 last.0.end
2144 } else {
2145 0
2146 };
2147 let mut code_block_ids = HashSet::default();
2148
2149 let mut current_img_block_range: Option<Range<usize>> = None;
2150 let mut handled_html_block = false;
2151 let mut rendered_mermaid_block = false;
2152 let mut rendered_metadata_block = false;
2153 for (index, (range, event)) in parsed_markdown.events.iter().enumerate() {
2154 // Skip alt text for images that rendered
2155 if let Some(current_img_block_range) = ¤t_img_block_range
2156 && current_img_block_range.end > range.end
2157 {
2158 continue;
2159 }
2160
2161 if handled_html_block {
2162 if let MarkdownEvent::End(MarkdownTagEnd::HtmlBlock) = event {
2163 handled_html_block = false;
2164 } else {
2165 continue;
2166 }
2167 }
2168
2169 if rendered_mermaid_block {
2170 if matches!(event, MarkdownEvent::End(MarkdownTagEnd::CodeBlock)) {
2171 rendered_mermaid_block = false;
2172 }
2173 continue;
2174 }
2175
2176 if rendered_metadata_block {
2177 if matches!(event, MarkdownEvent::End(MarkdownTagEnd::MetadataBlock(_))) {
2178 rendered_metadata_block = false;
2179 }
2180 continue;
2181 }
2182
2183 match event {
2184 MarkdownEvent::RootStart => {
2185 if self.show_root_block_markers {
2186 builder.push_root_block(range, markdown_end);
2187 }
2188 }
2189 MarkdownEvent::RootEnd(root_block_index) => {
2190 if self.show_root_block_markers {
2191 builder.pop_root_block(
2192 active_root_block == Some(*root_block_index),
2193 cx.theme().colors().border,
2194 cx.theme().colors().border_variant,
2195 );
2196 }
2197 }
2198 MarkdownEvent::Start(tag) => {
2199 match tag {
2200 MarkdownTag::Image { dest_url, .. } => {
2201 let alt_text = collect_image_alt_text(
2202 &parsed_markdown.events[index..],
2203 &parsed_markdown.source,
2204 );
2205 if let Some(image) = images.get(&range.start) {
2206 current_img_block_range = Some(range.clone());
2207 self.push_markdown_image(
2208 &mut builder,
2209 range,
2210 image.clone().into(),
2211 dest_url.clone(),
2212 alt_text,
2213 None,
2214 None,
2215 );
2216 } else if let Some(source) = self
2217 .image_resolver
2218 .as_ref()
2219 .and_then(|resolve| resolve(dest_url.as_ref()))
2220 {
2221 current_img_block_range = Some(range.clone());
2222 self.push_markdown_image(
2223 &mut builder,
2224 range,
2225 source,
2226 dest_url.clone(),
2227 alt_text,
2228 None,
2229 None,
2230 );
2231 }
2232 }
2233 MarkdownTag::Paragraph => {
2234 let text_align_override = builder
2235 .table
2236 .current_cell_alignment()
2237 .and_then(alignment_to_text_align);
2238 self.push_markdown_paragraph(
2239 &mut builder,
2240 range,
2241 markdown_end,
2242 text_align_override,
2243 );
2244 }
2245 MarkdownTag::Heading { level, .. } => {
2246 let text_align_override = builder
2247 .table
2248 .current_cell_alignment()
2249 .and_then(alignment_to_text_align);
2250 self.push_markdown_heading(
2251 &mut builder,
2252 *level,
2253 range,
2254 markdown_end,
2255 text_align_override,
2256 );
2257 }
2258 MarkdownTag::BlockQuote(kind) => {
2259 self.push_markdown_block_quote(
2260 &mut builder,
2261 *kind,
2262 range,
2263 markdown_end,
2264 );
2265 }
2266 MarkdownTag::CodeBlock { kind, .. } => {
2267 if render_mermaid_diagrams
2268 && let Some(mermaid_diagram) =
2269 parsed_markdown.mermaid_diagrams.get(&range.start)
2270 {
2271 let showing_code =
2272 self.markdown.read(cx).is_mermaid_showing_code(range.start);
2273 let copy_button_visibility = match &self.code_block_renderer {
2274 CodeBlockRenderer::Default {
2275 copy_button_visibility,
2276 ..
2277 } => *copy_button_visibility,
2278 _ => CopyButtonVisibility::VisibleOnHover,
2279 };
2280 builder.push_sourced_element(
2281 mermaid_diagram.content_range.clone(),
2282 render_mermaid_diagram(
2283 mermaid_diagram,
2284 &mermaid_state,
2285 &self.style,
2286 self.markdown.clone(),
2287 range.start,
2288 showing_code,
2289 copy_button_visibility,
2290 ),
2291 );
2292 rendered_mermaid_block = true;
2293 continue;
2294 }
2295
2296 let language = match kind {
2297 CodeBlockKind::Fenced => None,
2298 CodeBlockKind::FencedLang(language) => {
2299 parsed_markdown.languages_by_name.get(language).cloned()
2300 }
2301 CodeBlockKind::FencedSrc(path_range) => parsed_markdown
2302 .languages_by_path
2303 .get(&path_range.path)
2304 .cloned(),
2305 _ => None,
2306 };
2307
2308 let is_indented = matches!(kind, CodeBlockKind::Indented);
2309 let scroll_handle = if self.style.code_block_overflow_x_scroll {
2310 self.markdown.update(cx, |markdown, _| {
2311 markdown.code_block_scroll_handle(range.start)
2312 })
2313 } else {
2314 None
2315 };
2316 if scroll_handle.is_some() {
2317 code_block_ids.insert(range.start);
2318 }
2319
2320 match (&self.code_block_renderer, is_indented) {
2321 (CodeBlockRenderer::Default { .. }, _) | (_, true) => {
2322 // This is a parent container that we can position the copy button inside.
2323 let parent_container =
2324 div().group("code_block").relative().w_full();
2325
2326 let mut parent_container: AnyDiv = if let Some(scroll_handle) =
2327 scroll_handle.as_ref()
2328 {
2329 let scrollbars = Scrollbars::new(ScrollAxes::Horizontal)
2330 .id(("markdown-code-block-scrollbar", range.start))
2331 .tracked_scroll_handle(scroll_handle)
2332 .with_track_along(
2333 ScrollAxes::Horizontal,
2334 cx.theme().colors().editor_background,
2335 )
2336 .notify_content();
2337
2338 parent_container
2339 .rounded_lg()
2340 .custom_scrollbars(scrollbars, window, cx)
2341 .into()
2342 } else {
2343 parent_container.into()
2344 };
2345
2346 if let CodeBlockRenderer::Default { border: true, .. } =
2347 &self.code_block_renderer
2348 {
2349 parent_container = parent_container
2350 .rounded_md()
2351 .border_1()
2352 .border_color(cx.theme().colors().border_variant);
2353 }
2354
2355 parent_container.style().refine(&self.style.code_block);
2356 builder.push_div(parent_container, range, markdown_end);
2357
2358 let code_block = div()
2359 .id(("code-block", range.start))
2360 .rounded_lg()
2361 .map(|mut code_block| {
2362 if let Some(scroll_handle) = scroll_handle.as_ref() {
2363 code_block.style().restrict_scroll_to_axis =
2364 Some(true);
2365 code_block
2366 .flex()
2367 .overflow_x_scroll()
2368 .track_scroll(scroll_handle)
2369 } else {
2370 code_block.w_full()
2371 }
2372 });
2373
2374 builder.push_text_style(self.style.code_block.text.to_owned());
2375 builder.push_code_block(language);
2376 builder.push_div(code_block, range, markdown_end);
2377 }
2378 (CodeBlockRenderer::Custom { .. }, _) => {}
2379 }
2380 }
2381 MarkdownTag::HtmlBlock => {
2382 builder.push_div(div(), range, markdown_end);
2383 if let Some(block) = parsed_markdown.html_blocks.get(&range.start) {
2384 self.render_html_block(block, &mut builder, markdown_end, cx);
2385 handled_html_block = true;
2386 }
2387 }
2388 MarkdownTag::List(bullet_index) => {
2389 builder.push_list(*bullet_index);
2390 builder.push_div(div().pl_2p5(), range, markdown_end);
2391 }
2392 MarkdownTag::Item => {
2393 let bullet =
2394 if let Some((task_range, MarkdownEvent::TaskListMarker(checked))) =
2395 parsed_markdown.events.get(index.saturating_add(1))
2396 {
2397 let source = &parsed_markdown.source()[range.clone()];
2398 let checked = *checked;
2399 let toggle_state = if checked {
2400 ToggleState::Selected
2401 } else {
2402 ToggleState::Unselected
2403 };
2404
2405 let checkbox = Checkbox::new(
2406 ElementId::Name(source.to_string().into()),
2407 toggle_state,
2408 )
2409 .fill();
2410
2411 if let Some(on_toggle) = self.on_checkbox_toggle.clone() {
2412 let task_source_range = task_range.clone();
2413 checkbox
2414 .on_click(move |_state, window, cx| {
2415 on_toggle(
2416 task_source_range.clone(),
2417 !checked,
2418 window,
2419 cx,
2420 );
2421 })
2422 .into_any_element()
2423 } else {
2424 checkbox.visualization_only(true).into_any_element()
2425 }
2426 } else if let Some(bullet_index) = builder.next_bullet_index() {
2427 div().child(format!("{}.", bullet_index)).into_any_element()
2428 } else {
2429 div().child("•").into_any_element()
2430 };
2431 self.push_markdown_list_item(&mut builder, bullet, range, markdown_end);
2432 }
2433 MarkdownTag::Emphasis => builder.push_text_style(TextStyleRefinement {
2434 font_style: Some(FontStyle::Italic),
2435 ..Default::default()
2436 }),
2437 MarkdownTag::Strong => builder.push_text_style(TextStyleRefinement {
2438 font_weight: Some(FontWeight::BOLD),
2439 color: Some(cx.theme().colors().text),
2440 ..Default::default()
2441 }),
2442 MarkdownTag::Strikethrough => {
2443 builder.push_text_style(TextStyleRefinement {
2444 strikethrough: Some(StrikethroughStyle {
2445 thickness: px(1.),
2446 color: None,
2447 }),
2448 ..Default::default()
2449 })
2450 }
2451 MarkdownTag::Link { dest_url, .. } => {
2452 if builder.code_block_stack.is_empty() {
2453 builder.link_depth += 1;
2454 builder.push_link(dest_url.clone(), range.clone());
2455 let style = self
2456 .style
2457 .link_callback
2458 .as_ref()
2459 .and_then(|callback| callback(dest_url, cx))
2460 .unwrap_or_else(|| self.style.link.clone());
2461 builder.push_text_style(style)
2462 }
2463 }
2464 MarkdownTag::FootnoteDefinition(label) => {
2465 if !builder.rendered_footnote_separator {
2466 builder.rendered_footnote_separator = true;
2467 builder.push_div(
2468 div()
2469 .border_t_1()
2470 .mt_2()
2471 .border_color(self.style.rule_color),
2472 range,
2473 markdown_end,
2474 );
2475 builder.pop_div();
2476 }
2477 builder.push_div(
2478 div()
2479 .pt_1()
2480 .mb_1()
2481 .line_height(rems(1.3))
2482 .text_size(rems(0.85))
2483 .h_flex()
2484 .items_start()
2485 .gap_2()
2486 .child(
2487 div().text_size(rems(0.85)).child(format!("{}.", label)),
2488 ),
2489 range,
2490 markdown_end,
2491 );
2492 builder.push_div(div().flex_1().w_0(), range, markdown_end);
2493 }
2494 MarkdownTag::MetadataBlock(_) => {
2495 if let Some(metadata_block) =
2496 parsed_markdown.metadata_blocks.get(&range.start)
2497 {
2498 self.push_metadata_block(
2499 &mut builder,
2500 &parsed_markdown.source,
2501 metadata_block,
2502 markdown_end,
2503 cx,
2504 );
2505 rendered_metadata_block = true;
2506 }
2507 }
2508 MarkdownTag::Table(alignments) => {
2509 builder.table.start(alignments.clone());
2510
2511 let column_count = alignments.len();
2512 builder.push_div(
2513 div()
2514 .id(("table", range.start))
2515 .grid()
2516 .grid_cols(column_count as u16)
2517 .when(self.style.table_columns_min_size, |this| {
2518 this.grid_cols_min_content(column_count as u16)
2519 })
2520 .when(!self.style.table_columns_min_size, |this| {
2521 this.grid_cols(column_count as u16)
2522 })
2523 .w_full()
2524 .mb_2()
2525 .border(px(1.5))
2526 .border_color(cx.theme().colors().border)
2527 .rounded_sm()
2528 .overflow_hidden(),
2529 range,
2530 markdown_end,
2531 );
2532 }
2533 MarkdownTag::TableHead => {
2534 builder.table.start_head();
2535 builder.push_text_style(TextStyleRefinement {
2536 font_weight: Some(FontWeight::SEMIBOLD),
2537 ..Default::default()
2538 });
2539 }
2540 MarkdownTag::TableRow => {
2541 builder.table.start_row();
2542 }
2543 MarkdownTag::TableCell => {
2544 let is_header = builder.table.in_head;
2545 let row_index = builder.table.row_index;
2546 let col_index = builder.table.col_index;
2547 let alignment = builder.table.current_cell_alignment();
2548 let text_align = alignment
2549 .and_then(alignment_to_text_align)
2550 .unwrap_or(self.style.base_text_style.text_align);
2551
2552 let mut cell_div = div()
2553 .flex()
2554 .flex_col()
2555 .h_full()
2556 .when(col_index > 0, |this| this.border_l_1())
2557 .when(row_index > 0, |this| this.border_t_1())
2558 .border_color(cx.theme().colors().border)
2559 .px_1()
2560 .py_0p5()
2561 .when(is_header, |this| {
2562 this.bg(cx.theme().colors().title_bar_background)
2563 })
2564 .when(!is_header && row_index % 2 == 1, |this| {
2565 this.bg(cx.theme().colors().panel_background)
2566 });
2567
2568 cell_div = match alignment {
2569 Some(Alignment::Center) => cell_div.items_center(),
2570 Some(Alignment::Right) => cell_div.items_end(),
2571 _ => cell_div,
2572 };
2573
2574 builder.push_text_style(TextStyleRefinement {
2575 text_align: Some(text_align),
2576 ..Default::default()
2577 });
2578 builder.push_div(cell_div, range, markdown_end);
2579 builder.push_div(
2580 div()
2581 .flex()
2582 .flex_col()
2583 .flex_1()
2584 .w_full()
2585 .justify_center()
2586 .text_align(text_align),
2587 range,
2588 markdown_end,
2589 );
2590 }
2591 _ => log::debug!("unsupported markdown tag {:?}", tag),
2592 }
2593 }
2594 MarkdownEvent::End(tag) => match tag {
2595 MarkdownTagEnd::Image => {
2596 current_img_block_range.take();
2597 }
2598 MarkdownTagEnd::Paragraph => {
2599 self.pop_markdown_paragraph(&mut builder);
2600 }
2601 MarkdownTagEnd::Heading(_) => {
2602 self.pop_markdown_heading(&mut builder);
2603 }
2604 MarkdownTagEnd::BlockQuote(_kind) => {
2605 self.pop_markdown_block_quote(&mut builder);
2606 }
2607 MarkdownTagEnd::CodeBlock => {
2608 builder.trim_trailing_newline();
2609
2610 builder.pop_div();
2611 builder.pop_code_block();
2612 builder.pop_text_style();
2613
2614 if let CodeBlockRenderer::Default {
2615 copy_button_visibility,
2616 wrap_button_visibility,
2617 ..
2618 } = &self.code_block_renderer
2619 && (*copy_button_visibility != CopyButtonVisibility::Hidden
2620 || *wrap_button_visibility != WrapButtonVisibility::Hidden)
2621 {
2622 let copy_button_visibility = *copy_button_visibility;
2623 let wrap_button_visibility = *wrap_button_visibility;
2624 builder.modify_current_div(|el| {
2625 let content_range = parser::extract_code_block_content_range(
2626 &parsed_markdown.source()[range.clone()],
2627 );
2628 let content_range = content_range.start + range.start
2629 ..content_range.end + range.start;
2630
2631 let code = parsed_markdown.source()[content_range].to_string();
2632
2633 let any_hover = copy_button_visibility
2634 == CopyButtonVisibility::VisibleOnHover
2635 || wrap_button_visibility
2636 == WrapButtonVisibility::VisibleOnHover;
2637 let any_always = copy_button_visibility
2638 == CopyButtonVisibility::AlwaysVisible
2639 || wrap_button_visibility
2640 == WrapButtonVisibility::AlwaysVisible;
2641 let use_hover = any_hover && !any_always;
2642
2643 let button_row = h_flex()
2644 .gap_0p5()
2645 .absolute()
2646 .bg(cx.theme().colors().editor_background)
2647 .when_else(
2648 use_hover,
2649 |this| {
2650 this.top_1().right_1().visible_on_hover("code_block")
2651 },
2652 |this| this.top_1p5().right_1p5(),
2653 )
2654 .when(
2655 wrap_button_visibility != WrapButtonVisibility::Hidden,
2656 |this| {
2657 let is_wrapped = self
2658 .markdown
2659 .read(cx)
2660 .is_code_block_wrapped(range.start);
2661
2662 this.child(render_wrap_code_block_button(
2663 range.start,
2664 is_wrapped,
2665 self.markdown.clone(),
2666 ))
2667 },
2668 )
2669 .when(
2670 copy_button_visibility != CopyButtonVisibility::Hidden,
2671 |this| {
2672 this.child(render_copy_code_block_button(
2673 range.end,
2674 code,
2675 self.markdown.clone(),
2676 ))
2677 },
2678 );
2679
2680 el.child(button_row)
2681 });
2682 }
2683
2684 // Pop the parent container.
2685 builder.pop_div();
2686 }
2687 MarkdownTagEnd::HtmlBlock => builder.pop_div(),
2688 MarkdownTagEnd::List(_) => {
2689 builder.pop_list();
2690 builder.pop_div();
2691 }
2692 MarkdownTagEnd::Item => {
2693 self.pop_markdown_list_item(&mut builder);
2694 }
2695 MarkdownTagEnd::Emphasis => builder.pop_text_style(),
2696 MarkdownTagEnd::Strong => builder.pop_text_style(),
2697 MarkdownTagEnd::Strikethrough => builder.pop_text_style(),
2698 MarkdownTagEnd::Link => {
2699 if builder.code_block_stack.is_empty() {
2700 builder.link_depth = builder.link_depth.saturating_sub(1);
2701 builder.pop_text_style()
2702 }
2703 }
2704 MarkdownTagEnd::Table => {
2705 builder.pop_div();
2706 builder.table.end();
2707 }
2708 MarkdownTagEnd::TableHead => {
2709 builder.pop_text_style();
2710 builder.table.end_head();
2711 }
2712 MarkdownTagEnd::TableRow => {
2713 builder.table.end_row();
2714 }
2715 MarkdownTagEnd::TableCell => {
2716 builder.replace_pending_checkbox(self.on_checkbox_toggle.clone());
2717 builder.pop_div();
2718 builder.pop_div();
2719 builder.pop_text_style();
2720 builder.table.end_cell();
2721 }
2722 MarkdownTagEnd::FootnoteDefinition => {
2723 builder.pop_div();
2724 builder.pop_div();
2725 }
2726 MarkdownTagEnd::MetadataBlock(_) => {}
2727 _ => log::debug!("unsupported markdown tag end: {:?}", tag),
2728 },
2729 MarkdownEvent::Text => {
2730 builder.push_text(&parsed_markdown.source[range.clone()], range.clone());
2731 }
2732 MarkdownEvent::SubstitutedText(text) => {
2733 builder.push_text(text, range.clone());
2734 }
2735 MarkdownEvent::Code => {
2736 self.push_markdown_code_span(
2737 &mut builder,
2738 &parsed_markdown.source[range.clone()],
2739 range.clone(),
2740 cx,
2741 );
2742 }
2743 MarkdownEvent::SubstitutedCode(text) => {
2744 self.push_markdown_code_span(&mut builder, text, range.clone(), cx);
2745 }
2746 MarkdownEvent::Html => {
2747 let html = &parsed_markdown.source[range.clone()];
2748 if html.starts_with("<!--") {
2749 builder.html_comment = true;
2750 }
2751 if html.trim_end().ends_with("-->") {
2752 builder.html_comment = false;
2753 continue;
2754 }
2755 if builder.html_comment {
2756 continue;
2757 }
2758 builder.push_text(html, range.clone());
2759 }
2760 MarkdownEvent::InlineHtml => {
2761 let html = &parsed_markdown.source[range.clone()];
2762 if let Some(code) = html
2763 .strip_prefix("<code>")
2764 .and_then(|html| html.strip_suffix("</code>"))
2765 {
2766 let code_start = range.start + "<code>".len();
2767 self.push_markdown_code_span(
2768 &mut builder,
2769 code,
2770 code_start..code_start + code.len(),
2771 cx,
2772 );
2773 continue;
2774 }
2775 if html.starts_with("<code>") {
2776 builder.push_text_style(self.style.inline_code.clone());
2777 continue;
2778 }
2779 if html.trim_end().starts_with("</code>") {
2780 builder.pop_text_style();
2781 continue;
2782 }
2783 builder.push_text(&parsed_markdown.source[range.clone()], range.clone());
2784 }
2785 MarkdownEvent::Rule => {
2786 builder.push_div(
2787 div()
2788 .border_b_1()
2789 .my_2()
2790 .border_color(self.style.rule_color),
2791 range,
2792 markdown_end,
2793 );
2794 builder.pop_div()
2795 }
2796 MarkdownEvent::SoftBreak if !self.style.soft_break_as_hard_break => {
2797 builder.push_soft_break(range.clone());
2798 }
2799 MarkdownEvent::SoftBreak | MarkdownEvent::HardBreak => {
2800 builder.push_line_break(range.clone());
2801 }
2802 MarkdownEvent::TaskListMarker(_) => {
2803 // handled inside the `MarkdownTag::Item` case
2804 }
2805 MarkdownEvent::FootnoteReference(label) => {
2806 builder.push_footnote_ref(label.clone(), range.clone());
2807 builder.push_text_style(self.style.link.clone());
2808 builder.push_text(&format!("[{label}]"), range.clone());
2809 builder.pop_text_style();
2810 }
2811 }
2812 }
2813 if self.style.code_block_overflow_x_scroll {
2814 let code_block_ids = code_block_ids;
2815 self.markdown.update(cx, move |markdown, _| {
2816 markdown.retain_code_block_scroll_handles(&code_block_ids);
2817 });
2818 } else {
2819 self.markdown
2820 .update(cx, |markdown, _| markdown.clear_code_block_scroll_handles());
2821 }
2822 let mut rendered_markdown = builder.build();
2823 let child_layout_id = rendered_markdown.element.request_layout(window, cx);
2824 let layout_id = window.request_layout(gpui::Style::default(), [child_layout_id], cx);
2825 (layout_id, rendered_markdown)
2826 }
2827
2828 fn prepaint(
2829 &mut self,
2830 _id: Option<&GlobalElementId>,
2831 _inspector_id: Option<&gpui::InspectorElementId>,
2832 bounds: Bounds<Pixels>,
2833 rendered_markdown: &mut Self::RequestLayoutState,
2834 window: &mut Window,
2835 cx: &mut App,
2836 ) -> Self::PrepaintState {
2837 let focus_handle = self.markdown.read(cx).focus_handle.clone();
2838 window.set_focus_handle(&focus_handle, cx);
2839 window.set_view_id(self.markdown.entity_id());
2840
2841 let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal);
2842 rendered_markdown.element.prepaint(window, cx);
2843 self.autoscroll(&rendered_markdown.text, window, cx);
2844 hitbox
2845 }
2846
2847 fn paint(
2848 &mut self,
2849 _id: Option<&GlobalElementId>,
2850 _inspector_id: Option<&gpui::InspectorElementId>,
2851 _bounds: Bounds<Pixels>,
2852 rendered_markdown: &mut Self::RequestLayoutState,
2853 hitbox: &mut Self::PrepaintState,
2854 window: &mut Window,
2855 cx: &mut App,
2856 ) {
2857 let mut context = KeyContext::default();
2858 context.add("Markdown");
2859 window.set_key_context(context);
2860 window.on_action(std::any::TypeId::of::<crate::Copy>(), {
2861 let entity = self.markdown.clone();
2862 let text = rendered_markdown.text.clone();
2863 move |_, phase, window, cx| {
2864 let text = text.clone();
2865 if phase == DispatchPhase::Bubble {
2866 entity.update(cx, move |this, cx| this.copy(&text, window, cx))
2867 }
2868 }
2869 });
2870 window.on_action(std::any::TypeId::of::<crate::CopyAsMarkdown>(), {
2871 let entity = self.markdown.clone();
2872 move |_, phase, window, cx| {
2873 if phase == DispatchPhase::Bubble {
2874 entity.update(cx, move |this, cx| this.copy_as_markdown(window, cx))
2875 }
2876 }
2877 });
2878
2879 self.paint_mouse_listeners(hitbox, &rendered_markdown.text, window, cx);
2880 rendered_markdown.element.paint(window, cx);
2881 self.paint_search_highlights(&rendered_markdown.text, window, cx);
2882 self.paint_selection(&rendered_markdown.text, window, cx);
2883 }
2884}
2885
2886fn collect_image_alt_text(
2887 events_from_image_start: &[(Range<usize>, MarkdownEvent)],
2888 source: &str,
2889) -> Option<SharedString> {
2890 let mut alt_text = String::new();
2891 for (range, event) in events_from_image_start.iter().skip(1) {
2892 match event {
2893 MarkdownEvent::End(MarkdownTagEnd::Image) => break,
2894 MarkdownEvent::Text => alt_text.push_str(&source[range.clone()]),
2895 _ => {}
2896 }
2897 }
2898 if alt_text.is_empty() {
2899 None
2900 } else {
2901 Some(alt_text.into())
2902 }
2903}
2904
2905fn image_fallback_element(
2906 dest_url: SharedString,
2907 alt_text: Option<SharedString>,
2908 open_image_url_on_click: bool,
2909) -> AnyElement {
2910 let link_label = alt_text
2911 .filter(|alt| !alt.is_empty())
2912 .unwrap_or_else(|| dest_url.clone());
2913
2914 let label = format!("Failed to Load: {link_label}");
2915
2916 div()
2917 .id("image-fallback")
2918 .min_w_0()
2919 .child(Label::new(label).color(Color::Warning).underline())
2920 .tooltip(Tooltip::text(
2921 "Image failed to load. Open `workspace: open log` for more details.",
2922 ))
2923 .when(open_image_url_on_click, |this| {
2924 this.cursor_pointer()
2925 .on_click(move |_, _, cx| cx.open_url(&dest_url))
2926 })
2927 .into_any_element()
2928}
2929
2930fn apply_heading_style(
2931 mut heading: Div,
2932 level: pulldown_cmark::HeadingLevel,
2933 custom_styles: Option<&HeadingLevelStyles>,
2934 border_color: Option<Hsla>,
2935) -> Div {
2936 heading = match level {
2937 pulldown_cmark::HeadingLevel::H1 => heading.text_3xl(),
2938 pulldown_cmark::HeadingLevel::H2 => heading.text_2xl(),
2939 pulldown_cmark::HeadingLevel::H3 => heading.text_xl(),
2940 pulldown_cmark::HeadingLevel::H4 => heading.text_lg(),
2941 pulldown_cmark::HeadingLevel::H5 => heading.text_base(),
2942 pulldown_cmark::HeadingLevel::H6 => heading.text_sm(),
2943 };
2944
2945 heading = match level {
2946 pulldown_cmark::HeadingLevel::H1 => heading,
2947 _ => heading.mt_6(),
2948 };
2949
2950 if let Some(border_color) = border_color
2951 && matches!(
2952 level,
2953 pulldown_cmark::HeadingLevel::H1
2954 | pulldown_cmark::HeadingLevel::H2
2955 | pulldown_cmark::HeadingLevel::H3
2956 )
2957 {
2958 heading = heading.pb_1().border_b_1().border_color(border_color);
2959 }
2960
2961 if let Some(styles) = custom_styles {
2962 let style_opt = match level {
2963 pulldown_cmark::HeadingLevel::H1 => &styles.h1,
2964 pulldown_cmark::HeadingLevel::H2 => &styles.h2,
2965 pulldown_cmark::HeadingLevel::H3 => &styles.h3,
2966 pulldown_cmark::HeadingLevel::H4 => &styles.h4,
2967 pulldown_cmark::HeadingLevel::H5 => &styles.h5,
2968 pulldown_cmark::HeadingLevel::H6 => &styles.h6,
2969 };
2970
2971 if let Some(style) = style_opt {
2972 heading.style().text = style.clone();
2973 }
2974 }
2975
2976 heading
2977}
2978
2979fn render_wrap_code_block_button(
2980 id: usize,
2981 is_wrapped: bool,
2982 markdown: Entity<Markdown>,
2983) -> impl IntoElement {
2984 let (icon, tooltip) = if is_wrapped {
2985 (IconName::TextUnwrap, "Unwrap Content")
2986 } else {
2987 (IconName::TextWrap, "Wrap Content")
2988 };
2989 let button_id = ElementId::NamedChild(
2990 Arc::new(ElementId::from(("wrap-code-block", markdown.entity_id()))),
2991 id.to_string().into(),
2992 );
2993
2994 IconButton::new(button_id, icon)
2995 .icon_size(IconSize::Small)
2996 .icon_color(Color::Muted)
2997 .tooltip(Tooltip::text(tooltip))
2998 .on_click(move |_event, _window, cx| {
2999 markdown.update(cx, |markdown, cx| {
3000 markdown.toggle_code_block_wrap(id);
3001 cx.notify();
3002 });
3003 })
3004}
3005
3006fn render_copy_code_block_button(
3007 id: usize,
3008 code: String,
3009 markdown: Entity<Markdown>,
3010) -> impl IntoElement {
3011 let id = ElementId::NamedChild(
3012 Arc::new(ElementId::from((
3013 "copy-markdown-code",
3014 markdown.entity_id(),
3015 ))),
3016 id.to_string().into(),
3017 );
3018
3019 CopyButton::new(id.clone(), code.clone()).custom_on_click({
3020 let markdown = markdown;
3021 move |_window, cx| {
3022 let id = id.clone();
3023 markdown.update(cx, |this, cx| {
3024 this.copied_code_blocks.insert(id.clone());
3025
3026 cx.write_to_clipboard(ClipboardItem::new_string(code.clone()));
3027
3028 cx.spawn(async move |this, cx| {
3029 cx.background_executor().timer(Duration::from_secs(2)).await;
3030
3031 cx.update(|cx| {
3032 this.update(cx, |this, cx| {
3033 this.copied_code_blocks.remove(&id);
3034 cx.notify();
3035 })
3036 })
3037 .ok();
3038 })
3039 .detach();
3040 });
3041 }
3042 })
3043}
3044
3045impl IntoElement for MarkdownElement {
3046 type Element = Self;
3047
3048 fn into_element(self) -> Self::Element {
3049 self
3050 }
3051}
3052
3053pub enum AnyDiv {
3054 Div(Div),
3055 Stateful(Stateful<Div>),
3056}
3057
3058impl AnyDiv {
3059 fn into_any_element(self) -> AnyElement {
3060 match self {
3061 Self::Div(div) => div.into_any_element(),
3062 Self::Stateful(div) => div.into_any_element(),
3063 }
3064 }
3065}
3066
3067impl From<Div> for AnyDiv {
3068 fn from(value: Div) -> Self {
3069 Self::Div(value)
3070 }
3071}
3072
3073impl From<Stateful<Div>> for AnyDiv {
3074 fn from(value: Stateful<Div>) -> Self {
3075 Self::Stateful(value)
3076 }
3077}
3078
3079impl Styled for AnyDiv {
3080 fn style(&mut self) -> &mut StyleRefinement {
3081 match self {
3082 Self::Div(div) => div.style(),
3083 Self::Stateful(div) => div.style(),
3084 }
3085 }
3086}
3087
3088impl ParentElement for AnyDiv {
3089 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
3090 match self {
3091 Self::Div(div) => div.extend(elements),
3092 Self::Stateful(div) => div.extend(elements),
3093 }
3094 }
3095}
3096
3097#[derive(Default)]
3098struct TableState {
3099 alignments: Vec<Alignment>,
3100 in_head: bool,
3101 row_index: usize,
3102 col_index: usize,
3103}
3104
3105impl TableState {
3106 fn start(&mut self, alignments: Vec<Alignment>) {
3107 self.alignments = alignments;
3108 self.in_head = false;
3109 self.row_index = 0;
3110 self.col_index = 0;
3111 }
3112
3113 fn end(&mut self) {
3114 self.alignments.clear();
3115 self.in_head = false;
3116 self.row_index = 0;
3117 self.col_index = 0;
3118 }
3119
3120 fn start_head(&mut self) {
3121 self.in_head = true;
3122 }
3123
3124 fn end_head(&mut self) {
3125 self.in_head = false;
3126 }
3127
3128 fn start_row(&mut self) {
3129 self.col_index = 0;
3130 }
3131
3132 fn end_row(&mut self) {
3133 self.row_index += 1;
3134 }
3135
3136 fn end_cell(&mut self) {
3137 self.col_index += 1;
3138 }
3139
3140 fn current_cell_alignment(&self) -> Option<Alignment> {
3141 if self.alignments.is_empty() {
3142 return None;
3143 }
3144 if self.in_head {
3145 return Some(Alignment::Center);
3146 }
3147 self.alignments.get(self.col_index).copied()
3148 }
3149}
3150
3151fn alignment_to_text_align(alignment: Alignment) -> Option<TextAlign> {
3152 match alignment {
3153 Alignment::Left => Some(TextAlign::Left),
3154 Alignment::Center => Some(TextAlign::Center),
3155 Alignment::Right => Some(TextAlign::Right),
3156 Alignment::None => None,
3157 }
3158}
3159
3160struct MetadataCellStyle {
3161 row_index: usize,
3162 is_key: bool,
3163}
3164
3165struct MarkdownElementBuilder {
3166 div_stack: Vec<DivStackEntry>,
3167 rendered_lines: Vec<RenderedLine>,
3168 pending_line: PendingLine,
3169 rendered_links: Vec<RenderedLink>,
3170 rendered_image_links: Vec<RenderedImageLink>,
3171 rendered_footnote_refs: Vec<RenderedFootnoteRef>,
3172 current_source_index: usize,
3173 html_comment: bool,
3174 rendered_footnote_separator: bool,
3175 base_text_style: TextStyle,
3176 text_style_stack: Vec<TextStyleRefinement>,
3177 code_block_stack: Vec<Option<Arc<Language>>>,
3178 link_depth: usize,
3179 list_stack: Vec<ListStackEntry>,
3180 table: TableState,
3181 syntax_theme: Arc<SyntaxTheme>,
3182}
3183
3184struct DivStackEntry {
3185 div: AnyDiv,
3186 line_break_mode: LineBreakMode,
3187}
3188
3189#[derive(Clone, Copy, Eq, PartialEq)]
3190enum LineBreakMode {
3191 TextLayout,
3192 FlexWrap,
3193}
3194
3195impl DivStackEntry {
3196 fn new(div: impl Into<AnyDiv>) -> Self {
3197 Self {
3198 div: div.into(),
3199 line_break_mode: LineBreakMode::TextLayout,
3200 }
3201 }
3202}
3203
3204#[derive(Default)]
3205struct PendingLine {
3206 text: String,
3207 runs: Vec<TextRun>,
3208 source_mappings: Vec<SourceMapping>,
3209}
3210
3211struct ListStackEntry {
3212 bullet_index: Option<u64>,
3213}
3214
3215impl MarkdownElementBuilder {
3216 fn new(
3217 container_style: &StyleRefinement,
3218 base_text_style: TextStyle,
3219 syntax_theme: Arc<SyntaxTheme>,
3220 ) -> Self {
3221 Self {
3222 div_stack: vec![{
3223 let mut base_div = div();
3224 base_div.style().refine(container_style);
3225 DivStackEntry::new(base_div.debug_selector(|| "inner".into()))
3226 }],
3227 rendered_lines: Vec::new(),
3228 pending_line: PendingLine::default(),
3229 rendered_links: Vec::new(),
3230 rendered_image_links: Vec::new(),
3231 rendered_footnote_refs: Vec::new(),
3232 current_source_index: 0,
3233 html_comment: false,
3234 rendered_footnote_separator: false,
3235 base_text_style,
3236 text_style_stack: Vec::new(),
3237 code_block_stack: Vec::new(),
3238 link_depth: 0,
3239 list_stack: Vec::new(),
3240 table: TableState::default(),
3241 syntax_theme,
3242 }
3243 }
3244
3245 fn push_text_style(&mut self, style: TextStyleRefinement) {
3246 self.text_style_stack.push(style);
3247 }
3248
3249 fn text_style(&self) -> TextStyle {
3250 let mut style = self.base_text_style.clone();
3251 for refinement in &self.text_style_stack {
3252 style.refine(refinement);
3253 }
3254 style
3255 }
3256
3257 fn pop_text_style(&mut self) {
3258 self.text_style_stack.pop();
3259 }
3260
3261 fn push_div(&mut self, div: impl Into<AnyDiv>, range: &Range<usize>, markdown_end: usize) {
3262 let mut div = div.into();
3263 self.flush_text();
3264
3265 if range.start == 0 {
3266 // Remove the top margin on the first element.
3267 div.style().refine(&StyleRefinement {
3268 margin: gpui::EdgesRefinement {
3269 top: Some(Length::Definite(px(0.).into())),
3270 left: None,
3271 right: None,
3272 bottom: None,
3273 },
3274 ..Default::default()
3275 });
3276 }
3277
3278 if range.end == markdown_end {
3279 div.style().refine(&StyleRefinement {
3280 margin: gpui::EdgesRefinement {
3281 top: None,
3282 left: None,
3283 right: None,
3284 bottom: Some(Length::Definite(rems(0.).into())),
3285 },
3286 ..Default::default()
3287 });
3288 }
3289
3290 self.div_stack.push(DivStackEntry::new(div));
3291 }
3292
3293 fn push_root_block(&mut self, range: &Range<usize>, markdown_end: usize) {
3294 self.push_div(
3295 div().group("markdown-root-block").relative(),
3296 range,
3297 markdown_end,
3298 );
3299 self.push_div(div().pl_4(), range, markdown_end);
3300 }
3301
3302 fn push_image_child(&mut self, child: impl IntoElement) {
3303 self.modify_current_div(|el| el.flex().flex_row().flex_wrap().items_start());
3304 self.div_stack.last_mut().unwrap().line_break_mode = LineBreakMode::FlexWrap;
3305 self.append_child(child.into_any_element());
3306 }
3307
3308 fn push_line_break(&mut self, source_range: Range<usize>) {
3309 if self.uses_flex_line_breaks() {
3310 self.modify_current_div(|el| el.child(div().w_full().h_0()));
3311 } else {
3312 self.push_text("\n", source_range);
3313 }
3314 }
3315
3316 fn push_soft_break(&mut self, source_range: Range<usize>) {
3317 // A soft break right after an item in flex wrap container would otherwise
3318 // render as a stray leading space before the next wrapped item.
3319 if self.uses_flex_line_breaks() && self.pending_line.text.is_empty() {
3320 return;
3321 }
3322 self.push_text(" ", source_range);
3323 }
3324
3325 fn append_child(&mut self, child: AnyElement) {
3326 self.div_stack.last_mut().unwrap().div.extend([child]);
3327 }
3328
3329 fn uses_flex_line_breaks(&self) -> bool {
3330 self.div_stack
3331 .last()
3332 .is_some_and(|entry| entry.line_break_mode == LineBreakMode::FlexWrap)
3333 }
3334
3335 fn modify_current_div(&mut self, f: impl FnOnce(AnyDiv) -> AnyDiv) {
3336 self.flush_text();
3337 if let Some(mut entry) = self.div_stack.pop() {
3338 entry.div = f(entry.div);
3339 self.div_stack.push(entry);
3340 }
3341 }
3342
3343 fn pop_root_block(
3344 &mut self,
3345 is_active: bool,
3346 active_gutter_color: Hsla,
3347 hovered_gutter_color: Hsla,
3348 ) {
3349 self.pop_div();
3350 self.modify_current_div(|el| {
3351 el.child(
3352 div()
3353 .h_full()
3354 .w(px(4.0))
3355 .when(is_active, |this| this.bg(active_gutter_color))
3356 .group_hover("markdown-root-block", |this| {
3357 if is_active {
3358 this
3359 } else {
3360 this.bg(hovered_gutter_color)
3361 }
3362 })
3363 .rounded_xs()
3364 .absolute()
3365 .left_0()
3366 .top_0(),
3367 )
3368 });
3369 self.pop_div();
3370 }
3371
3372 fn pop_div(&mut self) {
3373 self.flush_text();
3374 let div = self.div_stack.pop().unwrap().div.into_any_element();
3375 self.append_child(div);
3376 }
3377
3378 fn push_sourced_element(&mut self, source_range: Range<usize>, element: impl Into<AnyElement>) {
3379 self.flush_text();
3380 let anchor = self.render_source_anchor(source_range);
3381 self.append_child(
3382 div()
3383 .relative()
3384 .child(anchor)
3385 .child(element.into())
3386 .into_any_element(),
3387 );
3388 }
3389
3390 fn push_list(&mut self, bullet_index: Option<u64>) {
3391 self.list_stack.push(ListStackEntry { bullet_index });
3392 }
3393
3394 fn next_bullet_index(&mut self) -> Option<u64> {
3395 self.list_stack.last_mut().and_then(|entry| {
3396 let item_index = entry.bullet_index.as_mut()?;
3397 *item_index += 1;
3398 Some(*item_index - 1)
3399 })
3400 }
3401
3402 fn pop_list(&mut self) {
3403 self.list_stack.pop();
3404 }
3405
3406 fn push_code_block(&mut self, language: Option<Arc<Language>>) {
3407 self.code_block_stack.push(language);
3408 }
3409
3410 fn pop_code_block(&mut self) {
3411 self.code_block_stack.pop();
3412 }
3413
3414 fn push_link(&mut self, destination_url: SharedString, source_range: Range<usize>) {
3415 self.rendered_links.push(RenderedLink {
3416 source_range,
3417 destination_url,
3418 });
3419 }
3420
3421 fn push_image_link(
3422 &mut self,
3423 destination_url: SharedString,
3424 bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
3425 ) {
3426 self.rendered_image_links.push(RenderedImageLink {
3427 bounds,
3428 destination_url,
3429 });
3430 }
3431
3432 fn push_footnote_ref(&mut self, label: SharedString, source_range: Range<usize>) {
3433 self.rendered_footnote_refs.push(RenderedFootnoteRef {
3434 source_range,
3435 label,
3436 });
3437 }
3438
3439 fn push_text(&mut self, text: &str, source_range: Range<usize>) {
3440 self.pending_line.source_mappings.push(SourceMapping {
3441 rendered_index: self.pending_line.text.len(),
3442 source_index: source_range.start,
3443 });
3444 self.pending_line.text.push_str(text);
3445 self.current_source_index = source_range.end;
3446
3447 // Compute the base text style once
3448 let text_style = self.text_style();
3449
3450 if let Some(Some(language)) = self.code_block_stack.last() {
3451 let mut offset = 0;
3452 for (range, highlight_id) in language.highlight_text(&Rope::from(text), 0..text.len()) {
3453 if range.start > offset {
3454 self.pending_line
3455 .runs
3456 .push(text_style.to_run(range.start - offset));
3457 }
3458
3459 let run_len = range.len();
3460 if let Some(highlight) = self.syntax_theme.get(highlight_id).cloned() {
3461 self.pending_line
3462 .runs
3463 .push(text_style.clone().highlight(highlight).to_run(run_len));
3464 } else {
3465 self.pending_line.runs.push(text_style.to_run(run_len));
3466 }
3467 offset = range.end;
3468 }
3469
3470 if offset < text.len() {
3471 self.pending_line
3472 .runs
3473 .push(text_style.to_run(text.len() - offset));
3474 }
3475 } else {
3476 self.pending_line.runs.push(text_style.to_run(text.len()));
3477 }
3478 }
3479
3480 fn trim_trailing_newline(&mut self) {
3481 if self.pending_line.text.ends_with('\n') {
3482 self.pending_line
3483 .text
3484 .truncate(self.pending_line.text.len() - 1);
3485 self.pending_line.runs.last_mut().unwrap().len -= 1;
3486 self.current_source_index -= 1;
3487 }
3488 }
3489
3490 fn replace_pending_checkbox(&mut self, on_toggle: Option<CheckboxToggleCallback>) {
3491 let text = &self.pending_line.text;
3492 let trimmed = text.trim();
3493 if trimmed != "[x]" && trimmed != "[X]" && trimmed != "[ ]" {
3494 return;
3495 }
3496 let checked = trimmed != "[ ]";
3497
3498 let leading_ws = text.len() - text.trim_start().len();
3499 let marker_rendered = leading_ws..leading_ws + trimmed.len();
3500 let marker_source = self
3501 .source_range_for_rendered(&marker_rendered)
3502 .expect("pending checkbox text must have source mappings");
3503
3504 self.pending_line = PendingLine::default();
3505
3506 let toggle_state = if checked {
3507 ToggleState::Selected
3508 } else {
3509 ToggleState::Unselected
3510 };
3511 let checkbox = Checkbox::new(
3512 ElementId::Name(
3513 format!(
3514 "table_checkbox_{}_{}",
3515 marker_source.start, marker_source.end
3516 )
3517 .into(),
3518 ),
3519 toggle_state,
3520 )
3521 .fill();
3522
3523 let checkbox = if let Some(on_toggle) = on_toggle {
3524 checkbox
3525 .on_click(move |_state, window, cx| {
3526 on_toggle(marker_source.clone(), !checked, window, cx);
3527 })
3528 .into_any_element()
3529 } else {
3530 checkbox.visualization_only(true).into_any_element()
3531 };
3532
3533 let mut checkbox_container = h_flex().w_full();
3534 checkbox_container = match self.text_style().text_align {
3535 TextAlign::Left => checkbox_container.justify_start(),
3536 TextAlign::Center => checkbox_container.justify_center(),
3537 TextAlign::Right => checkbox_container.justify_end(),
3538 };
3539
3540 self.append_child(checkbox_container.child(checkbox).into_any_element());
3541 }
3542
3543 fn source_range_for_rendered(&self, rendered: &Range<usize>) -> Option<Range<usize>> {
3544 source_range_for_rendered(&self.pending_line.source_mappings, rendered)
3545 }
3546
3547 fn render_source_anchor(&mut self, source_range: Range<usize>) -> AnyElement {
3548 let mut text_style = self.base_text_style.clone();
3549 text_style.color = Hsla::transparent_black();
3550 let text = "\u{200B}";
3551 let styled_text = StyledText::new(text).with_runs(vec![text_style.to_run(text.len())]);
3552 self.rendered_lines.push(RenderedLine {
3553 layout: styled_text.layout().clone(),
3554 source_mappings: vec![SourceMapping {
3555 rendered_index: 0,
3556 source_index: source_range.start,
3557 }],
3558 source_end: source_range.end,
3559 language: None,
3560 text_align: TextAlign::Left,
3561 });
3562 div()
3563 .absolute()
3564 .top_0()
3565 .left_0()
3566 .opacity(0.)
3567 .child(styled_text)
3568 .into_any_element()
3569 }
3570
3571 fn flush_text(&mut self) {
3572 let text_align = self.text_style().text_align;
3573 let line = mem::take(&mut self.pending_line);
3574 if line.text.is_empty() {
3575 return;
3576 }
3577
3578 let text = StyledText::new(line.text).with_runs(line.runs);
3579 self.rendered_lines.push(RenderedLine {
3580 layout: text.layout().clone(),
3581 source_mappings: line.source_mappings,
3582 source_end: self.current_source_index,
3583 language: self.code_block_stack.last().cloned().flatten(),
3584 text_align,
3585 });
3586 self.append_child(text.into_any());
3587 }
3588
3589 fn build(mut self) -> RenderedMarkdown {
3590 debug_assert_eq!(self.div_stack.len(), 1);
3591 self.flush_text();
3592 RenderedMarkdown {
3593 element: self.div_stack.pop().unwrap().div.into_any_element(),
3594 text: RenderedText {
3595 lines: self.rendered_lines.into(),
3596 links: self.rendered_links.into(),
3597 image_links: self.rendered_image_links.into(),
3598 footnote_refs: self.rendered_footnote_refs.into(),
3599 },
3600 }
3601 }
3602}
3603
3604struct RenderedLine {
3605 layout: TextLayout,
3606 source_mappings: Vec<SourceMapping>,
3607 source_end: usize,
3608 language: Option<Arc<Language>>,
3609 text_align: TextAlign,
3610}
3611
3612impl RenderedLine {
3613 fn rendered_index_for_source_index(&self, source_index: usize) -> usize {
3614 if source_index >= self.source_end {
3615 return self.layout.len();
3616 }
3617
3618 let mapping = match self
3619 .source_mappings
3620 .binary_search_by_key(&source_index, |probe| probe.source_index)
3621 {
3622 Ok(ix) => &self.source_mappings[ix],
3623 Err(ix) => &self.source_mappings[ix - 1],
3624 };
3625 (mapping.rendered_index + (source_index - mapping.source_index)).min(self.layout.len())
3626 }
3627
3628 fn source_index_for_rendered_index(&self, rendered_index: usize) -> usize {
3629 if rendered_index >= self.layout.len() {
3630 return self.source_end;
3631 }
3632
3633 let mapping = match self
3634 .source_mappings
3635 .binary_search_by_key(&rendered_index, |probe| probe.rendered_index)
3636 {
3637 Ok(ix) => &self.source_mappings[ix],
3638 Err(ix) => &self.source_mappings[ix - 1],
3639 };
3640 mapping.source_index + (rendered_index - mapping.rendered_index)
3641 }
3642
3643 /// Returns the source index for use as an exclusive range end at a word/selection boundary.
3644 /// When the rendered index is exactly at the start of a segment with a gap from the previous
3645 /// segment (e.g., after stripped markdown syntax like backticks), this returns the end of the
3646 /// previous segment rather than the start of the current one.
3647 fn source_index_for_exclusive_rendered_end(&self, rendered_index: usize) -> usize {
3648 if rendered_index >= self.layout.len() {
3649 return self.source_end;
3650 }
3651
3652 let ix = match self
3653 .source_mappings
3654 .binary_search_by_key(&rendered_index, |probe| probe.rendered_index)
3655 {
3656 Ok(ix) => ix,
3657 Err(ix) => {
3658 return self.source_mappings[ix - 1].source_index
3659 + (rendered_index - self.source_mappings[ix - 1].rendered_index);
3660 }
3661 };
3662
3663 // Exact match at the start of a segment. Check if there's a gap from the previous segment.
3664 if ix > 0 {
3665 let prev_mapping = &self.source_mappings[ix - 1];
3666 let mapping = &self.source_mappings[ix];
3667 let prev_segment_len = mapping.rendered_index - prev_mapping.rendered_index;
3668 let prev_source_end = prev_mapping.source_index + prev_segment_len;
3669 if prev_source_end < mapping.source_index {
3670 return prev_source_end;
3671 }
3672 }
3673
3674 self.source_mappings[ix].source_index
3675 }
3676
3677 fn alignment_offset_for_segment(
3678 &self,
3679 available_width: Pixels,
3680 segment_start_x: Pixels,
3681 segment_end_x: Pixels,
3682 ) -> Pixels {
3683 let segment_width = segment_end_x - segment_start_x;
3684 match self.text_align {
3685 TextAlign::Left => px(0.),
3686 TextAlign::Center => ((available_width - segment_width) / 2.).max(px(0.)),
3687 TextAlign::Right => (available_width - segment_width).max(px(0.)),
3688 }
3689 }
3690
3691 fn source_index_for_position(&self, position: Point<Pixels>) -> Result<usize, usize> {
3692 let adjusted_position = maybe!({
3693 if self.text_align == TextAlign::Left {
3694 return None;
3695 }
3696
3697 let Some(wrapped_line) = self.layout.line_layout_for_index(0) else {
3698 return None;
3699 };
3700
3701 let bounds = self.layout.bounds();
3702 let line_height = self.layout.line_height();
3703 let relative_y = (position.y - bounds.top()).max(px(0.));
3704 let wrapped_row_ix = (relative_y / line_height) as usize;
3705 let boundaries = wrapped_line.wrap_boundaries();
3706
3707 let segment_start_x = if wrapped_row_ix == 0 {
3708 px(0.)
3709 } else {
3710 boundaries
3711 .get(wrapped_row_ix - 1)
3712 .map(|b| {
3713 wrapped_line.unwrapped_layout.runs[b.run_ix].glyphs[b.glyph_ix]
3714 .position
3715 .x
3716 })
3717 .unwrap_or(px(0.))
3718 };
3719 let segment_end_x = boundaries
3720 .get(wrapped_row_ix)
3721 .map(|b| {
3722 wrapped_line.unwrapped_layout.runs[b.run_ix].glyphs[b.glyph_ix]
3723 .position
3724 .x
3725 })
3726 .unwrap_or(wrapped_line.unwrapped_layout.width);
3727
3728 let alignment_offset = self.alignment_offset_for_segment(
3729 bounds.size.width,
3730 segment_start_x,
3731 segment_end_x,
3732 );
3733 Some(point(position.x - alignment_offset, position.y))
3734 })
3735 .unwrap_or(position);
3736
3737 let line_rendered_index;
3738 let out_of_bounds;
3739 match self.layout.index_for_position(adjusted_position) {
3740 Ok(ix) => {
3741 line_rendered_index = ix;
3742 out_of_bounds = false;
3743 }
3744 Err(ix) => {
3745 line_rendered_index = ix;
3746 out_of_bounds = true;
3747 }
3748 };
3749 let source_index = self.source_index_for_rendered_index(line_rendered_index);
3750 if out_of_bounds {
3751 Err(source_index)
3752 } else {
3753 Ok(source_index)
3754 }
3755 }
3756}
3757
3758#[derive(Copy, Clone, Debug, Default)]
3759struct SourceMapping {
3760 rendered_index: usize,
3761 source_index: usize,
3762}
3763
3764fn source_range_for_rendered(
3765 mappings: &[SourceMapping],
3766 rendered: &Range<usize>,
3767) -> Option<Range<usize>> {
3768 if rendered.start >= rendered.end {
3769 return None;
3770 }
3771 let start = source_index_for_rendered(mappings, rendered.start)?;
3772 let end = source_index_for_rendered(mappings, rendered.end - 1)? + 1;
3773 Some(start..end)
3774}
3775
3776fn source_index_for_rendered(mappings: &[SourceMapping], rendered_index: usize) -> Option<usize> {
3777 let mut last: Option<&SourceMapping> = None;
3778 for mapping in mappings {
3779 if mapping.rendered_index <= rendered_index {
3780 last = Some(mapping);
3781 } else {
3782 break;
3783 }
3784 }
3785 last.map(|m| m.source_index + (rendered_index - m.rendered_index))
3786}
3787
3788pub struct RenderedMarkdown {
3789 element: AnyElement,
3790 text: RenderedText,
3791}
3792
3793#[derive(Clone)]
3794struct RenderedText {
3795 lines: Rc<[RenderedLine]>,
3796 links: Rc<[RenderedLink]>,
3797 image_links: Rc<[RenderedImageLink]>,
3798 footnote_refs: Rc<[RenderedFootnoteRef]>,
3799}
3800
3801struct WrappedLineSegment {
3802 start: usize,
3803 end: usize,
3804 row_top: Pixels,
3805 layout: Arc<WrappedLineLayout>,
3806}
3807
3808#[derive(Debug, Clone, Eq, PartialEq)]
3809struct RenderedLink {
3810 source_range: Range<usize>,
3811 destination_url: SharedString,
3812}
3813
3814#[derive(Clone)]
3815struct RenderedImageLink {
3816 // Populated once the image's `canvas` overlay is painted; images aren't part of the
3817 // text layout, so their hit-test region can't be derived from a source range.
3818 bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
3819 destination_url: SharedString,
3820}
3821
3822#[derive(Debug, Clone, Eq, PartialEq)]
3823struct RenderedFootnoteRef {
3824 source_range: Range<usize>,
3825 label: SharedString,
3826}
3827
3828impl RenderedText {
3829 fn bounds_for_source_range(&self, range: Range<usize>) -> Vec<Bounds<Pixels>> {
3830 self.bounds_for_sorted_source_ranges([(0, range)])
3831 .into_iter()
3832 .map(|(_, bounds)| bounds)
3833 .collect()
3834 }
3835
3836 fn bounds_for_sorted_source_ranges(
3837 &self,
3838 ranges: impl IntoIterator<Item = (usize, Range<usize>)>,
3839 ) -> Vec<(usize, Bounds<Pixels>)> {
3840 let ranges = ranges.into_iter().collect::<Vec<_>>();
3841 let mut all_bounds = Vec::new();
3842 let mut first_possible_range_ix = 0;
3843
3844 for line in self.lines.iter() {
3845 let line_source_start = line.source_mappings.first().unwrap().source_index;
3846 while ranges
3847 .get(first_possible_range_ix)
3848 .is_some_and(|(_, range)| range.end <= line_source_start)
3849 {
3850 first_possible_range_ix += 1;
3851 }
3852
3853 let Some((_, first_possible_range)) = ranges.get(first_possible_range_ix) else {
3854 break;
3855 };
3856 if first_possible_range.start >= line.source_end {
3857 continue;
3858 }
3859
3860 let wrapped_line_segments = Self::wrapped_line_segments(line);
3861 if wrapped_line_segments.is_empty() {
3862 continue;
3863 }
3864
3865 let mut range_ix = first_possible_range_ix;
3866 while let Some((highlight_ix, range)) = ranges.get(range_ix) {
3867 if range.start >= line.source_end {
3868 break;
3869 }
3870 Self::push_bounds_for_line_source_range(
3871 &mut all_bounds,
3872 *highlight_ix,
3873 line,
3874 &wrapped_line_segments,
3875 range.start.max(line_source_start)..range.end.min(line.source_end),
3876 );
3877 range_ix += 1;
3878 }
3879 }
3880
3881 all_bounds
3882 }
3883
3884 fn wrapped_line_segments(line: &RenderedLine) -> SmallVec<[WrappedLineSegment; 1]> {
3885 let layout = &line.layout;
3886 let line_height = layout.line_height();
3887 let mut row_top = layout.bounds().top();
3888 let mut wrapped_line_start = 0;
3889 let mut segments = SmallVec::new();
3890
3891 for wrapped_line in layout.line_layouts() {
3892 let wrapped_line_end = wrapped_line_start + wrapped_line.len();
3893 let wrapped_line_height = wrapped_line.size(line_height).height;
3894 segments.push(WrappedLineSegment {
3895 start: wrapped_line_start,
3896 end: wrapped_line_end,
3897 row_top,
3898 layout: wrapped_line,
3899 });
3900 row_top += wrapped_line_height;
3901 wrapped_line_start = wrapped_line_end + 1;
3902 }
3903
3904 segments
3905 }
3906
3907 fn push_bounds_for_line_source_range(
3908 all_bounds: &mut Vec<(usize, Bounds<Pixels>)>,
3909 highlight_ix: usize,
3910 line: &RenderedLine,
3911 wrapped_line_segments: &[WrappedLineSegment],
3912 range: Range<usize>,
3913 ) {
3914 if range.start >= range.end {
3915 return;
3916 }
3917
3918 let layout = &line.layout;
3919 let line_bounds = layout.bounds();
3920 let line_height = layout.line_height();
3921
3922 let rendered_start = line.rendered_index_for_source_index(range.start);
3923 let rendered_end = line.rendered_index_for_source_index(range.end);
3924
3925 for wrapped_line_segment in wrapped_line_segments {
3926 if wrapped_line_segment.start >= rendered_end {
3927 break;
3928 }
3929 if wrapped_line_segment.end <= rendered_start {
3930 continue;
3931 }
3932
3933 let wrapped_line = &wrapped_line_segment.layout;
3934 let unwrapped_layout = &wrapped_line.unwrapped_layout;
3935 let wrapped_line_start = wrapped_line_segment.start;
3936 let wrapped_line_end = wrapped_line_segment.end;
3937 let mut row_top = wrapped_line_segment.row_top;
3938
3939 let row_ends = wrapped_line
3940 .wrap_boundaries()
3941 .iter()
3942 .map(|wrap_boundary| {
3943 let glyph =
3944 &unwrapped_layout.runs[wrap_boundary.run_ix].glyphs[wrap_boundary.glyph_ix];
3945 (wrapped_line_start + glyph.index, glyph.position.x)
3946 })
3947 .chain([(wrapped_line_end, unwrapped_layout.width)]);
3948
3949 let mut row_start = wrapped_line_start;
3950 let mut row_start_x = Pixels::ZERO;
3951
3952 for (row_end, row_end_x) in row_ends {
3953 let selection_start = rendered_start.max(row_start);
3954 let selection_end = rendered_end.min(row_end);
3955
3956 if selection_start < selection_end {
3957 let alignment_offset = line.alignment_offset_for_segment(
3958 line_bounds.size.width,
3959 row_start_x,
3960 row_end_x,
3961 );
3962 let x_for_index = |index| {
3963 line_bounds.left()
3964 + alignment_offset
3965 + unwrapped_layout.x_for_index(index - wrapped_line_start)
3966 - row_start_x
3967 };
3968 all_bounds.push((
3969 highlight_ix,
3970 Bounds::from_corners(
3971 point(x_for_index(selection_start), row_top),
3972 point(x_for_index(selection_end), row_top + line_height),
3973 ),
3974 ));
3975 }
3976
3977 row_start = row_end;
3978 row_start_x = row_end_x;
3979 row_top += line_height;
3980 }
3981 }
3982 }
3983
3984 fn source_index_for_position(&self, position: Point<Pixels>) -> Result<usize, usize> {
3985 let mut lines = self.lines.iter().peekable();
3986 let mut fallback_line: Option<&RenderedLine> = None;
3987
3988 while let Some(line) = lines.next() {
3989 let line_bounds = line.layout.bounds();
3990
3991 // Exact match: position is within bounds (handles overlapping bounds like table columns)
3992 if line_bounds.contains(&position) {
3993 return line.source_index_for_position(position);
3994 }
3995
3996 // Track fallback for Y-coordinate based matching
3997 if position.y <= line_bounds.bottom() && fallback_line.is_none() {
3998 fallback_line = Some(line);
3999 }
4000
4001 // Handle gap between lines
4002 if position.y > line_bounds.bottom() {
4003 if let Some(next_line) = lines.peek()
4004 && position.y < next_line.layout.bounds().top()
4005 {
4006 return Err(line.source_end);
4007 }
4008 }
4009 }
4010
4011 // Fall back to Y-coordinate matched line
4012 if let Some(line) = fallback_line {
4013 return line.source_index_for_position(position);
4014 }
4015
4016 Err(self.lines.last().map_or(0, |line| line.source_end))
4017 }
4018
4019 fn position_for_source_index(&self, source_index: usize) -> Option<(Point<Pixels>, Pixels)> {
4020 for line in self.lines.iter() {
4021 let line_source_start = line.source_mappings.first().unwrap().source_index;
4022 if source_index < line_source_start {
4023 break;
4024 } else if source_index > line.source_end {
4025 continue;
4026 } else {
4027 let line_height = line.layout.line_height();
4028 let rendered_index_within_line = line.rendered_index_for_source_index(source_index);
4029 let position = line.layout.position_for_index(rendered_index_within_line)?;
4030 return Some((position, line_height));
4031 }
4032 }
4033 None
4034 }
4035
4036 fn surrounding_word_range(&self, source_index: usize) -> Range<usize> {
4037 for line in self.lines.iter() {
4038 if source_index > line.source_end {
4039 continue;
4040 }
4041
4042 let line_rendered_start = line.source_mappings.first().unwrap().rendered_index;
4043 let rendered_index_in_line =
4044 line.rendered_index_for_source_index(source_index) - line_rendered_start;
4045 let text = line.layout.text();
4046
4047 let scope = line.language.as_ref().map(|l| l.default_scope());
4048 let classifier = CharClassifier::new(scope);
4049
4050 let mut prev_chars = text[..rendered_index_in_line].chars().rev().peekable();
4051 let mut next_chars = text[rendered_index_in_line..].chars().peekable();
4052
4053 let word_kind = std::cmp::max(
4054 prev_chars.peek().map(|&c| classifier.kind(c)),
4055 next_chars.peek().map(|&c| classifier.kind(c)),
4056 );
4057
4058 let mut start = rendered_index_in_line;
4059 for c in prev_chars {
4060 if Some(classifier.kind(c)) == word_kind {
4061 start -= c.len_utf8();
4062 } else {
4063 break;
4064 }
4065 }
4066
4067 let mut end = rendered_index_in_line;
4068 for c in next_chars {
4069 if Some(classifier.kind(c)) == word_kind {
4070 end += c.len_utf8();
4071 } else {
4072 break;
4073 }
4074 }
4075
4076 return line.source_index_for_rendered_index(line_rendered_start + start)
4077 ..line.source_index_for_exclusive_rendered_end(line_rendered_start + end);
4078 }
4079
4080 source_index..source_index
4081 }
4082
4083 fn surrounding_line_range(&self, source_index: usize) -> Range<usize> {
4084 for line in self.lines.iter() {
4085 if source_index > line.source_end {
4086 continue;
4087 }
4088 let line_source_start = line.source_mappings.first().unwrap().source_index;
4089 return line_source_start..line.source_end;
4090 }
4091
4092 source_index..source_index
4093 }
4094
4095 fn text_for_range(&self, range: Range<usize>) -> String {
4096 let mut accumulator = String::new();
4097
4098 for line in self.lines.iter() {
4099 if range.start > line.source_end {
4100 continue;
4101 }
4102 let line_source_start = line.source_mappings.first().unwrap().source_index;
4103 if range.end < line_source_start {
4104 break;
4105 }
4106
4107 let text = line.layout.text();
4108
4109 let start = if range.start < line_source_start {
4110 0
4111 } else {
4112 line.rendered_index_for_source_index(range.start)
4113 };
4114 let end = if range.end > line.source_end {
4115 line.rendered_index_for_source_index(line.source_end)
4116 } else {
4117 line.rendered_index_for_source_index(range.end)
4118 }
4119 .min(text.len());
4120
4121 accumulator.push_str(&text[start..end]);
4122 accumulator.push('\n');
4123 }
4124 // Remove trailing newline
4125 accumulator.pop();
4126 accumulator
4127 }
4128
4129 fn link_for_source_index(&self, source_index: usize) -> Option<&RenderedLink> {
4130 self.links
4131 .iter()
4132 .find(|link| link.source_range.contains(&source_index))
4133 }
4134
4135 fn image_link_for_position(&self, position: Point<Pixels>) -> Option<&RenderedImageLink> {
4136 self.image_links.iter().find(|image| {
4137 image
4138 .bounds
4139 .get()
4140 .is_some_and(|bounds| bounds.contains(&position))
4141 })
4142 }
4143
4144 fn footnote_ref_for_source_index(&self, source_index: usize) -> Option<&RenderedFootnoteRef> {
4145 self.footnote_refs
4146 .iter()
4147 .find(|fref| fref.source_range.contains(&source_index))
4148 }
4149}
4150
4151#[cfg(test)]
4152mod tests {
4153 use super::*;
4154 use gpui::{RenderImage, TestAppContext, UpdateGlobal, size};
4155 use language::{Language, LanguageConfig, LanguageMatcher};
4156 use std::cell::RefCell;
4157 use std::sync::{
4158 Arc,
4159 atomic::{AtomicUsize, Ordering},
4160 };
4161
4162 struct TestWindow;
4163
4164 impl Render for TestWindow {
4165 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
4166 div()
4167 }
4168 }
4169
4170 fn ensure_theme_initialized(cx: &mut TestAppContext) {
4171 cx.update(|cx| {
4172 if !cx.has_global::<settings::SettingsStore>() {
4173 settings::init(cx);
4174 }
4175 if !cx.has_global::<theme::GlobalTheme>() {
4176 theme_settings::init(theme::LoadThemes::JustBase, cx);
4177 }
4178 });
4179 }
4180
4181 #[gpui::test]
4182 fn test_code_block_controls_are_unique_across_markdown_entities(cx: &mut TestAppContext) {
4183 struct TestWindow;
4184
4185 impl Render for TestWindow {
4186 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
4187 div()
4188 }
4189 }
4190
4191 struct TestMarkdowns {
4192 first_markdown: Entity<Markdown>,
4193 second_markdown: Entity<Markdown>,
4194 }
4195
4196 impl Render for TestMarkdowns {
4197 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
4198 div()
4199 .child(MarkdownElement::new(
4200 self.first_markdown.clone(),
4201 MarkdownStyle::default(),
4202 ))
4203 .child(MarkdownElement::new(
4204 self.second_markdown.clone(),
4205 MarkdownStyle::default(),
4206 ))
4207 }
4208 }
4209
4210 ensure_theme_initialized(cx);
4211
4212 let (_, cx) = cx.add_window_view(|_, _| TestWindow);
4213 let markdown = "```sh\necho hello\n```";
4214 let first_markdown = cx.new(|cx| Markdown::new(markdown.into(), None, None, cx));
4215 let second_markdown = cx.new(|cx| Markdown::new(markdown.into(), None, None, cx));
4216 cx.run_until_parked();
4217
4218 cx.draw(Default::default(), size(px(600.0), px(600.0)), |_, cx| {
4219 cx.new(|_| TestMarkdowns {
4220 first_markdown: first_markdown.clone(),
4221 second_markdown: second_markdown.clone(),
4222 })
4223 .into_any_element()
4224 });
4225 }
4226
4227 #[gpui::test]
4228 fn test_mappings(cx: &mut TestAppContext) {
4229 // Formatting.
4230 assert_mappings(
4231 &render_markdown("He*l*lo", cx),
4232 vec![vec![(0, 0), (1, 1), (2, 3), (3, 5), (4, 6), (5, 7)]],
4233 );
4234
4235 // Multiple lines.
4236 assert_mappings(
4237 &render_markdown("Hello\n\nWorld", cx),
4238 vec![
4239 vec![(0, 0), (1, 1), (2, 2), (3, 3), (4, 4), (5, 5)],
4240 vec![(0, 7), (1, 8), (2, 9), (3, 10), (4, 11), (5, 12)],
4241 ],
4242 );
4243
4244 // Multi-byte characters.
4245 assert_mappings(
4246 &render_markdown("αβγ\n\nδεζ", cx),
4247 vec![
4248 vec![(0, 0), (2, 2), (4, 4), (6, 6)],
4249 vec![(0, 8), (2, 10), (4, 12), (6, 14)],
4250 ],
4251 );
4252
4253 // Smart quotes.
4254 assert_mappings(&render_markdown("\"", cx), vec![vec![(0, 0), (3, 1)]]);
4255 assert_mappings(
4256 &render_markdown("\"hey\"", cx),
4257 vec![vec![(0, 0), (3, 1), (4, 2), (5, 3), (6, 4), (9, 5)]],
4258 );
4259
4260 // HTML Comments are ignored
4261 assert_mappings(
4262 &render_markdown(
4263 "<!--\nrdoc-file=string.c\n- str.intern -> symbol\n- str.to_sym -> symbol\n-->\nReturns",
4264 cx,
4265 ),
4266 vec![vec![
4267 (0, 78),
4268 (1, 79),
4269 (2, 80),
4270 (3, 81),
4271 (4, 82),
4272 (5, 83),
4273 (6, 84),
4274 ]],
4275 );
4276 }
4277
4278 fn render_markdown(markdown: &str, cx: &mut TestAppContext) -> RenderedText {
4279 render_markdown_with_language_registry(markdown, None, cx)
4280 }
4281
4282 #[gpui::test]
4283 fn test_active_search_highlight_uses_match_index(cx: &mut TestAppContext) {
4284 let markdown = cx.new(|cx| Markdown::new("zero one two".into(), None, None, cx));
4285
4286 markdown.update(cx, |markdown, cx| {
4287 markdown.set_search_highlights(vec![0..4, 5..8, 9..12], Some(0), cx);
4288 assert_eq!(markdown.search_highlights(), &[0..4, 5..8, 9..12]);
4289 assert_eq!(markdown.active_search_highlight(), Some(0));
4290
4291 markdown.set_active_search_highlight(Some(1), cx);
4292 assert_eq!(markdown.active_search_highlight(), Some(1));
4293
4294 markdown.set_active_search_highlight(Some(2), cx);
4295 assert_eq!(markdown.active_search_highlight(), Some(2));
4296
4297 markdown.set_active_search_highlight(Some(3), cx);
4298 assert_eq!(markdown.active_search_highlight(), None);
4299 });
4300 }
4301
4302 #[gpui::test]
4303 fn test_wrapped_code_block_has_no_scroll_handle(cx: &mut TestAppContext) {
4304 let markdown =
4305 cx.new(|cx| Markdown::new("```rust\nlet value = 1;\n```".into(), None, None, cx));
4306
4307 markdown.update(cx, |markdown, _| {
4308 assert!(markdown.code_block_scroll_handle(0).is_some());
4309
4310 markdown.toggle_code_block_wrap(0);
4311 assert!(markdown.code_block_scroll_handle(0).is_none());
4312
4313 markdown.toggle_code_block_wrap(0);
4314 assert!(markdown.code_block_scroll_handle(0).is_some());
4315 });
4316 }
4317
4318 #[gpui::test]
4319 fn test_frontmatter_renders_without_delimiters(cx: &mut TestAppContext) {
4320 let rendered = render_markdown_with_options(
4321 "---\ntitle: Post\n---\nBody",
4322 None,
4323 MarkdownOptions {
4324 render_metadata_blocks: true,
4325 ..Default::default()
4326 },
4327 cx,
4328 );
4329 assert_eq!(rendered.text_for_range(0..24), "title\nPost\nBody");
4330 }
4331
4332 #[gpui::test]
4333 fn test_frontmatter_falls_back_to_code_block_for_nested_yaml(cx: &mut TestAppContext) {
4334 let rendered = render_markdown_with_options(
4335 "---\ntags:\n - zed\n---\nBody",
4336 None,
4337 MarkdownOptions {
4338 render_metadata_blocks: true,
4339 ..Default::default()
4340 },
4341 cx,
4342 );
4343 assert_eq!(rendered.text_for_range(0..26), "tags:\n - zed\nBody");
4344 }
4345
4346 fn render_markdown_with_code_span_link(
4347 markdown: &str,
4348 callback: impl Fn(&str, &App) -> Option<SharedString> + 'static,
4349 cx: &mut TestAppContext,
4350 ) -> RenderedText {
4351 render_markdown_with_code_span_link_style(markdown, MarkdownStyle::default(), callback, cx)
4352 }
4353
4354 fn render_markdown_with_code_span_link_style(
4355 markdown: &str,
4356 style: MarkdownStyle,
4357 callback: impl Fn(&str, &App) -> Option<SharedString> + 'static,
4358 cx: &mut TestAppContext,
4359 ) -> RenderedText {
4360 ensure_theme_initialized(cx);
4361
4362 let (_, cx) = cx.add_window_view(|_, _| TestWindow);
4363 let markdown = cx.new(|cx| Markdown::new(markdown.to_string().into(), None, None, cx));
4364 cx.run_until_parked();
4365 let (rendered, _) = cx.draw(
4366 Default::default(),
4367 size(px(600.0), px(600.0)),
4368 |_window, _cx| {
4369 MarkdownElement::new(markdown, style)
4370 .on_code_span_link(callback)
4371 .code_block_renderer(CodeBlockRenderer::Default {
4372 copy_button_visibility: CopyButtonVisibility::Hidden,
4373 wrap_button_visibility: WrapButtonVisibility::Hidden,
4374 border: false,
4375 })
4376 },
4377 );
4378 rendered.text
4379 }
4380
4381 fn render_markdown_with_language_registry(
4382 markdown: &str,
4383 language_registry: Option<Arc<LanguageRegistry>>,
4384 cx: &mut TestAppContext,
4385 ) -> RenderedText {
4386 render_markdown_with_options(markdown, language_registry, MarkdownOptions::default(), cx)
4387 }
4388
4389 fn render_markdown_with_options(
4390 markdown: &str,
4391 language_registry: Option<Arc<LanguageRegistry>>,
4392 options: MarkdownOptions,
4393 cx: &mut TestAppContext,
4394 ) -> RenderedText {
4395 ensure_theme_initialized(cx);
4396
4397 let (_, cx) = cx.add_window_view(|_, _| TestWindow);
4398 let markdown = cx.new(|cx| {
4399 Markdown::new_with_options(
4400 markdown.to_string().into(),
4401 language_registry,
4402 None,
4403 options,
4404 cx,
4405 )
4406 });
4407 cx.run_until_parked();
4408 let (rendered, _) = cx.draw(
4409 Default::default(),
4410 size(px(600.0), px(600.0)),
4411 |_window, _cx| {
4412 MarkdownElement::new(markdown, MarkdownStyle::default()).code_block_renderer(
4413 CodeBlockRenderer::Default {
4414 copy_button_visibility: CopyButtonVisibility::Hidden,
4415 wrap_button_visibility: WrapButtonVisibility::Hidden,
4416 border: false,
4417 },
4418 )
4419 },
4420 );
4421 rendered.text
4422 }
4423
4424 fn render_markdown_with_image_resolver(
4425 markdown: &str,
4426 options: MarkdownOptions,
4427 resolver: impl Fn(&str) -> Option<ImageSource> + 'static,
4428 cx: &mut TestAppContext,
4429 ) -> RenderedText {
4430 ensure_theme_initialized(cx);
4431
4432 let (_, cx) = cx.add_window_view(|_, _| TestWindow);
4433 let markdown = cx.new(|cx| {
4434 Markdown::new_with_options(markdown.to_string().into(), None, None, options, cx)
4435 });
4436 cx.run_until_parked();
4437 let (rendered, _) = cx.draw(
4438 Default::default(),
4439 size(px(600.0), px(600.0)),
4440 |_window, _cx| {
4441 MarkdownElement::new(markdown, MarkdownStyle::default())
4442 .image_resolver(resolver)
4443 .code_block_renderer(CodeBlockRenderer::Default {
4444 copy_button_visibility: CopyButtonVisibility::Hidden,
4445 wrap_button_visibility: WrapButtonVisibility::Hidden,
4446 border: false,
4447 })
4448 },
4449 );
4450 rendered.text
4451 }
4452
4453 fn test_image(cx: &mut TestAppContext) -> Arc<RenderImage> {
4454 cx.update(|cx| {
4455 cx.svg_renderer()
4456 .render_single_frame(
4457 br#"<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"></svg>"#,
4458 1.0,
4459 )
4460 .expect("test svg should render")
4461 })
4462 }
4463
4464 #[gpui::test]
4465 fn test_soft_break_keeps_space_in_paragraph_with_image(cx: &mut TestAppContext) {
4466 let image = test_image(cx);
4467 let rendered = render_markdown_with_image_resolver(
4468 "Here is an image  and more text\nthat continues",
4469 MarkdownOptions::default(),
4470 move |_| Some(ImageSource::Render(image.clone())),
4471 cx,
4472 );
4473 let text: String = rendered
4474 .lines
4475 .iter()
4476 .map(|line| line.layout.wrapped_text())
4477 .collect();
4478 assert!(
4479 text.contains("more text that continues"),
4480 "soft break in an image paragraph should still separate words with a space; got: {text:?}"
4481 );
4482 assert!(
4483 !text.contains("textthat"),
4484 "soft break between words must not be dropped; got: {text:?}"
4485 );
4486 }
4487
4488 #[gpui::test]
4489 fn test_soft_break_after_image_does_not_insert_leading_space(cx: &mut TestAppContext) {
4490 let image = test_image(cx);
4491 let rendered = render_markdown_with_image_resolver(
4492 "\ncaption",
4493 MarkdownOptions::default(),
4494 move |_| Some(ImageSource::Render(image.clone())),
4495 cx,
4496 );
4497 let text: String = rendered
4498 .lines
4499 .iter()
4500 .map(|line| line.layout.wrapped_text())
4501 .collect();
4502 assert_eq!(
4503 text, "caption",
4504 "a soft break after an image should not insert leading whitespace before caption text; got: {text:?}"
4505 );
4506 }
4507
4508 #[gpui::test]
4509 fn test_break_between_images_does_not_inject_leading_space(cx: &mut TestAppContext) {
4510 let image = test_image(cx);
4511 let rendered = render_markdown_with_image_resolver(
4512 "\n<br>\n",
4513 MarkdownOptions {
4514 parse_html: true,
4515 ..Default::default()
4516 },
4517 move |_| Some(ImageSource::Render(image.clone())),
4518 cx,
4519 );
4520 let stray_whitespace_line = rendered.lines.iter().find_map(|line| {
4521 let text = line.layout.wrapped_text();
4522 (!text.is_empty() && text.trim().is_empty()).then_some(text)
4523 });
4524 assert!(
4525 stray_whitespace_line.is_none(),
4526 "soft break after a <br> between images must not render a stray space \
4527 before the wrapped image; got whitespace-only line: {stray_whitespace_line:?}"
4528 );
4529 }
4530
4531 #[gpui::test]
4532 fn test_break_in_tight_list_item_after_image_item_is_newline(cx: &mut TestAppContext) {
4533 let image = test_image(cx);
4534 let rendered = render_markdown_with_image_resolver(
4535 "- \n- first<br>second",
4536 MarkdownOptions {
4537 parse_html: true,
4538 ..Default::default()
4539 },
4540 move |_| Some(ImageSource::Render(image.clone())),
4541 cx,
4542 );
4543 let text: String = rendered
4544 .lines
4545 .iter()
4546 .map(|line| line.layout.wrapped_text())
4547 .collect();
4548 assert!(
4549 text.contains("first\nsecond"),
4550 "break in a text-only tight list item should render as a newline; got: {text:?}"
4551 );
4552 }
4553
4554 #[gpui::test]
4555 fn test_hard_style_soft_break_after_image_moves_caption_to_next_row(cx: &mut TestAppContext) {
4556 ensure_theme_initialized(cx);
4557
4558 let image = test_image(cx);
4559 let markdown_source = "\ncaption";
4560 let caption_range = markdown_source.len() - "caption".len()..markdown_source.len();
4561
4562 let (_, cx) = cx.add_window_view(|_, _| TestWindow);
4563 let markdown = cx.new(|cx| {
4564 Markdown::new_with_options(
4565 markdown_source.to_string().into(),
4566 None,
4567 None,
4568 MarkdownOptions::default(),
4569 cx,
4570 )
4571 });
4572 cx.run_until_parked();
4573
4574 let mut caption_top = |soft_break_as_hard_break: bool| {
4575 let mut style = MarkdownStyle::default();
4576 style.soft_break_as_hard_break = soft_break_as_hard_break;
4577 let image = image.clone();
4578 let (rendered, _) = cx.draw(
4579 Default::default(),
4580 size(px(600.0), px(600.0)),
4581 |_window, _cx| {
4582 MarkdownElement::new(markdown.clone(), style)
4583 .image_resolver(move |_| Some(ImageSource::Render(image.clone())))
4584 .code_block_renderer(CodeBlockRenderer::Default {
4585 copy_button_visibility: CopyButtonVisibility::Hidden,
4586 wrap_button_visibility: WrapButtonVisibility::Hidden,
4587 border: false,
4588 })
4589 },
4590 );
4591 rendered
4592 .text
4593 .bounds_for_source_range(caption_range.clone())
4594 .into_iter()
4595 .next()
4596 .expect("caption should have text bounds")
4597 .top()
4598 };
4599
4600 let caption_top_with_break = caption_top(true);
4601 let caption_top_without_break = caption_top(false);
4602
4603 assert!(
4604 caption_top_with_break > caption_top_without_break,
4605 "caption should render below the image for hard-style soft breaks; \
4606 top with break: {caption_top_with_break:?}, top without break: {caption_top_without_break:?}"
4607 );
4608 }
4609
4610 #[gpui::test]
4611 fn test_surrounding_word_range(cx: &mut TestAppContext) {
4612 let rendered = render_markdown("Hello world tesεζ", cx);
4613
4614 // Test word selection for "Hello"
4615 let word_range = rendered.surrounding_word_range(2); // Simulate click on 'l' in "Hello"
4616 let selected_text = rendered.text_for_range(word_range);
4617 assert_eq!(selected_text, "Hello");
4618
4619 // Test word selection for "world"
4620 let word_range = rendered.surrounding_word_range(7); // Simulate click on 'o' in "world"
4621 let selected_text = rendered.text_for_range(word_range);
4622 assert_eq!(selected_text, "world");
4623
4624 // Test word selection for "tesεζ"
4625 let word_range = rendered.surrounding_word_range(14); // Simulate click on 's' in "tesεζ"
4626 let selected_text = rendered.text_for_range(word_range);
4627 assert_eq!(selected_text, "tesεζ");
4628
4629 // Test word selection at word boundary (space)
4630 let word_range = rendered.surrounding_word_range(5); // Simulate click on space between "Hello" and "world", expect highlighting word to the left
4631 let selected_text = rendered.text_for_range(word_range);
4632 assert_eq!(selected_text, "Hello");
4633 }
4634
4635 #[gpui::test]
4636 fn test_surrounding_line_range(cx: &mut TestAppContext) {
4637 let rendered = render_markdown("First line\n\nSecond line\n\nThird lineεζ", cx);
4638
4639 // Test getting line range for first line
4640 let line_range = rendered.surrounding_line_range(5); // Simulate click somewhere in first line
4641 let selected_text = rendered.text_for_range(line_range);
4642 assert_eq!(selected_text, "First line");
4643
4644 // Test getting line range for second line
4645 let line_range = rendered.surrounding_line_range(13); // Simulate click at beginning in second line
4646 let selected_text = rendered.text_for_range(line_range);
4647 assert_eq!(selected_text, "Second line");
4648
4649 // Test getting line range for third line
4650 let line_range = rendered.surrounding_line_range(37); // Simulate click at end of third line with multi-byte chars
4651 let selected_text = rendered.text_for_range(line_range);
4652 assert_eq!(selected_text, "Third lineεζ");
4653 }
4654
4655 #[gpui::test]
4656 fn test_selection_head_movement(cx: &mut TestAppContext) {
4657 let rendered = render_markdown("Hello world test", cx);
4658
4659 let mut selection = Selection {
4660 start: 5,
4661 end: 5,
4662 reversed: false,
4663 pending: false,
4664 mode: SelectMode::Character,
4665 };
4666
4667 // Test forward selection
4668 selection.set_head(10, &rendered);
4669 assert_eq!(selection.start, 5);
4670 assert_eq!(selection.end, 10);
4671 assert!(!selection.reversed);
4672 assert_eq!(selection.tail(), 5);
4673
4674 // Test backward selection
4675 selection.set_head(2, &rendered);
4676 assert_eq!(selection.start, 2);
4677 assert_eq!(selection.end, 5);
4678 assert!(selection.reversed);
4679 assert_eq!(selection.tail(), 5);
4680
4681 // Test forward selection again from reversed state
4682 selection.set_head(15, &rendered);
4683 assert_eq!(selection.start, 5);
4684 assert_eq!(selection.end, 15);
4685 assert!(!selection.reversed);
4686 assert_eq!(selection.tail(), 5);
4687 }
4688
4689 #[gpui::test]
4690 fn test_word_selection_drag(cx: &mut TestAppContext) {
4691 let rendered = render_markdown("Hello world test", cx);
4692
4693 // Start with a simulated double-click on "world" (index 6-10)
4694 let word_range = rendered.surrounding_word_range(7); // Click on 'o' in "world"
4695 let mut selection = Selection {
4696 start: word_range.start,
4697 end: word_range.end,
4698 reversed: false,
4699 pending: true,
4700 mode: SelectMode::Word(word_range),
4701 };
4702
4703 // Drag forward to "test" - should expand selection to include "test"
4704 selection.set_head(13, &rendered); // Index in "test"
4705 assert_eq!(selection.start, 6); // Start of "world"
4706 assert_eq!(selection.end, 16); // End of "test"
4707 assert!(!selection.reversed);
4708 let selected_text = rendered.text_for_range(selection.start..selection.end);
4709 assert_eq!(selected_text, "world test");
4710
4711 // Drag backward to "Hello" - should expand selection to include "Hello"
4712 selection.set_head(2, &rendered); // Index in "Hello"
4713 assert_eq!(selection.start, 0); // Start of "Hello"
4714 assert_eq!(selection.end, 11); // End of "world" (original selection)
4715 assert!(selection.reversed);
4716 let selected_text = rendered.text_for_range(selection.start..selection.end);
4717 assert_eq!(selected_text, "Hello world");
4718
4719 // Drag back within original word - should revert to original selection
4720 selection.set_head(8, &rendered); // Back within "world"
4721 assert_eq!(selection.start, 6); // Start of "world"
4722 assert_eq!(selection.end, 11); // End of "world"
4723 assert!(!selection.reversed);
4724 let selected_text = rendered.text_for_range(selection.start..selection.end);
4725 assert_eq!(selected_text, "world");
4726 }
4727
4728 #[gpui::test]
4729 fn test_selection_with_markdown_formatting(cx: &mut TestAppContext) {
4730 let rendered = render_markdown(
4731 "This is **bold** text, this is *italic* text, use `code` here",
4732 cx,
4733 );
4734 let word_range = rendered.surrounding_word_range(10); // Inside "bold"
4735 let selected_text = rendered.text_for_range(word_range);
4736 assert_eq!(selected_text, "bold");
4737
4738 let word_range = rendered.surrounding_word_range(32); // Inside "italic"
4739 let selected_text = rendered.text_for_range(word_range);
4740 assert_eq!(selected_text, "italic");
4741
4742 let word_range = rendered.surrounding_word_range(51); // Inside "code"
4743 let selected_text = rendered.text_for_range(word_range);
4744 assert_eq!(selected_text, "code");
4745 }
4746
4747 #[gpui::test]
4748 fn test_table_column_selection(cx: &mut TestAppContext) {
4749 let rendered = render_markdown("| a | b |\n|---|---|\n| c | d |", cx);
4750
4751 assert!(rendered.lines.len() >= 2);
4752 let first_bounds = rendered.lines[0].layout.bounds();
4753 let second_bounds = rendered.lines[1].layout.bounds();
4754
4755 let first_index = match rendered.source_index_for_position(first_bounds.center()) {
4756 Ok(index) | Err(index) => index,
4757 };
4758 let second_index = match rendered.source_index_for_position(second_bounds.center()) {
4759 Ok(index) | Err(index) => index,
4760 };
4761
4762 let first_word = rendered.text_for_range(rendered.surrounding_word_range(first_index));
4763 let second_word = rendered.text_for_range(rendered.surrounding_word_range(second_index));
4764
4765 assert_eq!(first_word, "a");
4766 assert_eq!(second_word, "b");
4767 }
4768
4769 #[test]
4770 fn test_table_state_current_cell_alignment_centers_headers() {
4771 let mut table = TableState::default();
4772 table.start(vec![Alignment::Left, Alignment::Right, Alignment::None]);
4773
4774 table.start_head();
4775 for _ in 0..3 {
4776 assert_eq!(table.current_cell_alignment(), Some(Alignment::Center));
4777 table.end_cell();
4778 }
4779
4780 table.end_head();
4781 table.start_row();
4782 assert_eq!(table.current_cell_alignment(), Some(Alignment::Left));
4783 table.end_cell();
4784 assert_eq!(table.current_cell_alignment(), Some(Alignment::Right));
4785 table.end_cell();
4786 assert_eq!(table.current_cell_alignment(), Some(Alignment::None));
4787 table.end_cell();
4788 table.end_row();
4789
4790 table.end();
4791 assert_eq!(table.current_cell_alignment(), None);
4792 }
4793
4794 #[test]
4795 fn test_table_state_current_cell_alignment_outside_table() {
4796 let table = TableState::default();
4797 assert_eq!(table.current_cell_alignment(), None);
4798 }
4799
4800 #[test]
4801 fn test_table_checkbox_detection() {
4802 let md = "| Done |\n|------|\n| [x] |\n| [ ] |";
4803 let events = crate::parser::parse_markdown_with_options(md, false, false, false).events;
4804
4805 let mut in_table = false;
4806 let mut cell_texts: Vec<String> = Vec::new();
4807 let mut current_cell = String::new();
4808
4809 for (range, event) in &events {
4810 match event {
4811 MarkdownEvent::Start(MarkdownTag::Table(_)) => in_table = true,
4812 MarkdownEvent::End(MarkdownTagEnd::Table) => in_table = false,
4813 MarkdownEvent::Start(MarkdownTag::TableCell) => current_cell.clear(),
4814 MarkdownEvent::End(MarkdownTagEnd::TableCell) => {
4815 if in_table {
4816 cell_texts.push(current_cell.clone());
4817 }
4818 }
4819 MarkdownEvent::Text if in_table => {
4820 current_cell.push_str(&md[range.clone()]);
4821 }
4822 _ => {}
4823 }
4824 }
4825
4826 let checkbox_cells: Vec<&String> = cell_texts
4827 .iter()
4828 .filter(|t| {
4829 let trimmed = t.trim();
4830 trimmed == "[x]" || trimmed == "[X]" || trimmed == "[ ]"
4831 })
4832 .collect();
4833 assert_eq!(
4834 checkbox_cells.len(),
4835 2,
4836 "Expected 2 checkbox cells, got: {cell_texts:?}"
4837 );
4838 assert_eq!(checkbox_cells[0].trim(), "[x]");
4839 assert_eq!(checkbox_cells[1].trim(), "[ ]");
4840 }
4841
4842 #[test]
4843 fn test_table_checkbox_marker_source_range() {
4844 let md = "| Done |\n|------|\n| [x] |\n| [ ] |";
4845 let events = crate::parser::parse_markdown_with_options(md, false, false, false).events;
4846
4847 let mut in_cell = false;
4848 let mut pending_text = String::new();
4849 let mut mappings: Vec<SourceMapping> = Vec::new();
4850 let mut cell_ranges: Vec<Range<usize>> = Vec::new();
4851
4852 for (range, event) in &events {
4853 match event {
4854 MarkdownEvent::Start(MarkdownTag::TableCell) => {
4855 in_cell = true;
4856 pending_text.clear();
4857 mappings.clear();
4858 }
4859 MarkdownEvent::End(MarkdownTagEnd::TableCell) => {
4860 if in_cell {
4861 let trimmed = pending_text.trim();
4862 if trimmed == "[x]" || trimmed == "[X]" || trimmed == "[ ]" {
4863 let leading = pending_text.len() - pending_text.trim_start().len();
4864 let rendered = leading..leading + trimmed.len();
4865 let marker_source = source_range_for_rendered(&mappings, &rendered)
4866 .expect("marker source range");
4867 cell_ranges.push(marker_source);
4868 }
4869 }
4870 in_cell = false;
4871 }
4872 MarkdownEvent::Text if in_cell => {
4873 mappings.push(SourceMapping {
4874 rendered_index: pending_text.len(),
4875 source_index: range.start,
4876 });
4877 pending_text.push_str(&md[range.clone()]);
4878 }
4879 _ => {}
4880 }
4881 }
4882
4883 assert_eq!(cell_ranges.len(), 2);
4884 for marker_range in &cell_ranges {
4885 let slice = &md[marker_range.clone()];
4886 assert!(
4887 slice == "[x]" || slice == "[X]" || slice == "[ ]",
4888 "expected `[x]`/`[X]`/`[ ]`, got {slice:?} at {marker_range:?}"
4889 );
4890 }
4891 }
4892
4893 #[gpui::test]
4894 fn test_escaped_pipes_in_inline_code_inside_tables(cx: &mut TestAppContext) {
4895 let markdown = "\
4896| Pattern | What it does |
4897| --- | --- |
4898| `^echo(\\s\\|$)` | command pattern |
4899| `a\\|b` | alternation |
4900| `(a\\|b)` | grouped alternation |
4901| `a\\|\\|b` | empty middle alternative |";
4902 let rendered = render_markdown(markdown, cx);
4903 let text = rendered.text_for_range(0..markdown.len());
4904
4905 assert_eq!(
4906 text,
4907 "Pattern\n\
4908 What it does\n\
4909 ^echo(\\s|$)\n\
4910 command pattern\n\
4911 a|b\n\
4912 alternation\n\
4913 (a|b)\n\
4914 grouped alternation\n\
4915 a||b\n\
4916 empty middle alternative"
4917 );
4918 }
4919
4920 #[test]
4921 fn test_source_range_for_rendered_handles_split_chunks() {
4922 let mappings = vec![
4923 SourceMapping {
4924 rendered_index: 0,
4925 source_index: 20,
4926 },
4927 SourceMapping {
4928 rendered_index: 1,
4929 source_index: 21,
4930 },
4931 SourceMapping {
4932 rendered_index: 2,
4933 source_index: 22,
4934 },
4935 ];
4936
4937 let range = source_range_for_rendered(&mappings, &(0..3)).unwrap();
4938 assert_eq!(range, 20..23);
4939
4940 let range = source_range_for_rendered(&mappings, &(1..2)).unwrap();
4941 assert_eq!(range, 21..22);
4942
4943 assert_eq!(source_range_for_rendered(&mappings, &(2..2)), None);
4944 }
4945
4946 #[gpui::test]
4947 fn test_inline_code_word_selection_excludes_backticks(cx: &mut TestAppContext) {
4948 // Test that double-clicking on inline code selects just the code content,
4949 // not the backticks. This verifies the fix for the bug where selecting
4950 // inline code would include the trailing backtick.
4951 let rendered = render_markdown("use `blah` here", cx);
4952
4953 // Source layout: "use `blah` here"
4954 // 0123456789...
4955 // The inline code "blah" is at source positions 5-8 (content range 5..9)
4956
4957 // Click inside "blah" - should select just "blah", not "blah`"
4958 let word_range = rendered.surrounding_word_range(6); // 'l' in "blah"
4959
4960 // text_for_range extracts from the rendered text (without backticks), so it
4961 // would return "blah" even with a wrong source range. We check it anyway.
4962 let selected_text = rendered.text_for_range(word_range.clone());
4963 assert_eq!(selected_text, "blah");
4964
4965 // The source range is what matters for copy_as_markdown and selected_text,
4966 // which extract directly from the source. With the bug, this would be 5..10
4967 // which includes the closing backtick at position 9.
4968 assert_eq!(word_range, 5..9);
4969 }
4970
4971 #[gpui::test]
4972 fn test_surrounding_word_range_respects_word_characters(cx: &mut TestAppContext) {
4973 let rendered = render_markdown("foo.bar() baz", cx);
4974
4975 // Double clicking on 'f' in "foo" - should select just "foo"
4976 let word_range = rendered.surrounding_word_range(0);
4977 let selected_text = rendered.text_for_range(word_range);
4978 assert_eq!(selected_text, "foo");
4979
4980 // Double clicking on 'b' in "bar" - should select just "bar"
4981 let word_range = rendered.surrounding_word_range(4);
4982 let selected_text = rendered.text_for_range(word_range);
4983 assert_eq!(selected_text, "bar");
4984
4985 // Double clicking on 'b' in "baz" - should select "baz"
4986 let word_range = rendered.surrounding_word_range(10);
4987 let selected_text = rendered.text_for_range(word_range);
4988 assert_eq!(selected_text, "baz");
4989
4990 // Double clicking selects word characters in code blocks
4991 let javascript_language = Arc::new(Language::new(
4992 LanguageConfig {
4993 name: "JavaScript".into(),
4994 matcher: (LanguageMatcher {
4995 path_suffixes: vec!["js".to_string()],
4996 ..Default::default()
4997 })
4998 .into(),
4999 word_characters: ['$', '#'].into_iter().collect(),
5000 ..Default::default()
5001 },
5002 None,
5003 ));
5004
5005 let language_registry = Arc::new(LanguageRegistry::test(cx.executor()));
5006 language_registry.add(javascript_language);
5007
5008 let rendered = render_markdown_with_language_registry(
5009 "```javascript\n$foo #bar\n```",
5010 Some(language_registry),
5011 cx,
5012 );
5013
5014 let word_range = rendered.surrounding_word_range(14);
5015 let selected_text = rendered.text_for_range(word_range);
5016 assert_eq!(selected_text, "$foo");
5017
5018 let word_range = rendered.surrounding_word_range(19);
5019 let selected_text = rendered.text_for_range(word_range);
5020 assert_eq!(selected_text, "#bar");
5021 }
5022
5023 #[gpui::test]
5024 fn test_all_selection(cx: &mut TestAppContext) {
5025 let rendered = render_markdown("Hello world\n\nThis is a test\n\nwith multiple lines", cx);
5026
5027 let total_length = rendered
5028 .lines
5029 .last()
5030 .map(|line| line.source_end)
5031 .unwrap_or(0);
5032
5033 let mut selection = Selection {
5034 start: 0,
5035 end: total_length,
5036 reversed: false,
5037 pending: true,
5038 mode: SelectMode::All,
5039 };
5040
5041 selection.set_head(5, &rendered); // Try to set head in middle
5042 assert_eq!(selection.start, 0);
5043 assert_eq!(selection.end, total_length);
5044 assert!(!selection.reversed);
5045
5046 selection.set_head(25, &rendered); // Try to set head near end
5047 assert_eq!(selection.start, 0);
5048 assert_eq!(selection.end, total_length);
5049 assert!(!selection.reversed);
5050
5051 let selected_text = rendered.text_for_range(selection.start..selection.end);
5052 assert_eq!(
5053 selected_text,
5054 "Hello world\nThis is a test\nwith multiple lines"
5055 );
5056 }
5057
5058 fn nbsp(n: usize) -> String {
5059 "\u{00A0}".repeat(n)
5060 }
5061
5062 #[test]
5063 fn test_escape_plain_text() {
5064 assert_eq!(Markdown::escape("hello world"), "hello world");
5065 assert_eq!(Markdown::escape(""), "");
5066 assert_eq!(Markdown::escape("café ☕ naïve"), "café ☕ naïve");
5067 }
5068
5069 #[test]
5070 fn test_escape_punctuation() {
5071 assert_eq!(Markdown::escape("hello `world`"), r"hello \`world\`");
5072 assert_eq!(Markdown::escape("a|b"), r"a\|b");
5073 }
5074
5075 #[test]
5076 fn test_escape_leading_spaces() {
5077 assert_eq!(Markdown::escape(" hello"), [ (4), "hello"].concat());
5078 assert_eq!(
5079 Markdown::escape(" | { a: string }"),
5080 [ (4), r"\| \{ a\: string \}"].concat()
5081 );
5082 assert_eq!(
5083 Markdown::escape(" first\n second"),
5084 [ (2), "first\n\n",  (2), "second"].concat()
5085 );
5086 assert_eq!(Markdown::escape("hello world"), "hello world");
5087 }
5088
5089 #[test]
5090 fn test_escape_leading_tabs() {
5091 assert_eq!(Markdown::escape("\thello"), [ (4), "hello"].concat());
5092 assert_eq!(
5093 Markdown::escape("hello\n\t\tindented"),
5094 ["hello\n\n",  (8), "indented"].concat()
5095 );
5096 assert_eq!(
5097 Markdown::escape(" \t hello"),
5098 [ (1 + 4 + 1), "hello"].concat()
5099 );
5100 assert_eq!(Markdown::escape("hello\tworld"), "hello\tworld");
5101 }
5102
5103 #[test]
5104 fn test_escape_newlines() {
5105 assert_eq!(Markdown::escape("a\nb"), "a\n\nb");
5106 assert_eq!(Markdown::escape("a\n\nb"), "a\n\n\n\nb");
5107 assert_eq!(Markdown::escape("\nhello"), "\n\nhello");
5108 }
5109
5110 #[test]
5111 fn test_escape_multiline_diagnostic() {
5112 assert_eq!(
5113 Markdown::escape(" | { a: string }\n | { b: number }"),
5114 [
5115  (4),
5116 r"\| \{ a\: string \}",
5117 "\n\n",
5118  (4),
5119 r"\| \{ b\: number \}",
5120 ]
5121 .concat()
5122 );
5123 }
5124
5125 #[test]
5126 fn test_escape_non_ascii() {
5127 // Cyrillic characters should not have backslashes added before them,
5128 // but ASCII punctuation should still be escaped.
5129 assert_eq!(Markdown::escape("Привет, мир"), r"Привет\, мир");
5130 // Test with markdown special characters mixed in
5131 assert_eq!(Markdown::escape("Привет, *мир*"), r"Привет\, \*мир\*");
5132 // Test with the exact example from the issue (single quotes are also ASCII punctuation)
5133 assert_eq!(
5134 Markdown::escape("Отсутствует пробел справа от ','"),
5135 r"Отсутствует пробел справа от \'\,\'"
5136 );
5137 // Test more non-ASCII scripts
5138 assert_eq!(
5139 Markdown::escape("こんにちは *world*"),
5140 r"こんにちは \*world\*"
5141 );
5142 assert_eq!(Markdown::escape("العربيّة [link]"), r"العربيّة \[link\]");
5143 assert_eq!(Markdown::escape("Ελληνικά _text_"), r"Ελληνικά \_text\_");
5144 assert_eq!(Markdown::escape("עברית `code`"), r"עברית \`code\`");
5145 // Non-ASCII followed by ASCII punctuation
5146 assert_eq!(Markdown::escape("Test: тест"), r"Test\: тест");
5147 }
5148
5149 fn has_code_block(markdown: &str) -> bool {
5150 let parsed_data = parse_markdown_with_options(markdown, false, false, false);
5151 parsed_data
5152 .events
5153 .iter()
5154 .any(|(_, event)| matches!(event, MarkdownEvent::Start(MarkdownTag::CodeBlock { .. })))
5155 }
5156
5157 #[test]
5158 fn test_escape_output_len_matches_precomputed() {
5159 let cases = [
5160 "",
5161 "hello world",
5162 "hello `world`",
5163 " hello",
5164 " | { a: string }",
5165 "\thello",
5166 "hello\n\t\tindented",
5167 " \t hello",
5168 "hello\tworld",
5169 "a\nb",
5170 "a\n\nb",
5171 "\nhello",
5172 " | { a: string }\n | { b: number }",
5173 "café ☕ naïve",
5174 ];
5175 for input in cases {
5176 let mut escaper = MarkdownEscaper::new();
5177 let precomputed: usize = input.chars().map(|c| escaper.next(c).output_len(c)).sum();
5178
5179 let mut escaper = MarkdownEscaper::new();
5180 let mut output = String::new();
5181 for c in input.chars() {
5182 escaper.next(c).write_to(c, &mut output);
5183 }
5184
5185 assert_eq!(precomputed, output.len(), "length mismatch for {:?}", input);
5186 }
5187 }
5188
5189 #[gpui::test]
5190 fn test_inline_br_renders_as_line_break(cx: &mut TestAppContext) {
5191 let options = MarkdownOptions {
5192 parse_html: true,
5193 ..Default::default()
5194 };
5195
5196 for br in ["<br>", "<br/>", "<br />"] {
5197 let md = format!("first{br}second");
5198 let rendered = render_markdown_with_options(&md, None, options, cx);
5199 let text: String = rendered
5200 .lines
5201 .iter()
5202 .map(|line| line.layout.wrapped_text())
5203 .collect();
5204 assert!(
5205 !text.contains(br),
5206 "{br} should not appear as literal text; got: {text:?}"
5207 );
5208 assert!(
5209 text.contains("first\nsecond"),
5210 "{br} should produce a newline between 'first' and 'second'; got: {text:?}"
5211 );
5212 }
5213 }
5214
5215 #[gpui::test]
5216 fn test_hard_break_in_text_paragraph_after_paragraph(cx: &mut TestAppContext) {
5217 let options = MarkdownOptions {
5218 parse_html: true,
5219 ..Default::default()
5220 };
5221 let rendered =
5222 render_markdown_with_options("para one\n\nfirst<br>second", None, options, cx);
5223 let all_text: String = rendered
5224 .lines
5225 .iter()
5226 .map(|line| line.layout.wrapped_text())
5227 .collect::<Vec<_>>()
5228 .join("\n");
5229 assert!(
5230 all_text.contains("first") && all_text.contains("second"),
5231 "both sides of <br> should be present; got: {all_text:?}"
5232 );
5233 assert!(
5234 !all_text.contains("<br>"),
5235 "<br> should not appear as literal text; got: {all_text:?}"
5236 );
5237 }
5238
5239 #[test]
5240 fn test_escape_prevents_code_block() {
5241 let diagnostic = " | { a: string }";
5242 assert!(has_code_block(diagnostic));
5243 assert!(!has_code_block(&Markdown::escape(diagnostic)));
5244 }
5245
5246 #[gpui::test]
5247 fn test_link_detected_for_source_index(cx: &mut TestAppContext) {
5248 let rendered = render_markdown("[Click here](https://example.com)", cx);
5249
5250 assert_eq!(rendered.links.len(), 1);
5251 assert_eq!(rendered.links[0].destination_url, "https://example.com");
5252
5253 // Source index 1 ('C' in "Click") is inside the link's source range
5254 let link = rendered.link_for_source_index(1);
5255 assert!(link.is_some());
5256 assert_eq!(link.unwrap().destination_url, "https://example.com");
5257
5258 // A source index past the end of the link range returns None
5259 let past_end = rendered.links[0].source_range.end;
5260 assert!(rendered.link_for_source_index(past_end).is_none());
5261 }
5262
5263 #[gpui::test]
5264 fn test_link_for_source_index_ignores_plain_text(cx: &mut TestAppContext) {
5265 let rendered = render_markdown("Hello world", cx);
5266
5267 assert!(rendered.links.is_empty());
5268 assert!(rendered.link_for_source_index(0).is_none());
5269 assert!(rendered.link_for_source_index(5).is_none());
5270 }
5271
5272 #[gpui::test]
5273 fn test_code_span_link_detected_for_source_index(cx: &mut TestAppContext) {
5274 let source = "see `foo.rs` for details";
5275 let rendered = render_markdown_with_code_span_link(
5276 source,
5277 |text, _cx| (text == "foo.rs").then(|| "file:///tmp/foo.rs".into()),
5278 cx,
5279 );
5280
5281 assert_eq!(rendered.links.len(), 1);
5282 assert_eq!(rendered.links[0].destination_url, "file:///tmp/foo.rs");
5283
5284 let code_index = source.find("foo.rs").unwrap();
5285 let link = rendered.link_for_source_index(code_index);
5286 assert!(link.is_some());
5287 assert_eq!(link.unwrap().destination_url, "file:///tmp/foo.rs");
5288
5289 assert!(
5290 rendered
5291 .link_for_source_index(source.find("see").unwrap())
5292 .is_none()
5293 );
5294 }
5295
5296 #[gpui::test]
5297 fn test_code_span_link_receives_decoded_inline_code(cx: &mut TestAppContext) {
5298 let source = r"| Pattern |
5299| --- |
5300| `a\|b` |";
5301 let rendered = render_markdown_with_code_span_link(
5302 source,
5303 |text, _cx| (text == "a|b").then(|| "file:///tmp/a-or-b".into()),
5304 cx,
5305 );
5306
5307 assert_eq!(rendered.links.len(), 1);
5308 assert_eq!(rendered.links[0].destination_url, "file:///tmp/a-or-b");
5309 }
5310
5311 #[gpui::test]
5312 fn test_code_span_link_ignores_code_when_mouse_interaction_is_prevented(
5313 cx: &mut TestAppContext,
5314 ) {
5315 let callback_count = Arc::new(AtomicUsize::new(0));
5316 let rendered = render_markdown_with_code_span_link_style(
5317 "see `foo.rs` for details",
5318 MarkdownStyle {
5319 prevent_mouse_interaction: true,
5320 ..MarkdownStyle::default()
5321 },
5322 {
5323 let callback_count = callback_count.clone();
5324 move |text, _cx| {
5325 callback_count.fetch_add(1, Ordering::Relaxed);
5326 (text == "foo.rs").then(|| "file:///tmp/foo.rs".into())
5327 }
5328 },
5329 cx,
5330 );
5331
5332 assert!(rendered.links.is_empty());
5333 assert_eq!(callback_count.load(Ordering::Relaxed), 0);
5334 }
5335
5336 #[gpui::test]
5337 fn test_code_span_link_ignores_code_without_callback(cx: &mut TestAppContext) {
5338 let rendered = render_markdown("see `foo.rs` for details", cx);
5339
5340 assert!(rendered.links.is_empty());
5341 }
5342
5343 #[gpui::test]
5344 fn test_code_span_link_ignores_code_inside_markdown_link(cx: &mut TestAppContext) {
5345 let source = "see [`foo.rs`](https://example.com) for details";
5346 let rendered = render_markdown_with_code_span_link(
5347 source,
5348 |text, _cx| (text == "foo.rs").then(|| "file:///tmp/foo.rs".into()),
5349 cx,
5350 );
5351
5352 assert_eq!(rendered.links.len(), 1);
5353 assert_eq!(rendered.links[0].destination_url, "https://example.com");
5354 }
5355
5356 #[gpui::test]
5357 fn test_context_menu_link_initial_state(cx: &mut TestAppContext) {
5358 ensure_theme_initialized(cx);
5359 let (_, cx) = cx.add_window_view(|_, _| TestWindow);
5360 let markdown =
5361 cx.new(|cx| Markdown::new("Hello [world](https://example.com)".into(), None, None, cx));
5362 cx.run_until_parked();
5363
5364 cx.update(|_window, cx| {
5365 assert!(markdown.read(cx).context_menu_link().is_none());
5366 });
5367 }
5368
5369 #[gpui::test]
5370 fn test_url_hover_callback(cx: &mut TestAppContext) {
5371 struct HoverTestView {
5372 markdown: Entity<Markdown>,
5373 hovered_urls: Rc<RefCell<Vec<Option<SharedString>>>>,
5374 }
5375
5376 impl Render for HoverTestView {
5377 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
5378 let hovered_urls = self.hovered_urls.clone();
5379 div().size_full().child(
5380 MarkdownElement::new(self.markdown.clone(), MarkdownStyle::default())
5381 .on_url_hover(move |url, _, _| {
5382 hovered_urls.borrow_mut().push(url);
5383 }),
5384 )
5385 }
5386 }
5387
5388 ensure_theme_initialized(cx);
5389 let hovered_urls = Rc::new(RefCell::new(Vec::new()));
5390 let (_, cx) = cx.add_window_view({
5391 let hovered_urls = hovered_urls.clone();
5392 move |_, cx| HoverTestView {
5393 markdown: cx
5394 .new(|cx| Markdown::new("[link](https://example.com)".into(), None, None, cx)),
5395 hovered_urls,
5396 }
5397 });
5398 cx.run_until_parked();
5399
5400 cx.simulate_mouse_move(point(px(8.), px(8.)), None, gpui::Modifiers::default());
5401 assert_eq!(
5402 hovered_urls.borrow().last().cloned().flatten().as_deref(),
5403 Some("https://example.com")
5404 );
5405
5406 cx.simulate_mouse_move(point(px(500.), px(500.)), None, gpui::Modifiers::default());
5407 assert_eq!(hovered_urls.borrow().last(), Some(&None));
5408 }
5409
5410 #[gpui::test]
5411 fn test_url_hover_callback_for_linked_image(cx: &mut TestAppContext) {
5412 struct HoverTestView {
5413 markdown: Entity<Markdown>,
5414 hovered_urls: Rc<RefCell<Vec<Option<SharedString>>>>,
5415 }
5416
5417 impl Render for HoverTestView {
5418 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
5419 let hovered_urls = self.hovered_urls.clone();
5420 div().size_full().child(
5421 MarkdownElement::new(self.markdown.clone(), MarkdownStyle::default())
5422 .image_resolver(|_| Some(loaded_image_source()))
5423 .on_url_hover(move |url, _, _| {
5424 hovered_urls.borrow_mut().push(url);
5425 }),
5426 )
5427 }
5428 }
5429
5430 ensure_theme_initialized(cx);
5431 let hovered_urls = Rc::new(RefCell::new(Vec::new()));
5432 let (_, cx) = cx.add_window_view({
5433 let hovered_urls = hovered_urls.clone();
5434 move |_, cx| HoverTestView {
5435 markdown: cx.new(|cx| {
5436 Markdown::new(
5437 "[](https://example.com)".into(),
5438 None,
5439 None,
5440 cx,
5441 )
5442 }),
5443 hovered_urls,
5444 }
5445 });
5446 cx.run_until_parked();
5447
5448 cx.simulate_mouse_move(point(px(4.), px(4.)), None, gpui::Modifiers::default());
5449 assert_eq!(
5450 hovered_urls.borrow().last().cloned().flatten().as_deref(),
5451 Some("https://example.com")
5452 );
5453
5454 cx.simulate_mouse_move(point(px(8.), px(8.)), None, gpui::Modifiers::default());
5455 assert_eq!(
5456 hovered_urls.borrow().last().cloned().flatten().as_deref(),
5457 Some("https://example.com")
5458 );
5459
5460 cx.simulate_mouse_move(point(px(500.), px(500.)), None, gpui::Modifiers::default());
5461 assert_eq!(hovered_urls.borrow().last(), Some(&None));
5462 }
5463
5464 #[gpui::test]
5465 fn test_capture_for_context_menu(cx: &mut TestAppContext) {
5466 ensure_theme_initialized(cx);
5467 let (_, cx) = cx.add_window_view(|_, _| TestWindow);
5468 let markdown = cx.new(|cx| Markdown::new("some text".into(), None, None, cx));
5469 cx.run_until_parked();
5470
5471 // Simulates right-clicking on a link, with "text" selected
5472 let url: SharedString = "https://example.com".into();
5473 markdown.update(cx, |md, _cx| {
5474 md.selection.start = 5;
5475 md.selection.end = 9;
5476 md.capture_for_context_menu(Some(url.clone()), None);
5477 });
5478 cx.update(|_window, cx| {
5479 let markdown = markdown.read(cx);
5480 assert_eq!(
5481 markdown.context_menu_link().map(SharedString::as_ref),
5482 Some("https://example.com")
5483 );
5484 assert_eq!(
5485 markdown
5486 .context_menu_selected_markdown()
5487 .map(SharedString::as_ref),
5488 Some("text")
5489 );
5490 assert_eq!(
5491 markdown
5492 .context_menu_selected_text()
5493 .map(SharedString::as_ref),
5494 Some("text")
5495 );
5496 });
5497
5498 // Simulates right-clicking on plain text with no selection — everything is cleared
5499 markdown.update(cx, |md, _cx| {
5500 md.selection.start = 0;
5501 md.selection.end = 0;
5502 md.capture_for_context_menu(None, None);
5503 });
5504 cx.update(|_window, cx| {
5505 let markdown = markdown.read(cx);
5506 assert!(markdown.context_menu_link().is_none());
5507 assert!(markdown.context_menu_selected_markdown().is_none());
5508 assert!(markdown.context_menu_selected_text().is_none());
5509 });
5510 }
5511
5512 #[gpui::test]
5513 fn test_preview_body_font_size_is_rem_based(cx: &mut TestAppContext) {
5514 ensure_theme_initialized(cx);
5515 let (_, cx) = cx.add_window_view(|_, _| TestWindow);
5516 cx.update(|window, cx| {
5517 let style = MarkdownStyle::themed(MarkdownFont::Preview, window, cx);
5518 assert!(
5519 matches!(style.base_text_style.font_size, AbsoluteLength::Rems(_)),
5520 "preview body font size must be rem-based, got {:?}",
5521 style.base_text_style.font_size
5522 );
5523 assert!(
5524 matches!(
5525 style.container_style.text.font_size,
5526 Some(AbsoluteLength::Rems(_))
5527 ),
5528 "preview container font size must be rem-based, got {:?}",
5529 style.container_style.text.font_size
5530 );
5531 });
5532 }
5533
5534 fn failing_image_source() -> ImageSource {
5535 ImageSource::Custom(Arc::new(|_, _| {
5536 Some(Err(gpui::ImageCacheError::Asset(
5537 "failed to load image".into(),
5538 )))
5539 }))
5540 }
5541
5542 fn loaded_image_source() -> ImageSource {
5543 let buffer = image::ImageBuffer::from_pixel(16, 16, image::Rgba([0, 0, 0, 255]));
5544 ImageSource::Render(Arc::new(gpui::RenderImage::new(SmallVec::from_elem(
5545 image::Frame::new(buffer),
5546 1,
5547 ))))
5548 }
5549
5550 fn open_markdown_image_test_window<'a>(
5551 source: &str,
5552 image_source: ImageSource,
5553 cx: &'a mut TestAppContext,
5554 ) -> &'a mut gpui::VisualTestContext {
5555 struct ImageTestView {
5556 markdown: Entity<Markdown>,
5557 image_source: ImageSource,
5558 }
5559
5560 impl Render for ImageTestView {
5561 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
5562 let image_source = self.image_source.clone();
5563 div().size_full().child(
5564 MarkdownElement::new(self.markdown.clone(), MarkdownStyle::default())
5565 .image_resolver(move |_| Some(image_source.clone())),
5566 )
5567 }
5568 }
5569
5570 ensure_theme_initialized(cx);
5571
5572 let source = source.to_string();
5573 let (_, cx) = cx.add_window_view(|_, cx| ImageTestView {
5574 markdown: cx.new(|cx| Markdown::new(source.into(), None, None, cx)),
5575 image_source,
5576 });
5577 cx.run_until_parked();
5578 cx
5579 }
5580
5581 #[gpui::test]
5582 fn test_clicking_image_fallback_opens_image_url(cx: &mut TestAppContext) {
5583 let cx = open_markdown_image_test_window(
5584 "",
5585 failing_image_source(),
5586 cx,
5587 );
5588
5589 cx.simulate_click(point(px(8.), px(8.)), gpui::Modifiers::default());
5590 assert_eq!(
5591 cx.opened_url(),
5592 Some("https://example.com/image.png".to_string())
5593 );
5594 }
5595
5596 #[gpui::test]
5597 fn test_clicking_image_fallback_inside_link_opens_link_url(cx: &mut TestAppContext) {
5598 let cx = open_markdown_image_test_window(
5599 "[](https://example.com/link)",
5600 failing_image_source(),
5601 cx,
5602 );
5603
5604 cx.simulate_click(point(px(8.), px(8.)), gpui::Modifiers::default());
5605 assert_eq!(
5606 cx.opened_url(),
5607 Some("https://example.com/link".to_string())
5608 );
5609 }
5610
5611 #[gpui::test]
5612 fn test_clicking_loaded_image_inside_link_opens_link_url(cx: &mut TestAppContext) {
5613 let cx = open_markdown_image_test_window(
5614 "[](https://example.com/link)",
5615 loaded_image_source(),
5616 cx,
5617 );
5618
5619 cx.simulate_click(point(px(8.), px(8.)), gpui::Modifiers::default());
5620 assert_eq!(
5621 cx.opened_url(),
5622 Some("https://example.com/link".to_string())
5623 );
5624 }
5625
5626 #[track_caller]
5627 fn assert_mappings(rendered: &RenderedText, expected: Vec<Vec<(usize, usize)>>) {
5628 assert_eq!(rendered.lines.len(), expected.len(), "line count mismatch");
5629 for (line_ix, line_mappings) in expected.into_iter().enumerate() {
5630 let line = &rendered.lines[line_ix];
5631
5632 assert!(
5633 line.source_mappings.windows(2).all(|mappings| {
5634 mappings[0].source_index < mappings[1].source_index
5635 && mappings[0].rendered_index < mappings[1].rendered_index
5636 }),
5637 "line {} has duplicate mappings: {:?}",
5638 line_ix,
5639 line.source_mappings
5640 );
5641
5642 for (rendered_ix, source_ix) in line_mappings {
5643 assert_eq!(
5644 line.source_index_for_rendered_index(rendered_ix),
5645 source_ix,
5646 "line {}, rendered_ix {}",
5647 line_ix,
5648 rendered_ix
5649 );
5650
5651 assert_eq!(
5652 line.rendered_index_for_source_index(source_ix),
5653 rendered_ix,
5654 "line {}, source_ix {}",
5655 line_ix,
5656 source_ix
5657 );
5658 }
5659 }
5660 }
5661
5662 #[gpui::test]
5663 fn test_bounds_for_source_range_skips_gaps_between_rendered_lines(cx: &mut TestAppContext) {
5664 let source = "First\n\nSecond";
5665 let rendered = render_markdown(source, cx);
5666 let highlight_bounds = rendered.bounds_for_source_range(0..source.len());
5667 assert_eq!(highlight_bounds.len(), rendered.lines.len());
5668
5669 for (line, highlight_bounds) in rendered.lines.iter().zip(highlight_bounds.iter()) {
5670 let line_bounds = line.layout.bounds();
5671 assert_eq!(highlight_bounds.top(), line_bounds.top());
5672 assert_eq!(
5673 highlight_bounds.bottom(),
5674 line_bounds.top() + line.layout.line_height()
5675 );
5676 }
5677 }
5678
5679 #[gpui::test]
5680 fn test_bounds_for_source_range_returns_one_bound_per_soft_wrap_row(cx: &mut TestAppContext) {
5681 let sentence = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, \
5682 sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.";
5683 let source = [sentence, sentence, sentence, sentence].join(" ");
5684 let rendered = render_markdown(&source, cx);
5685 let line = &rendered.lines[0];
5686 let line_bounds = line.layout.bounds();
5687 let line_height = line.layout.line_height();
5688 let wrapped_line = line.layout.line_layout_for_index(0).unwrap();
5689 let visual_row_count = wrapped_line.wrap_boundaries().len() + 1;
5690
5691 let highlight_bounds = rendered.bounds_for_source_range(0..source.len());
5692 assert_eq!(highlight_bounds.len(), visual_row_count);
5693
5694 let mut row_top = line_bounds.top();
5695 for (row_index, row_bounds) in highlight_bounds.iter().enumerate() {
5696 assert_eq!(row_bounds.top(), row_top);
5697 assert_eq!(row_bounds.bottom(), row_top + line_height);
5698 assert!(
5699 row_bounds.size.width > Pixels::ZERO,
5700 "row {row_index} should have a non-empty highlight"
5701 );
5702 row_top += line_height;
5703 }
5704 }
5705
5706 #[gpui::test]
5707 fn test_heading_font_sizes_are_distinct(cx: &mut TestAppContext) {
5708 let rendered = render_markdown("# H1\n\n## H2\n\n### H3\n\nBody text", cx);
5709
5710 assert!(
5711 rendered.lines.len() >= 4,
5712 "expected at least 4 rendered lines, got {}",
5713 rendered.lines.len()
5714 );
5715
5716 let h1_line_height = rendered.lines[0].layout.line_height();
5717 let h2_line_height = rendered.lines[1].layout.line_height();
5718 let h3_line_height = rendered.lines[2].layout.line_height();
5719 let body_line_height = rendered.lines[3].layout.line_height();
5720
5721 assert!(
5722 h1_line_height > h2_line_height,
5723 "H1 line height ({h1_line_height:?}) should be greater than H2 ({h2_line_height:?})"
5724 );
5725 assert!(
5726 h2_line_height > h3_line_height,
5727 "H2 line height ({h2_line_height:?}) should be greater than H3 ({h3_line_height:?})"
5728 );
5729 assert!(
5730 h3_line_height > body_line_height,
5731 "H3 line height ({h3_line_height:?}) should be greater than body text ({body_line_height:?})"
5732 );
5733 }
5734
5735 #[gpui::test]
5736 fn test_ui_zoom_does_not_affect_markdown_preview(cx: &mut TestAppContext) {
5737 ensure_theme_initialized(cx);
5738
5739 cx.update(|cx| {
5740 settings::SettingsStore::update_global(cx, |store, cx| {
5741 store.update_user_settings(cx, |settings| {
5742 settings.theme.ui_font_size = Some(16.0.into());
5743 settings.theme.markdown_preview_font_size = None;
5744 });
5745 });
5746 });
5747 cx.run_until_parked();
5748
5749 cx.update(|cx| {
5750 let before = ThemeSettings::get_global(cx).markdown_preview_font_size(cx);
5751 assert_eq!(before, px(16.0));
5752
5753 theme_settings::adjust_ui_font_size(cx, |size| size + px(3.0));
5754
5755 assert_eq!(ThemeSettings::get_global(cx).ui_font_size(cx), px(19.0));
5756 assert_eq!(
5757 ThemeSettings::get_global(cx).markdown_preview_font_size(cx),
5758 before
5759 );
5760 });
5761 }
5762
5763 #[gpui::test]
5764 fn test_markdown_preview_follows_ui_font_size_setting_when_unset(cx: &mut TestAppContext) {
5765 ensure_theme_initialized(cx);
5766
5767 cx.update(|cx| {
5768 settings::SettingsStore::update_global(cx, |store, cx| {
5769 store.update_user_settings(cx, |settings| {
5770 settings.theme.ui_font_size = Some(20.0.into());
5771 settings.theme.markdown_preview_font_size = None;
5772 });
5773 });
5774 });
5775 cx.run_until_parked();
5776 cx.update(|cx| {
5777 assert_eq!(
5778 ThemeSettings::get_global(cx).markdown_preview_font_size(cx),
5779 px(20.0)
5780 );
5781 });
5782
5783 cx.update(|cx| {
5784 settings::SettingsStore::update_global(cx, |store, cx| {
5785 store.update_user_settings(cx, |settings| {
5786 settings.theme.ui_font_size = Some(24.0.into());
5787 });
5788 });
5789 });
5790 cx.run_until_parked();
5791 cx.update(|cx| {
5792 assert_eq!(
5793 ThemeSettings::get_global(cx).markdown_preview_font_size(cx),
5794 px(24.0)
5795 );
5796 });
5797 }
5798}
5799