hover_popover.rs

  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}