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