editor: Persist multi-line diagnostic hovers in whitespace areas#47471
Conversation
|
Thank you for your pull request and welcome to our community. We could not parse the GitHub identity of the following contributors: daydalek.
|
7c79571 to
bace58c
Compare
|
Thank you for your pull request and welcome to our community. We could not parse the GitHub identity of the following contributors: daydalek.
|
bace58c to
43b3cf0
Compare
|
a possible problem with this is how it interacts with inactive regions in clangd which are implemented using diagnostics. since its between |
Thanks for pointing that out! I think a delay is a viable alternative. My only concern, echoing @xdBronch's point, is that a fixed duration might feel brittle for large diagnostic ranges. If we decide to pursue this path, we should probably explore an adaptive delay based on the block's size to make the solution more robust. |
8433f28 to
43b3cf0
Compare
@daydalek how about displaying the popover near the user's cursor instead of at the beginning of the range? It might not be the best user experience if someone hovers over a line and then has to scroll up to read the error/warning message. What do you think? I made PR #49257 implementing that, feel free to let me know your thoughts. |
Thanks for the suggestion! I actually considered this approach earlier, as it aligns with how VS Code handles tooltips by tracking the cursor. However, VS Code actually allows only one hover widget at the same time, also they've implemented other logic like automatically adjust the direction of tooltips to make this fine for VS Code. As for Zed which allows multiple tooltips/popovers at the same time, there can be some problems as mentioned in #49257 (comment) ,and It may takes significant effort to fix all of these. |
|
I'm currently working on a new strategy based on how VS Code deals with normal hovers and debug hovers. (Actually, VS Code deals with them differently: a debug hover is persisted using a "safety triangle" mechanism, while a normal hover is similar to what I'm going to describe here.) Basically, the idea is to set a timer for a fixed period (e.g., 300ms) and track the cursor to see if it's getting closer to the diagnostic hover. (Currently, we don't track the exact hover shown by the diagnostic, so if there are still other hovers there, it persists.) If the cursor is moving closer, we skip the hiding logic to keep the hover visible(however, if a hiding task is already in progress, it will continue to count down ,so maybe we should probably cancel the pending task to allow the user to change their mind); if it's leaving, a 300ms delay is applied before we hide all hovers. This is implemented through the following steps, referencing the implementation in VS Code: I'm now passing mouse_position into hover_at to track if the mouse is getting closer to the diagnostic hover: pub fn hover_at(
editor: &mut Editor,
anchor: Option<Anchor>,
/*track the cursor for the intention logic*/
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 {
let mut getting_closer = false;
/*intention logic implemented here*/
if let Some(mouse_position) = mouse_position {
getting_closer =
editor
.hover_state
.is_mouse_getting_closer(mouse_position, window, cx);
}
if getting_closer {
return;
}
if editor.hover_state.hiding_delay_task.is_some() {
return;
}
let delay = 300u64; // Fixed 300ms hiding delay
if delay > 0 {
let task = cx.spawn(move |this: WeakEntity<Editor>, cx: &mut AsyncApp| {
let mut cx = cx.clone();
async move {
cx.background_executor()
.timer(Duration::from_millis(delay))
.await;
this.update(&mut cx, |editor, cx| {
hide_hover(editor, cx);
})
.ok();
}
});
editor.hover_state.hiding_delay_task = Some(task);
} else {
hide_hover(editor, cx);
}
}
}
}And in HoverState, the helper functions are as follows: 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>,
_window: &Window,
_cx: &mut Context<Editor>,
) -> 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())
}For the bounds of tooltips in DiagnosticPopover and InfoPopover, I used a canvas to retrieve them: // Inside InfoPopover::render and DiagnosticPopover::render
let bounds_cell = self.last_bounds.clone();
div()
.id("info_popover")
.occlude()
.elevation_2(cx)
.child(
canvas(
{
let bounds_cell = bounds_cell.clone();
move |bounds, _window, _cx| {
bounds_cell.set(Some(bounds));
}
},
|_, _, _, _| {},
)
.absolute()
.size_full(),
)And It works like this , the hover persists when the cursor gets closer , I think this works even its a large block here. 2026-02-18.19.40.24.movAnd If the cursor goes away, the hover disappears after a 300ms delay 2026-02-18.19.42.59.movAs I know this is called Please feel free to let me know your thoughts—maybe some corner cases that this strategy might not cover, or potential improvements to the implementation. |
02ee2e0 to
c4e8eee
Compare
438ddc0 to
6588966
Compare
6588966 to
c26e208
Compare
c26e208 to
3410fe2
Compare
|
The commit is implemented based on the strategy mentioned in #47471 (comment) ,but improved logic to handle overshooting or intent changes, It works like this: 2026-02-23.19.20.47.movThe current method for getting popover bounds is a bit of a workaround. I haven't found a cleaner API for this yet, so I'd appreciate any feedback on a better approach. |
Fix an issue where diagnostic hovers could disappear when the cursor moves into trailing whitespace within a multi-line diagnostic range. This change introduces a direction-aware dismissal strategy with a 300ms delayed close. The hover remains visible while the cursor moves towards the popover, and is dismissed only after a short timeout when the cursor stops or moves away. This reduces premature hover dismissal while preserving predictable behavior. Fixes zed-industries#46841 Release Notes: • Multi-line diagnostic hovers are less likely to dismiss prematurely when hovering over whitespace.
3410fe2 to
700cedd
Compare
SomeoneToIgnore
left a comment
There was a problem hiding this comment.
Sorry for the pause, revisiting it now, I think this is quite nicely blends both the timeout idea proposed and the heuristics on top.
- One wish would be to have settings like
in Zed's settings (+ an import in vscode_import.rs), smth. like hover_popover_hide_delay?
that apply instead of the hardcoded let delay = 300u64 one.
- I wonder if the test is possible somehow to create for this, but not sure we have the ability to move mouse over random parts, hence not suggesting anything.
I'll merge the PR after Wednesday's release to have more time to test it internally, but overall things worked way better than before, thank you for the fix!
…-industries#47471) When the mouse cursor moves into the whitespace of a line within a multi-line diagnostic range, the hover popover would previously disappear. This change adds a check to keep the diagnostic hover visible if the mouse row intersects with the active diagnostic's range. Fixes zed-industries#46841 Release Notes: - Improved hover behavior for multi-line diagnostics to persist when hovering over whitespace. https://github.com/user-attachments/assets/0965cb25-6207-4d4a-9165-0d51157fc6e4

When the mouse cursor moves into the whitespace of a line within a multi-line diagnostic range, the hover popover would previously disappear. This change adds a check to keep the diagnostic hover visible if the mouse row intersects with the active diagnostic's range.
Fixes #46841
Release Notes:
2026-01-23.19.15.39.mov