1use gpui::{
2 actions,
3 elements::{Flex, MouseEventHandler, Padding, Text},
4 impl_internal_actions,
5 platform::CursorStyle,
6 Axis, Element, ElementBox, ModelHandle, MouseButton, MutableAppContext, RenderContext, Task,
7 ViewContext,
8};
9use language::{Bias, DiagnosticEntry, DiagnosticSeverity};
10use project::{HoverBlock, Project};
11use settings::Settings;
12use std::{ops::Range, time::Duration};
13use util::TryFutureExt;
14
15use crate::{
16 display_map::ToDisplayPoint, Anchor, AnchorRangeExt, DisplayPoint, Editor, EditorSnapshot,
17 EditorStyle, GoToDiagnostic, RangeToAnchorExt,
18};
19
20pub const HOVER_DELAY_MILLIS: u64 = 350;
21pub const HOVER_REQUEST_DELAY_MILLIS: u64 = 200;
22
23#[derive(Clone, PartialEq)]
24pub struct HoverAt {
25 pub point: Option<DisplayPoint>,
26}
27
28actions!(editor, [Hover]);
29impl_internal_actions!(editor, [HoverAt]);
30
31pub fn init(cx: &mut MutableAppContext) {
32 cx.add_action(hover);
33 cx.add_action(hover_at);
34}
35
36/// Bindable action which uses the most recent selection head to trigger a hover
37pub fn hover(editor: &mut Editor, _: &Hover, cx: &mut ViewContext<Editor>) {
38 let head = editor.selections.newest_display(cx).head();
39 show_hover(editor, head, true, cx);
40}
41
42/// The internal hover action dispatches between `show_hover` or `hide_hover`
43/// depending on whether a point to hover over is provided.
44pub fn hover_at(editor: &mut Editor, action: &HoverAt, cx: &mut ViewContext<Editor>) {
45 if cx.global::<Settings>().hover_popover_enabled {
46 if let Some(point) = action.point {
47 show_hover(editor, point, false, cx);
48 } else {
49 hide_hover(editor, cx);
50 }
51 }
52}
53
54/// Hides the type information popup.
55/// Triggered by the `Hover` action when the cursor is not over a symbol or when the
56/// selections changed.
57pub fn hide_hover(editor: &mut Editor, cx: &mut ViewContext<Editor>) -> bool {
58 let did_hide = editor.hover_state.info_popover.take().is_some()
59 | editor.hover_state.diagnostic_popover.take().is_some();
60
61 editor.hover_state.info_task = None;
62 editor.hover_state.triggered_from = None;
63
64 editor.clear_background_highlights::<HoverState>(cx);
65
66 did_hide
67}
68
69/// Queries the LSP and shows type info and documentation
70/// about the symbol the mouse is currently hovering over.
71/// Triggered by the `Hover` action when the cursor may be over a symbol.
72fn show_hover(
73 editor: &mut Editor,
74 point: DisplayPoint,
75 ignore_timeout: bool,
76 cx: &mut ViewContext<Editor>,
77) {
78 if editor.pending_rename.is_some() {
79 return;
80 }
81
82 let snapshot = editor.snapshot(cx);
83 let multibuffer_offset = point.to_offset(&snapshot.display_snapshot, Bias::Left);
84
85 let (buffer, buffer_position) = if let Some(output) = editor
86 .buffer
87 .read(cx)
88 .text_anchor_for_position(multibuffer_offset, cx)
89 {
90 output
91 } else {
92 return;
93 };
94
95 let excerpt_id = if let Some((excerpt_id, _, _)) = editor
96 .buffer()
97 .read(cx)
98 .excerpt_containing(multibuffer_offset, cx)
99 {
100 excerpt_id
101 } else {
102 return;
103 };
104
105 let project = if let Some(project) = editor.project.clone() {
106 project
107 } else {
108 return;
109 };
110
111 if !ignore_timeout {
112 if let Some(InfoPopover { symbol_range, .. }) = &editor.hover_state.info_popover {
113 if symbol_range
114 .to_offset(&snapshot.buffer_snapshot)
115 .contains(&multibuffer_offset)
116 {
117 // Hover triggered from same location as last time. Don't show again.
118 return;
119 } else {
120 hide_hover(editor, cx);
121 }
122 }
123 }
124
125 // Get input anchor
126 let anchor = snapshot
127 .buffer_snapshot
128 .anchor_at(multibuffer_offset, Bias::Left);
129
130 // Don't request again if the location is the same as the previous request
131 if let Some(triggered_from) = &editor.hover_state.triggered_from {
132 if triggered_from
133 .cmp(&anchor, &snapshot.buffer_snapshot)
134 .is_eq()
135 {
136 return;
137 }
138 }
139
140 let task = cx.spawn_weak(|this, mut cx| {
141 async move {
142 // If we need to delay, delay a set amount initially before making the lsp request
143 let delay = if !ignore_timeout {
144 // Construct delay task to wait for later
145 let total_delay = Some(
146 cx.background()
147 .timer(Duration::from_millis(HOVER_DELAY_MILLIS)),
148 );
149
150 cx.background()
151 .timer(Duration::from_millis(HOVER_REQUEST_DELAY_MILLIS))
152 .await;
153 total_delay
154 } else {
155 None
156 };
157
158 // query the LSP for hover info
159 let hover_request = cx.update(|cx| {
160 project.update(cx, |project, cx| {
161 project.hover(&buffer, buffer_position.clone(), cx)
162 })
163 });
164
165 if let Some(delay) = delay {
166 delay.await;
167 }
168
169 // If there's a diagnostic, assign it on the hover state and notify
170 let local_diagnostic = snapshot
171 .buffer_snapshot
172 .diagnostics_in_range::<_, usize>(multibuffer_offset..multibuffer_offset, false)
173 // Find the entry with the most specific range
174 .min_by_key(|entry| entry.range.end - entry.range.start)
175 .map(|entry| DiagnosticEntry {
176 diagnostic: entry.diagnostic,
177 range: entry.range.to_anchors(&snapshot.buffer_snapshot),
178 });
179
180 // Pull the primary diagnostic out so we can jump to it if the popover is clicked
181 let primary_diagnostic = local_diagnostic.as_ref().and_then(|local_diagnostic| {
182 snapshot
183 .buffer_snapshot
184 .diagnostic_group::<usize>(local_diagnostic.diagnostic.group_id)
185 .find(|diagnostic| diagnostic.diagnostic.is_primary)
186 .map(|entry| DiagnosticEntry {
187 diagnostic: entry.diagnostic,
188 range: entry.range.to_anchors(&snapshot.buffer_snapshot),
189 })
190 });
191
192 if let Some(this) = this.upgrade(&cx) {
193 this.update(&mut cx, |this, _| {
194 this.hover_state.diagnostic_popover =
195 local_diagnostic.map(|local_diagnostic| DiagnosticPopover {
196 local_diagnostic,
197 primary_diagnostic,
198 });
199 });
200 }
201
202 // Construct new hover popover from hover request
203 let hover_popover = hover_request.await.ok().flatten().and_then(|hover_result| {
204 if hover_result.contents.is_empty() {
205 return None;
206 }
207
208 // Create symbol range of anchors for highlighting and filtering
209 // of future requests.
210 let range = if let Some(range) = hover_result.range {
211 let start = snapshot
212 .buffer_snapshot
213 .anchor_in_excerpt(excerpt_id.clone(), range.start);
214 let end = snapshot
215 .buffer_snapshot
216 .anchor_in_excerpt(excerpt_id.clone(), range.end);
217
218 start..end
219 } else {
220 anchor.clone()..anchor.clone()
221 };
222
223 Some(InfoPopover {
224 project: project.clone(),
225 symbol_range: range.clone(),
226 contents: hover_result.contents,
227 })
228 });
229
230 if let Some(this) = this.upgrade(&cx) {
231 this.update(&mut cx, |this, cx| {
232 if let Some(hover_popover) = hover_popover.as_ref() {
233 // Highlight the selected symbol using a background highlight
234 this.highlight_background::<HoverState>(
235 vec![hover_popover.symbol_range.clone()],
236 |theme| theme.editor.hover_popover.highlight,
237 cx,
238 );
239 } else {
240 this.clear_background_highlights::<HoverState>(cx);
241 }
242
243 this.hover_state.info_popover = hover_popover;
244 cx.notify();
245 });
246 }
247 Ok::<_, anyhow::Error>(())
248 }
249 .log_err()
250 });
251
252 editor.hover_state.info_task = Some(task);
253}
254
255#[derive(Default)]
256pub struct HoverState {
257 pub info_popover: Option<InfoPopover>,
258 pub diagnostic_popover: Option<DiagnosticPopover>,
259 pub triggered_from: Option<Anchor>,
260 pub info_task: Option<Task<Option<()>>>,
261}
262
263impl HoverState {
264 pub fn visible(&self) -> bool {
265 self.info_popover.is_some() || self.diagnostic_popover.is_some()
266 }
267
268 pub fn render(
269 &self,
270 snapshot: &EditorSnapshot,
271 style: &EditorStyle,
272 visible_rows: Range<u32>,
273 cx: &mut RenderContext<Editor>,
274 ) -> Option<(DisplayPoint, Vec<ElementBox>)> {
275 // If there is a diagnostic, position the popovers based on that.
276 // Otherwise use the start of the hover range
277 let anchor = self
278 .diagnostic_popover
279 .as_ref()
280 .map(|diagnostic_popover| &diagnostic_popover.local_diagnostic.range.start)
281 .or_else(|| {
282 self.info_popover
283 .as_ref()
284 .map(|info_popover| &info_popover.symbol_range.start)
285 })?;
286 let point = anchor.to_display_point(&snapshot.display_snapshot);
287
288 // Don't render if the relevant point isn't on screen
289 if !self.visible() || !visible_rows.contains(&point.row()) {
290 return None;
291 }
292
293 let mut elements = Vec::new();
294
295 if let Some(diagnostic_popover) = self.diagnostic_popover.as_ref() {
296 elements.push(diagnostic_popover.render(style, cx));
297 }
298 if let Some(info_popover) = self.info_popover.as_ref() {
299 elements.push(info_popover.render(style, cx));
300 }
301
302 Some((point, elements))
303 }
304}
305
306#[derive(Debug, Clone)]
307pub struct InfoPopover {
308 pub project: ModelHandle<Project>,
309 pub symbol_range: Range<Anchor>,
310 pub contents: Vec<HoverBlock>,
311}
312
313impl InfoPopover {
314 pub fn render(&self, style: &EditorStyle, cx: &mut RenderContext<Editor>) -> ElementBox {
315 MouseEventHandler::new::<InfoPopover, _, _>(0, cx, |_, cx| {
316 let mut flex = Flex::new(Axis::Vertical).scrollable::<HoverBlock, _>(1, None, cx);
317 flex.extend(self.contents.iter().map(|content| {
318 let project = self.project.read(cx);
319 if let Some(language) = content
320 .language
321 .clone()
322 .and_then(|language| project.languages().get_language(&language))
323 {
324 let runs = language
325 .highlight_text(&content.text.as_str().into(), 0..content.text.len());
326
327 Text::new(content.text.clone(), style.text.clone())
328 .with_soft_wrap(true)
329 .with_highlights(
330 runs.iter()
331 .filter_map(|(range, id)| {
332 id.style(style.theme.syntax.as_ref())
333 .map(|style| (range.clone(), style))
334 })
335 .collect(),
336 )
337 .boxed()
338 } else {
339 let mut text_style = style.hover_popover.prose.clone();
340 text_style.font_size = style.text.font_size;
341
342 Text::new(content.text.clone(), text_style)
343 .with_soft_wrap(true)
344 .contained()
345 .with_style(style.hover_popover.block_style)
346 .boxed()
347 }
348 }));
349 flex.contained()
350 .with_style(style.hover_popover.container)
351 .boxed()
352 })
353 .with_cursor_style(CursorStyle::Arrow)
354 .with_padding(Padding {
355 bottom: 5.,
356 top: 5.,
357 ..Default::default()
358 })
359 .boxed()
360 }
361}
362
363#[derive(Debug, Clone)]
364pub struct DiagnosticPopover {
365 local_diagnostic: DiagnosticEntry<Anchor>,
366 primary_diagnostic: Option<DiagnosticEntry<Anchor>>,
367}
368
369impl DiagnosticPopover {
370 pub fn render(&self, style: &EditorStyle, cx: &mut RenderContext<Editor>) -> ElementBox {
371 enum PrimaryDiagnostic {}
372
373 let mut text_style = style.hover_popover.prose.clone();
374 text_style.font_size = style.text.font_size;
375
376 let container_style = match self.local_diagnostic.diagnostic.severity {
377 DiagnosticSeverity::HINT => style.hover_popover.info_container,
378 DiagnosticSeverity::INFORMATION => style.hover_popover.info_container,
379 DiagnosticSeverity::WARNING => style.hover_popover.warning_container,
380 DiagnosticSeverity::ERROR => style.hover_popover.error_container,
381 _ => style.hover_popover.container,
382 };
383
384 let tooltip_style = cx.global::<Settings>().theme.tooltip.clone();
385
386 MouseEventHandler::new::<DiagnosticPopover, _, _>(0, cx, |_, _| {
387 Text::new(self.local_diagnostic.diagnostic.message.clone(), text_style)
388 .with_soft_wrap(true)
389 .contained()
390 .with_style(container_style)
391 .boxed()
392 })
393 .on_click(MouseButton::Left, |_, cx| {
394 cx.dispatch_action(GoToDiagnostic)
395 })
396 .with_cursor_style(CursorStyle::PointingHand)
397 .with_tooltip::<PrimaryDiagnostic, _>(
398 0,
399 "Go To Diagnostic".to_string(),
400 Some(Box::new(crate::GoToDiagnostic)),
401 tooltip_style,
402 cx,
403 )
404 .boxed()
405 }
406
407 pub fn activation_info(&self) -> (usize, Anchor) {
408 let entry = self
409 .primary_diagnostic
410 .as_ref()
411 .unwrap_or(&self.local_diagnostic);
412
413 (entry.diagnostic.group_id, entry.range.start.clone())
414 }
415}
416
417#[cfg(test)]
418mod tests {
419 use futures::StreamExt;
420 use indoc::indoc;
421
422 use language::{Diagnostic, DiagnosticSet};
423 use project::HoverBlock;
424
425 use crate::test::EditorLspTestContext;
426
427 use super::*;
428
429 #[gpui::test]
430 async fn test_mouse_hover_info_popover(cx: &mut gpui::TestAppContext) {
431 let mut cx = EditorLspTestContext::new_rust(
432 lsp::ServerCapabilities {
433 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
434 ..Default::default()
435 },
436 cx,
437 )
438 .await;
439
440 // Basic hover delays and then pops without moving the mouse
441 cx.set_state(indoc! {"
442 fn |test()
443 println!();"});
444 let hover_point = cx.display_point(indoc! {"
445 fn test()
446 print|ln!();"});
447
448 cx.update_editor(|editor, cx| {
449 hover_at(
450 editor,
451 &HoverAt {
452 point: Some(hover_point),
453 },
454 cx,
455 )
456 });
457 assert!(!cx.editor(|editor, _| editor.hover_state.visible()));
458
459 // After delay, hover should be visible.
460 let symbol_range = cx.lsp_range(indoc! {"
461 fn test()
462 [println!]();"});
463 let mut requests =
464 cx.handle_request::<lsp::request::HoverRequest, _, _>(move |_, _, _| async move {
465 Ok(Some(lsp::Hover {
466 contents: lsp::HoverContents::Markup(lsp::MarkupContent {
467 kind: lsp::MarkupKind::Markdown,
468 value: indoc! {"
469 # Some basic docs
470 Some test documentation"}
471 .to_string(),
472 }),
473 range: Some(symbol_range),
474 }))
475 });
476 cx.foreground()
477 .advance_clock(Duration::from_millis(HOVER_DELAY_MILLIS + 100));
478 requests.next().await;
479
480 cx.editor(|editor, _| {
481 assert!(editor.hover_state.visible());
482 assert_eq!(
483 editor.hover_state.info_popover.clone().unwrap().contents,
484 vec![
485 HoverBlock {
486 text: "Some basic docs".to_string(),
487 language: None
488 },
489 HoverBlock {
490 text: "Some test documentation".to_string(),
491 language: None
492 }
493 ]
494 )
495 });
496
497 // Mouse moved with no hover response dismisses
498 let hover_point = cx.display_point(indoc! {"
499 fn te|st()
500 println!();"});
501 let mut request = cx
502 .lsp
503 .handle_request::<lsp::request::HoverRequest, _, _>(|_, _| async move { Ok(None) });
504 cx.update_editor(|editor, cx| {
505 hover_at(
506 editor,
507 &HoverAt {
508 point: Some(hover_point),
509 },
510 cx,
511 )
512 });
513 cx.foreground()
514 .advance_clock(Duration::from_millis(HOVER_DELAY_MILLIS + 100));
515 request.next().await;
516 cx.editor(|editor, _| {
517 assert!(!editor.hover_state.visible());
518 });
519 }
520
521 #[gpui::test]
522 async fn test_keyboard_hover_info_popover(cx: &mut gpui::TestAppContext) {
523 let mut cx = EditorLspTestContext::new_rust(
524 lsp::ServerCapabilities {
525 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
526 ..Default::default()
527 },
528 cx,
529 )
530 .await;
531
532 // Hover with keyboard has no delay
533 cx.set_state(indoc! {"
534 f|n test()
535 println!();"});
536 cx.update_editor(|editor, cx| hover(editor, &Hover, cx));
537 let symbol_range = cx.lsp_range(indoc! {"
538 [fn] test()
539 println!();"});
540 cx.handle_request::<lsp::request::HoverRequest, _, _>(move |_, _, _| async move {
541 Ok(Some(lsp::Hover {
542 contents: lsp::HoverContents::Markup(lsp::MarkupContent {
543 kind: lsp::MarkupKind::Markdown,
544 value: indoc! {"
545 # Some other basic docs
546 Some other test documentation"}
547 .to_string(),
548 }),
549 range: Some(symbol_range),
550 }))
551 })
552 .next()
553 .await;
554
555 cx.condition(|editor, _| editor.hover_state.visible()).await;
556 cx.editor(|editor, _| {
557 assert_eq!(
558 editor.hover_state.info_popover.clone().unwrap().contents,
559 vec![
560 HoverBlock {
561 text: "Some other basic docs".to_string(),
562 language: None
563 },
564 HoverBlock {
565 text: "Some other test documentation".to_string(),
566 language: None
567 }
568 ]
569 )
570 });
571 }
572
573 #[gpui::test]
574 async fn test_hover_diagnostic_and_info_popovers(cx: &mut gpui::TestAppContext) {
575 let mut cx = EditorLspTestContext::new_rust(
576 lsp::ServerCapabilities {
577 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
578 ..Default::default()
579 },
580 cx,
581 )
582 .await;
583
584 // Hover with just diagnostic, pops DiagnosticPopover immediately and then
585 // info popover once request completes
586 cx.set_state(indoc! {"
587 fn te|st()
588 println!();"});
589
590 // Send diagnostic to client
591 let range = cx.text_anchor_range(indoc! {"
592 fn [test]()
593 println!();"});
594 cx.update_buffer(|buffer, cx| {
595 let snapshot = buffer.text_snapshot();
596 let set = DiagnosticSet::from_sorted_entries(
597 vec![DiagnosticEntry {
598 range,
599 diagnostic: Diagnostic {
600 message: "A test diagnostic message.".to_string(),
601 ..Default::default()
602 },
603 }],
604 &snapshot,
605 );
606 buffer.update_diagnostics(set, cx);
607 });
608
609 // Hover pops diagnostic immediately
610 cx.update_editor(|editor, cx| hover(editor, &Hover, cx));
611 cx.foreground().run_until_parked();
612
613 cx.editor(|Editor { hover_state, .. }, _| {
614 assert!(hover_state.diagnostic_popover.is_some() && hover_state.info_popover.is_none())
615 });
616
617 // Info Popover shows after request responded to
618 let range = cx.lsp_range(indoc! {"
619 fn [test]()
620 println!();"});
621 cx.handle_request::<lsp::request::HoverRequest, _, _>(move |_, _, _| async move {
622 Ok(Some(lsp::Hover {
623 contents: lsp::HoverContents::Markup(lsp::MarkupContent {
624 kind: lsp::MarkupKind::Markdown,
625 value: indoc! {"
626 # Some other basic docs
627 Some other test documentation"}
628 .to_string(),
629 }),
630 range: Some(range),
631 }))
632 });
633 cx.foreground()
634 .advance_clock(Duration::from_millis(HOVER_DELAY_MILLIS + 100));
635
636 cx.foreground().run_until_parked();
637 cx.editor(|Editor { hover_state, .. }, _| {
638 hover_state.diagnostic_popover.is_some() && hover_state.info_task.is_some()
639 });
640 }
641}