1use futures::FutureExt;
2use gpui::{
3 actions,
4 elements::{Flex, MouseEventHandler, Padding, ParentElement, Text},
5 fonts::{HighlightStyle, Underline, Weight},
6 impl_internal_actions,
7 platform::{CursorStyle, MouseButton},
8 AnyElement, AppContext, CursorRegion, Element, ModelHandle, MouseRegion, Task, ViewContext,
9};
10use language::{Bias, DiagnosticEntry, DiagnosticSeverity, Language, LanguageRegistry};
11use project::{HoverBlock, HoverBlockKind, Project};
12use settings::Settings;
13use std::{ops::Range, sync::Arc, time::Duration};
14use util::TryFutureExt;
15
16use crate::{
17 display_map::ToDisplayPoint, Anchor, AnchorRangeExt, DisplayPoint, Editor, EditorSnapshot,
18 EditorStyle, GoToDiagnostic, RangeToAnchorExt,
19};
20
21pub const HOVER_DELAY_MILLIS: u64 = 350;
22pub const HOVER_REQUEST_DELAY_MILLIS: u64 = 200;
23
24pub const MIN_POPOVER_CHARACTER_WIDTH: f32 = 20.;
25pub const MIN_POPOVER_LINE_HEIGHT: f32 = 4.;
26pub const HOVER_POPOVER_GAP: f32 = 10.;
27
28#[derive(Clone, PartialEq)]
29pub struct HoverAt {
30 pub point: Option<DisplayPoint>,
31}
32
33#[derive(Copy, Clone, PartialEq)]
34pub struct HideHover;
35
36actions!(editor, [Hover]);
37impl_internal_actions!(editor, [HoverAt, HideHover]);
38
39pub fn init(cx: &mut AppContext) {
40 cx.add_action(hover);
41 cx.add_action(hover_at);
42 cx.add_action(hide_hover);
43}
44
45/// Bindable action which uses the most recent selection head to trigger a hover
46pub fn hover(editor: &mut Editor, _: &Hover, cx: &mut ViewContext<Editor>) {
47 let head = editor.selections.newest_display(cx).head();
48 show_hover(editor, head, true, cx);
49}
50
51/// The internal hover action dispatches between `show_hover` or `hide_hover`
52/// depending on whether a point to hover over is provided.
53pub fn hover_at(editor: &mut Editor, action: &HoverAt, cx: &mut ViewContext<Editor>) {
54 if cx.global::<Settings>().hover_popover_enabled {
55 if let Some(point) = action.point {
56 show_hover(editor, point, false, cx);
57 } else {
58 hide_hover(editor, &HideHover, cx);
59 }
60 }
61}
62
63/// Hides the type information popup.
64/// Triggered by the `Hover` action when the cursor is not over a symbol or when the
65/// selections changed.
66pub fn hide_hover(editor: &mut Editor, _: &HideHover, cx: &mut ViewContext<Editor>) -> bool {
67 let did_hide = editor.hover_state.info_popover.take().is_some()
68 | editor.hover_state.diagnostic_popover.take().is_some();
69
70 editor.hover_state.info_task = None;
71 editor.hover_state.triggered_from = None;
72
73 editor.clear_background_highlights::<HoverState>(cx);
74
75 if did_hide {
76 cx.notify();
77 }
78
79 did_hide
80}
81
82/// Queries the LSP and shows type info and documentation
83/// about the symbol the mouse is currently hovering over.
84/// Triggered by the `Hover` action when the cursor may be over a symbol.
85fn show_hover(
86 editor: &mut Editor,
87 point: DisplayPoint,
88 ignore_timeout: bool,
89 cx: &mut ViewContext<Editor>,
90) {
91 if editor.pending_rename.is_some() {
92 return;
93 }
94
95 let snapshot = editor.snapshot(cx);
96 let multibuffer_offset = point.to_offset(&snapshot.display_snapshot, Bias::Left);
97
98 let (buffer, buffer_position) = if let Some(output) = editor
99 .buffer
100 .read(cx)
101 .text_anchor_for_position(multibuffer_offset, cx)
102 {
103 output
104 } else {
105 return;
106 };
107
108 let excerpt_id = if let Some((excerpt_id, _, _)) = editor
109 .buffer()
110 .read(cx)
111 .excerpt_containing(multibuffer_offset, cx)
112 {
113 excerpt_id
114 } else {
115 return;
116 };
117
118 let project = if let Some(project) = editor.project.clone() {
119 project
120 } else {
121 return;
122 };
123
124 if !ignore_timeout {
125 if let Some(InfoPopover { symbol_range, .. }) = &editor.hover_state.info_popover {
126 if symbol_range
127 .to_offset(&snapshot.buffer_snapshot)
128 .contains(&multibuffer_offset)
129 {
130 // Hover triggered from same location as last time. Don't show again.
131 return;
132 } else {
133 hide_hover(editor, &HideHover, cx);
134 }
135 }
136 }
137
138 // Get input anchor
139 let anchor = snapshot
140 .buffer_snapshot
141 .anchor_at(multibuffer_offset, Bias::Left);
142
143 // Don't request again if the location is the same as the previous request
144 if let Some(triggered_from) = &editor.hover_state.triggered_from {
145 if triggered_from
146 .cmp(&anchor, &snapshot.buffer_snapshot)
147 .is_eq()
148 {
149 return;
150 }
151 }
152
153 let task = cx.spawn(|this, mut cx| {
154 async move {
155 // If we need to delay, delay a set amount initially before making the lsp request
156 let delay = if !ignore_timeout {
157 // Construct delay task to wait for later
158 let total_delay = Some(
159 cx.background()
160 .timer(Duration::from_millis(HOVER_DELAY_MILLIS)),
161 );
162
163 cx.background()
164 .timer(Duration::from_millis(HOVER_REQUEST_DELAY_MILLIS))
165 .await;
166 total_delay
167 } else {
168 None
169 };
170
171 // query the LSP for hover info
172 let hover_request = cx.update(|cx| {
173 project.update(cx, |project, cx| {
174 project.hover(&buffer, buffer_position, cx)
175 })
176 });
177
178 if let Some(delay) = delay {
179 delay.await;
180 }
181
182 // If there's a diagnostic, assign it on the hover state and notify
183 let local_diagnostic = snapshot
184 .buffer_snapshot
185 .diagnostics_in_range::<_, usize>(multibuffer_offset..multibuffer_offset, false)
186 // Find the entry with the most specific range
187 .min_by_key(|entry| entry.range.end - entry.range.start)
188 .map(|entry| DiagnosticEntry {
189 diagnostic: entry.diagnostic,
190 range: entry.range.to_anchors(&snapshot.buffer_snapshot),
191 });
192
193 // Pull the primary diagnostic out so we can jump to it if the popover is clicked
194 let primary_diagnostic = local_diagnostic.as_ref().and_then(|local_diagnostic| {
195 snapshot
196 .buffer_snapshot
197 .diagnostic_group::<usize>(local_diagnostic.diagnostic.group_id)
198 .find(|diagnostic| diagnostic.diagnostic.is_primary)
199 .map(|entry| DiagnosticEntry {
200 diagnostic: entry.diagnostic,
201 range: entry.range.to_anchors(&snapshot.buffer_snapshot),
202 })
203 });
204
205 this.update(&mut cx, |this, _| {
206 this.hover_state.diagnostic_popover =
207 local_diagnostic.map(|local_diagnostic| DiagnosticPopover {
208 local_diagnostic,
209 primary_diagnostic,
210 });
211 })?;
212
213 // Construct new hover popover from hover request
214 let hover_popover = hover_request.await.ok().flatten().and_then(|hover_result| {
215 if hover_result.contents.is_empty() {
216 return None;
217 }
218
219 // Create symbol range of anchors for highlighting and filtering
220 // of future requests.
221 let range = if let Some(range) = hover_result.range {
222 let start = snapshot
223 .buffer_snapshot
224 .anchor_in_excerpt(excerpt_id.clone(), range.start);
225 let end = snapshot
226 .buffer_snapshot
227 .anchor_in_excerpt(excerpt_id.clone(), range.end);
228
229 start..end
230 } else {
231 anchor..anchor
232 };
233
234 Some(InfoPopover {
235 project: project.clone(),
236 symbol_range: range,
237 blocks: hover_result.contents,
238 rendered_content: None,
239 })
240 });
241
242 this.update(&mut cx, |this, cx| {
243 if let Some(hover_popover) = hover_popover.as_ref() {
244 // Highlight the selected symbol using a background highlight
245 this.highlight_background::<HoverState>(
246 vec![hover_popover.symbol_range.clone()],
247 |theme| theme.editor.hover_popover.highlight,
248 cx,
249 );
250 } else {
251 this.clear_background_highlights::<HoverState>(cx);
252 }
253
254 this.hover_state.info_popover = hover_popover;
255 cx.notify();
256 })?;
257
258 Ok::<_, anyhow::Error>(())
259 }
260 .log_err()
261 });
262
263 editor.hover_state.info_task = Some(task);
264}
265
266fn render_blocks(
267 theme_id: usize,
268 blocks: &[HoverBlock],
269 language_registry: &Arc<LanguageRegistry>,
270 style: &EditorStyle,
271) -> RenderedInfo {
272 let mut text = String::new();
273 let mut highlights = Vec::new();
274 let mut region_ranges = Vec::new();
275 let mut regions = Vec::new();
276
277 for block in blocks {
278 match &block.kind {
279 HoverBlockKind::PlainText => {
280 new_paragraph(&mut text, &mut Vec::new());
281 text.push_str(&block.text);
282 }
283 HoverBlockKind::Markdown => {
284 use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag};
285
286 let mut bold_depth = 0;
287 let mut italic_depth = 0;
288 let mut link_url = None;
289 let mut current_language = None;
290 let mut list_stack = Vec::new();
291
292 for event in Parser::new_ext(&block.text, Options::all()) {
293 let prev_len = text.len();
294 match event {
295 Event::Text(t) => {
296 if let Some(language) = ¤t_language {
297 render_code(
298 &mut text,
299 &mut highlights,
300 t.as_ref(),
301 language,
302 style,
303 );
304 } else {
305 text.push_str(t.as_ref());
306
307 let mut style = HighlightStyle::default();
308 if bold_depth > 0 {
309 style.weight = Some(Weight::BOLD);
310 }
311 if italic_depth > 0 {
312 style.italic = Some(true);
313 }
314 if let Some(link_url) = link_url.clone() {
315 region_ranges.push(prev_len..text.len());
316 regions.push(RenderedRegion {
317 link_url: Some(link_url),
318 code: false,
319 });
320 style.underline = Some(Underline {
321 thickness: 1.0.into(),
322 ..Default::default()
323 });
324 }
325
326 if style != HighlightStyle::default() {
327 let mut new_highlight = true;
328 if let Some((last_range, last_style)) = highlights.last_mut() {
329 if last_range.end == prev_len && last_style == &style {
330 last_range.end = text.len();
331 new_highlight = false;
332 }
333 }
334 if new_highlight {
335 highlights.push((prev_len..text.len(), style));
336 }
337 }
338 }
339 }
340 Event::Code(t) => {
341 text.push_str(t.as_ref());
342 region_ranges.push(prev_len..text.len());
343 if link_url.is_some() {
344 highlights.push((
345 prev_len..text.len(),
346 HighlightStyle {
347 underline: Some(Underline {
348 thickness: 1.0.into(),
349 ..Default::default()
350 }),
351 ..Default::default()
352 },
353 ));
354 }
355 regions.push(RenderedRegion {
356 code: true,
357 link_url: link_url.clone(),
358 });
359 }
360 Event::Start(tag) => match tag {
361 Tag::Paragraph => new_paragraph(&mut text, &mut list_stack),
362 Tag::Heading(_, _, _) => {
363 new_paragraph(&mut text, &mut list_stack);
364 bold_depth += 1;
365 }
366 Tag::CodeBlock(kind) => {
367 new_paragraph(&mut text, &mut list_stack);
368 if let CodeBlockKind::Fenced(language) = kind {
369 current_language = language_registry
370 .language_for_name(language.as_ref())
371 .now_or_never()
372 .and_then(Result::ok);
373 }
374 }
375 Tag::Emphasis => italic_depth += 1,
376 Tag::Strong => bold_depth += 1,
377 Tag::Link(_, url, _) => link_url = Some(url.to_string()),
378 Tag::List(number) => {
379 list_stack.push((number, false));
380 }
381 Tag::Item => {
382 let len = list_stack.len();
383 if let Some((list_number, has_content)) = list_stack.last_mut() {
384 *has_content = false;
385 if !text.is_empty() && !text.ends_with('\n') {
386 text.push('\n');
387 }
388 for _ in 0..len - 1 {
389 text.push_str(" ");
390 }
391 if let Some(number) = list_number {
392 text.push_str(&format!("{}. ", number));
393 *number += 1;
394 *has_content = false;
395 } else {
396 text.push_str("- ");
397 }
398 }
399 }
400 _ => {}
401 },
402 Event::End(tag) => match tag {
403 Tag::Heading(_, _, _) => bold_depth -= 1,
404 Tag::CodeBlock(_) => current_language = None,
405 Tag::Emphasis => italic_depth -= 1,
406 Tag::Strong => bold_depth -= 1,
407 Tag::Link(_, _, _) => link_url = None,
408 Tag::List(_) => drop(list_stack.pop()),
409 _ => {}
410 },
411 Event::HardBreak => text.push('\n'),
412 Event::SoftBreak => text.push(' '),
413 _ => {}
414 }
415 }
416 }
417 HoverBlockKind::Code { language } => {
418 if let Some(language) = language_registry
419 .language_for_name(language)
420 .now_or_never()
421 .and_then(Result::ok)
422 {
423 render_code(&mut text, &mut highlights, &block.text, &language, style);
424 } else {
425 text.push_str(&block.text);
426 }
427 }
428 }
429 }
430
431 if !text.is_empty() && !text.ends_with('\n') {
432 text.push('\n');
433 }
434
435 RenderedInfo {
436 theme_id,
437 text,
438 highlights,
439 region_ranges,
440 regions,
441 }
442}
443
444fn render_code(
445 text: &mut String,
446 highlights: &mut Vec<(Range<usize>, HighlightStyle)>,
447 content: &str,
448 language: &Arc<Language>,
449 style: &EditorStyle,
450) {
451 let prev_len = text.len();
452 text.push_str(content);
453 for (range, highlight_id) in language.highlight_text(&content.into(), 0..content.len()) {
454 if let Some(style) = highlight_id.style(&style.syntax) {
455 highlights.push((prev_len + range.start..prev_len + range.end, style));
456 }
457 }
458}
459
460fn new_paragraph(text: &mut String, list_stack: &mut Vec<(Option<u64>, bool)>) {
461 let mut is_subsequent_paragraph_of_list = false;
462 if let Some((_, has_content)) = list_stack.last_mut() {
463 if *has_content {
464 is_subsequent_paragraph_of_list = true;
465 } else {
466 *has_content = true;
467 return;
468 }
469 }
470
471 if !text.is_empty() {
472 if !text.ends_with('\n') {
473 text.push('\n');
474 }
475 text.push('\n');
476 }
477 for _ in 0..list_stack.len().saturating_sub(1) {
478 text.push_str(" ");
479 }
480 if is_subsequent_paragraph_of_list {
481 text.push_str(" ");
482 }
483}
484
485#[derive(Default)]
486pub struct HoverState {
487 pub info_popover: Option<InfoPopover>,
488 pub diagnostic_popover: Option<DiagnosticPopover>,
489 pub triggered_from: Option<Anchor>,
490 pub info_task: Option<Task<Option<()>>>,
491}
492
493impl HoverState {
494 pub fn visible(&self) -> bool {
495 self.info_popover.is_some() || self.diagnostic_popover.is_some()
496 }
497
498 pub fn render(
499 &mut self,
500 snapshot: &EditorSnapshot,
501 style: &EditorStyle,
502 visible_rows: Range<u32>,
503 cx: &mut ViewContext<Editor>,
504 ) -> Option<(DisplayPoint, Vec<AnyElement<Editor>>)> {
505 // If there is a diagnostic, position the popovers based on that.
506 // Otherwise use the start of the hover range
507 let anchor = self
508 .diagnostic_popover
509 .as_ref()
510 .map(|diagnostic_popover| &diagnostic_popover.local_diagnostic.range.start)
511 .or_else(|| {
512 self.info_popover
513 .as_ref()
514 .map(|info_popover| &info_popover.symbol_range.start)
515 })?;
516 let point = anchor.to_display_point(&snapshot.display_snapshot);
517
518 // Don't render if the relevant point isn't on screen
519 if !self.visible() || !visible_rows.contains(&point.row()) {
520 return None;
521 }
522
523 let mut elements = Vec::new();
524
525 if let Some(diagnostic_popover) = self.diagnostic_popover.as_ref() {
526 elements.push(diagnostic_popover.render(style, cx));
527 }
528 if let Some(info_popover) = self.info_popover.as_mut() {
529 elements.push(info_popover.render(style, cx));
530 }
531
532 Some((point, elements))
533 }
534}
535
536#[derive(Debug, Clone)]
537pub struct InfoPopover {
538 pub project: ModelHandle<Project>,
539 pub symbol_range: Range<Anchor>,
540 pub blocks: Vec<HoverBlock>,
541 rendered_content: Option<RenderedInfo>,
542}
543
544#[derive(Debug, Clone)]
545struct RenderedInfo {
546 theme_id: usize,
547 text: String,
548 highlights: Vec<(Range<usize>, HighlightStyle)>,
549 region_ranges: Vec<Range<usize>>,
550 regions: Vec<RenderedRegion>,
551}
552
553#[derive(Debug, Clone)]
554struct RenderedRegion {
555 code: bool,
556 link_url: Option<String>,
557}
558
559impl InfoPopover {
560 pub fn render(
561 &mut self,
562 style: &EditorStyle,
563 cx: &mut ViewContext<Editor>,
564 ) -> AnyElement<Editor> {
565 if let Some(rendered) = &self.rendered_content {
566 if rendered.theme_id != style.theme_id {
567 self.rendered_content = None;
568 }
569 }
570
571 let rendered_content = self.rendered_content.get_or_insert_with(|| {
572 render_blocks(
573 style.theme_id,
574 &self.blocks,
575 self.project.read(cx).languages(),
576 style,
577 )
578 });
579
580 MouseEventHandler::<InfoPopover, _>::new(0, cx, |_, cx| {
581 let mut region_id = 0;
582 let view_id = cx.view_id();
583
584 let code_span_background_color = style.document_highlight_read_background;
585 let regions = rendered_content.regions.clone();
586 Flex::column()
587 .scrollable::<HoverBlock>(1, None, cx)
588 .with_child(
589 Text::new(rendered_content.text.clone(), style.text.clone())
590 .with_highlights(rendered_content.highlights.clone())
591 .with_custom_runs(
592 rendered_content.region_ranges.clone(),
593 move |ix, bounds, scene, _| {
594 region_id += 1;
595 let region = regions[ix].clone();
596 if let Some(url) = region.link_url {
597 scene.push_cursor_region(CursorRegion {
598 bounds,
599 style: CursorStyle::PointingHand,
600 });
601 scene.push_mouse_region(
602 MouseRegion::new::<Self>(view_id, region_id, bounds)
603 .on_click::<Editor, _>(
604 MouseButton::Left,
605 move |_, _, cx| {
606 println!("clicked link {url}");
607 cx.platform().open_url(&url);
608 },
609 ),
610 );
611 }
612 if region.code {
613 scene.push_quad(gpui::Quad {
614 bounds,
615 background: Some(code_span_background_color),
616 border: Default::default(),
617 corner_radius: 2.0,
618 });
619 }
620 },
621 )
622 .with_soft_wrap(true),
623 )
624 .contained()
625 .with_style(style.hover_popover.container)
626 })
627 .on_move(|_, _, _| {}) // Consume move events so they don't reach regions underneath.
628 .with_cursor_style(CursorStyle::Arrow)
629 .with_padding(Padding {
630 bottom: HOVER_POPOVER_GAP,
631 top: HOVER_POPOVER_GAP,
632 ..Default::default()
633 })
634 .into_any()
635 }
636}
637
638#[derive(Debug, Clone)]
639pub struct DiagnosticPopover {
640 local_diagnostic: DiagnosticEntry<Anchor>,
641 primary_diagnostic: Option<DiagnosticEntry<Anchor>>,
642}
643
644impl DiagnosticPopover {
645 pub fn render(&self, style: &EditorStyle, cx: &mut ViewContext<Editor>) -> AnyElement<Editor> {
646 enum PrimaryDiagnostic {}
647
648 let mut text_style = style.hover_popover.prose.clone();
649 text_style.font_size = style.text.font_size;
650 let diagnostic_source_style = style.hover_popover.diagnostic_source_highlight.clone();
651
652 let text = match &self.local_diagnostic.diagnostic.source {
653 Some(source) => Text::new(
654 format!("{source}: {}", self.local_diagnostic.diagnostic.message),
655 text_style,
656 )
657 .with_highlights(vec![(0..source.len(), diagnostic_source_style)]),
658
659 None => Text::new(self.local_diagnostic.diagnostic.message.clone(), text_style),
660 };
661
662 let container_style = match self.local_diagnostic.diagnostic.severity {
663 DiagnosticSeverity::HINT => style.hover_popover.info_container,
664 DiagnosticSeverity::INFORMATION => style.hover_popover.info_container,
665 DiagnosticSeverity::WARNING => style.hover_popover.warning_container,
666 DiagnosticSeverity::ERROR => style.hover_popover.error_container,
667 _ => style.hover_popover.container,
668 };
669
670 let tooltip_style = cx.global::<Settings>().theme.tooltip.clone();
671
672 MouseEventHandler::<DiagnosticPopover, _>::new(0, cx, |_, _| {
673 text.with_soft_wrap(true)
674 .contained()
675 .with_style(container_style)
676 })
677 .with_padding(Padding {
678 top: HOVER_POPOVER_GAP,
679 bottom: HOVER_POPOVER_GAP,
680 ..Default::default()
681 })
682 .on_move(|_, _, _| {}) // Consume move events so they don't reach regions underneath.
683 .on_click(MouseButton::Left, |_, _, cx| {
684 cx.dispatch_action(GoToDiagnostic)
685 })
686 .with_cursor_style(CursorStyle::PointingHand)
687 .with_tooltip::<PrimaryDiagnostic>(
688 0,
689 "Go To Diagnostic".to_string(),
690 Some(Box::new(crate::GoToDiagnostic)),
691 tooltip_style,
692 cx,
693 )
694 .into_any()
695 }
696
697 pub fn activation_info(&self) -> (usize, Anchor) {
698 let entry = self
699 .primary_diagnostic
700 .as_ref()
701 .unwrap_or(&self.local_diagnostic);
702
703 (entry.diagnostic.group_id, entry.range.start.clone())
704 }
705}
706
707#[cfg(test)]
708mod tests {
709 use super::*;
710 use crate::test::editor_lsp_test_context::EditorLspTestContext;
711 use gpui::fonts::Weight;
712 use indoc::indoc;
713 use language::{Diagnostic, DiagnosticSet};
714 use lsp::LanguageServerId;
715 use project::{HoverBlock, HoverBlockKind};
716 use smol::stream::StreamExt;
717 use unindent::Unindent;
718 use util::test::marked_text_ranges;
719
720 #[gpui::test]
721 async fn test_mouse_hover_info_popover(cx: &mut gpui::TestAppContext) {
722 let mut cx = EditorLspTestContext::new_rust(
723 lsp::ServerCapabilities {
724 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
725 ..Default::default()
726 },
727 cx,
728 )
729 .await;
730
731 // Basic hover delays and then pops without moving the mouse
732 cx.set_state(indoc! {"
733 fn ˇtest() { println!(); }
734 "});
735 let hover_point = cx.display_point(indoc! {"
736 fn test() { printˇln!(); }
737 "});
738
739 cx.update_editor(|editor, cx| {
740 hover_at(
741 editor,
742 &HoverAt {
743 point: Some(hover_point),
744 },
745 cx,
746 )
747 });
748 assert!(!cx.editor(|editor, _| editor.hover_state.visible()));
749
750 // After delay, hover should be visible.
751 let symbol_range = cx.lsp_range(indoc! {"
752 fn test() { «println!»(); }
753 "});
754 let mut requests =
755 cx.handle_request::<lsp::request::HoverRequest, _, _>(move |_, _, _| async move {
756 Ok(Some(lsp::Hover {
757 contents: lsp::HoverContents::Markup(lsp::MarkupContent {
758 kind: lsp::MarkupKind::Markdown,
759 value: "some basic docs".to_string(),
760 }),
761 range: Some(symbol_range),
762 }))
763 });
764 cx.foreground()
765 .advance_clock(Duration::from_millis(HOVER_DELAY_MILLIS + 100));
766 requests.next().await;
767
768 cx.editor(|editor, _| {
769 assert!(editor.hover_state.visible());
770 assert_eq!(
771 editor.hover_state.info_popover.clone().unwrap().blocks,
772 vec![HoverBlock {
773 text: "some basic docs".to_string(),
774 kind: HoverBlockKind::Markdown,
775 },]
776 )
777 });
778
779 // Mouse moved with no hover response dismisses
780 let hover_point = cx.display_point(indoc! {"
781 fn teˇst() { println!(); }
782 "});
783 let mut request = cx
784 .lsp
785 .handle_request::<lsp::request::HoverRequest, _, _>(|_, _| async move { Ok(None) });
786 cx.update_editor(|editor, cx| {
787 hover_at(
788 editor,
789 &HoverAt {
790 point: Some(hover_point),
791 },
792 cx,
793 )
794 });
795 cx.foreground()
796 .advance_clock(Duration::from_millis(HOVER_DELAY_MILLIS + 100));
797 request.next().await;
798 cx.editor(|editor, _| {
799 assert!(!editor.hover_state.visible());
800 });
801 }
802
803 #[gpui::test]
804 async fn test_keyboard_hover_info_popover(cx: &mut gpui::TestAppContext) {
805 let mut cx = EditorLspTestContext::new_rust(
806 lsp::ServerCapabilities {
807 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
808 ..Default::default()
809 },
810 cx,
811 )
812 .await;
813
814 // Hover with keyboard has no delay
815 cx.set_state(indoc! {"
816 fˇn test() { println!(); }
817 "});
818 cx.update_editor(|editor, cx| hover(editor, &Hover, cx));
819 let symbol_range = cx.lsp_range(indoc! {"
820 «fn» test() { println!(); }
821 "});
822 cx.handle_request::<lsp::request::HoverRequest, _, _>(move |_, _, _| async move {
823 Ok(Some(lsp::Hover {
824 contents: lsp::HoverContents::Markup(lsp::MarkupContent {
825 kind: lsp::MarkupKind::Markdown,
826 value: "some other basic docs".to_string(),
827 }),
828 range: Some(symbol_range),
829 }))
830 })
831 .next()
832 .await;
833
834 cx.condition(|editor, _| editor.hover_state.visible()).await;
835 cx.editor(|editor, _| {
836 assert_eq!(
837 editor.hover_state.info_popover.clone().unwrap().blocks,
838 vec![HoverBlock {
839 text: "some other basic docs".to_string(),
840 kind: HoverBlockKind::Markdown,
841 }]
842 )
843 });
844 }
845
846 #[gpui::test]
847 async fn test_hover_diagnostic_and_info_popovers(cx: &mut gpui::TestAppContext) {
848 let mut cx = EditorLspTestContext::new_rust(
849 lsp::ServerCapabilities {
850 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
851 ..Default::default()
852 },
853 cx,
854 )
855 .await;
856
857 // Hover with just diagnostic, pops DiagnosticPopover immediately and then
858 // info popover once request completes
859 cx.set_state(indoc! {"
860 fn teˇst() { println!(); }
861 "});
862
863 // Send diagnostic to client
864 let range = cx.text_anchor_range(indoc! {"
865 fn «test»() { println!(); }
866 "});
867 cx.update_buffer(|buffer, cx| {
868 let snapshot = buffer.text_snapshot();
869 let set = DiagnosticSet::from_sorted_entries(
870 vec![DiagnosticEntry {
871 range,
872 diagnostic: Diagnostic {
873 message: "A test diagnostic message.".to_string(),
874 ..Default::default()
875 },
876 }],
877 &snapshot,
878 );
879 buffer.update_diagnostics(LanguageServerId(0), set, cx);
880 });
881
882 // Hover pops diagnostic immediately
883 cx.update_editor(|editor, cx| hover(editor, &Hover, cx));
884 cx.foreground().run_until_parked();
885
886 cx.editor(|Editor { hover_state, .. }, _| {
887 assert!(hover_state.diagnostic_popover.is_some() && hover_state.info_popover.is_none())
888 });
889
890 // Info Popover shows after request responded to
891 let range = cx.lsp_range(indoc! {"
892 fn «test»() { println!(); }
893 "});
894 cx.handle_request::<lsp::request::HoverRequest, _, _>(move |_, _, _| async move {
895 Ok(Some(lsp::Hover {
896 contents: lsp::HoverContents::Markup(lsp::MarkupContent {
897 kind: lsp::MarkupKind::Markdown,
898 value: "some new docs".to_string(),
899 }),
900 range: Some(range),
901 }))
902 });
903 cx.foreground()
904 .advance_clock(Duration::from_millis(HOVER_DELAY_MILLIS + 100));
905
906 cx.foreground().run_until_parked();
907 cx.editor(|Editor { hover_state, .. }, _| {
908 hover_state.diagnostic_popover.is_some() && hover_state.info_task.is_some()
909 });
910 }
911
912 #[gpui::test]
913 fn test_render_blocks(cx: &mut gpui::TestAppContext) {
914 Settings::test_async(cx);
915 cx.add_window(|cx| {
916 let editor = Editor::single_line(None, cx);
917 let style = editor.style(cx);
918
919 struct Row {
920 blocks: Vec<HoverBlock>,
921 expected_marked_text: String,
922 expected_styles: Vec<HighlightStyle>,
923 }
924
925 let rows = &[
926 // Strong emphasis
927 Row {
928 blocks: vec![HoverBlock {
929 text: "one **two** three".to_string(),
930 kind: HoverBlockKind::Markdown,
931 }],
932 expected_marked_text: "one «two» three\n".to_string(),
933 expected_styles: vec![HighlightStyle {
934 weight: Some(Weight::BOLD),
935 ..Default::default()
936 }],
937 },
938 // Links
939 Row {
940 blocks: vec three".to_string(),
942 kind: HoverBlockKind::Markdown,
943 }],
944 expected_marked_text: "one «two» three\n".to_string(),
945 expected_styles: vec![HighlightStyle {
946 underline: Some(Underline {
947 thickness: 1.0.into(),
948 ..Default::default()
949 }),
950 ..Default::default()
951 }],
952 },
953 // Lists
954 Row {
955 blocks: vec
963 - d
964 "
965 .unindent(),
966 kind: HoverBlockKind::Markdown,
967 }],
968 expected_marked_text: "
969 lists:
970 - one
971 - a
972 - b
973 - two
974 - «c»
975 - d
976 "
977 .unindent(),
978 expected_styles: vec![HighlightStyle {
979 underline: Some(Underline {
980 thickness: 1.0.into(),
981 ..Default::default()
982 }),
983 ..Default::default()
984 }],
985 },
986 // Multi-paragraph list items
987 Row {
988 blocks: vec![HoverBlock {
989 text: "
990 * one two
991 three
992
993 * four five
994 * six seven
995 eight
996
997 nine
998 * ten
999 * six
1000 "
1001 .unindent(),
1002 kind: HoverBlockKind::Markdown,
1003 }],
1004 expected_marked_text: "
1005 - one two three
1006 - four five
1007 - six seven eight
1008
1009 nine
1010 - ten
1011 - six
1012 "
1013 .unindent(),
1014 expected_styles: vec![HighlightStyle {
1015 underline: Some(Underline {
1016 thickness: 1.0.into(),
1017 ..Default::default()
1018 }),
1019 ..Default::default()
1020 }],
1021 },
1022 ];
1023
1024 for Row {
1025 blocks,
1026 expected_marked_text,
1027 expected_styles,
1028 } in &rows[0..]
1029 {
1030 let rendered = render_blocks(0, &blocks, &Default::default(), &style);
1031
1032 let (expected_text, ranges) = marked_text_ranges(expected_marked_text, false);
1033 let expected_highlights = ranges
1034 .into_iter()
1035 .zip(expected_styles.iter().cloned())
1036 .collect::<Vec<_>>();
1037 assert_eq!(
1038 rendered.text,
1039 dbg!(expected_text),
1040 "wrong text for input {blocks:?}"
1041 );
1042 assert_eq!(
1043 rendered.highlights, expected_highlights,
1044 "wrong highlights for input {blocks:?}"
1045 );
1046 }
1047
1048 editor
1049 });
1050 }
1051}