hover_popover.rs

  1use std::{
  2    ops::Range,
  3    time::{Duration, Instant},
  4};
  5
  6use gpui::{
  7    actions,
  8    elements::{Flex, MouseEventHandler, Padding, Text},
  9    impl_internal_actions,
 10    platform::CursorStyle,
 11    Axis, Element, ElementBox, ModelHandle, MutableAppContext, RenderContext, Task, ViewContext,
 12};
 13use language::Bias;
 14use project::{HoverBlock, Project};
 15use util::TryFutureExt;
 16
 17use crate::{
 18    display_map::ToDisplayPoint, Anchor, AnchorRangeExt, DisplayPoint, Editor, EditorSnapshot,
 19    EditorStyle,
 20};
 21
 22pub const HOVER_DELAY_MILLIS: u64 = 350;
 23pub const HOVER_REQUEST_DELAY_MILLIS: u64 = 200;
 24
 25#[derive(Clone, PartialEq)]
 26pub struct HoverAt {
 27    pub point: Option<DisplayPoint>,
 28}
 29
 30actions!(editor, [Hover]);
 31impl_internal_actions!(editor, [HoverAt]);
 32
 33pub fn init(cx: &mut MutableAppContext) {
 34    cx.add_action(hover);
 35    cx.add_action(hover_at);
 36}
 37
 38/// Bindable action which uses the most recent selection head to trigger a hover
 39pub fn hover(editor: &mut Editor, _: &Hover, cx: &mut ViewContext<Editor>) {
 40    let head = editor.selections.newest_display(cx).head();
 41    show_hover(editor, head, true, cx);
 42}
 43
 44/// The internal hover action dispatches between `show_hover` or `hide_hover`
 45/// depending on whether a point to hover over is provided.
 46pub fn hover_at(editor: &mut Editor, action: &HoverAt, cx: &mut ViewContext<Editor>) {
 47    if let Some(point) = action.point {
 48        show_hover(editor, point, false, cx);
 49    } else {
 50        hide_hover(editor, cx);
 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 mut did_hide = false;
 59
 60    // only notify the context once
 61    if editor.hover_state.popover.is_some() {
 62        editor.hover_state.popover = None;
 63        editor.hover_state.hidden_at = Some(cx.background().now());
 64        did_hide = true;
 65        cx.notify();
 66    }
 67    editor.hover_state.task = None;
 68    editor.hover_state.triggered_from = None;
 69    editor.hover_state.symbol_range = None;
 70
 71    editor.clear_background_highlights::<HoverState>(cx);
 72
 73    did_hide
 74}
 75
 76/// Queries the LSP and shows type info and documentation
 77/// about the symbol the mouse is currently hovering over.
 78/// Triggered by the `Hover` action when the cursor may be over a symbol.
 79fn show_hover(
 80    editor: &mut Editor,
 81    point: DisplayPoint,
 82    ignore_timeout: bool,
 83    cx: &mut ViewContext<Editor>,
 84) {
 85    if editor.pending_rename.is_some() {
 86        return;
 87    }
 88
 89    let snapshot = editor.snapshot(cx);
 90    let multibuffer_offset = point.to_offset(&snapshot.display_snapshot, Bias::Left);
 91
 92    let (buffer, buffer_position) = if let Some(output) = editor
 93        .buffer
 94        .read(cx)
 95        .text_anchor_for_position(multibuffer_offset, cx)
 96    {
 97        output
 98    } else {
 99        return;
100    };
101
102    let excerpt_id = if let Some((excerpt_id, _, _)) = editor
103        .buffer()
104        .read(cx)
105        .excerpt_containing(multibuffer_offset, cx)
106    {
107        excerpt_id
108    } else {
109        return;
110    };
111
112    let project = if let Some(project) = editor.project.clone() {
113        project
114    } else {
115        return;
116    };
117
118    if !ignore_timeout {
119        if let Some(range) = &editor.hover_state.symbol_range {
120            if range
121                .to_offset(&snapshot.buffer_snapshot)
122                .contains(&multibuffer_offset)
123            {
124                // Hover triggered from same location as last time. Don't show again.
125                return;
126            } else {
127                hide_hover(editor, cx);
128            }
129        }
130    }
131
132    // Get input anchor
133    let anchor = snapshot
134        .buffer_snapshot
135        .anchor_at(multibuffer_offset, Bias::Left);
136
137    // Don't request again if the location is the same as the previous request
138    if let Some(triggered_from) = &editor.hover_state.triggered_from {
139        if triggered_from
140            .cmp(&anchor, &snapshot.buffer_snapshot)
141            .is_eq()
142        {
143            return;
144        }
145    }
146
147    let task = cx.spawn_weak(|this, mut cx| {
148        async move {
149            // If we need to delay, delay a set amount initially before making the lsp request
150            let delay = if !ignore_timeout {
151                // Construct delay task to wait for later
152                let total_delay = Some(
153                    cx.background()
154                        .timer(Duration::from_millis(HOVER_DELAY_MILLIS)),
155                );
156
157                cx.background()
158                    .timer(Duration::from_millis(HOVER_REQUEST_DELAY_MILLIS))
159                    .await;
160                total_delay
161            } else {
162                None
163            };
164
165            // query the LSP for hover info
166            let hover_request = cx.update(|cx| {
167                project.update(cx, |project, cx| {
168                    project.hover(&buffer, buffer_position.clone(), cx)
169                })
170            });
171
172            // Construct new hover popover from hover request
173            let hover_popover = hover_request.await.ok().flatten().and_then(|hover_result| {
174                if hover_result.contents.is_empty() {
175                    return None;
176                }
177
178                // Create symbol range of anchors for highlighting and filtering
179                // of future requests.
180                let range = if let Some(range) = hover_result.range {
181                    let start = snapshot
182                        .buffer_snapshot
183                        .anchor_in_excerpt(excerpt_id.clone(), range.start);
184                    let end = snapshot
185                        .buffer_snapshot
186                        .anchor_in_excerpt(excerpt_id.clone(), range.end);
187
188                    start..end
189                } else {
190                    anchor.clone()..anchor.clone()
191                };
192
193                if let Some(this) = this.upgrade(&cx) {
194                    this.update(&mut cx, |this, _| {
195                        this.hover_state.symbol_range = Some(range.clone());
196                    });
197                }
198
199                Some(HoverPopover {
200                    project: project.clone(),
201                    anchor: range.start.clone(),
202                    contents: hover_result.contents,
203                })
204            });
205
206            if let Some(delay) = delay {
207                delay.await;
208            }
209
210            if let Some(this) = this.upgrade(&cx) {
211                this.update(&mut cx, |this, cx| {
212                    if hover_popover.is_some() {
213                        // Highlight the selected symbol using a background highlight
214                        if let Some(range) = this.hover_state.symbol_range.clone() {
215                            this.highlight_background::<HoverState>(
216                                vec![range],
217                                |theme| theme.editor.hover_popover.highlight,
218                                cx,
219                            );
220                        }
221                        this.hover_state.popover = hover_popover;
222                        cx.notify();
223                    } else {
224                        if this.hover_state.visible() {
225                            // Popover was visible, but now is hidden. Dismiss it
226                            hide_hover(this, cx);
227                        } else {
228                            // Clear selected symbol range for future requests
229                            this.hover_state.symbol_range = None;
230                        }
231                    }
232                });
233            }
234            Ok::<_, anyhow::Error>(())
235        }
236        .log_err()
237    });
238
239    editor.hover_state.task = Some(task);
240}
241
242#[derive(Default)]
243pub struct HoverState {
244    pub popover: Option<HoverPopover>,
245    pub hidden_at: Option<Instant>,
246    pub triggered_from: Option<Anchor>,
247    pub symbol_range: Option<Range<Anchor>>,
248    pub task: Option<Task<Option<()>>>,
249}
250
251impl HoverState {
252    pub fn visible(&self) -> bool {
253        self.popover.is_some()
254    }
255}
256
257#[derive(Debug, Clone)]
258pub struct HoverPopover {
259    pub project: ModelHandle<Project>,
260    pub anchor: Anchor,
261    pub contents: Vec<HoverBlock>,
262}
263
264impl HoverPopover {
265    pub fn render(
266        &self,
267        snapshot: &EditorSnapshot,
268        style: EditorStyle,
269        cx: &mut RenderContext<Editor>,
270    ) -> (DisplayPoint, ElementBox) {
271        let element = MouseEventHandler::new::<HoverPopover, _, _>(0, cx, |_, cx| {
272            let mut flex = Flex::new(Axis::Vertical).scrollable::<HoverBlock, _>(1, None, cx);
273            flex.extend(self.contents.iter().map(|content| {
274                let project = self.project.read(cx);
275                if let Some(language) = content
276                    .language
277                    .clone()
278                    .and_then(|language| project.languages().get_language(&language))
279                {
280                    let runs = language
281                        .highlight_text(&content.text.as_str().into(), 0..content.text.len());
282
283                    Text::new(content.text.clone(), style.text.clone())
284                        .with_soft_wrap(true)
285                        .with_highlights(
286                            runs.iter()
287                                .filter_map(|(range, id)| {
288                                    id.style(style.theme.syntax.as_ref())
289                                        .map(|style| (range.clone(), style))
290                                })
291                                .collect(),
292                        )
293                        .boxed()
294                } else {
295                    let mut text_style = style.hover_popover.prose.clone();
296                    text_style.font_size = style.text.font_size;
297
298                    Text::new(content.text.clone(), text_style)
299                        .with_soft_wrap(true)
300                        .contained()
301                        .with_style(style.hover_popover.block_style)
302                        .boxed()
303                }
304            }));
305            flex.contained()
306                .with_style(style.hover_popover.container)
307                .boxed()
308        })
309        .with_cursor_style(CursorStyle::Arrow)
310        .with_padding(Padding {
311            bottom: 5.,
312            top: 5.,
313            ..Default::default()
314        })
315        .boxed();
316
317        let display_point = self.anchor.to_display_point(&snapshot.display_snapshot);
318        (display_point, element)
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use futures::StreamExt;
325    use indoc::indoc;
326
327    use project::HoverBlock;
328
329    use crate::test::EditorLspTestContext;
330
331    use super::*;
332
333    #[gpui::test]
334    async fn test_hover_popover(cx: &mut gpui::TestAppContext) {
335        let mut cx = EditorLspTestContext::new_rust(
336            lsp::ServerCapabilities {
337                hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
338                ..Default::default()
339            },
340            cx,
341        )
342        .await;
343
344        // Basic hover delays and then pops without moving the mouse
345        cx.set_state(indoc! {"
346            fn |test()
347                println!();"});
348        let hover_point = cx.display_point(indoc! {"
349            fn test()
350                print|ln!();"});
351
352        cx.update_editor(|editor, cx| {
353            hover_at(
354                editor,
355                &HoverAt {
356                    point: Some(hover_point),
357                },
358                cx,
359            )
360        });
361        assert!(!cx.editor(|editor, _| editor.hover_state.visible()));
362
363        // After delay, hover should be visible.
364        let symbol_range = cx.lsp_range(indoc! {"
365            fn test()
366                [println!]();"});
367        let mut requests =
368            cx.lsp
369                .handle_request::<lsp::request::HoverRequest, _, _>(move |_, _| async move {
370                    Ok(Some(lsp::Hover {
371                        contents: lsp::HoverContents::Markup(lsp::MarkupContent {
372                            kind: lsp::MarkupKind::Markdown,
373                            value: indoc! {"
374                                # Some basic docs
375                                Some test documentation"}
376                            .to_string(),
377                        }),
378                        range: Some(symbol_range),
379                    }))
380                });
381        cx.foreground()
382            .advance_clock(Duration::from_millis(HOVER_DELAY_MILLIS + 100));
383        requests.next().await;
384
385        cx.editor(|editor, _| {
386            assert!(editor.hover_state.visible());
387            assert_eq!(
388                editor.hover_state.popover.clone().unwrap().contents,
389                vec![
390                    HoverBlock {
391                        text: "Some basic docs".to_string(),
392                        language: None
393                    },
394                    HoverBlock {
395                        text: "Some test documentation".to_string(),
396                        language: None
397                    }
398                ]
399            )
400        });
401
402        // Mouse moved with no hover response dismisses
403        let hover_point = cx.display_point(indoc! {"
404            fn te|st()
405                println!();"});
406        cx.update_editor(|editor, cx| {
407            hover_at(
408                editor,
409                &HoverAt {
410                    point: Some(hover_point),
411                },
412                cx,
413            )
414        });
415        let mut request = cx
416            .lsp
417            .handle_request::<lsp::request::HoverRequest, _, _>(|_, _| async move { Ok(None) });
418        cx.foreground()
419            .advance_clock(Duration::from_millis(HOVER_DELAY_MILLIS + 100));
420        request.next().await;
421        cx.editor(|editor, _| {
422            assert!(!editor.hover_state.visible());
423        });
424
425        // Hover with keyboard has no delay
426        cx.set_state(indoc! {"
427            f|n test()
428                println!();"});
429        cx.update_editor(|editor, cx| hover(editor, &Hover, cx));
430        let symbol_range = cx.lsp_range(indoc! {"
431            [fn] test()
432                println!();"});
433        cx.lsp
434            .handle_request::<lsp::request::HoverRequest, _, _>(move |_, _| async move {
435                Ok(Some(lsp::Hover {
436                    contents: lsp::HoverContents::Markup(lsp::MarkupContent {
437                        kind: lsp::MarkupKind::Markdown,
438                        value: indoc! {"
439                            # Some other basic docs
440                            Some other test documentation"}
441                        .to_string(),
442                    }),
443                    range: Some(symbol_range),
444                }))
445            })
446            .next()
447            .await;
448        cx.foreground().run_until_parked();
449        cx.editor(|editor, _| {
450            assert!(editor.hover_state.visible());
451            assert_eq!(
452                editor.hover_state.popover.clone().unwrap().contents,
453                vec![
454                    HoverBlock {
455                        text: "Some other basic docs".to_string(),
456                        language: None
457                    },
458                    HoverBlock {
459                        text: "Some other test documentation".to_string(),
460                        language: None
461                    }
462                ]
463            )
464        });
465    }
466}