1use futures::FutureExt;
2use gpui::{
3 actions,
4 elements::{Flex, MouseEventHandler, Padding, Text},
5 impl_internal_actions,
6 platform::CursorStyle,
7 Axis, Element, ElementBox, ModelHandle, MouseButton, MutableAppContext, RenderContext, Task,
8 ViewContext,
9};
10use language::{Bias, DiagnosticEntry, DiagnosticSeverity};
11use project::{HoverBlock, Project};
12use settings::Settings;
13use std::{ops::Range, 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 MutableAppContext) {
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_weak(|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 if let Some(this) = this.upgrade(&cx) {
206 this.update(&mut cx, |this, _| {
207 this.hover_state.diagnostic_popover =
208 local_diagnostic.map(|local_diagnostic| DiagnosticPopover {
209 local_diagnostic,
210 primary_diagnostic,
211 });
212 });
213 }
214
215 // Construct new hover popover from hover request
216 let hover_popover = hover_request.await.ok().flatten().and_then(|hover_result| {
217 if hover_result.contents.is_empty() {
218 return None;
219 }
220
221 // Create symbol range of anchors for highlighting and filtering
222 // of future requests.
223 let range = if let Some(range) = hover_result.range {
224 let start = snapshot
225 .buffer_snapshot
226 .anchor_in_excerpt(excerpt_id.clone(), range.start);
227 let end = snapshot
228 .buffer_snapshot
229 .anchor_in_excerpt(excerpt_id.clone(), range.end);
230
231 start..end
232 } else {
233 anchor..anchor
234 };
235
236 Some(InfoPopover {
237 project: project.clone(),
238 symbol_range: range,
239 contents: hover_result.contents,
240 })
241 });
242
243 if let Some(this) = this.upgrade(&cx) {
244 this.update(&mut cx, |this, cx| {
245 if let Some(hover_popover) = hover_popover.as_ref() {
246 // Highlight the selected symbol using a background highlight
247 this.highlight_background::<HoverState>(
248 vec![hover_popover.symbol_range.clone()],
249 |theme| theme.editor.hover_popover.highlight,
250 cx,
251 );
252 } else {
253 this.clear_background_highlights::<HoverState>(cx);
254 }
255
256 this.hover_state.info_popover = hover_popover;
257 cx.notify();
258 });
259 }
260 Ok::<_, anyhow::Error>(())
261 }
262 .log_err()
263 });
264
265 editor.hover_state.info_task = Some(task);
266}
267
268#[derive(Default)]
269pub struct HoverState {
270 pub info_popover: Option<InfoPopover>,
271 pub diagnostic_popover: Option<DiagnosticPopover>,
272 pub triggered_from: Option<Anchor>,
273 pub info_task: Option<Task<Option<()>>>,
274}
275
276impl HoverState {
277 pub fn visible(&self) -> bool {
278 self.info_popover.is_some() || self.diagnostic_popover.is_some()
279 }
280
281 pub fn render(
282 &self,
283 snapshot: &EditorSnapshot,
284 style: &EditorStyle,
285 visible_rows: Range<u32>,
286 cx: &mut RenderContext<Editor>,
287 ) -> Option<(DisplayPoint, Vec<ElementBox>)> {
288 // If there is a diagnostic, position the popovers based on that.
289 // Otherwise use the start of the hover range
290 let anchor = self
291 .diagnostic_popover
292 .as_ref()
293 .map(|diagnostic_popover| &diagnostic_popover.local_diagnostic.range.start)
294 .or_else(|| {
295 self.info_popover
296 .as_ref()
297 .map(|info_popover| &info_popover.symbol_range.start)
298 })?;
299 let point = anchor.to_display_point(&snapshot.display_snapshot);
300
301 // Don't render if the relevant point isn't on screen
302 if !self.visible() || !visible_rows.contains(&point.row()) {
303 return None;
304 }
305
306 let mut elements = Vec::new();
307
308 if let Some(diagnostic_popover) = self.diagnostic_popover.as_ref() {
309 elements.push(diagnostic_popover.render(style, cx));
310 }
311 if let Some(info_popover) = self.info_popover.as_ref() {
312 elements.push(info_popover.render(style, cx));
313 }
314
315 Some((point, elements))
316 }
317}
318
319#[derive(Debug, Clone)]
320pub struct InfoPopover {
321 pub project: ModelHandle<Project>,
322 pub symbol_range: Range<Anchor>,
323 pub contents: Vec<HoverBlock>,
324}
325
326impl InfoPopover {
327 pub fn render(&self, style: &EditorStyle, cx: &mut RenderContext<Editor>) -> ElementBox {
328 MouseEventHandler::<InfoPopover>::new(0, cx, |_, cx| {
329 let mut flex = Flex::new(Axis::Vertical).scrollable::<HoverBlock, _>(1, None, cx);
330 flex.extend(self.contents.iter().map(|content| {
331 let languages = self.project.read(cx).languages();
332 if let Some(language) = content.language.clone().and_then(|language| {
333 languages.language_for_name(&language).now_or_never()?.ok()
334 }) {
335 let runs = language
336 .highlight_text(&content.text.as_str().into(), 0..content.text.len());
337
338 Text::new(content.text.clone(), style.text.clone())
339 .with_soft_wrap(true)
340 .with_highlights(
341 runs.iter()
342 .filter_map(|(range, id)| {
343 id.style(style.theme.syntax.as_ref())
344 .map(|style| (range.clone(), style))
345 })
346 .collect(),
347 )
348 .boxed()
349 } else {
350 let mut text_style = style.hover_popover.prose.clone();
351 text_style.font_size = style.text.font_size;
352
353 Text::new(content.text.clone(), text_style)
354 .with_soft_wrap(true)
355 .contained()
356 .with_style(style.hover_popover.block_style)
357 .boxed()
358 }
359 }));
360 flex.contained()
361 .with_style(style.hover_popover.container)
362 .boxed()
363 })
364 .on_move(|_, _| {}) // Consume move events so they don't reach regions underneath.
365 .with_cursor_style(CursorStyle::Arrow)
366 .with_padding(Padding {
367 bottom: HOVER_POPOVER_GAP,
368 top: HOVER_POPOVER_GAP,
369 ..Default::default()
370 })
371 .boxed()
372 }
373}
374
375#[derive(Debug, Clone)]
376pub struct DiagnosticPopover {
377 local_diagnostic: DiagnosticEntry<Anchor>,
378 primary_diagnostic: Option<DiagnosticEntry<Anchor>>,
379}
380
381impl DiagnosticPopover {
382 pub fn render(&self, style: &EditorStyle, cx: &mut RenderContext<Editor>) -> ElementBox {
383 enum PrimaryDiagnostic {}
384
385 let mut text_style = style.hover_popover.prose.clone();
386 text_style.font_size = style.text.font_size;
387
388 let container_style = match self.local_diagnostic.diagnostic.severity {
389 DiagnosticSeverity::HINT => style.hover_popover.info_container,
390 DiagnosticSeverity::INFORMATION => style.hover_popover.info_container,
391 DiagnosticSeverity::WARNING => style.hover_popover.warning_container,
392 DiagnosticSeverity::ERROR => style.hover_popover.error_container,
393 _ => style.hover_popover.container,
394 };
395
396 let tooltip_style = cx.global::<Settings>().theme.tooltip.clone();
397
398 MouseEventHandler::<DiagnosticPopover>::new(0, cx, |_, _| {
399 Text::new(self.local_diagnostic.diagnostic.message.clone(), text_style)
400 .with_soft_wrap(true)
401 .contained()
402 .with_style(container_style)
403 .boxed()
404 })
405 .with_padding(Padding {
406 top: HOVER_POPOVER_GAP,
407 bottom: HOVER_POPOVER_GAP,
408 ..Default::default()
409 })
410 .on_move(|_, _| {}) // Consume move events so they don't reach regions underneath.
411 .on_click(MouseButton::Left, |_, cx| {
412 cx.dispatch_action(GoToDiagnostic)
413 })
414 .with_cursor_style(CursorStyle::PointingHand)
415 .with_tooltip::<PrimaryDiagnostic, _>(
416 0,
417 "Go To Diagnostic".to_string(),
418 Some(Box::new(crate::GoToDiagnostic)),
419 tooltip_style,
420 cx,
421 )
422 .boxed()
423 }
424
425 pub fn activation_info(&self) -> (usize, Anchor) {
426 let entry = self
427 .primary_diagnostic
428 .as_ref()
429 .unwrap_or(&self.local_diagnostic);
430
431 (entry.diagnostic.group_id, entry.range.start.clone())
432 }
433}
434
435#[cfg(test)]
436mod tests {
437 use indoc::indoc;
438
439 use language::{Diagnostic, DiagnosticSet};
440 use project::HoverBlock;
441 use smol::stream::StreamExt;
442
443 use crate::test::editor_lsp_test_context::EditorLspTestContext;
444
445 use super::*;
446
447 #[gpui::test]
448 async fn test_mouse_hover_info_popover(cx: &mut gpui::TestAppContext) {
449 let mut cx = EditorLspTestContext::new_rust(
450 lsp::ServerCapabilities {
451 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
452 ..Default::default()
453 },
454 cx,
455 )
456 .await;
457
458 // Basic hover delays and then pops without moving the mouse
459 cx.set_state(indoc! {"
460 fn ˇtest() { println!(); }
461 "});
462 let hover_point = cx.display_point(indoc! {"
463 fn test() { printˇln!(); }
464 "});
465
466 cx.update_editor(|editor, cx| {
467 hover_at(
468 editor,
469 &HoverAt {
470 point: Some(hover_point),
471 },
472 cx,
473 )
474 });
475 assert!(!cx.editor(|editor, _| editor.hover_state.visible()));
476
477 // After delay, hover should be visible.
478 let symbol_range = cx.lsp_range(indoc! {"
479 fn test() { «println!»(); }
480 "});
481 let mut requests =
482 cx.handle_request::<lsp::request::HoverRequest, _, _>(move |_, _, _| async move {
483 Ok(Some(lsp::Hover {
484 contents: lsp::HoverContents::Markup(lsp::MarkupContent {
485 kind: lsp::MarkupKind::Markdown,
486 value: indoc! {"
487 # Some basic docs
488 Some test documentation"}
489 .to_string(),
490 }),
491 range: Some(symbol_range),
492 }))
493 });
494 cx.foreground()
495 .advance_clock(Duration::from_millis(HOVER_DELAY_MILLIS + 100));
496 requests.next().await;
497
498 cx.editor(|editor, _| {
499 assert!(editor.hover_state.visible());
500 assert_eq!(
501 editor.hover_state.info_popover.clone().unwrap().contents,
502 vec![
503 HoverBlock {
504 text: "Some basic docs".to_string(),
505 language: None
506 },
507 HoverBlock {
508 text: "Some test documentation".to_string(),
509 language: None
510 }
511 ]
512 )
513 });
514
515 // Mouse moved with no hover response dismisses
516 let hover_point = cx.display_point(indoc! {"
517 fn teˇst() { println!(); }
518 "});
519 let mut request = cx
520 .lsp
521 .handle_request::<lsp::request::HoverRequest, _, _>(|_, _| async move { Ok(None) });
522 cx.update_editor(|editor, cx| {
523 hover_at(
524 editor,
525 &HoverAt {
526 point: Some(hover_point),
527 },
528 cx,
529 )
530 });
531 cx.foreground()
532 .advance_clock(Duration::from_millis(HOVER_DELAY_MILLIS + 100));
533 request.next().await;
534 cx.editor(|editor, _| {
535 assert!(!editor.hover_state.visible());
536 });
537 }
538
539 #[gpui::test]
540 async fn test_keyboard_hover_info_popover(cx: &mut gpui::TestAppContext) {
541 let mut cx = EditorLspTestContext::new_rust(
542 lsp::ServerCapabilities {
543 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
544 ..Default::default()
545 },
546 cx,
547 )
548 .await;
549
550 // Hover with keyboard has no delay
551 cx.set_state(indoc! {"
552 fˇn test() { println!(); }
553 "});
554 cx.update_editor(|editor, cx| hover(editor, &Hover, cx));
555 let symbol_range = cx.lsp_range(indoc! {"
556 «fn» test() { println!(); }
557 "});
558 cx.handle_request::<lsp::request::HoverRequest, _, _>(move |_, _, _| async move {
559 Ok(Some(lsp::Hover {
560 contents: lsp::HoverContents::Markup(lsp::MarkupContent {
561 kind: lsp::MarkupKind::Markdown,
562 value: indoc! {"
563 # Some other basic docs
564 Some other test documentation"}
565 .to_string(),
566 }),
567 range: Some(symbol_range),
568 }))
569 })
570 .next()
571 .await;
572
573 cx.condition(|editor, _| editor.hover_state.visible()).await;
574 cx.editor(|editor, _| {
575 assert_eq!(
576 editor.hover_state.info_popover.clone().unwrap().contents,
577 vec![
578 HoverBlock {
579 text: "Some other basic docs".to_string(),
580 language: None
581 },
582 HoverBlock {
583 text: "Some other test documentation".to_string(),
584 language: None
585 }
586 ]
587 )
588 });
589 }
590
591 #[gpui::test]
592 async fn test_hover_diagnostic_and_info_popovers(cx: &mut gpui::TestAppContext) {
593 let mut cx = EditorLspTestContext::new_rust(
594 lsp::ServerCapabilities {
595 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
596 ..Default::default()
597 },
598 cx,
599 )
600 .await;
601
602 // Hover with just diagnostic, pops DiagnosticPopover immediately and then
603 // info popover once request completes
604 cx.set_state(indoc! {"
605 fn teˇst() { println!(); }
606 "});
607
608 // Send diagnostic to client
609 let range = cx.text_anchor_range(indoc! {"
610 fn «test»() { println!(); }
611 "});
612 cx.update_buffer(|buffer, cx| {
613 let snapshot = buffer.text_snapshot();
614 let set = DiagnosticSet::from_sorted_entries(
615 vec![DiagnosticEntry {
616 range,
617 diagnostic: Diagnostic {
618 message: "A test diagnostic message.".to_string(),
619 ..Default::default()
620 },
621 }],
622 &snapshot,
623 );
624 buffer.update_diagnostics(set, cx);
625 });
626
627 // Hover pops diagnostic immediately
628 cx.update_editor(|editor, cx| hover(editor, &Hover, cx));
629 cx.foreground().run_until_parked();
630
631 cx.editor(|Editor { hover_state, .. }, _| {
632 assert!(hover_state.diagnostic_popover.is_some() && hover_state.info_popover.is_none())
633 });
634
635 // Info Popover shows after request responded to
636 let range = cx.lsp_range(indoc! {"
637 fn «test»() { println!(); }
638 "});
639 cx.handle_request::<lsp::request::HoverRequest, _, _>(move |_, _, _| async move {
640 Ok(Some(lsp::Hover {
641 contents: lsp::HoverContents::Markup(lsp::MarkupContent {
642 kind: lsp::MarkupKind::Markdown,
643 value: indoc! {"
644 # Some other basic docs
645 Some other test documentation"}
646 .to_string(),
647 }),
648 range: Some(range),
649 }))
650 });
651 cx.foreground()
652 .advance_clock(Duration::from_millis(HOVER_DELAY_MILLIS + 100));
653
654 cx.foreground().run_until_parked();
655 cx.editor(|Editor { hover_state, .. }, _| {
656 hover_state.diagnostic_popover.is_some() && hover_state.info_task.is_some()
657 });
658 }
659}