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