hover_popover.rs

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