mirror of
https://github.com/zed-industries/zed.git
synced 2025-01-27 12:54:42 +00:00
Add hover test and tweak dismiss logic
This commit is contained in:
parent
1da1853a76
commit
dddfc7beae
5 changed files with 410 additions and 28 deletions
|
@ -6147,8 +6147,15 @@ 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,
|
||||
HOVER_REQUEST_DELAY_MILLIS,
|
||||
},
|
||||
test::{
|
||||
assert_text_with_selections, build_editor, select_ranges, EditorLspTestContext,
|
||||
EditorTestContext,
|
||||
},
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
@ -6159,7 +6166,7 @@ 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};
|
||||
|
@ -9391,6 +9398,184 @@ 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!]();"});
|
||||
cx.handle_request::<lsp::request::HoverRequest, _>(move |_| {
|
||||
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),
|
||||
})
|
||||
})
|
||||
.await;
|
||||
cx.foreground()
|
||||
.advance_clock(Duration::from_millis(HOVER_DELAY_MILLIS + 100));
|
||||
|
||||
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.handle_request::<lsp::request::HoverRequest, _>(move |_| None)
|
||||
.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.handle_request::<lsp::request::HoverRequest, _>(move |_| {
|
||||
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),
|
||||
})
|
||||
})
|
||||
.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.handle_request::<lsp::request::HoverRequest, _>(move |_| {
|
||||
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),
|
||||
})
|
||||
})
|
||||
.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)));
|
||||
|
|
|
@ -19,6 +19,10 @@ use crate::{
|
|||
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>,
|
||||
|
@ -33,14 +37,14 @@ pub fn init(cx: &mut MutableAppContext) {
|
|||
}
|
||||
|
||||
/// Bindable action which uses the most recent selection head to trigger a hover
|
||||
fn hover(editor: &mut Editor, _: &Hover, cx: &mut ViewContext<Editor>) {
|
||||
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.
|
||||
fn hover_at(editor: &mut Editor, action: &HoverAt, cx: &mut ViewContext<Editor>) {
|
||||
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 {
|
||||
|
@ -57,12 +61,12 @@ pub fn hide_hover(editor: &mut Editor, cx: &mut ViewContext<Editor>) -> bool {
|
|||
// only notify the context once
|
||||
if editor.hover_state.popover.is_some() {
|
||||
editor.hover_state.popover = None;
|
||||
editor.hover_state.hidden_at = Some(Instant::now());
|
||||
editor.hover_state.symbol_range = None;
|
||||
editor.hover_state.hidden_at = Some(cx.background().now());
|
||||
did_hide = true;
|
||||
cx.notify();
|
||||
}
|
||||
editor.hover_state.task = None;
|
||||
editor.hover_state.symbol_range = None;
|
||||
|
||||
editor.clear_background_highlights::<HoverState>(cx);
|
||||
|
||||
|
@ -118,13 +122,13 @@ fn show_hover(
|
|||
|
||||
// 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 visible currently
|
||||
let should_delay = editor.hover_state.popover.is_none() // Hover not visible currently
|
||||
&& editor
|
||||
.hover_state
|
||||
.hidden_at
|
||||
.map(|hidden| hidden.elapsed().as_millis() > 200)
|
||||
.unwrap_or(true) // Hover was visible recently enough
|
||||
&& !ignore_timeout; // Hover triggered from keyboard
|
||||
.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 {
|
||||
|
@ -145,8 +149,18 @@ fn show_hover(
|
|||
|
||||
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 {
|
||||
Some(cx.background().timer(Duration::from_millis(500)))
|
||||
// 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
|
||||
};
|
||||
|
@ -157,6 +171,8 @@ fn show_hover(
|
|||
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
|
||||
|
@ -189,7 +205,7 @@ fn show_hover(
|
|||
|
||||
if let Some(this) = this.upgrade(&cx) {
|
||||
this.update(&mut cx, |this, cx| {
|
||||
if this.hover_state.popover.is_some() || hover_popover.is_some() {
|
||||
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>(
|
||||
|
@ -198,18 +214,16 @@ fn show_hover(
|
|||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
this.hover_state.popover = hover_popover;
|
||||
|
||||
if this.hover_state.popover.is_none() {
|
||||
this.hover_state.hidden_at = Some(Instant::now());
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
if this.hover_state.popover.is_none() {
|
||||
this.hover_state.symbol_range = None;
|
||||
} else {
|
||||
if this.hover_state.popover.is_some() {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -229,7 +243,13 @@ pub struct HoverState {
|
|||
pub task: Option<Task<Option<()>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
impl HoverState {
|
||||
pub fn visible(&self) -> bool {
|
||||
self.popover.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HoverPopover {
|
||||
pub project: ModelHandle<Project>,
|
||||
pub anchor: Anchor,
|
||||
|
|
|
@ -1,10 +1,16 @@
|
|||
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 lsp::request;
|
||||
use project::{FakeFs, Project};
|
||||
use settings::Settings;
|
||||
use util::{
|
||||
set_eq,
|
||||
|
@ -13,7 +19,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 +109,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 +146,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 +387,141 @@ impl<'a> DerefMut for EditorTestContext<'a> {
|
|||
&mut self.cx
|
||||
}
|
||||
}
|
||||
|
||||
pub struct EditorLspTestContext<'a> {
|
||||
pub cx: EditorTestContext<'a>,
|
||||
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
|
||||
}
|
||||
|
||||
pub async fn handle_request<T, F>(&mut self, mut construct_result: F)
|
||||
where
|
||||
T: 'static + request::Request,
|
||||
T::Params: 'static + Send,
|
||||
T::Result: 'static + Send + Clone,
|
||||
F: 'static + Send + FnMut(T::Params) -> T::Result,
|
||||
{
|
||||
self.lsp
|
||||
.handle_request::<T, _, _>(move |params, _| {
|
||||
let result = construct_result(params);
|
||||
async move { Ok(result.clone()) }
|
||||
})
|
||||
.next()
|
||||
.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.editor_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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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::*;
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue