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