Merge pull request #1144 from zed-industries/hover-fixes

Keith Simmons created

Hover fixes. Addresses delay issues with current hover implementation, shrinks the hover popover, and tweaks the display of markdown data

Change summary

crates/editor/Cargo.toml                  |   2 
crates/editor/src/display_map.rs          |   4 
crates/editor/src/display_map/wrap_map.rs |  12 
crates/editor/src/editor.rs               | 524 ++++++++++--------------
crates/editor/src/element.rs              |  15 
crates/editor/src/hover_popover.rs        | 329 +++++++++++++++
crates/editor/src/multi_buffer.rs         |   3 
crates/editor/src/test.rs                 | 151 +++++++
crates/gpui/src/executor.rs               |  13 
crates/language/src/language.rs           |   4 
crates/project/src/lsp_command.rs         |   1 
styles/src/buildTokens.ts                 |   2 
12 files changed, 743 insertions(+), 317 deletions(-)

Detailed changes

crates/editor/Cargo.toml 🔗

@@ -16,6 +16,7 @@ test-support = [
     "project/test-support",
     "util/test-support",
     "workspace/test-support",
+    "tree-sitter-rust"
 ]
 
 [dependencies]
@@ -48,6 +49,7 @@ rand = { version = "0.8.3", optional = true }
 serde = { version = "1.0", features = ["derive", "rc"] }
 smallvec = { version = "1.6", features = ["union"] }
 smol = "1.2"
+tree-sitter-rust = { version = "*", optional = true }
 
 [dev-dependencies]
 text = { path = "../text", features = ["test-support"] }

crates/editor/src/display_map.rs 🔗

@@ -193,9 +193,9 @@ impl DisplayMap {
         self.text_highlights.remove(&Some(type_id))
     }
 
-    pub fn set_font(&self, font_id: FontId, font_size: f32, cx: &mut ModelContext<Self>) {
+    pub fn set_font(&self, font_id: FontId, font_size: f32, cx: &mut ModelContext<Self>) -> bool {
         self.wrap_map
-            .update(cx, |map, cx| map.set_font(font_id, font_size, cx));
+            .update(cx, |map, cx| map.set_font(font_id, font_size, cx))
     }
 
     pub fn set_wrap_width(&self, width: Option<f32>, cx: &mut ModelContext<Self>) -> bool {

crates/editor/src/display_map/wrap_map.rs 🔗

@@ -121,10 +121,18 @@ impl WrapMap {
         (self.snapshot.clone(), mem::take(&mut self.edits_since_sync))
     }
 
-    pub fn set_font(&mut self, font_id: FontId, font_size: f32, cx: &mut ModelContext<Self>) {
+    pub fn set_font(
+        &mut self,
+        font_id: FontId,
+        font_size: f32,
+        cx: &mut ModelContext<Self>,
+    ) -> bool {
         if (font_id, font_size) != self.font {
             self.font = (font_id, font_size);
-            self.rewrap(cx)
+            self.rewrap(cx);
+            true
+        } else {
+            false
         }
     }
 

crates/editor/src/editor.rs 🔗

@@ -1,5 +1,6 @@
 pub mod display_map;
 mod element;
+mod hover_popover;
 pub mod items;
 pub mod movement;
 mod multi_buffer;
@@ -25,10 +26,11 @@ use gpui::{
     geometry::vector::{vec2f, Vector2F},
     impl_actions, impl_internal_actions,
     platform::CursorStyle,
-    text_layout, AppContext, AsyncAppContext, Axis, ClipboardItem, Element, ElementBox, Entity,
+    text_layout, AppContext, AsyncAppContext, ClipboardItem, Element, ElementBox, Entity,
     ModelHandle, MutableAppContext, RenderContext, Task, View, ViewContext, ViewHandle,
     WeakViewHandle,
 };
+use hover_popover::{hide_hover, HoverState};
 pub use language::{char_kind, CharKind};
 use language::{
     BracketPair, Buffer, CodeAction, CodeLabel, Completion, Diagnostic, DiagnosticSeverity,
@@ -41,7 +43,7 @@ pub use multi_buffer::{
     ToPoint,
 };
 use ordered_float::OrderedFloat;
-use project::{HoverBlock, Project, ProjectPath, ProjectTransaction};
+use project::{Project, ProjectPath, ProjectTransaction};
 use selections_collection::{resolve_multiple, MutableSelectionsCollection, SelectionsCollection};
 use serde::{Deserialize, Serialize};
 use settings::Settings;
@@ -82,11 +84,6 @@ pub struct Scroll(pub Vector2F);
 #[derive(Clone, PartialEq)]
 pub struct Select(pub SelectPhase);
 
-#[derive(Clone, PartialEq)]
-pub struct HoverAt {
-    point: Option<DisplayPoint>,
-}
-
 #[derive(Clone, Debug, PartialEq)]
 pub struct Jump {
     path: ProjectPath,
@@ -127,11 +124,6 @@ pub struct ConfirmCodeAction {
     pub item_ix: Option<usize>,
 }
 
-#[derive(Clone, Default)]
-pub struct GoToDefinitionAt {
-    pub location: Option<DisplayPoint>,
-}
-
 actions!(
     editor,
     [
@@ -224,7 +216,7 @@ impl_actions!(
     ]
 );
 
-impl_internal_actions!(editor, [Scroll, Select, HoverAt, Jump]);
+impl_internal_actions!(editor, [Scroll, Select, Jump]);
 
 enum DocumentHighlightRead {}
 enum DocumentHighlightWrite {}
@@ -311,8 +303,6 @@ pub fn init(cx: &mut MutableAppContext) {
     cx.add_action(Editor::fold_selected_ranges);
     cx.add_action(Editor::show_completions);
     cx.add_action(Editor::toggle_code_actions);
-    cx.add_action(Editor::hover);
-    cx.add_action(Editor::hover_at);
     cx.add_action(Editor::open_excerpts);
     cx.add_action(Editor::jump);
     cx.add_action(Editor::restart_language_server);
@@ -322,6 +312,8 @@ pub fn init(cx: &mut MutableAppContext) {
     cx.add_async_action(Editor::confirm_rename);
     cx.add_async_action(Editor::find_all_references);
 
+    hover_popover::init(cx);
+
     workspace::register_project_item::<Editor>(cx);
     workspace::register_followable_item::<Editor>(cx);
 }
@@ -431,7 +423,6 @@ pub struct Editor {
     next_completion_id: CompletionId,
     available_code_actions: Option<(ModelHandle<Buffer>, Arc<[CodeAction]>)>,
     code_actions_task: Option<Task<()>>,
-    hover_task: Option<Task<Option<()>>>,
     document_highlights_task: Option<Task<()>>,
     pending_rename: Option<RenameState>,
     searchable: bool,
@@ -442,39 +433,6 @@ pub struct Editor {
     hover_state: HoverState,
 }
 
-/// Keeps track of the state of the [`HoverPopover`].
-/// Times out the initial delay and the grace period.
-pub struct HoverState {
-    popover: Option<HoverPopover>,
-    last_hover: std::time::Instant,
-    start_grace: std::time::Instant,
-}
-
-impl HoverState {
-    /// Takes whether the cursor is currently hovering over a symbol,
-    /// and returns a tuple containing whether there was a recent hover,
-    /// and whether the hover is still in the grace period.
-    pub fn determine_state(&mut self, hovering: bool) -> (bool, bool) {
-        // NOTE: We use some sane defaults, but it might be
-        //       nice to make these values configurable.
-        let recent_hover = self.last_hover.elapsed() < std::time::Duration::from_millis(500);
-        if !hovering {
-            self.last_hover = std::time::Instant::now();
-        }
-
-        let in_grace = self.start_grace.elapsed() < std::time::Duration::from_millis(250);
-        if hovering && !recent_hover {
-            self.start_grace = std::time::Instant::now();
-        }
-
-        return (recent_hover, in_grace);
-    }
-
-    pub fn close(&mut self) {
-        self.popover.take();
-    }
-}
-
 pub struct EditorSnapshot {
     pub mode: EditorMode,
     pub display_snapshot: DisplaySnapshot,
@@ -899,67 +857,6 @@ impl CodeActionsMenu {
     }
 }
 
-#[derive(Clone)]
-pub(crate) struct HoverPopover {
-    pub project: ModelHandle<Project>,
-    pub hover_point: DisplayPoint,
-    pub range: Range<DisplayPoint>,
-    pub contents: Vec<HoverBlock>,
-}
-
-impl HoverPopover {
-    fn render(
-        &self,
-        style: EditorStyle,
-        cx: &mut RenderContext<Editor>,
-    ) -> (DisplayPoint, ElementBox) {
-        let element = MouseEventHandler::new::<HoverPopover, _, _>(0, cx, |_, cx| {
-            let mut flex = Flex::new(Axis::Vertical).scrollable::<HoverBlock, _>(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 {
-                    Text::new(content.text.clone(), style.hover_popover.prose.clone())
-                        .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();
-
-        (self.range.start, element)
-    }
-}
-
 #[derive(Debug)]
 struct ActiveDiagnosticGroup {
     primary_range: Range<Anchor>,
@@ -1117,7 +1014,7 @@ impl Editor {
             next_completion_id: 0,
             available_code_actions: Default::default(),
             code_actions_task: Default::default(),
-            hover_task: Default::default(),
+
             document_highlights_task: Default::default(),
             pending_rename: Default::default(),
             searchable: true,
@@ -1126,11 +1023,7 @@ impl Editor {
             keymap_context_layers: Default::default(),
             input_enabled: true,
             leader_replica_id: None,
-            hover_state: HoverState {
-                popover: None,
-                last_hover: std::time::Instant::now(),
-                start_grace: std::time::Instant::now(),
-            },
+            hover_state: Default::default(),
         };
         this.end_selection(cx);
 
@@ -1253,6 +1146,8 @@ impl Editor {
         }
 
         self.autoscroll_request.take();
+        hide_hover(self, cx);
+
         cx.emit(Event::ScrollPositionChanged { local });
         cx.notify();
     }
@@ -1516,7 +1411,7 @@ impl Editor {
                 }
             }
 
-            self.hide_hover(cx);
+            hide_hover(self, cx);
 
             if old_cursor_position.to_display_point(&display_map).row()
                 != new_cursor_position.to_display_point(&display_map).row()
@@ -1879,7 +1774,7 @@ impl Editor {
             return;
         }
 
-        if self.hide_hover(cx) {
+        if hide_hover(self, cx) {
             return;
         }
 
@@ -2510,179 +2405,6 @@ impl Editor {
         }))
     }
 
-    /// Bindable action which uses the most recent selection head to trigger a hover
-    fn hover(&mut self, _: &Hover, cx: &mut ViewContext<Self>) {
-        let head = self.selections.newest_display(cx).head();
-        self.show_hover(head, true, cx);
-    }
-
-    /// The internal hover action dispatches between `show_hover` or `hide_hover`
-    /// depending on whether a point to hover over is provided.
-    fn hover_at(&mut self, action: &HoverAt, cx: &mut ViewContext<Self>) {
-        if let Some(point) = action.point {
-            self.show_hover(point, false, cx);
-        } else {
-            self.hide_hover(cx);
-        }
-    }
-
-    /// Hides the type information popup.
-    /// Triggered by the `Hover` action when the cursor is not over a symbol or when the
-    /// selecitons changed.
-    fn hide_hover(&mut self, cx: &mut ViewContext<Self>) -> bool {
-        // consistently keep track of state to make handoff smooth
-        self.hover_state.determine_state(false);
-
-        let mut did_hide = false;
-
-        // only notify the context once
-        if self.hover_state.popover.is_some() {
-            self.hover_state.popover = None;
-            did_hide = true;
-            cx.notify();
-        }
-
-        self.clear_background_highlights::<HoverState>(cx);
-
-        self.hover_task = None;
-
-        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(
-        &mut self,
-        point: DisplayPoint,
-        ignore_timeout: bool,
-        cx: &mut ViewContext<Self>,
-    ) {
-        if self.pending_rename.is_some() {
-            return;
-        }
-
-        if let Some(hover) = &self.hover_state.popover {
-            if hover.hover_point == point {
-                // Hover triggered from same location as last time. Don't show again.
-                return;
-            }
-        }
-
-        let snapshot = self.snapshot(cx);
-        let (buffer, buffer_position) = if let Some(output) = self
-            .buffer
-            .read(cx)
-            .text_anchor_for_position(point.to_point(&snapshot.display_snapshot), cx)
-        {
-            output
-        } else {
-            return;
-        };
-
-        let project = if let Some(project) = self.project.clone() {
-            project
-        } else {
-            return;
-        };
-
-        // query the LSP for hover info
-        let hover_request = project.update(cx, |project, cx| {
-            project.hover(&buffer, buffer_position.clone(), cx)
-        });
-
-        let buffer_snapshot = buffer.read(cx).snapshot();
-
-        let task = cx.spawn_weak(|this, mut cx| {
-            async move {
-                // 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;
-                    }
-
-                    let range = if let Some(range) = hover_result.range {
-                        let offset_range = range.to_offset(&buffer_snapshot);
-                        if !offset_range
-                            .contains(&point.to_offset(&snapshot.display_snapshot, Bias::Left))
-                        {
-                            return None;
-                        }
-
-                        offset_range
-                            .start
-                            .to_display_point(&snapshot.display_snapshot)
-                            ..offset_range
-                                .end
-                                .to_display_point(&snapshot.display_snapshot)
-                    } else {
-                        point..point
-                    };
-
-                    Some(HoverPopover {
-                        project: project.clone(),
-                        hover_point: point,
-                        range,
-                        contents: hover_result.contents,
-                    })
-                });
-
-                if let Some(this) = this.upgrade(&cx) {
-                    this.update(&mut cx, |this, cx| {
-                        // this was trickier than expected, trying to do a couple things:
-                        //
-                        // 1. if you hover over a symbol, there should be a slight delay
-                        //    before the popover shows
-                        // 2. if you move to another symbol when the popover is showing,
-                        //    the popover should switch right away, and you should
-                        //    not have to wait for it to come up again
-                        let (recent_hover, in_grace) =
-                            this.hover_state.determine_state(hover_popover.is_some());
-                        let smooth_handoff =
-                            this.hover_state.popover.is_some() && hover_popover.is_some();
-                        let visible = this.hover_state.popover.is_some() || hover_popover.is_some();
-
-                        // `smooth_handoff` and `in_grace` determine whether to switch right away.
-                        // `recent_hover` will activate the handoff after the initial delay.
-                        // `ignore_timeout` is set when the user manually sent the hover action.
-                        if (ignore_timeout || smooth_handoff || !recent_hover || in_grace)
-                            && visible
-                        {
-                            // Highlight the selected symbol using a background highlight
-                            if let Some(display_range) =
-                                hover_popover.as_ref().map(|popover| popover.range.clone())
-                            {
-                                let start = snapshot.display_snapshot.buffer_snapshot.anchor_after(
-                                    display_range
-                                        .start
-                                        .to_offset(&snapshot.display_snapshot, Bias::Right),
-                                );
-                                let end = snapshot.display_snapshot.buffer_snapshot.anchor_before(
-                                    display_range
-                                        .end
-                                        .to_offset(&snapshot.display_snapshot, Bias::Left),
-                                );
-
-                                this.highlight_background::<HoverState>(
-                                    vec![start..end],
-                                    |theme| theme.editor.hover_popover.highlight,
-                                    cx,
-                                );
-                            }
-
-                            this.hover_state.popover = hover_popover;
-                            cx.notify();
-                        }
-                    });
-                }
-                Ok::<_, anyhow::Error>(())
-            }
-            .log_err()
-        });
-
-        self.hover_task = Some(task);
-    }
-
     async fn open_project_transaction(
         this: ViewHandle<Editor>,
         workspace: ViewHandle<Workspace>,
@@ -2708,7 +2430,7 @@ impl Editor {
                         .read(cx)
                         .excerpt_containing(editor.selections.newest_anchor().head(), cx)
                 });
-                if let Some((excerpted_buffer, excerpt_range)) = excerpt {
+                if let Some((_, excerpted_buffer, excerpt_range)) = excerpt {
                     if excerpted_buffer == *buffer {
                         let snapshot = buffer.read_with(&cx, |buffer, _| buffer.snapshot());
                         let excerpt_range = excerpt_range.to_offset(&snapshot);
@@ -2929,10 +2651,6 @@ impl Editor {
             .map(|menu| menu.render(cursor_position, style, cx))
     }
 
-    pub(crate) fn hover_popover(&self) -> Option<HoverPopover> {
-        self.hover_state.popover.clone()
-    }
-
     fn show_context_menu(&mut self, menu: ContextMenu, cx: &mut ViewContext<Self>) {
         if !matches!(menu, ContextMenu::Completions(_)) {
             self.completion_tasks.clear();
@@ -5970,9 +5688,22 @@ impl Entity for Editor {
 impl View for Editor {
     fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
         let style = self.style(cx);
-        self.display_map.update(cx, |map, cx| {
+        let font_changed = self.display_map.update(cx, |map, cx| {
             map.set_font(style.text.font_id, style.text.font_size, cx)
         });
+
+        // If the
+        if font_changed {
+            let handle = self.handle.clone();
+            cx.defer(move |cx| {
+                if let Some(editor) = handle.upgrade(cx) {
+                    editor.update(cx, |editor, cx| {
+                        hide_hover(editor, cx);
+                    })
+                }
+            });
+        }
+
         EditorElement::new(self.handle.clone(), style.clone(), self.cursor_shape).boxed()
     }
 
@@ -6416,11 +6147,16 @@ pub fn styled_runs_for_code_label<'a>(
 
 #[cfg(test)]
 mod tests {
-    use crate::test::{
-        assert_text_with_selections, build_editor, select_ranges, EditorTestContext,
+    use crate::{
+        hover_popover::{hover, hover_at, HoverAt, HOVER_DELAY_MILLIS, HOVER_GRACE_MILLIS},
+        test::{
+            assert_text_with_selections, build_editor, select_ranges, EditorLspTestContext,
+            EditorTestContext,
+        },
     };
 
     use super::*;
+    use futures::StreamExt;
     use gpui::{
         geometry::rect::RectF,
         platform::{WindowBounds, WindowOptions},
@@ -6428,9 +6164,8 @@ mod tests {
     use indoc::indoc;
     use language::{FakeLspAdapter, LanguageConfig};
     use lsp::FakeLanguageServer;
-    use project::FakeFs;
+    use project::{FakeFs, HoverBlock};
     use settings::LanguageOverride;
-    use smol::stream::StreamExt;
     use std::{cell::RefCell, rc::Rc, time::Instant};
     use text::Point;
     use unindent::Unindent;
@@ -9660,6 +9395,193 @@ mod tests {
         }
     }
 
+    #[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::<lsp::request::HoverRequest, _, _>(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.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,
+            )
+        });
+        cx.lsp
+            .handle_request::<lsp::request::HoverRequest, _, _>(|_, _| async move { Ok(None) })
+            .next()
+            .await;
+        cx.foreground().run_until_parked();
+        cx.editor(|editor, _| {
+            assert!(!editor.hover_state.visible());
+        });
+        cx.foreground()
+            .advance_clock(Duration::from_millis(HOVER_GRACE_MILLIS + 100));
+
+        // Hover with keyboard has no delay
+        cx.set_state(indoc! {"
+            f|n test()
+                println!();"});
+        cx.update_editor(|editor, cx| hover(editor, &hover_popover::Hover, cx));
+        let symbol_range = cx.lsp_range(indoc! {"
+            [fn] test()
+                println!();"});
+        cx.lsp
+            .handle_request::<lsp::request::HoverRequest, _, _>(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.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
+                    }
+                ]
+            )
+        });
+
+        // Open hover popover disables delay
+        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,
+            )
+        });
+
+        let symbol_range = cx.lsp_range(indoc! {"
+            fn test()
+                [println!]();"});
+        cx.lsp
+            .handle_request::<lsp::request::HoverRequest, _, _>(move |_, _| async move {
+                Ok(Some(lsp::Hover {
+                    contents: lsp::HoverContents::Markup(lsp::MarkupContent {
+                        kind: lsp::MarkupKind::Markdown,
+                        value: indoc! {"
+                        # Some third basic docs
+                        Some third test documentation"}
+                        .to_string(),
+                    }),
+                    range: Some(symbol_range),
+                }))
+            })
+            .next()
+            .await;
+        cx.foreground().run_until_parked();
+        // No delay as the popover is already visible
+
+        cx.editor(|editor, _| {
+            assert!(editor.hover_state.visible());
+            assert_eq!(
+                editor.hover_state.popover.clone().unwrap().contents,
+                vec![
+                    HoverBlock {
+                        text: "Some third basic docs".to_string(),
+                        language: None
+                    },
+                    HoverBlock {
+                        text: "Some third test documentation".to_string(),
+                        language: None
+                    }
+                ]
+            )
+        });
+    }
+
     #[gpui::test]
     async fn test_toggle_comment(cx: &mut gpui::TestAppContext) {
         cx.update(|cx| cx.set_global(Settings::test(cx)));

crates/editor/src/element.rs 🔗

@@ -3,9 +3,10 @@ use super::{
     Anchor, DisplayPoint, Editor, EditorMode, EditorSnapshot, Input, Scroll, Select, SelectPhase,
     SoftWrap, ToPoint, MAX_LINE_LEN,
 };
+use crate::hover_popover::HoverAt;
 use crate::{
     display_map::{DisplaySnapshot, TransformBlock},
-    EditorStyle, HoverAt,
+    EditorStyle,
 };
 use clock::ReplicaId;
 use collections::{BTreeMap, HashMap};
@@ -1196,8 +1197,8 @@ impl Element for EditorElement {
                     .map(|indicator| (newest_selection_head.row(), indicator));
             }
 
-            hover = view.hover_popover().and_then(|hover| {
-                let (point, rendered) = hover.render(style.clone(), cx);
+            hover = view.hover_state.popover.clone().and_then(|hover| {
+                let (point, rendered) = hover.render(&snapshot, style.clone(), cx);
                 if point.row() >= snapshot.scroll_position().y() as u32 {
                     if line_layouts.len() > (point.row() - start_row) as usize {
                         return Some((point, rendered));
@@ -1233,8 +1234,12 @@ impl Element for EditorElement {
                 SizeConstraint {
                     min: Vector2F::zero(),
                     max: vec2f(
-                        (120. * em_width).min(size.x()),
-                        (size.y() - line_height) * 1. / 2.,
+                        (120. * em_width) // Default size
+                            .min(size.x() / 2.) // Shrink to half of the editor width
+                            .max(20. * em_width), // Apply minimum width of 20 characters
+                        (16. * line_height) // Default size
+                            .min(size.y() / 2.) // Shrink to half of the editor height
+                            .max(4. * line_height), // Apply minimum height of 4 lines
                     ),
                 },
                 cx,

crates/editor/src/hover_popover.rs 🔗

@@ -0,0 +1,329 @@
+use std::{
+    ops::Range,
+    time::{Duration, Instant},
+};
+
+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 util::TryFutureExt;
+
+use crate::{
+    display_map::ToDisplayPoint, Anchor, AnchorRangeExt, DisplayPoint, Editor, EditorSnapshot,
+    EditorStyle,
+};
+
+pub const HOVER_DELAY_MILLIS: u64 = 500;
+pub const HOVER_REQUEST_DELAY_MILLIS: u64 = 250;
+pub const HOVER_GRACE_MILLIS: u64 = 250;
+
+#[derive(Clone, PartialEq)]
+pub struct HoverAt {
+    pub point: Option<DisplayPoint>,
+}
+
+actions!(editor, [Hover]);
+impl_internal_actions!(editor, [HoverAt]);
+
+pub fn init(cx: &mut MutableAppContext) {
+    cx.add_action(hover);
+    cx.add_action(hover_at);
+}
+
+/// Bindable action which uses the most recent selection head to trigger a hover
+pub fn hover(editor: &mut Editor, _: &Hover, cx: &mut ViewContext<Editor>) {
+    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<Editor>) {
+    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<Editor>) -> bool {
+    let mut did_hide = false;
+
+    // only notify the context once
+    if editor.hover_state.popover.is_some() {
+        editor.hover_state.popover = None;
+        editor.hover_state.hidden_at = Some(cx.background().now());
+        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::<HoverState>(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<Editor>,
+) {
+    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;
+    };
+
+    // We should only delay if the hover popover isn't visible, it wasn't recently hidden, and
+    // the hover wasn't triggered from the keyboard
+    let should_delay = editor.hover_state.popover.is_none() // Hover not visible currently
+    && editor
+            .hover_state
+            .hidden_at
+            .map(|hidden| hidden.elapsed().as_millis() > HOVER_GRACE_MILLIS as u128)
+            .unwrap_or(true) // Hover wasn't recently visible
+        && !ignore_timeout; // Hover was not triggered from keyboard
+
+    if should_delay {
+        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;
+            }
+        }
+    }
+
+    // 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 should_delay {
+                // 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)
+                })
+            });
+
+            // 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(HoverPopover {
+                    project: project.clone(),
+                    anchor: range.start.clone(),
+                    contents: hover_result.contents,
+                })
+            });
+
+            if let Some(delay) = delay {
+                delay.await;
+            }
+
+            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::<HoverState>(
+                                vec![range],
+                                |theme| theme.editor.hover_popover.highlight,
+                                cx,
+                            );
+                        }
+                        this.hover_state.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(Default)]
+pub struct HoverState {
+    pub popover: Option<HoverPopover>,
+    pub hidden_at: Option<Instant>,
+    pub triggered_from: Option<Anchor>,
+    pub symbol_range: Option<Range<Anchor>>,
+    pub task: Option<Task<Option<()>>>,
+}
+
+impl HoverState {
+    pub fn visible(&self) -> bool {
+        self.popover.is_some()
+    }
+}
+
+#[derive(Debug, Clone)]
+pub struct HoverPopover {
+    pub project: ModelHandle<Project>,
+    pub anchor: Anchor,
+    pub contents: Vec<HoverBlock>,
+}
+
+impl HoverPopover {
+    pub fn render(
+        &self,
+        snapshot: &EditorSnapshot,
+        style: EditorStyle,
+        cx: &mut RenderContext<Editor>,
+    ) -> (DisplayPoint, ElementBox) {
+        let element = MouseEventHandler::new::<HoverPopover, _, _>(0, cx, |_, cx| {
+            let mut flex = Flex::new(Axis::Vertical).scrollable::<HoverBlock, _>(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)
+    }
+}

crates/editor/src/multi_buffer.rs 🔗

@@ -937,7 +937,7 @@ impl MultiBuffer {
         &self,
         position: impl ToOffset,
         cx: &AppContext,
-    ) -> Option<(ModelHandle<Buffer>, Range<text::Anchor>)> {
+    ) -> Option<(ExcerptId, ModelHandle<Buffer>, Range<text::Anchor>)> {
         let snapshot = self.read(cx);
         let position = position.to_offset(&snapshot);
 
@@ -945,6 +945,7 @@ impl MultiBuffer {
         cursor.seek(&position, Bias::Right, &());
         cursor.item().map(|excerpt| {
             (
+                excerpt.id.clone(),
                 self.buffers
                     .borrow()
                     .get(&excerpt.buffer_id)

crates/editor/src/test.rs 🔗

@@ -1,10 +1,15 @@
-use std::ops::{Deref, DerefMut, Range};
+use std::{
+    ops::{Deref, DerefMut, Range},
+    sync::Arc,
+};
 
+use futures::StreamExt;
 use indoc::indoc;
 
 use collections::BTreeMap;
-use gpui::{keymap::Keystroke, ModelHandle, ViewContext, ViewHandle};
-use language::Selection;
+use gpui::{keymap::Keystroke, AppContext, ModelHandle, ViewContext, ViewHandle};
+use language::{point_to_lsp, FakeLspAdapter, Language, LanguageConfig, Selection};
+use project::{FakeFs, Project};
 use settings::Settings;
 use util::{
     set_eq,
@@ -13,7 +18,8 @@ use util::{
 
 use crate::{
     display_map::{DisplayMap, DisplaySnapshot, ToDisplayPoint},
-    Autoscroll, DisplayPoint, Editor, EditorMode, MultiBuffer,
+    multi_buffer::ToPointUtf16,
+    Autoscroll, DisplayPoint, Editor, EditorMode, MultiBuffer, ToPoint,
 };
 
 #[cfg(test)]
@@ -102,6 +108,13 @@ impl<'a> EditorTestContext<'a> {
         }
     }
 
+    pub fn editor<F, T>(&mut self, read: F) -> T
+    where
+        F: FnOnce(&Editor, &AppContext) -> T,
+    {
+        self.editor.read_with(self.cx, read)
+    }
+
     pub fn update_editor<F, T>(&mut self, update: F) -> T
     where
         F: FnOnce(&mut Editor, &mut ViewContext<Editor>) -> T,
@@ -132,6 +145,14 @@ impl<'a> EditorTestContext<'a> {
         }
     }
 
+    pub fn display_point(&mut self, cursor_location: &str) -> DisplayPoint {
+        let (_, locations) = marked_text(cursor_location);
+        let snapshot = self
+            .editor
+            .update(self.cx, |editor, cx| editor.snapshot(cx));
+        locations[0].to_display_point(&snapshot.display_snapshot)
+    }
+
     // Sets the editor state via a marked string.
     // `|` characters represent empty selections
     // `[` to `}` represents a non empty selection with the head at `}`
@@ -365,3 +386,125 @@ impl<'a> DerefMut for EditorTestContext<'a> {
         &mut self.cx
     }
 }
+
+pub struct EditorLspTestContext<'a> {
+    pub cx: EditorTestContext<'a>,
+    pub lsp: lsp::FakeLanguageServer,
+}
+
+impl<'a> EditorLspTestContext<'a> {
+    pub async fn new(
+        mut language: Language,
+        capabilities: lsp::ServerCapabilities,
+        cx: &'a mut gpui::TestAppContext,
+    ) -> EditorLspTestContext<'a> {
+        let file_name = format!(
+            "/file.{}",
+            language
+                .path_suffixes()
+                .first()
+                .unwrap_or(&"txt".to_string())
+        );
+
+        let mut fake_servers = language.set_fake_lsp_adapter(FakeLspAdapter {
+            capabilities,
+            ..Default::default()
+        });
+
+        let fs = FakeFs::new(cx.background().clone());
+        fs.insert_file(file_name.clone(), "".to_string()).await;
+
+        let project = Project::test(fs, [file_name.as_ref()], cx).await;
+        project.update(cx, |project, _| project.languages().add(Arc::new(language)));
+        let buffer = project
+            .update(cx, |project, cx| project.open_local_buffer(file_name, cx))
+            .await
+            .unwrap();
+
+        let (window_id, editor) = cx.update(|cx| {
+            cx.set_global(Settings::test(cx));
+            crate::init(cx);
+
+            let (window_id, editor) = cx.add_window(Default::default(), |cx| {
+                let buffer = cx.add_model(|cx| MultiBuffer::singleton(buffer, cx));
+
+                Editor::new(EditorMode::Full, buffer, Some(project), None, None, cx)
+            });
+
+            editor.update(cx, |_, cx| cx.focus_self());
+
+            (window_id, editor)
+        });
+
+        let lsp = fake_servers.next().await.unwrap();
+
+        Self {
+            cx: EditorTestContext {
+                cx,
+                window_id,
+                editor,
+            },
+            lsp,
+        }
+    }
+
+    pub async fn new_rust(
+        capabilities: lsp::ServerCapabilities,
+        cx: &'a mut gpui::TestAppContext,
+    ) -> EditorLspTestContext<'a> {
+        let language = Language::new(
+            LanguageConfig {
+                name: "Rust".into(),
+                path_suffixes: vec!["rs".to_string()],
+                ..Default::default()
+            },
+            Some(tree_sitter_rust::language()),
+        );
+
+        Self::new(language, capabilities, cx).await
+    }
+
+    // Constructs lsp range using a marked string with '[', ']' range delimiters
+    pub fn lsp_range(&mut self, marked_text: &str) -> lsp::Range {
+        let (unmarked, mut ranges) = marked_text_ranges_by(marked_text, vec![('[', ']').into()]);
+        assert_eq!(unmarked, self.cx.buffer_text());
+        let snapshot = self.update_editor(|editor, cx| editor.snapshot(cx));
+
+        let offset_range = ranges.remove(&('[', ']').into()).unwrap()[0].clone();
+        let start_point = offset_range.start.to_point(&snapshot.buffer_snapshot);
+        let end_point = offset_range.end.to_point(&snapshot.buffer_snapshot);
+        self.editor(|editor, cx| {
+            let buffer = editor.buffer().read(cx);
+            let start = point_to_lsp(
+                buffer
+                    .point_to_buffer_offset(start_point, cx)
+                    .unwrap()
+                    .1
+                    .to_point_utf16(&buffer.read(cx)),
+            );
+            let end = point_to_lsp(
+                buffer
+                    .point_to_buffer_offset(end_point, cx)
+                    .unwrap()
+                    .1
+                    .to_point_utf16(&buffer.read(cx)),
+            );
+
+            lsp::Range { start, end }
+        })
+    }
+}
+
+impl<'a> Deref for EditorLspTestContext<'a> {
+    type Target = EditorTestContext<'a>;
+
+    fn deref(&self) -> &Self::Target {
+        &self.cx
+    }
+}
+
+impl<'a> DerefMut for EditorLspTestContext<'a> {
+    fn deref_mut(&mut self) -> &mut Self::Target {
+        &mut self.cx
+    }
+}

crates/gpui/src/executor.rs 🔗

@@ -330,6 +330,11 @@ impl Deterministic {
         Timer::Deterministic(DeterministicTimer { rx, id, state })
     }
 
+    pub fn now(&self) -> std::time::Instant {
+        let state = self.state.lock();
+        state.now.clone()
+    }
+
     pub fn advance_clock(&self, duration: Duration) {
         let new_now = self.state.lock().now + duration;
         loop {
@@ -647,6 +652,14 @@ impl Background {
         }
     }
 
+    pub fn now(&self) -> std::time::Instant {
+        match self {
+            Background::Production { .. } => std::time::Instant::now(),
+            #[cfg(any(test, feature = "test-support"))]
+            Background::Deterministic { executor } => executor.now(),
+        }
+    }
+
     #[cfg(any(test, feature = "test-support"))]
     pub async fn simulate_random_delay(&self) {
         use rand::prelude::*;

crates/language/src/language.rs 🔗

@@ -571,6 +571,10 @@ impl Language {
         &self.config.brackets
     }
 
+    pub fn path_suffixes(&self) -> &[String] {
+        &self.config.path_suffixes
+    }
+
     pub fn should_autoclose_before(&self, c: char) -> bool {
         c.is_whitespace() || self.config.autoclose_before.contains(c)
     }

crates/project/src/lsp_command.rs 🔗

@@ -988,7 +988,6 @@ impl LspCommand for GetHover {
         _: ModelHandle<Buffer>,
         _: AsyncAppContext,
     ) -> Result<Self::Response> {
-        println!("Response from proto");
         let range = if let (Some(start), Some(end)) = (message.start, message.end) {
             language::proto::deserialize_anchor(start)
                 .and_then(|start| language::proto::deserialize_anchor(end).map(|end| start..end))

styles/src/buildTokens.ts 🔗

@@ -30,7 +30,7 @@ function themeTokens(theme: Theme) {
       boolean: theme.syntax.boolean.color,
     },
     player: theme.player,
-    shadowAlpha: theme.shadowAlpha,
+    shadow: theme.shadow,
   };
 }