hover_popover.rs

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