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