zed/crates/editor/src/hover_popover.rs
Daniel Martín 6049ceaecf
Auto-scroll hover popup while selecting text (#57518)
Selecting text inside the hover documentation or git popups did not
scroll the popup when the drag passed the visible area, so any text
below the area could not be selected with the mouse.

The popup's container already had a `ScrollHandle` wired to its
scrollbar and wheel scrolling, but the inner `MarkdownElement` was
constructed without one. That left it in the default
`AutoscrollBehavior::Propagate` mode, which routes drag-autoscroll
requests to the editor-wide autoscroll listener, which is a listener
that does not exist inside a floating popover, so the requests were
silently dropped.

Passing the popup's existing `ScrollHandle` into the `MarkdownElement`
switches it to `AutoscrollBehavior::Controlled`, which scrolls the
popup's own container directly during a drag. The markdown preview view
already uses this same pattern.

Release Notes:

- Fixed hover documentation and git popups not scrolling while selecting
text with the mouse

Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable
2026-05-28 09:55:14 +00:00

2701 lines
100 KiB
Rust

use crate::{
Anchor, AnchorRangeExt, DisplayPoint, DisplayRow, Editor, EditorSettings, EditorSnapshot,
GlobalDiagnosticRenderer, HighlightKey, Hover,
display_map::{InlayOffset, ToDisplayPoint, is_invisible},
editor_settings::EditorSettingsScrollbarProxy,
hover_links::{InlayHighlight, RangeInEditor},
movement::TextLayoutDetails,
scroll::ScrollAmount,
};
use anyhow::Context as _;
use gpui::{
AnyElement, App, AsyncWindowContext, Bounds, Context, Entity, Focusable as _, FontWeight, Hsla,
InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, ScrollHandle, Size,
StatefulInteractiveElement, StyleRefinement, Styled, Subscription, Task, TaskExt,
TextStyleRefinement, Window, canvas, div, px,
};
use itertools::Itertools;
use language::{DiagnosticEntry, Language, LanguageRegistry};
use lsp::DiagnosticSeverity;
use markdown::{CopyButtonVisibility, Markdown, MarkdownElement, MarkdownStyle};
use multi_buffer::{MultiBufferOffset, ToOffset, ToPoint};
use project::{HoverBlock, HoverBlockKind, InlayHintLabelPart};
use settings::Settings;
use std::{
borrow::Cow,
cell::{Cell, RefCell},
};
use std::{ops::Range, sync::Arc, time::Duration};
use std::{path::PathBuf, rc::Rc};
use theme_settings::ThemeSettings;
use ui::{CopyButton, Scrollbars, WithScrollbar, prelude::*, theme_is_transparent};
use url::Url;
use util::TryFutureExt;
use workspace::{OpenOptions, OpenVisible, Workspace};
pub const MIN_POPOVER_CHARACTER_WIDTH: f32 = 20.;
pub const MIN_POPOVER_LINE_HEIGHT: f32 = 4.;
pub const POPOVER_RIGHT_OFFSET: Pixels = px(8.0);
pub const HOVER_POPOVER_GAP: Pixels = px(10.);
/// Bindable action which uses the most recent selection head to trigger a hover
pub fn hover(editor: &mut Editor, _: &Hover, window: &mut Window, cx: &mut Context<Editor>) {
let head = editor.selections.newest_anchor().head();
show_hover(editor, head, true, window, 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,
anchor: Option<Anchor>,
mouse_position: Option<gpui::Point<Pixels>>,
window: &mut Window,
cx: &mut Context<Editor>,
) {
if EditorSettings::get_global(cx).hover_popover_enabled {
if show_keyboard_hover(editor, window, cx) {
return;
}
if let Some(anchor) = anchor {
editor.hover_state.hiding_delay_task = None;
editor.hover_state.closest_mouse_distance = None;
show_hover(editor, anchor, false, window, cx);
} else if !editor.hover_state.visible() {
editor.hover_state.info_task = None;
} else {
let settings = EditorSettings::get_global(cx);
if !settings.hover_popover_sticky {
hide_hover(editor, cx);
return;
}
let mut getting_closer = false;
if let Some(mouse_position) = mouse_position {
getting_closer = editor.hover_state.is_mouse_getting_closer(mouse_position);
}
// If we are moving away and a timer is already running, just let it count down.
if !getting_closer && editor.hover_state.hiding_delay_task.is_some() {
return;
}
// If we are moving closer, or if no timer is running at all, start/restart the timer.
let delay = Duration::from_millis(settings.hover_popover_hiding_delay.0);
let task = cx.spawn(async move |this, cx| {
cx.background_executor().timer(delay).await;
this.update(cx, |editor, cx| {
hide_hover(editor, cx);
})
.ok();
});
editor.hover_state.hiding_delay_task = Some(task);
}
}
}
pub fn show_keyboard_hover(
editor: &mut Editor,
window: &mut Window,
cx: &mut Context<Editor>,
) -> bool {
if let Some(anchor) = editor.hover_state.info_popovers.iter().find_map(|p| {
if *p.keyboard_grace.borrow() {
p.anchor
} else {
None
}
}) {
show_hover(editor, anchor, false, window, cx);
return true;
}
if let Some(anchor) = editor
.hover_state
.diagnostic_popover
.as_ref()
.and_then(|d| {
if *d.keyboard_grace.borrow() {
Some(d.anchor)
} else {
None
}
})
{
show_hover(editor, anchor, false, window, cx);
return true;
}
false
}
pub struct InlayHover {
pub(crate) range: InlayHighlight,
pub tooltip: HoverBlock,
}
pub fn find_hovered_hint_part(
label_parts: Vec<InlayHintLabelPart>,
hint_start: InlayOffset,
hovered_offset: InlayOffset,
) -> Option<(InlayHintLabelPart, Range<InlayOffset>)> {
if hovered_offset >= hint_start {
let mut offset_in_hint = hovered_offset - hint_start;
let mut part_start = hint_start;
for part in label_parts {
let part_len = part.value.len();
if offset_in_hint >= part_len {
offset_in_hint -= part_len;
part_start.0 += part_len;
} else {
let part_end = InlayOffset(part_start.0 + part_len);
return Some((part, part_start..part_end));
}
}
}
None
}
pub fn hover_at_inlay(
editor: &mut Editor,
inlay_hover: InlayHover,
window: &mut Window,
cx: &mut Context<Editor>,
) {
if EditorSettings::get_global(cx).hover_popover_enabled {
if editor.pending_rename.is_some() {
return;
}
let Some(project) = editor.project.clone() else {
return;
};
if editor
.hover_state
.info_popovers
.iter()
.any(|InfoPopover { symbol_range, .. }| {
if let RangeInEditor::Inlay(range) = symbol_range
&& range == &inlay_hover.range
{
// Hover triggered from same location as last time. Don't show again.
return true;
}
false
})
{
return;
}
let hover_popover_delay = EditorSettings::get_global(cx).hover_popover_delay.0;
editor.hover_state.hiding_delay_task = None;
editor.hover_state.closest_mouse_distance = None;
let task = cx.spawn_in(window, async move |this, cx| {
async move {
cx.background_executor()
.timer(Duration::from_millis(hover_popover_delay))
.await;
this.update(cx, |this, _| {
this.hover_state.diagnostic_popover = None;
})?;
let language_registry = project.read_with(cx, |p, _| p.languages().clone());
let blocks = vec![inlay_hover.tooltip];
let parsed_content = parse_blocks(&blocks, Some(&language_registry), None, cx);
let scroll_handle = ScrollHandle::new();
let subscription = this
.update(cx, |_, cx| {
parsed_content.as_ref().map(|parsed_content| {
cx.observe(parsed_content, |_, _, cx| cx.notify())
})
})
.ok()
.flatten();
let hover_popover = InfoPopover {
symbol_range: RangeInEditor::Inlay(inlay_hover.range.clone()),
parsed_content,
scroll_handle,
keyboard_grace: Rc::new(RefCell::new(false)),
anchor: None,
last_bounds: Rc::new(Cell::new(None)),
_subscription: subscription,
};
this.update(cx, |this, cx| {
// TODO: no background highlights happen for inlays currently
this.hover_state.info_popovers = vec![hover_popover];
cx.notify();
})?;
anyhow::Ok(())
}
.log_err()
.await
});
editor.hover_state.info_task = Some(task);
}
}
/// 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 Context<Editor>) -> bool {
let info_popovers = editor.hover_state.info_popovers.drain(..);
let diagnostics_popover = editor.hover_state.diagnostic_popover.take();
let did_hide = info_popovers.count() > 0 || diagnostics_popover.is_some();
editor.hover_state.info_task = None;
editor.hover_state.triggered_from = None;
editor.hover_state.hiding_delay_task = None;
editor.hover_state.closest_mouse_distance = None;
editor.clear_background_highlights(HighlightKey::HoverState, cx);
if did_hide {
cx.notify();
}
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,
anchor: Anchor,
ignore_timeout: bool,
window: &mut Window,
cx: &mut Context<Editor>,
) -> Option<()> {
if editor.pending_rename.is_some() {
return None;
}
let snapshot = editor.snapshot(window, cx);
let (buffer_position, _) = editor
.buffer
.read(cx)
.snapshot(cx)
.anchor_to_buffer_anchor(anchor)?;
let buffer = editor.buffer.read(cx).buffer(buffer_position.buffer_id)?;
let language_registry = editor
.project()
.map(|project| project.read(cx).languages().clone());
let provider = editor.semantics_provider.clone()?;
editor.hover_state.hiding_delay_task = None;
editor.hover_state.closest_mouse_distance = None;
if !ignore_timeout {
if same_info_hover(editor, &snapshot, anchor)
|| same_diagnostic_hover(editor, &snapshot, anchor)
|| editor.hover_state.diagnostic_popover.is_some()
{
// Hover triggered from same location as last time. Don't show again.
return None;
} else {
hide_hover(editor, cx);
}
}
// Don't request again if the location is the same as the previous request
if let Some(triggered_from) = &editor.hover_state.triggered_from
&& triggered_from
.cmp(&anchor, &snapshot.buffer_snapshot())
.is_eq()
{
return None;
}
let hover_popover_delay = EditorSettings::get_global(cx).hover_popover_delay.0;
let all_diagnostics_active = editor.all_diagnostics_active();
let active_group_id = editor.active_diagnostic_group_id();
let renderer = GlobalDiagnosticRenderer::global(cx);
let task = cx.spawn_in(window, async move |this, cx| {
async move {
// If we need to delay, delay a set amount initially before making the lsp request
let delay = if ignore_timeout {
None
} else {
let lsp_request_early = hover_popover_delay / 2;
cx.background_executor()
.timer(Duration::from_millis(
hover_popover_delay - lsp_request_early,
))
.await;
// Construct delay task to wait for later
let total_delay = Some(
cx.background_executor()
.timer(Duration::from_millis(lsp_request_early)),
);
total_delay
};
let hover_request = cx.update(|_, cx| provider.hover(&buffer, buffer_position, cx))?;
if let Some(delay) = delay {
delay.await;
}
let offset = anchor.to_offset(&snapshot.buffer_snapshot());
let local_diagnostic = if all_diagnostics_active {
None
} else {
snapshot
.buffer_snapshot()
.diagnostics_with_buffer_ids_in_range::<MultiBufferOffset>(offset..offset)
.filter(|(_, diagnostic)| {
Some(diagnostic.diagnostic.group_id) != active_group_id
})
// Find the entry with the most specific range
.min_by_key(|(_, entry)| entry.range.end - entry.range.start)
};
let diagnostic_popover = if let Some((buffer_id, local_diagnostic)) = local_diagnostic {
let group = snapshot
.buffer_snapshot()
.diagnostic_group(buffer_id, local_diagnostic.diagnostic.group_id)
.collect::<Vec<_>>();
let point_range = local_diagnostic
.range
.start
.to_point(&snapshot.buffer_snapshot())
..local_diagnostic
.range
.end
.to_point(&snapshot.buffer_snapshot());
let markdown = cx.update(|_, cx| {
renderer
.as_ref()
.and_then(|renderer| {
renderer.render_hover(
group,
point_range,
buffer_id,
language_registry.clone(),
cx,
)
})
.context("no rendered diagnostic")
})??;
let (background_color, border_color) = cx.update(|_, cx| {
let status_colors = cx.theme().status();
match local_diagnostic.diagnostic.severity {
DiagnosticSeverity::ERROR => {
(status_colors.error_background, status_colors.error_border)
}
DiagnosticSeverity::WARNING => (
status_colors.warning_background,
status_colors.warning_border,
),
DiagnosticSeverity::INFORMATION => {
(status_colors.info_background, status_colors.info_border)
}
DiagnosticSeverity::HINT => {
(status_colors.hint_background, status_colors.hint_border)
}
_ => (
status_colors.ignored_background,
status_colors.ignored_border,
),
}
})?;
let subscription =
this.update(cx, |_, cx| cx.observe(&markdown, |_, _, cx| cx.notify()))?;
let local_diagnostic = DiagnosticEntry {
diagnostic: local_diagnostic.diagnostic.to_owned(),
range: snapshot
.buffer_snapshot()
.anchor_before(local_diagnostic.range.start)
..snapshot
.buffer_snapshot()
.anchor_after(local_diagnostic.range.end),
};
let scroll_handle = ScrollHandle::new();
Some(DiagnosticPopover {
local_diagnostic,
markdown,
border_color,
scroll_handle,
background_color,
keyboard_grace: Rc::new(RefCell::new(ignore_timeout)),
anchor,
last_bounds: Rc::new(Cell::new(None)),
_subscription: subscription,
})
} else {
None
};
this.update(cx, |this, _| {
this.hover_state.diagnostic_popover = diagnostic_popover;
})?;
let invisible_char = if let Some(invisible) = snapshot
.buffer_snapshot()
.chars_at(anchor)
.next()
.filter(|&c| is_invisible(c))
{
let after = snapshot.buffer_snapshot().anchor_after(
anchor.to_offset(&snapshot.buffer_snapshot()) + invisible.len_utf8(),
);
Some((invisible, anchor..after))
} else if let Some(invisible) = snapshot
.buffer_snapshot()
.reversed_chars_at(anchor)
.next()
.filter(|&c| is_invisible(c))
{
let before = snapshot.buffer_snapshot().anchor_before(
anchor.to_offset(&snapshot.buffer_snapshot()) - invisible.len_utf8(),
);
Some((invisible, before..anchor))
} else {
None
};
let hovers_response = if let Some(hover_request) = hover_request {
hover_request.await.unwrap_or_default()
} else {
Vec::new()
};
let snapshot = this.update_in(cx, |this, window, cx| this.snapshot(window, cx))?;
let mut hover_highlights = Vec::with_capacity(hovers_response.len());
let mut info_popovers = Vec::with_capacity(
hovers_response.len() + if invisible_char.is_some() { 1 } else { 0 },
);
if let Some((invisible, range)) = invisible_char {
let blocks = vec![HoverBlock {
text: format!("Unicode character U+{:02X}", invisible as u32),
kind: HoverBlockKind::PlainText,
}];
let parsed_content = parse_blocks(&blocks, language_registry.as_ref(), None, cx);
let scroll_handle = ScrollHandle::new();
let subscription = this
.update(cx, |_, cx| {
parsed_content.as_ref().map(|parsed_content| {
cx.observe(parsed_content, |_, _, cx| cx.notify())
})
})
.ok()
.flatten();
info_popovers.push(InfoPopover {
symbol_range: RangeInEditor::Text(range),
parsed_content,
scroll_handle,
keyboard_grace: Rc::new(RefCell::new(ignore_timeout)),
anchor: Some(anchor),
last_bounds: Rc::new(Cell::new(None)),
_subscription: subscription,
})
}
let doc_link_task = this
.update(cx, |editor, cx| {
editor.document_links_at(buffer.clone(), buffer_position, cx)
})
.ok()
.flatten();
let doc_link_tooltips = match doc_link_task {
Some(task) => task
.await
.into_iter()
.filter_map(|(_, link)| {
let multi_buffer_range = snapshot
.buffer_snapshot()
.buffer_anchor_range_to_anchor_range(link.range.clone())?;
let tooltip = link.tooltip?;
Some((multi_buffer_range, tooltip))
})
.collect::<Vec<_>>(),
None => Vec::new(),
};
for hover_result in hovers_response {
// Create symbol range of anchors for highlighting and filtering of future requests.
let range = hover_result
.range
.and_then(|range| {
let range = snapshot
.buffer_snapshot()
.buffer_anchor_range_to_anchor_range(range)?;
Some(range)
})
.or_else(|| {
let snapshot = &snapshot.buffer_snapshot();
let range = snapshot.syntax_ancestor(anchor..anchor)?.1;
Some(snapshot.anchor_before(range.start)..snapshot.anchor_after(range.end))
})
.unwrap_or_else(|| anchor..anchor);
let blocks = hover_result.contents;
let language = hover_result.language;
let parsed_content =
parse_blocks(&blocks, language_registry.as_ref(), language, cx);
let scroll_handle = ScrollHandle::new();
hover_highlights.push(range.clone());
let subscription = this
.update(cx, |_, cx| {
parsed_content.as_ref().map(|parsed_content| {
cx.observe(parsed_content, |_, _, cx| cx.notify())
})
})
.ok()
.flatten();
info_popovers.push(InfoPopover {
symbol_range: RangeInEditor::Text(range),
parsed_content,
scroll_handle,
keyboard_grace: Rc::new(RefCell::new(ignore_timeout)),
anchor: Some(anchor),
last_bounds: Rc::new(Cell::new(None)),
_subscription: subscription,
});
}
for (multi_buffer_range, tooltip) in doc_link_tooltips {
let blocks = vec![HoverBlock {
text: tooltip.to_string(),
kind: HoverBlockKind::Markdown,
}];
let parsed_content = parse_blocks(&blocks, language_registry.as_ref(), None, cx);
let scroll_handle = ScrollHandle::new();
let subscription = this
.update(cx, |_, cx| {
parsed_content.as_ref().map(|parsed_content| {
cx.observe(parsed_content, |_, _, cx| cx.notify())
})
})
.ok()
.flatten();
info_popovers.push(InfoPopover {
symbol_range: RangeInEditor::Text(multi_buffer_range),
parsed_content,
scroll_handle,
keyboard_grace: Rc::new(RefCell::new(ignore_timeout)),
anchor: Some(anchor),
last_bounds: Rc::new(Cell::new(None)),
_subscription: subscription,
});
}
this.update_in(cx, |editor, window, cx| {
if hover_highlights.is_empty() {
editor.clear_background_highlights(HighlightKey::HoverState, cx);
} else {
// Highlight the selected symbol using a background highlight
editor.highlight_background(
HighlightKey::HoverState,
&hover_highlights,
|_, theme| theme.colors().element_hover, // todo update theme
cx,
);
}
editor.hover_state.info_popovers = info_popovers;
cx.notify();
window.refresh();
})?;
anyhow::Ok(())
}
.log_err()
.await
});
editor.hover_state.info_task = Some(task);
None
}
fn same_info_hover(editor: &Editor, snapshot: &EditorSnapshot, anchor: Anchor) -> bool {
editor
.hover_state
.info_popovers
.iter()
.any(|InfoPopover { symbol_range, .. }| {
symbol_range
.as_text_range()
.map(|range| {
let hover_range = range.to_offset(&snapshot.buffer_snapshot());
let offset = anchor.to_offset(&snapshot.buffer_snapshot());
// LSP returns a hover result for the end index of ranges that should be hovered, so we need to
// use an inclusive range here to check if we should dismiss the popover
(hover_range.start..=hover_range.end).contains(&offset)
})
.unwrap_or(false)
})
}
fn same_diagnostic_hover(editor: &Editor, snapshot: &EditorSnapshot, anchor: Anchor) -> bool {
editor
.hover_state
.diagnostic_popover
.as_ref()
.map(|diagnostic| {
let hover_range = diagnostic
.local_diagnostic
.range
.to_offset(&snapshot.buffer_snapshot());
let offset = anchor.to_offset(&snapshot.buffer_snapshot());
// Here we do basically the same as in `same_info_hover`, see comment there for an explanation
(hover_range.start..=hover_range.end).contains(&offset)
})
.unwrap_or(false)
}
fn parse_blocks(
blocks: &[HoverBlock],
language_registry: Option<&Arc<LanguageRegistry>>,
language: Option<Arc<Language>>,
cx: &mut AsyncWindowContext,
) -> Option<Entity<Markdown>> {
let combined_text = blocks
.iter()
.map(|block| match &block.kind {
project::HoverBlockKind::PlainText | project::HoverBlockKind::Markdown => {
Cow::Borrowed(block.text.trim())
}
project::HoverBlockKind::Code { language } => {
Cow::Owned(format!("```{}\n{}\n```", language, block.text.trim()))
}
})
.join("\n\n");
cx.new_window_entity(|_window, cx| {
Markdown::new(
combined_text.into(),
language_registry.cloned(),
language.map(|language| language.name()),
cx,
)
})
.ok()
}
pub fn hover_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
let settings = ThemeSettings::get_global(cx);
let ui_font_family = settings.ui_font.family.clone();
let ui_font_features = settings.ui_font.features.clone();
let ui_font_fallbacks = settings.ui_font.fallbacks.clone();
let buffer_font_family = settings.buffer_font.family.clone();
let buffer_font_features = settings.buffer_font.features.clone();
let buffer_font_fallbacks = settings.buffer_font.fallbacks.clone();
let buffer_font_weight = settings.buffer_font.weight;
let mut base_text_style = window.text_style();
base_text_style.refine(&TextStyleRefinement {
font_family: Some(ui_font_family),
font_features: Some(ui_font_features),
font_fallbacks: ui_font_fallbacks,
color: Some(cx.theme().colors().editor_foreground),
..Default::default()
});
MarkdownStyle {
base_text_style,
code_block: StyleRefinement::default()
.my(rems(1.))
.font_buffer(cx)
.font_features(buffer_font_features.clone())
.font_weight(buffer_font_weight),
inline_code: TextStyleRefinement {
background_color: Some(cx.theme().colors().background),
font_family: Some(buffer_font_family),
font_features: Some(buffer_font_features),
font_fallbacks: buffer_font_fallbacks,
font_weight: Some(buffer_font_weight),
..Default::default()
},
rule_color: cx.theme().colors().border,
block_quote_border_color: Color::Muted.color(cx),
block_quote: TextStyleRefinement {
color: Some(Color::Muted.color(cx)),
..Default::default()
},
link: TextStyleRefinement {
color: Some(cx.theme().colors().editor_foreground),
underline: Some(gpui::UnderlineStyle {
thickness: px(1.),
color: Some(cx.theme().colors().editor_foreground),
wavy: false,
}),
..Default::default()
},
syntax: cx.theme().syntax().clone(),
selection_background_color: cx.theme().colors().element_selection_background,
heading: StyleRefinement::default()
.font_weight(FontWeight::BOLD)
.text_base()
.mt(rems(1.))
.mb_0(),
table_columns_min_size: true,
..Default::default()
}
}
pub fn diagnostics_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
let settings = ThemeSettings::get_global(cx);
let ui_font_family = settings.ui_font.family.clone();
let ui_font_fallbacks = settings.ui_font.fallbacks.clone();
let ui_font_features = settings.ui_font.features.clone();
let buffer_font_family = settings.buffer_font.family.clone();
let buffer_font_features = settings.buffer_font.features.clone();
let buffer_font_fallbacks = settings.buffer_font.fallbacks.clone();
let mut base_text_style = window.text_style();
base_text_style.refine(&TextStyleRefinement {
font_family: Some(ui_font_family),
font_features: Some(ui_font_features),
font_fallbacks: ui_font_fallbacks,
color: Some(cx.theme().colors().editor_foreground),
..Default::default()
});
MarkdownStyle {
base_text_style,
code_block: StyleRefinement::default().my(rems(1.)).font_buffer(cx),
inline_code: TextStyleRefinement {
background_color: Some(cx.theme().colors().editor_background.opacity(0.5)),
font_family: Some(buffer_font_family),
font_features: Some(buffer_font_features),
font_fallbacks: buffer_font_fallbacks,
..Default::default()
},
rule_color: cx.theme().colors().border,
block_quote_border_color: Color::Muted.color(cx),
block_quote: TextStyleRefinement {
color: Some(Color::Muted.color(cx)),
..Default::default()
},
link: TextStyleRefinement {
color: Some(cx.theme().colors().editor_foreground),
underline: Some(gpui::UnderlineStyle {
thickness: px(1.),
color: Some(cx.theme().colors().editor_foreground),
wavy: false,
}),
..Default::default()
},
syntax: cx.theme().syntax().clone(),
selection_background_color: cx.theme().colors().element_selection_background,
height_is_multiple_of_line_height: true,
heading: StyleRefinement::default()
.font_weight(FontWeight::BOLD)
.text_base()
.mb_0(),
table_columns_min_size: true,
..Default::default()
}
}
pub fn open_markdown_url(link: SharedString, window: &mut Window, cx: &mut App) {
if let Ok(uri) = Url::parse(&link)
&& uri.scheme() == "file"
&& let Some(workspace) = Workspace::for_window(window, cx)
{
workspace.update(cx, |workspace, cx| {
let task = workspace.open_abs_path(
PathBuf::from(uri.path()),
OpenOptions {
visible: Some(OpenVisible::None),
..Default::default()
},
window,
cx,
);
cx.spawn_in(window, async move |_, cx| {
let item = task.await?;
// Ruby LSP uses URLs with #L1,1-4,4
// we'll just take the first number and assume it's a line number
let Some(fragment) = uri.fragment() else {
return anyhow::Ok(());
};
let mut accum = 0u32;
for c in fragment.chars() {
if c >= '0' && c <= '9' && accum < u32::MAX / 2 {
accum *= 10;
accum += c as u32 - '0' as u32;
} else if accum > 0 {
break;
}
}
if accum == 0 {
return Ok(());
}
let Some(editor) = cx.update(|_, cx| item.act_as::<Editor>(cx))? else {
return Ok(());
};
editor.update_in(cx, |editor, window, cx| {
editor.change_selections(Default::default(), window, cx, |selections| {
selections.select_ranges([
text::Point::new(accum - 1, 0)..text::Point::new(accum - 1, 0)
]);
});
})
})
.detach_and_log_err(cx);
});
return;
}
cx.open_url(&link);
}
#[derive(Default)]
pub struct HoverState {
pub info_popovers: Vec<InfoPopover>,
pub diagnostic_popover: Option<DiagnosticPopover>,
pub triggered_from: Option<Anchor>,
pub info_task: Option<Task<Option<()>>>,
pub closest_mouse_distance: Option<Pixels>,
pub hiding_delay_task: Option<Task<()>>,
}
impl HoverState {
pub fn visible(&self) -> bool {
!self.info_popovers.is_empty() || self.diagnostic_popover.is_some()
}
pub fn is_mouse_getting_closer(&mut self, mouse_position: gpui::Point<Pixels>) -> bool {
if !self.visible() {
return false;
}
let mut popover_bounds = Vec::new();
for info_popover in &self.info_popovers {
if let Some(bounds) = info_popover.last_bounds.get() {
popover_bounds.push(bounds);
}
}
if let Some(diagnostic_popover) = &self.diagnostic_popover {
if let Some(bounds) = diagnostic_popover.last_bounds.get() {
popover_bounds.push(bounds);
}
}
if popover_bounds.is_empty() {
return false;
}
let distance = popover_bounds
.iter()
.map(|bounds| self.distance_from_point_to_bounds(mouse_position, *bounds))
.min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
.unwrap_or(px(f32::MAX));
if let Some(closest_distance) = self.closest_mouse_distance {
if distance > closest_distance + px(4.0) {
return false;
}
}
self.closest_mouse_distance =
Some(distance.min(self.closest_mouse_distance.unwrap_or(distance)));
true
}
fn distance_from_point_to_bounds(
&self,
point: gpui::Point<Pixels>,
bounds: Bounds<Pixels>,
) -> Pixels {
let center_x = bounds.origin.x + bounds.size.width / 2.;
let center_y = bounds.origin.y + bounds.size.height / 2.;
let dx: f32 = ((point.x - center_x).abs() - bounds.size.width / 2.)
.max(px(0.0))
.into();
let dy: f32 = ((point.y - center_y).abs() - bounds.size.height / 2.)
.max(px(0.0))
.into();
px((dx.powi(2) + dy.powi(2)).sqrt())
}
pub(crate) fn render(
&mut self,
snapshot: &EditorSnapshot,
visible_rows: Range<DisplayRow>,
max_size: Size<Pixels>,
text_layout_details: &TextLayoutDetails,
window: &mut Window,
cx: &mut Context<Editor>,
) -> Option<(DisplayPoint, Vec<AnyElement>)> {
if !self.visible() {
return None;
}
// If there is a diagnostic, position the popovers based on that.
// Otherwise use the start of the hover range
let anchor = self
.diagnostic_popover
.as_ref()
.map(|diagnostic_popover| &diagnostic_popover.local_diagnostic.range.start)
.or_else(|| {
self.info_popovers.iter().find_map(|info_popover| {
match &info_popover.symbol_range {
RangeInEditor::Text(range) => Some(&range.start),
RangeInEditor::Inlay(_) => None,
}
})
})
.or_else(|| {
self.info_popovers.iter().find_map(|info_popover| {
match &info_popover.symbol_range {
RangeInEditor::Text(_) => None,
RangeInEditor::Inlay(range) => Some(&range.inlay_position),
}
})
})?;
let mut point = anchor.to_display_point(&snapshot.display_snapshot);
// Clamp the point within the visible rows in case the popup source spans multiple lines
if visible_rows.end <= point.row() {
point = crate::movement::up_by_rows(
&snapshot.display_snapshot,
point,
1 + (point.row() - visible_rows.end).0,
text::SelectionGoal::None,
true,
text_layout_details,
)
.0;
} else if point.row() < visible_rows.start {
point = crate::movement::down_by_rows(
&snapshot.display_snapshot,
point,
(visible_rows.start - point.row()).0,
text::SelectionGoal::None,
true,
text_layout_details,
)
.0;
}
if !visible_rows.contains(&point.row()) {
log::error!("Hover popover point out of bounds after moving");
return None;
}
let mut elements = Vec::new();
if let Some(diagnostic_popover) = self.diagnostic_popover.as_ref() {
elements.push(diagnostic_popover.render(max_size, window, cx));
}
for info_popover in &mut self.info_popovers {
elements.push(info_popover.render(max_size, window, cx));
}
Some((point, elements))
}
pub fn focused(&self, window: &mut Window, cx: &mut Context<Editor>) -> bool {
let mut hover_popover_is_focused = false;
for info_popover in &self.info_popovers {
if let Some(markdown_view) = &info_popover.parsed_content
&& markdown_view.focus_handle(cx).is_focused(window)
{
hover_popover_is_focused = true;
}
}
if let Some(diagnostic_popover) = &self.diagnostic_popover
&& diagnostic_popover
.markdown
.focus_handle(cx)
.is_focused(window)
{
hover_popover_is_focused = true;
}
hover_popover_is_focused
}
}
pub struct InfoPopover {
pub symbol_range: RangeInEditor,
pub parsed_content: Option<Entity<Markdown>>,
pub scroll_handle: ScrollHandle,
pub keyboard_grace: Rc<RefCell<bool>>,
pub anchor: Option<Anchor>,
pub last_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
_subscription: Option<Subscription>,
}
impl InfoPopover {
pub(crate) fn render(
&mut self,
max_size: Size<Pixels>,
window: &mut Window,
cx: &mut Context<Editor>,
) -> AnyElement {
let keyboard_grace = Rc::clone(&self.keyboard_grace);
let this = cx.entity().downgrade();
let bounds_cell = self.last_bounds.clone();
div()
.id("info_popover")
.occlude()
.elevation_2(cx)
.child(
canvas(
{
move |bounds, _window, _cx| {
bounds_cell.set(Some(bounds));
}
},
|_, _, _, _| {},
)
.absolute()
.size_full(),
)
// Prevent a mouse down/move on the popover from being propagated to the editor,
// because that would dismiss the popover.
.on_mouse_move({
move |_, _, cx: &mut App| {
this.update(cx, |editor, _| {
editor.hover_state.closest_mouse_distance = Some(px(0.0));
editor.hover_state.hiding_delay_task = None;
})
.ok();
cx.stop_propagation()
}
})
.on_mouse_down(MouseButton::Left, move |_, _, cx| {
let mut keyboard_grace = keyboard_grace.borrow_mut();
*keyboard_grace = false;
cx.stop_propagation();
})
.when_some(self.parsed_content.clone(), |this, markdown| {
this.child(
div()
.id("info-md-container")
.overflow_y_scroll()
.max_w(max_size.width)
.max_h(max_size.height)
.track_scroll(&self.scroll_handle)
.child(
MarkdownElement::new(markdown, hover_markdown_style(window, cx))
.scroll_handle(self.scroll_handle.clone())
.code_block_renderer(markdown::CodeBlockRenderer::Default {
copy_button_visibility: CopyButtonVisibility::Hidden,
wrap_button_visibility: markdown::WrapButtonVisibility::Hidden,
border: false,
})
.on_url_click(open_markdown_url)
.p_2(),
),
)
.custom_scrollbars(
Scrollbars::for_settings::<EditorSettingsScrollbarProxy>()
.tracked_scroll_handle(&self.scroll_handle),
window,
cx,
)
})
.into_any_element()
}
pub fn scroll(&self, amount: ScrollAmount, window: &mut Window, cx: &mut Context<Editor>) {
let mut current = self.scroll_handle.offset();
current.y -= amount.pixels(
window.line_height(),
self.scroll_handle.bounds().size.height - px(16.),
) / 2.0;
cx.notify();
self.scroll_handle.set_offset(current);
}
}
pub struct DiagnosticPopover {
pub(crate) local_diagnostic: DiagnosticEntry<Anchor>,
markdown: Entity<Markdown>,
border_color: Hsla,
background_color: Hsla,
pub keyboard_grace: Rc<RefCell<bool>>,
pub anchor: Anchor,
pub last_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
_subscription: Subscription,
pub scroll_handle: ScrollHandle,
}
impl DiagnosticPopover {
pub fn render(
&self,
max_size: Size<Pixels>,
window: &mut Window,
cx: &mut Context<Editor>,
) -> AnyElement {
let keyboard_grace = Rc::clone(&self.keyboard_grace);
let this = cx.entity().downgrade();
let bounds_cell = self.last_bounds.clone();
div()
.id("diagnostic")
.occlude()
.elevation_2_borderless(cx)
.child(
canvas(
{
move |bounds, _window, _cx| {
bounds_cell.set(Some(bounds));
}
},
|_, _, _, _| {},
)
.absolute()
.size_full(),
)
// Don't draw the background color if the theme
// allows transparent surfaces.
.when(theme_is_transparent(cx), |this| {
this.bg(gpui::transparent_black())
})
// Prevent a mouse move on the popover from being propagated to the editor,
// because that would dismiss the popover.
.on_mouse_move({
let this = this.clone();
move |_, _, cx: &mut App| {
this.update(cx, |editor, _| {
editor.hover_state.closest_mouse_distance = Some(px(0.0));
editor.hover_state.hiding_delay_task = None;
})
.ok();
cx.stop_propagation()
}
})
// Prevent a mouse down on the popover from being propagated to the editor,
// because that would move the cursor.
.on_mouse_down(MouseButton::Left, move |_, _, cx| {
let mut keyboard_grace = keyboard_grace.borrow_mut();
*keyboard_grace = false;
cx.stop_propagation();
})
.child(
div()
.relative()
.py_1()
.pl_2()
.pr_8()
.bg(self.background_color)
.border_1()
.border_color(self.border_color)
.rounded_lg()
.child(
div()
.id("diagnostic-content-container")
.max_w(max_size.width)
.max_h(max_size.height)
.overflow_y_scroll()
.track_scroll(&self.scroll_handle)
.child(
MarkdownElement::new(
self.markdown.clone(),
diagnostics_markdown_style(window, cx),
)
.code_block_renderer(markdown::CodeBlockRenderer::Default {
copy_button_visibility: CopyButtonVisibility::Hidden,
wrap_button_visibility: markdown::WrapButtonVisibility::Hidden,
border: false,
})
.on_url_click(
move |link, window, cx| {
if let Some(renderer) = GlobalDiagnosticRenderer::global(cx)
{
this.update(cx, |this, cx| {
renderer.as_ref().open_link(this, link, window, cx);
})
.ok();
}
},
),
),
)
.child(div().absolute().top_1().right_1().child({
let message = self.local_diagnostic.diagnostic.message.clone();
CopyButton::new("copy-diagnostic", message).tooltip_label("Copy Diagnostic")
}))
.custom_scrollbars(
Scrollbars::for_settings::<EditorSettingsScrollbarProxy>()
.tracked_scroll_handle(&self.scroll_handle),
window,
cx,
),
)
.into_any_element()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
PointForPosition,
actions::ConfirmCompletion,
editor_tests::{handle_completion_request, init_test},
inlays::inlay_hints::tests::{cached_hint_labels, visible_hint_labels},
test::editor_lsp_test_context::EditorLspTestContext,
};
use collections::BTreeSet;
use futures::stream::StreamExt;
use gpui::App;
use indoc::indoc;
use markdown::parser::MarkdownEvent;
use project::InlayId;
use settings::InlayHintSettingsContent;
use settings::{DelayMs, SettingsStore};
use std::sync::atomic;
use std::sync::atomic::AtomicUsize;
use text::Bias;
fn get_hover_popover_delay(cx: &gpui::TestAppContext) -> u64 {
cx.read(|cx: &App| -> u64 { EditorSettings::get_global(cx).hover_popover_delay.0 })
}
impl InfoPopover {
fn get_rendered_text(&self, cx: &gpui::App) -> String {
let mut rendered_text = String::new();
if let Some(parsed_content) = self.parsed_content.clone() {
let markdown = parsed_content.read(cx);
let text = markdown.parsed_markdown().source().to_string();
let data = markdown.parsed_markdown().events();
let slice = data;
for (range, event) in slice.iter() {
match event {
MarkdownEvent::SubstitutedText(parsed) => {
rendered_text.push_str(parsed.as_str())
}
MarkdownEvent::Text | MarkdownEvent::Code => {
rendered_text.push_str(&text[range.clone()])
}
_ => {}
}
}
}
rendered_text
}
}
#[gpui::test]
async fn test_mouse_hover_info_popover_with_autocomplete_popover(
cx: &mut gpui::TestAppContext,
) {
init_test(cx, |_| {});
let mut cx = EditorLspTestContext::new_rust(
lsp::ServerCapabilities {
hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
completion_provider: Some(lsp::CompletionOptions {
trigger_characters: Some(vec![".".to_string(), ":".to_string()]),
resolve_provider: Some(true),
..Default::default()
}),
..Default::default()
},
cx,
)
.await;
let counter = Arc::new(AtomicUsize::new(0));
// Basic hover delays and then pops without moving the mouse
cx.set_state(indoc! {"
oneˇ
two
three
fn test() { println!(); }
"});
//prompt autocompletion menu
cx.simulate_keystroke(".");
handle_completion_request(
indoc! {"
one.|<>
two
three
"},
vec!["first_completion", "second_completion"],
true,
counter.clone(),
&mut cx,
)
.await;
cx.condition(|editor, _| editor.context_menu_visible()) // wait until completion menu is visible
.await;
assert_eq!(counter.load(atomic::Ordering::Acquire), 1); // 1 completion request
let hover_point = cx.display_point(indoc! {"
one.
two
three
fn test() { printˇln!(); }
"});
cx.update_editor(|editor, window, cx| {
let snapshot = editor.snapshot(window, cx);
let anchor = snapshot
.buffer_snapshot()
.anchor_before(hover_point.to_offset(&snapshot, Bias::Left));
hover_at(editor, Some(anchor), None, window, cx)
});
assert!(!cx.editor(|editor, _window, _cx| editor.hover_state.visible()));
// After delay, hover should be visible.
let symbol_range = cx.lsp_range(indoc! {"
one.
two
three
fn test() { «println!»(); }
"});
let mut requests =
cx.set_request_handler::<lsp::request::HoverRequest, _, _>(move |_, _, _| async move {
Ok(Some(lsp::Hover {
contents: lsp::HoverContents::Markup(lsp::MarkupContent {
kind: lsp::MarkupKind::Markdown,
value: "some basic docs".to_string(),
}),
range: Some(symbol_range),
}))
});
cx.background_executor
.advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100));
requests.next().await;
cx.editor(|editor, _window, cx| {
assert!(editor.hover_state.visible());
assert_eq!(
editor.hover_state.info_popovers.len(),
1,
"Expected exactly one hover but got: {:?}",
editor.hover_state.info_popovers.len()
);
let rendered_text = editor
.hover_state
.info_popovers
.first()
.unwrap()
.get_rendered_text(cx);
assert_eq!(rendered_text, "some basic docs".to_string())
});
// check that the completion menu is still visible and that there still has only been 1 completion request
cx.editor(|editor, _, _| assert!(editor.context_menu_visible()));
assert_eq!(counter.load(atomic::Ordering::Acquire), 1);
//apply a completion and check it was successfully applied
let _apply_additional_edits = cx.update_editor(|editor, window, cx| {
editor.context_menu_next(&Default::default(), window, cx);
editor
.confirm_completion(&ConfirmCompletion::default(), window, cx)
.unwrap()
});
cx.assert_editor_state(indoc! {"
one.second_completionˇ
two
three
fn test() { println!(); }
"});
// check that the completion menu is no longer visible and that there still has only been 1 completion request
cx.editor(|editor, _, _| assert!(!editor.context_menu_visible()));
assert_eq!(counter.load(atomic::Ordering::Acquire), 1);
//verify the information popover is still visible and unchanged
cx.editor(|editor, _, cx| {
assert!(editor.hover_state.visible());
assert_eq!(
editor.hover_state.info_popovers.len(),
1,
"Expected exactly one hover but got: {:?}",
editor.hover_state.info_popovers.len()
);
let rendered_text = editor
.hover_state
.info_popovers
.first()
.unwrap()
.get_rendered_text(cx);
assert_eq!(rendered_text, "some basic docs".to_string())
});
// Mouse moved with no hover response dismisses
let hover_point = cx.display_point(indoc! {"
one.second_completionˇ
two
three
fn teˇst() { println!(); }
"});
let mut request = cx
.lsp
.set_request_handler::<lsp::request::HoverRequest, _, _>(
|_, _| async move { Ok(None) },
);
cx.update_editor(|editor, window, cx| {
let snapshot = editor.snapshot(window, cx);
let anchor = snapshot
.buffer_snapshot()
.anchor_before(hover_point.to_offset(&snapshot, Bias::Left));
hover_at(editor, Some(anchor), None, window, cx)
});
cx.background_executor
.advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100));
request.next().await;
// verify that the information popover is no longer visible
cx.editor(|editor, _, _| {
assert!(!editor.hover_state.visible());
});
}
#[gpui::test]
async fn test_mouse_hover_info_popover(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {});
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, window, cx| {
let snapshot = editor.snapshot(window, cx);
let anchor = snapshot
.buffer_snapshot()
.anchor_before(hover_point.to_offset(&snapshot, Bias::Left));
hover_at(editor, Some(anchor), None, window, cx)
});
assert!(!cx.editor(|editor, _window, _cx| editor.hover_state.visible()));
// After delay, hover should be visible.
let symbol_range = cx.lsp_range(indoc! {"
fn test() { «println!»(); }
"});
let mut requests =
cx.set_request_handler::<lsp::request::HoverRequest, _, _>(move |_, _, _| async move {
Ok(Some(lsp::Hover {
contents: lsp::HoverContents::Markup(lsp::MarkupContent {
kind: lsp::MarkupKind::Markdown,
value: "some basic docs".to_string(),
}),
range: Some(symbol_range),
}))
});
cx.background_executor
.advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100));
requests.next().await;
cx.editor(|editor, _, cx| {
assert!(editor.hover_state.visible());
assert_eq!(
editor.hover_state.info_popovers.len(),
1,
"Expected exactly one hover but got: {:?}",
editor.hover_state.info_popovers.len()
);
let rendered_text = editor
.hover_state
.info_popovers
.first()
.unwrap()
.get_rendered_text(cx);
assert_eq!(rendered_text, "some basic docs".to_string())
});
// Mouse moved with no hover response dismisses
let hover_point = cx.display_point(indoc! {"
fn teˇst() { println!(); }
"});
let mut request = cx
.lsp
.set_request_handler::<lsp::request::HoverRequest, _, _>(
|_, _| async move { Ok(None) },
);
cx.update_editor(|editor, window, cx| {
let snapshot = editor.snapshot(window, cx);
let anchor = snapshot
.buffer_snapshot()
.anchor_before(hover_point.to_offset(&snapshot, Bias::Left));
hover_at(editor, Some(anchor), None, window, cx)
});
cx.background_executor
.advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100));
request.next().await;
cx.editor(|editor, _, _| {
assert!(!editor.hover_state.visible());
});
}
#[gpui::test]
async fn test_mouse_hover_cancelled_before_delay(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorLspTestContext::new_rust(
lsp::ServerCapabilities {
hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
..Default::default()
},
cx,
)
.await;
cx.set_state(indoc! {"
fn ˇtest() { println!(); }
"});
let hover_point = cx.display_point(indoc! {"
fn test() { printˇln!(); }
"});
cx.update_editor(|editor, window, cx| {
let snapshot = editor.snapshot(window, cx);
let anchor = snapshot
.buffer_snapshot()
.anchor_before(hover_point.to_offset(&snapshot, Bias::Left));
hover_at(editor, Some(anchor), None, window, cx);
hover_at(editor, None, None, window, cx);
});
let request_count = Arc::new(AtomicUsize::new(0));
cx.set_request_handler::<lsp::request::HoverRequest, _, _>({
let request_count = request_count.clone();
move |_, _, _| {
let request_count = request_count.clone();
async move {
request_count.fetch_add(1, atomic::Ordering::Release);
Ok(Some(lsp::Hover {
contents: lsp::HoverContents::Markup(lsp::MarkupContent {
kind: lsp::MarkupKind::Markdown,
value: "some basic docs".to_string(),
}),
range: None,
}))
}
}
});
cx.background_executor
.advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100));
cx.background_executor.run_until_parked();
cx.run_until_parked();
assert_eq!(request_count.load(atomic::Ordering::Acquire), 0);
cx.editor(|editor, _, _| {
assert!(!editor.hover_state.visible());
});
}
#[gpui::test]
async fn test_keyboard_hover_info_popover(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorLspTestContext::new_rust(
lsp::ServerCapabilities {
hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
..Default::default()
},
cx,
)
.await;
// Hover with keyboard has no delay
cx.set_state(indoc! {"
fˇn test() { println!(); }
"});
cx.update_editor(|editor, window, cx| hover(editor, &Hover, window, cx));
let symbol_range = cx.lsp_range(indoc! {"
«fn» test() { println!(); }
"});
cx.editor(|editor, _window, _cx| {
assert!(!editor.hover_state.visible());
assert_eq!(
editor.hover_state.info_popovers.len(),
0,
"Expected no hovers but got but got: {:?}",
editor.hover_state.info_popovers.len()
);
});
let mut requests =
cx.set_request_handler::<lsp::request::HoverRequest, _, _>(move |_, _, _| async move {
Ok(Some(lsp::Hover {
contents: lsp::HoverContents::Markup(lsp::MarkupContent {
kind: lsp::MarkupKind::Markdown,
value: "some other basic docs".to_string(),
}),
range: Some(symbol_range),
}))
});
requests.next().await;
cx.dispatch_action(Hover);
cx.condition(|editor, _| editor.hover_state.visible()).await;
cx.editor(|editor, _, cx| {
assert_eq!(
editor.hover_state.info_popovers.len(),
1,
"Expected exactly one hover but got: {:?}",
editor.hover_state.info_popovers.len()
);
let rendered_text = editor
.hover_state
.info_popovers
.first()
.unwrap()
.get_rendered_text(cx);
assert_eq!(rendered_text, "some other basic docs".to_string())
});
}
#[gpui::test]
async fn test_empty_hovers_filtered(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorLspTestContext::new_rust(
lsp::ServerCapabilities {
hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
..Default::default()
},
cx,
)
.await;
// Hover with keyboard has no delay
cx.set_state(indoc! {"
fˇn test() { println!(); }
"});
cx.update_editor(|editor, window, cx| hover(editor, &Hover, window, cx));
let symbol_range = cx.lsp_range(indoc! {"
«fn» test() { println!(); }
"});
cx.set_request_handler::<lsp::request::HoverRequest, _, _>(move |_, _, _| async move {
Ok(Some(lsp::Hover {
contents: lsp::HoverContents::Array(vec![
lsp::MarkedString::String("regular text for hover to show".to_string()),
lsp::MarkedString::String("".to_string()),
lsp::MarkedString::LanguageString(lsp::LanguageString {
language: "Rust".to_string(),
value: "".to_string(),
}),
]),
range: Some(symbol_range),
}))
})
.next()
.await;
cx.dispatch_action(Hover);
cx.condition(|editor, _| editor.hover_state.visible()).await;
cx.editor(|editor, _, cx| {
assert_eq!(
editor.hover_state.info_popovers.len(),
1,
"Expected exactly one hover but got: {:?}",
editor.hover_state.info_popovers.len()
);
let rendered_text = editor
.hover_state
.info_popovers
.first()
.unwrap()
.get_rendered_text(cx);
assert_eq!(
rendered_text,
"regular text for hover to show".to_string(),
"No empty string hovers should be shown"
);
});
}
#[gpui::test]
async fn test_line_ends_trimmed(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorLspTestContext::new_rust(
lsp::ServerCapabilities {
hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
..Default::default()
},
cx,
)
.await;
// Hover with keyboard has no delay
cx.set_state(indoc! {"
fˇn test() { println!(); }
"});
cx.update_editor(|editor, window, cx| hover(editor, &Hover, window, cx));
let symbol_range = cx.lsp_range(indoc! {"
«fn» test() { println!(); }
"});
let code_str = "\nlet hovered_point: Vector2F // size = 8, align = 0x4\n";
let markdown_string = format!("\n```rust\n{code_str}```");
let closure_markdown_string = markdown_string.clone();
cx.set_request_handler::<lsp::request::HoverRequest, _, _>(move |_, _, _| {
let future_markdown_string = closure_markdown_string.clone();
async move {
Ok(Some(lsp::Hover {
contents: lsp::HoverContents::Markup(lsp::MarkupContent {
kind: lsp::MarkupKind::Markdown,
value: future_markdown_string,
}),
range: Some(symbol_range),
}))
}
})
.next()
.await;
cx.dispatch_action(Hover);
cx.condition(|editor, _| editor.hover_state.visible()).await;
cx.editor(|editor, _, cx| {
assert_eq!(
editor.hover_state.info_popovers.len(),
1,
"Expected exactly one hover but got: {:?}",
editor.hover_state.info_popovers.len()
);
let rendered_text = editor
.hover_state
.info_popovers
.first()
.unwrap()
.get_rendered_text(cx);
assert_eq!(
rendered_text, code_str,
"Should not have extra line breaks at end of rendered hover"
);
});
}
#[gpui::test]
// https://github.com/zed-industries/zed/issues/15498
async fn test_info_hover_with_hrs(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorLspTestContext::new_rust(
lsp::ServerCapabilities {
hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
..Default::default()
},
cx,
)
.await;
cx.set_state(indoc! {"
fn fuˇnc(abc def: i32) -> u32 {
}
"});
cx.lsp
.set_request_handler::<lsp::request::HoverRequest, _, _>({
|_, _| async move {
Ok(Some(lsp::Hover {
contents: lsp::HoverContents::Markup(lsp::MarkupContent {
kind: lsp::MarkupKind::Markdown,
value: indoc!(
r#"
### function `errands_data_read`
---
→ `char *`
Function to read a file into a string
---
```cpp
static char *errands_data_read()
```
"#
)
.to_string(),
}),
range: None,
}))
}
});
cx.update_editor(|editor, window, cx| hover(editor, &Default::default(), window, cx));
cx.run_until_parked();
cx.update_editor(|editor, _, cx| {
let popover = editor.hover_state.info_popovers.first().unwrap();
let content = popover.get_rendered_text(cx);
assert!(content.contains("Function to read a file"));
});
}
#[gpui::test]
async fn test_hover_inlay_label_parts(cx: &mut gpui::TestAppContext) {
init_test(cx, |settings| {
settings.defaults.inlay_hints = Some(InlayHintSettingsContent {
show_value_hints: Some(true),
enabled: Some(true),
edit_debounce_ms: Some(0),
scroll_debounce_ms: Some(0),
show_type_hints: Some(true),
show_parameter_hints: Some(true),
show_other_hints: Some(true),
show_background: Some(false),
toggle_on_modifiers_press: None,
})
});
let mut cx = EditorLspTestContext::new_rust(
lsp::ServerCapabilities {
inlay_hint_provider: Some(lsp::OneOf::Right(
lsp::InlayHintServerCapabilities::Options(lsp::InlayHintOptions {
resolve_provider: Some(true),
..Default::default()
}),
)),
..Default::default()
},
cx,
)
.await;
cx.set_state(indoc! {"
struct TestStruct;
// ==================
struct TestNewType<T>(T);
fn main() {
let variableˇ = TestNewType(TestStruct);
}
"});
let hint_start_offset = cx.ranges(indoc! {"
struct TestStruct;
// ==================
struct TestNewType<T>(T);
fn main() {
let variableˇ = TestNewType(TestStruct);
}
"})[0]
.start;
let hint_position = cx.to_lsp(MultiBufferOffset(hint_start_offset));
let new_type_target_range = cx.lsp_range(indoc! {"
struct TestStruct;
// ==================
struct «TestNewType»<T>(T);
fn main() {
let variable = TestNewType(TestStruct);
}
"});
let struct_target_range = cx.lsp_range(indoc! {"
struct «TestStruct»;
// ==================
struct TestNewType<T>(T);
fn main() {
let variable = TestNewType(TestStruct);
}
"});
let uri = cx.buffer_lsp_url.clone();
let new_type_label = "TestNewType";
let struct_label = "TestStruct";
let entire_hint_label = ": TestNewType<TestStruct>";
let closure_uri = uri.clone();
cx.lsp
.set_request_handler::<lsp::request::InlayHintRequest, _, _>(move |params, _| {
let task_uri = closure_uri.clone();
async move {
assert_eq!(params.text_document.uri, task_uri);
Ok(Some(vec![lsp::InlayHint {
position: hint_position,
label: lsp::InlayHintLabel::LabelParts(vec![lsp::InlayHintLabelPart {
value: entire_hint_label.to_string(),
..Default::default()
}]),
kind: Some(lsp::InlayHintKind::TYPE),
text_edits: None,
tooltip: None,
padding_left: Some(false),
padding_right: Some(false),
data: None,
}]))
}
})
.next()
.await;
cx.background_executor.run_until_parked();
cx.update_editor(|editor, _, cx| {
let expected_layers = vec![entire_hint_label.to_string()];
assert_eq!(expected_layers, cached_hint_labels(editor, cx));
assert_eq!(expected_layers, visible_hint_labels(editor, cx));
});
let inlay_range = cx
.ranges(indoc! {"
struct TestStruct;
// ==================
struct TestNewType<T>(T);
fn main() {
let variable« »= TestNewType(TestStruct);
}
"})
.first()
.cloned()
.unwrap();
let new_type_hint_part_hover_position = cx.update_editor(|editor, window, cx| {
let snapshot = editor.snapshot(window, cx);
let previous_valid = MultiBufferOffset(inlay_range.start).to_display_point(&snapshot);
let next_valid = MultiBufferOffset(inlay_range.end).to_display_point(&snapshot);
assert_eq!(previous_valid.row(), next_valid.row());
assert!(previous_valid.column() < next_valid.column());
let exact_unclipped = DisplayPoint::new(
previous_valid.row(),
previous_valid.column()
+ (entire_hint_label.find(new_type_label).unwrap() + new_type_label.len() / 2)
as u32,
);
PointForPosition {
previous_valid,
next_valid,
nearest_valid: previous_valid,
exact_unclipped,
column_overshoot_after_line_end: 0,
}
});
cx.update_editor(|editor, window, cx| {
editor.update_inlay_link_and_hover_points(
&editor.snapshot(window, cx),
new_type_hint_part_hover_position,
None,
true,
false,
window,
cx,
);
});
let resolve_closure_uri = uri.clone();
cx.lsp
.set_request_handler::<lsp::request::InlayHintResolveRequest, _, _>(
move |mut hint_to_resolve, _| {
let mut resolved_hint_positions = BTreeSet::new();
let task_uri = resolve_closure_uri.clone();
async move {
let inserted = resolved_hint_positions.insert(hint_to_resolve.position);
assert!(inserted, "Hint {hint_to_resolve:?} was resolved twice");
// `: TestNewType<TestStruct>`
hint_to_resolve.label = lsp::InlayHintLabel::LabelParts(vec![
lsp::InlayHintLabelPart {
value: ": ".to_string(),
..Default::default()
},
lsp::InlayHintLabelPart {
value: new_type_label.to_string(),
location: Some(lsp::Location {
uri: task_uri.clone(),
range: new_type_target_range,
}),
tooltip: Some(lsp::InlayHintLabelPartTooltip::String(format!(
"A tooltip for `{new_type_label}`"
))),
..Default::default()
},
lsp::InlayHintLabelPart {
value: "<".to_string(),
..Default::default()
},
lsp::InlayHintLabelPart {
value: struct_label.to_string(),
location: Some(lsp::Location {
uri: task_uri,
range: struct_target_range,
}),
tooltip: Some(lsp::InlayHintLabelPartTooltip::MarkupContent(
lsp::MarkupContent {
kind: lsp::MarkupKind::Markdown,
value: format!("A tooltip for `{struct_label}`"),
},
)),
..Default::default()
},
lsp::InlayHintLabelPart {
value: ">".to_string(),
..Default::default()
},
]);
Ok(hint_to_resolve)
}
},
)
.next()
.await;
cx.background_executor.run_until_parked();
cx.update_editor(|editor, window, cx| {
editor.update_inlay_link_and_hover_points(
&editor.snapshot(window, cx),
new_type_hint_part_hover_position,
None,
true,
false,
window,
cx,
);
});
cx.background_executor
.advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100));
cx.background_executor.run_until_parked();
cx.update_editor(|editor, _, cx| {
let hover_state = &editor.hover_state;
assert!(
hover_state.diagnostic_popover.is_none() && hover_state.info_popovers.len() == 1
);
let popover = hover_state.info_popovers.first().unwrap();
let buffer_snapshot = editor.buffer().update(cx, |buffer, cx| buffer.snapshot(cx));
assert_eq!(
popover.symbol_range,
RangeInEditor::Inlay(InlayHighlight {
inlay: InlayId::Hint(0),
inlay_position: buffer_snapshot
.anchor_after(MultiBufferOffset(inlay_range.start)),
range: ": ".len()..": ".len() + new_type_label.len(),
}),
"Popover range should match the new type label part"
);
assert_eq!(
popover.get_rendered_text(cx),
format!("A tooltip for {new_type_label}"),
);
});
let struct_hint_part_hover_position = cx.update_editor(|editor, window, cx| {
let snapshot = editor.snapshot(window, cx);
let previous_valid = MultiBufferOffset(inlay_range.start).to_display_point(&snapshot);
let next_valid = MultiBufferOffset(inlay_range.end).to_display_point(&snapshot);
assert_eq!(previous_valid.row(), next_valid.row());
assert!(previous_valid.column() < next_valid.column());
let exact_unclipped = DisplayPoint::new(
previous_valid.row(),
previous_valid.column()
+ (entire_hint_label.find(struct_label).unwrap() + struct_label.len() / 2)
as u32,
);
PointForPosition {
previous_valid,
next_valid,
nearest_valid: previous_valid,
exact_unclipped,
column_overshoot_after_line_end: 0,
}
});
cx.update_editor(|editor, window, cx| {
editor.update_inlay_link_and_hover_points(
&editor.snapshot(window, cx),
struct_hint_part_hover_position,
None,
true,
false,
window,
cx,
);
});
cx.background_executor
.advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100));
cx.background_executor.run_until_parked();
cx.update_editor(|editor, _, cx| {
let hover_state = &editor.hover_state;
assert!(
hover_state.diagnostic_popover.is_none() && hover_state.info_popovers.len() == 1
);
let popover = hover_state.info_popovers.first().unwrap();
let buffer_snapshot = editor.buffer().update(cx, |buffer, cx| buffer.snapshot(cx));
assert_eq!(
popover.symbol_range,
RangeInEditor::Inlay(InlayHighlight {
inlay: InlayId::Hint(0),
inlay_position: buffer_snapshot
.anchor_after(MultiBufferOffset(inlay_range.start)),
range: ": ".len() + new_type_label.len() + "<".len()
..": ".len() + new_type_label.len() + "<".len() + struct_label.len(),
}),
"Popover range should match the struct label part"
);
assert_eq!(
popover.get_rendered_text(cx),
format!("A tooltip for {struct_label}"),
"Rendered markdown element should remove backticks from text"
);
});
}
#[test]
fn test_find_hovered_hint_part_with_multibyte_characters() {
use crate::display_map::InlayOffset;
use multi_buffer::MultiBufferOffset;
use project::InlayHintLabelPart;
// Test with multi-byte UTF-8 character "→" (3 bytes, 1 character)
let label = "→ app/Livewire/UserProfile.php";
let label_parts = vec![InlayHintLabelPart {
value: label.to_string(),
tooltip: None,
location: None,
}];
let hint_start = InlayOffset(MultiBufferOffset(100));
// Verify the label has more bytes than characters (due to "→")
assert_eq!(label.len(), 32); // bytes
assert_eq!(label.chars().count(), 30); // characters
// Test hovering at the last byte (should find the part)
let last_byte_offset = InlayOffset(MultiBufferOffset(100 + label.len() - 1));
let result = find_hovered_hint_part(label_parts.clone(), hint_start, last_byte_offset);
assert!(
result.is_some(),
"Should find part when hovering at last byte"
);
let (part, range) = result.unwrap();
assert_eq!(part.value, label);
assert_eq!(range.start, hint_start);
assert_eq!(range.end, InlayOffset(MultiBufferOffset(100 + label.len())));
// Test hovering at the first byte of "→" (byte 0)
let first_byte_offset = InlayOffset(MultiBufferOffset(100));
let result = find_hovered_hint_part(label_parts.clone(), hint_start, first_byte_offset);
assert!(
result.is_some(),
"Should find part when hovering at first byte"
);
// Test hovering in the middle of "→" (byte 1, still part of the arrow character)
let mid_arrow_offset = InlayOffset(MultiBufferOffset(101));
let result = find_hovered_hint_part(label_parts, hint_start, mid_arrow_offset);
assert!(
result.is_some(),
"Should find part when hovering in middle of multi-byte char"
);
// Test with multiple parts containing multi-byte characters
// Part ranges are [start, end) - start inclusive, end exclusive
// "→ " occupies bytes [0, 4), "path" occupies bytes [4, 8)
let parts = vec![
InlayHintLabelPart {
value: "".to_string(), // 4 bytes (3 + 1)
tooltip: None,
location: None,
},
InlayHintLabelPart {
value: "path".to_string(), // 4 bytes
tooltip: None,
location: None,
},
];
// Hover at byte 3 (last byte of "→ ", the space character)
let arrow_last_byte = InlayOffset(MultiBufferOffset(100 + 3));
let result = find_hovered_hint_part(parts.clone(), hint_start, arrow_last_byte);
assert!(result.is_some(), "Should find first part at its last byte");
let (part, range) = result.unwrap();
assert_eq!(part.value, "");
assert_eq!(
range,
InlayOffset(MultiBufferOffset(100))..InlayOffset(MultiBufferOffset(104))
);
// Hover at byte 4 (first byte of "path", at the boundary)
let path_start_offset = InlayOffset(MultiBufferOffset(100 + 4));
let result = find_hovered_hint_part(parts.clone(), hint_start, path_start_offset);
assert!(result.is_some(), "Should find second part at boundary");
let (part, _) = result.unwrap();
assert_eq!(part.value, "path");
// Hover at byte 7 (last byte of "path")
let path_end_offset = InlayOffset(MultiBufferOffset(100 + 7));
let result = find_hovered_hint_part(parts, hint_start, path_end_offset);
assert!(result.is_some(), "Should find second part at last byte");
let (part, range) = result.unwrap();
assert_eq!(part.value, "path");
assert_eq!(
range,
InlayOffset(MultiBufferOffset(104))..InlayOffset(MultiBufferOffset(108))
);
}
#[gpui::test]
async fn test_hover_popover_hiding_delay(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {});
let custom_delay_ms = 500u64;
cx.update(|cx| {
cx.update_global::<SettingsStore, _>(|settings, cx| {
settings.update_user_settings(cx, |settings| {
settings.editor.hover_popover_sticky = Some(true);
settings.editor.hover_popover_hiding_delay = Some(DelayMs(custom_delay_ms));
});
});
});
let mut cx = EditorLspTestContext::new_rust(
lsp::ServerCapabilities {
hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
..Default::default()
},
cx,
)
.await;
cx.set_state(indoc! {"
fn ˇtest() { println!(); }
"});
// Trigger hover on a symbol
let hover_point = cx.display_point(indoc! {"
fn test() { printˇln!(); }
"});
let symbol_range = cx.lsp_range(indoc! {"
fn test() { «println!»(); }
"});
let mut requests =
cx.set_request_handler::<lsp::request::HoverRequest, _, _>(move |_, _, _| async move {
Ok(Some(lsp::Hover {
contents: lsp::HoverContents::Markup(lsp::MarkupContent {
kind: lsp::MarkupKind::Markdown,
value: "some basic docs".to_string(),
}),
range: Some(symbol_range),
}))
});
cx.update_editor(|editor, window, cx| {
let snapshot = editor.snapshot(window, cx);
let anchor = snapshot
.buffer_snapshot()
.anchor_before(hover_point.to_offset(&snapshot, Bias::Left));
hover_at(editor, Some(anchor), None, window, cx)
});
cx.background_executor
.advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100));
requests.next().await;
// Hover should be visible
cx.editor(|editor, _, _| {
assert!(editor.hover_state.visible());
});
// Move mouse away (hover_at with None anchor triggers the hiding delay)
cx.update_editor(|editor, window, cx| hover_at(editor, None, None, window, cx));
// Popover should still be visible before the custom hiding delay expires
cx.background_executor
.advance_clock(Duration::from_millis(custom_delay_ms - 100));
cx.editor(|editor, _, _| {
assert!(
editor.hover_state.visible(),
"Popover should remain visible before the hiding delay expires"
);
});
// After the full custom delay, the popover should be hidden
cx.background_executor
.advance_clock(Duration::from_millis(200));
cx.editor(|editor, _, _| {
assert!(
!editor.hover_state.visible(),
"Popover should be hidden after the hiding delay expires"
);
});
}
#[gpui::test]
async fn test_hover_popover_sticky_disabled(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {});
cx.update(|cx| {
cx.update_global::<SettingsStore, _>(|settings, cx| {
settings.update_user_settings(cx, |settings| {
settings.editor.hover_popover_sticky = Some(false);
});
});
});
let mut cx = EditorLspTestContext::new_rust(
lsp::ServerCapabilities {
hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
..Default::default()
},
cx,
)
.await;
cx.set_state(indoc! {"
fn ˇtest() { println!(); }
"});
// Trigger hover on a symbol
let hover_point = cx.display_point(indoc! {"
fn test() { printˇln!(); }
"});
let symbol_range = cx.lsp_range(indoc! {"
fn test() { «println!»(); }
"});
let mut requests =
cx.set_request_handler::<lsp::request::HoverRequest, _, _>(move |_, _, _| async move {
Ok(Some(lsp::Hover {
contents: lsp::HoverContents::Markup(lsp::MarkupContent {
kind: lsp::MarkupKind::Markdown,
value: "some basic docs".to_string(),
}),
range: Some(symbol_range),
}))
});
cx.update_editor(|editor, window, cx| {
let snapshot = editor.snapshot(window, cx);
let anchor = snapshot
.buffer_snapshot()
.anchor_before(hover_point.to_offset(&snapshot, Bias::Left));
hover_at(editor, Some(anchor), None, window, cx)
});
cx.background_executor
.advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100));
requests.next().await;
// Hover should be visible
cx.editor(|editor, _, _| {
assert!(editor.hover_state.visible());
});
// Move mouse away — with sticky disabled, hide immediately
cx.update_editor(|editor, window, cx| hover_at(editor, None, None, window, cx));
// Popover should be hidden immediately without any delay
cx.editor(|editor, _, _| {
assert!(
!editor.hover_state.visible(),
"Popover should be hidden immediately when sticky is disabled"
);
});
}
#[gpui::test]
async fn test_hover_popover_hiding_delay_restarts_when_mouse_gets_closer(
cx: &mut gpui::TestAppContext,
) {
init_test(cx, |_| {});
let custom_delay_ms = 600u64;
cx.update(|cx| {
cx.update_global::<SettingsStore, _>(|settings, cx| {
settings.update_user_settings(cx, |settings| {
settings.editor.hover_popover_sticky = Some(true);
settings.editor.hover_popover_hiding_delay = Some(DelayMs(custom_delay_ms));
});
});
});
let mut cx = EditorLspTestContext::new_rust(
lsp::ServerCapabilities {
hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
..Default::default()
},
cx,
)
.await;
cx.set_state(indoc! {"
fn ˇtest() { println!(); }
"});
let hover_point = cx.display_point(indoc! {"
fn test() { printˇln!(); }
"});
let symbol_range = cx.lsp_range(indoc! {"
fn test() { «println!»(); }
"});
let mut requests =
cx.set_request_handler::<lsp::request::HoverRequest, _, _>(move |_, _, _| async move {
Ok(Some(lsp::Hover {
contents: lsp::HoverContents::Markup(lsp::MarkupContent {
kind: lsp::MarkupKind::Markdown,
value: "some basic docs".to_string(),
}),
range: Some(symbol_range),
}))
});
cx.update_editor(|editor, window, cx| {
let snapshot = editor.snapshot(window, cx);
let anchor = snapshot
.buffer_snapshot()
.anchor_before(hover_point.to_offset(&snapshot, Bias::Left));
hover_at(editor, Some(anchor), None, window, cx)
});
cx.background_executor
.advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100));
requests.next().await;
cx.editor(|editor, _, _| {
assert!(editor.hover_state.visible());
});
cx.update_editor(|editor, _, _| {
let popover = editor.hover_state.info_popovers.first().unwrap();
popover.last_bounds.set(Some(Bounds {
origin: gpui::Point {
x: px(100.0),
y: px(100.0),
},
size: Size {
width: px(100.0),
height: px(60.0),
},
}));
});
let far_point = gpui::Point {
x: px(260.0),
y: px(130.0),
};
cx.update_editor(|editor, window, cx| hover_at(editor, None, Some(far_point), window, cx));
cx.background_executor
.advance_clock(Duration::from_millis(400));
cx.background_executor.run_until_parked();
let closer_point = gpui::Point {
x: px(220.0),
y: px(130.0),
};
cx.update_editor(|editor, window, cx| {
hover_at(editor, None, Some(closer_point), window, cx)
});
cx.background_executor
.advance_clock(Duration::from_millis(250));
cx.background_executor.run_until_parked();
cx.editor(|editor, _, _| {
assert!(
editor.hover_state.visible(),
"Popover should remain visible because moving closer restarts the hiding timer"
);
});
cx.background_executor
.advance_clock(Duration::from_millis(350));
cx.background_executor.run_until_parked();
cx.editor(|editor, _, _| {
assert!(
!editor.hover_state.visible(),
"Popover should hide after the restarted hiding timer expires"
);
});
}
#[gpui::test]
async fn test_hover_popover_cancel_hide_on_rehover(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {});
let custom_delay_ms = 500u64;
cx.update(|cx| {
cx.update_global::<SettingsStore, _>(|settings, cx| {
settings.update_user_settings(cx, |settings| {
settings.editor.hover_popover_sticky = Some(true);
settings.editor.hover_popover_hiding_delay = Some(DelayMs(custom_delay_ms));
});
});
});
let mut cx = EditorLspTestContext::new_rust(
lsp::ServerCapabilities {
hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
..Default::default()
},
cx,
)
.await;
cx.set_state(indoc! {"
fn ˇtest() { println!(); }
"});
let hover_point = cx.display_point(indoc! {"
fn test() { printˇln!(); }
"});
let symbol_range = cx.lsp_range(indoc! {"
fn test() { «println!»(); }
"});
let mut requests =
cx.set_request_handler::<lsp::request::HoverRequest, _, _>(move |_, _, _| async move {
Ok(Some(lsp::Hover {
contents: lsp::HoverContents::Markup(lsp::MarkupContent {
kind: lsp::MarkupKind::Markdown,
value: "some basic docs".to_string(),
}),
range: Some(symbol_range),
}))
});
cx.update_editor(|editor, window, cx| {
let snapshot = editor.snapshot(window, cx);
let anchor = snapshot
.buffer_snapshot()
.anchor_before(hover_point.to_offset(&snapshot, Bias::Left));
hover_at(editor, Some(anchor), None, window, cx)
});
cx.background_executor
.advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100));
requests.next().await;
cx.editor(|editor, _, _| {
assert!(editor.hover_state.visible());
});
// Move mouse away — starts the 500ms hide timer
cx.update_editor(|editor, window, cx| hover_at(editor, None, None, window, cx));
cx.background_executor
.advance_clock(Duration::from_millis(300));
cx.background_executor.run_until_parked();
cx.editor(|editor, _, _| {
assert!(
editor.hover_state.visible(),
"Popover should still be visible before hiding delay expires"
);
});
// Move back to the symbol — should cancel the hiding timer
cx.update_editor(|editor, window, cx| {
let snapshot = editor.snapshot(window, cx);
let anchor = snapshot
.buffer_snapshot()
.anchor_before(hover_point.to_offset(&snapshot, Bias::Left));
hover_at(editor, Some(anchor), None, window, cx)
});
// Advance past the original deadline — popover should still be visible
// because re-hovering cleared the hiding_delay_task
cx.background_executor
.advance_clock(Duration::from_millis(300));
cx.background_executor.run_until_parked();
cx.editor(|editor, _, _| {
assert!(
editor.hover_state.visible(),
"Popover should remain visible after re-hovering the symbol"
);
assert!(
editor.hover_state.hiding_delay_task.is_none(),
"Hiding delay task should have been cleared by re-hover"
);
});
// Move away again — starts a fresh 500ms timer
cx.update_editor(|editor, window, cx| hover_at(editor, None, None, window, cx));
cx.background_executor
.advance_clock(Duration::from_millis(custom_delay_ms + 100));
cx.background_executor.run_until_parked();
cx.editor(|editor, _, _| {
assert!(
!editor.hover_state.visible(),
"Popover should hide after the new hiding timer expires"
);
});
}
#[gpui::test]
async fn test_hover_popover_enabled_false_ignores_sticky(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {});
cx.update(|cx| {
cx.update_global::<SettingsStore, _>(|settings, cx| {
settings.update_user_settings(cx, |settings| {
settings.editor.hover_popover_enabled = Some(false);
settings.editor.hover_popover_sticky = Some(true);
settings.editor.hover_popover_hiding_delay = Some(DelayMs(500));
});
});
});
let mut cx = EditorLspTestContext::new_rust(
lsp::ServerCapabilities {
hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
..Default::default()
},
cx,
)
.await;
cx.set_state(indoc! {"
fn ˇtest() { println!(); }
"});
let hover_point = cx.display_point(indoc! {"
fn test() { printˇln!(); }
"});
// Trigger hover_at — should be gated by hover_popover_enabled=false
cx.update_editor(|editor, window, cx| {
let snapshot = editor.snapshot(window, cx);
let anchor = snapshot
.buffer_snapshot()
.anchor_before(hover_point.to_offset(&snapshot, Bias::Left));
hover_at(editor, Some(anchor), None, window, cx)
});
// No need to advance clock or wait for LSP — the gate should prevent any work
cx.editor(|editor, _, _| {
assert!(
!editor.hover_state.visible(),
"Popover should not appear when hover_popover_enabled is false"
);
assert!(
editor.hover_state.info_task.is_none(),
"No hover info task should be scheduled when hover is disabled"
);
assert!(
editor.hover_state.triggered_from.is_none(),
"No hover trigger should be recorded when hover is disabled"
);
});
}
}