hover_popover.rs

  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}