use gpui::{ actions, elements::{Flex, MouseEventHandler, Padding, Text}, impl_internal_actions, platform::CursorStyle, Axis, Element, ElementBox, ModelHandle, MutableAppContext, RenderContext, Task, ViewContext, }; use language::Bias; use project::{HoverBlock, Project}; use settings::Settings; use std::{ops::Range, time::Duration}; use util::TryFutureExt; use crate::{ display_map::ToDisplayPoint, Anchor, AnchorRangeExt, DisplayPoint, Editor, EditorSnapshot, EditorStyle, }; pub const HOVER_DELAY_MILLIS: u64 = 350; pub const HOVER_REQUEST_DELAY_MILLIS: u64 = 200; #[derive(Clone, PartialEq)] pub struct HoverAt { pub point: Option, } actions!(editor, [Hover]); impl_internal_actions!(editor, [HoverAt]); pub fn init(cx: &mut MutableAppContext) { cx.add_action(hover); cx.add_action(hover_at); } #[derive(Default)] pub struct HoverState { pub info_popover: Option, pub diagnostic_popover: Option, pub triggered_from: Option, pub symbol_range: Option>, pub task: Option>>, } impl HoverState { pub fn visible(&self) -> bool { self.info_popover.is_some() } } /// Bindable action which uses the most recent selection head to trigger a hover pub fn hover(editor: &mut Editor, _: &Hover, cx: &mut ViewContext) { let head = editor.selections.newest_display(cx).head(); show_hover(editor, head, true, cx); } /// The internal hover action dispatches between `show_hover` or `hide_hover` /// depending on whether a point to hover over is provided. pub fn hover_at(editor: &mut Editor, action: &HoverAt, cx: &mut ViewContext) { if cx.global::().hover_popover_enabled { if let Some(point) = action.point { show_hover(editor, point, false, cx); } else { hide_hover(editor, cx); } } } /// Hides the type information popup. /// Triggered by the `Hover` action when the cursor is not over a symbol or when the /// selections changed. pub fn hide_hover(editor: &mut Editor, cx: &mut ViewContext) -> bool { let mut did_hide = false; // only notify the context once if editor.hover_state.info_popover.is_some() { editor.hover_state.info_popover = None; did_hide = true; cx.notify(); } editor.hover_state.task = None; editor.hover_state.triggered_from = None; editor.hover_state.symbol_range = None; editor.clear_background_highlights::(cx); did_hide } /// Queries the LSP and shows type info and documentation /// about the symbol the mouse is currently hovering over. /// Triggered by the `Hover` action when the cursor may be over a symbol. fn show_hover( editor: &mut Editor, point: DisplayPoint, ignore_timeout: bool, cx: &mut ViewContext, ) { if editor.pending_rename.is_some() { return; } let snapshot = editor.snapshot(cx); let multibuffer_offset = point.to_offset(&snapshot.display_snapshot, Bias::Left); let (buffer, buffer_position) = if let Some(output) = editor .buffer .read(cx) .text_anchor_for_position(multibuffer_offset, cx) { output } else { return; }; let excerpt_id = if let Some((excerpt_id, _, _)) = editor .buffer() .read(cx) .excerpt_containing(multibuffer_offset, cx) { excerpt_id } else { return; }; let project = if let Some(project) = editor.project.clone() { project } else { return; }; if !ignore_timeout { if let Some(range) = &editor.hover_state.symbol_range { if range .to_offset(&snapshot.buffer_snapshot) .contains(&multibuffer_offset) { // Hover triggered from same location as last time. Don't show again. return; } else { hide_hover(editor, cx); } } } // Get input anchor let anchor = snapshot .buffer_snapshot .anchor_at(multibuffer_offset, Bias::Left); // Don't request again if the location is the same as the previous request if let Some(triggered_from) = &editor.hover_state.triggered_from { if triggered_from .cmp(&anchor, &snapshot.buffer_snapshot) .is_eq() { return; } } let task = cx.spawn_weak(|this, mut cx| { async move { // If we need to delay, delay a set amount initially before making the lsp request let delay = if !ignore_timeout { // Construct delay task to wait for later let total_delay = Some( cx.background() .timer(Duration::from_millis(HOVER_DELAY_MILLIS)), ); cx.background() .timer(Duration::from_millis(HOVER_REQUEST_DELAY_MILLIS)) .await; total_delay } else { None }; // query the LSP for hover info let hover_request = cx.update(|cx| { project.update(cx, |project, cx| { project.hover(&buffer, buffer_position.clone(), cx) }) }); if let Some(delay) = delay { delay.await; } // If there's a diagnostic, assign it on the hover state and notify let diagnostic = snapshot .buffer_snapshot .diagnostics_in_range::<_, usize>(multibuffer_offset..multibuffer_offset, false) .next(); // Construct new hover popover from hover request let hover_popover = hover_request.await.ok().flatten().and_then(|hover_result| { if hover_result.contents.is_empty() { return None; } // Create symbol range of anchors for highlighting and filtering // of future requests. let range = if let Some(range) = hover_result.range { let start = snapshot .buffer_snapshot .anchor_in_excerpt(excerpt_id.clone(), range.start); let end = snapshot .buffer_snapshot .anchor_in_excerpt(excerpt_id.clone(), range.end); start..end } else { anchor.clone()..anchor.clone() }; if let Some(this) = this.upgrade(&cx) { this.update(&mut cx, |this, _| { this.hover_state.symbol_range = Some(range.clone()); }); } Some(InfoPopover { project: project.clone(), anchor: range.start.clone(), contents: hover_result.contents, }) }); if let Some(this) = this.upgrade(&cx) { this.update(&mut cx, |this, cx| { if hover_popover.is_some() { // Highlight the selected symbol using a background highlight if let Some(range) = this.hover_state.symbol_range.clone() { this.highlight_background::( vec![range], |theme| theme.editor.hover_popover.highlight, cx, ); } this.hover_state.info_popover = hover_popover; cx.notify(); } else { if this.hover_state.visible() { // Popover was visible, but now is hidden. Dismiss it hide_hover(this, cx); } else { // Clear selected symbol range for future requests this.hover_state.symbol_range = None; } } }); } Ok::<_, anyhow::Error>(()) } .log_err() }); editor.hover_state.task = Some(task); } #[derive(Debug, Clone)] pub struct InfoPopover { pub project: ModelHandle, pub anchor: Anchor, pub contents: Vec, } impl InfoPopover { pub fn render( &self, snapshot: &EditorSnapshot, style: EditorStyle, cx: &mut RenderContext, ) -> (DisplayPoint, ElementBox) { let element = MouseEventHandler::new::(0, cx, |_, cx| { let mut flex = Flex::new(Axis::Vertical).scrollable::(1, None, cx); flex.extend(self.contents.iter().map(|content| { let project = self.project.read(cx); if let Some(language) = content .language .clone() .and_then(|language| project.languages().get_language(&language)) { let runs = language .highlight_text(&content.text.as_str().into(), 0..content.text.len()); Text::new(content.text.clone(), style.text.clone()) .with_soft_wrap(true) .with_highlights( runs.iter() .filter_map(|(range, id)| { id.style(style.theme.syntax.as_ref()) .map(|style| (range.clone(), style)) }) .collect(), ) .boxed() } else { let mut text_style = style.hover_popover.prose.clone(); text_style.font_size = style.text.font_size; Text::new(content.text.clone(), text_style) .with_soft_wrap(true) .contained() .with_style(style.hover_popover.block_style) .boxed() } })); flex.contained() .with_style(style.hover_popover.container) .boxed() }) .with_cursor_style(CursorStyle::Arrow) .with_padding(Padding { bottom: 5., top: 5., ..Default::default() }) .boxed(); let display_point = self.anchor.to_display_point(&snapshot.display_snapshot); (display_point, element) } } #[derive(Debug, Clone)] pub struct DiagnosticPopover {} #[cfg(test)] mod tests { use futures::StreamExt; use indoc::indoc; use project::HoverBlock; use crate::test::EditorLspTestContext; use super::*; #[gpui::test] async fn test_hover_popover(cx: &mut gpui::TestAppContext) { let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { hover_provider: Some(lsp::HoverProviderCapability::Simple(true)), ..Default::default() }, cx, ) .await; // Basic hover delays and then pops without moving the mouse cx.set_state(indoc! {" fn |test() println!();"}); let hover_point = cx.display_point(indoc! {" fn test() print|ln!();"}); cx.update_editor(|editor, cx| { hover_at( editor, &HoverAt { point: Some(hover_point), }, cx, ) }); assert!(!cx.editor(|editor, _| editor.hover_state.visible())); // After delay, hover should be visible. let symbol_range = cx.lsp_range(indoc! {" fn test() [println!]();"}); let mut requests = cx.lsp .handle_request::(move |_, _| async move { Ok(Some(lsp::Hover { contents: lsp::HoverContents::Markup(lsp::MarkupContent { kind: lsp::MarkupKind::Markdown, value: indoc! {" # Some basic docs Some test documentation"} .to_string(), }), range: Some(symbol_range), })) }); cx.foreground() .advance_clock(Duration::from_millis(HOVER_DELAY_MILLIS + 100)); requests.next().await; cx.editor(|editor, _| { assert!(editor.hover_state.visible()); assert_eq!( editor.hover_state.info_popover.clone().unwrap().contents, vec![ HoverBlock { text: "Some basic docs".to_string(), language: None }, HoverBlock { text: "Some test documentation".to_string(), language: None } ] ) }); // Mouse moved with no hover response dismisses let hover_point = cx.display_point(indoc! {" fn te|st() println!();"}); cx.update_editor(|editor, cx| { hover_at( editor, &HoverAt { point: Some(hover_point), }, cx, ) }); let mut request = cx .lsp .handle_request::(|_, _| async move { Ok(None) }); cx.foreground() .advance_clock(Duration::from_millis(HOVER_DELAY_MILLIS + 100)); request.next().await; cx.editor(|editor, _| { assert!(!editor.hover_state.visible()); }); // Hover with keyboard has no delay cx.set_state(indoc! {" f|n test() println!();"}); cx.update_editor(|editor, cx| hover(editor, &Hover, cx)); let symbol_range = cx.lsp_range(indoc! {" [fn] test() println!();"}); cx.lsp .handle_request::(move |_, _| async move { Ok(Some(lsp::Hover { contents: lsp::HoverContents::Markup(lsp::MarkupContent { kind: lsp::MarkupKind::Markdown, value: indoc! {" # Some other basic docs Some other test documentation"} .to_string(), }), range: Some(symbol_range), })) }) .next() .await; cx.foreground().run_until_parked(); cx.editor(|editor, _| { assert!(editor.hover_state.visible()); assert_eq!( editor.hover_state.info_popover.clone().unwrap().contents, vec![ HoverBlock { text: "Some other basic docs".to_string(), language: None }, HoverBlock { text: "Some other test documentation".to_string(), language: None } ] ) }); } }