diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml index 741dd93f33b057102dc2c73c16c926028829c302..6e1e3b09bd9b56f228b8a9c8524de5fc9d7e6fe3 100644 --- a/crates/editor/Cargo.toml +++ b/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"] } diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index 3e740f7b75deb7c9c3c0b8be1923cb0739306e6a..80676138e2de280e359ea754be59c3a460183804 100644 --- a/crates/editor/src/display_map.rs +++ b/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) { + pub fn set_font(&self, font_id: FontId, font_size: f32, cx: &mut ModelContext) -> 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, cx: &mut ModelContext) -> bool { diff --git a/crates/editor/src/display_map/wrap_map.rs b/crates/editor/src/display_map/wrap_map.rs index 0e88d87bd77b06ff3a4ab8bfb49c7f0c1aa2433e..33cd5438e3ae9bceab63566afb7851b8617aca5d 100644 --- a/crates/editor/src/display_map/wrap_map.rs +++ b/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) { + pub fn set_font( + &mut self, + font_id: FontId, + font_size: f32, + cx: &mut ModelContext, + ) -> bool { if (font_id, font_size) != self.font { self.font = (font_id, font_size); - self.rewrap(cx) + self.rewrap(cx); + true + } else { + false } } diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 41f2b4092a5e368575afcce178faef6cbf5856a6..5c637d8759712f85985dad6413ceb759812500d5 100644 --- a/crates/editor/src/editor.rs +++ b/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, -} - #[derive(Clone, Debug, PartialEq)] pub struct Jump { path: ProjectPath, @@ -127,11 +124,6 @@ pub struct ConfirmCodeAction { pub item_ix: Option, } -#[derive(Clone, Default)] -pub struct GoToDefinitionAt { - pub location: Option, -} - 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::(cx); workspace::register_followable_item::(cx); } @@ -431,7 +423,6 @@ pub struct Editor { next_completion_id: CompletionId, available_code_actions: Option<(ModelHandle, Arc<[CodeAction]>)>, code_actions_task: Option>, - hover_task: Option>>, document_highlights_task: Option>, pending_rename: Option, 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, - 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, - pub hover_point: DisplayPoint, - pub range: Range, - pub contents: Vec, -} - -impl HoverPopover { - fn render( - &self, - 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 { - 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, @@ -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) { - 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) { - 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) -> 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::(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, - ) { - 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::( - 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, workspace: ViewHandle, @@ -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 { - self.hover_state.popover.clone() - } - fn show_context_menu(&mut self, menu: ContextMenu, cx: &mut ViewContext) { 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) -> 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::(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::(|_, _| 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::(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::(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))); diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index a62a75debcbc1da50bebe0347a8983a7c6080359..899063e138bf1c6a4664ad649ec0e4c9c77c7f25 100644 --- a/crates/editor/src/element.rs +++ b/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, diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs new file mode 100644 index 0000000000000000000000000000000000000000..23b9e5432771c721ed14cf6e35f9bd003b782234 --- /dev/null +++ b/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, +} + +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) { + 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 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.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::(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; + }; + + // 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::( + 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, + pub hidden_at: Option, + pub triggered_from: Option, + pub symbol_range: Option>, + pub task: Option>>, +} + +impl HoverState { + pub fn visible(&self) -> bool { + self.popover.is_some() + } +} + +#[derive(Debug, Clone)] +pub struct HoverPopover { + pub project: ModelHandle, + pub anchor: Anchor, + pub contents: Vec, +} + +impl HoverPopover { + 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) + } +} diff --git a/crates/editor/src/multi_buffer.rs b/crates/editor/src/multi_buffer.rs index 98eb7b12071b37e4cbadc130f178d73884229331..88bfe28a27bdd0803d3da59d71592cba776810e4 100644 --- a/crates/editor/src/multi_buffer.rs +++ b/crates/editor/src/multi_buffer.rs @@ -937,7 +937,7 @@ impl MultiBuffer { &self, position: impl ToOffset, cx: &AppContext, - ) -> Option<(ModelHandle, Range)> { + ) -> Option<(ExcerptId, ModelHandle, Range)> { 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) diff --git a/crates/editor/src/test.rs b/crates/editor/src/test.rs index 0f495769361f595a004d7c554cae08ef9f4e47b7..41eadd6130b75da9fe47ef86e00e8618290fd0b9 100644 --- a/crates/editor/src/test.rs +++ b/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(&mut self, read: F) -> T + where + F: FnOnce(&Editor, &AppContext) -> T, + { + self.editor.read_with(self.cx, read) + } + pub fn update_editor(&mut self, update: F) -> T where F: FnOnce(&mut Editor, &mut ViewContext) -> 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 + } +} diff --git a/crates/gpui/src/executor.rs b/crates/gpui/src/executor.rs index 3126e18c7aef6caf6a51e9b97e263b23199e5f78..1bc8d61c44306abd612d412f4db9a5c18ba17136 100644 --- a/crates/gpui/src/executor.rs +++ b/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::*; diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index cebe666da03575e9e1fb1bb4e9b78e6a84038122..c8ce904a42a3435d0e2cc8d1b13ef86ae8641a24 100644 --- a/crates/language/src/language.rs +++ b/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) } diff --git a/crates/project/src/lsp_command.rs b/crates/project/src/lsp_command.rs index 0a5a4946df0087b06254135299e57376a43b8d69..2e124335c0c83a2bafa94dec76af5445d76baf7f 100644 --- a/crates/project/src/lsp_command.rs +++ b/crates/project/src/lsp_command.rs @@ -988,7 +988,6 @@ impl LspCommand for GetHover { _: ModelHandle, _: AsyncAppContext, ) -> Result { - 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)) diff --git a/styles/src/buildTokens.ts b/styles/src/buildTokens.ts index 04b4a6b7523f50dede166f539c9d5fb83e86af67..a6ec840bbf8166d4b4fd04df957aa3f434b23cdc 100644 --- a/styles/src/buildTokens.ts +++ b/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, }; }