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