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