Skip to content

editor: Persist multi-line diagnostic hovers in whitespace areas#47471

Merged
SomeoneToIgnore merged 1 commit intozed-industries:mainfrom
daydalek:fix/multiline-hover-fix
Mar 11, 2026
Merged

editor: Persist multi-line diagnostic hovers in whitespace areas#47471
SomeoneToIgnore merged 1 commit intozed-industries:mainfrom
daydalek:fix/multiline-hover-fix

Conversation

@daydalek
Copy link
Copy Markdown
Contributor

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:

  • Improved hover behavior for multi-line diagnostics to persist when hovering over whitespace.
2026-01-23.19.15.39.mov

@cla-bot
Copy link
Copy Markdown

cla-bot bot commented Jan 23, 2026

Thank you for your pull request and welcome to our community. We could not parse the GitHub identity of the following contributors: daydalek.
This is most likely caused by a git client misconfiguration; please make sure to:

  1. check if your git client is configured with an email to sign commits git config --list | grep email
  2. If not, set it up using git config --global user.email [email protected]
  3. Make sure that the git commit email is configured in your GitHub account settings, see https://github.com/settings/emails

@zed-community-bot zed-community-bot bot added the first contribution the author's first pull request to Zed. NOTE: the label application is automated via github actions label Jan 23, 2026
@daydalek daydalek force-pushed the fix/multiline-hover-fix branch from 7c79571 to bace58c Compare January 23, 2026 12:56
@cla-bot
Copy link
Copy Markdown

cla-bot bot commented Jan 23, 2026

Thank you for your pull request and welcome to our community. We could not parse the GitHub identity of the following contributors: daydalek.
This is most likely caused by a git client misconfiguration; please make sure to:

  1. check if your git client is configured with an email to sign commits git config --list | grep email
  2. If not, set it up using git config --global user.email [email protected]
  3. Make sure that the git commit email is configured in your GitHub account settings, see https://github.com/settings/emails

@daydalek daydalek force-pushed the fix/multiline-hover-fix branch from bace58c to 43b3cf0 Compare January 23, 2026 12:57
@cla-bot cla-bot bot added the cla-signed The user has signed the Contributor License Agreement label Jan 23, 2026
@maxdeviant maxdeviant changed the title fix: persist multi-line diagnostic hovers in whitespace areas editor: Persist multi-line diagnostic hovers in whitespace areas Jan 23, 2026
@SomeoneToIgnore SomeoneToIgnore self-assigned this Feb 4, 2026
@xdBronch
Copy link
Copy Markdown
Contributor

xdBronch commented Feb 7, 2026

a possible problem with this is how it interacts with inactive regions in clangd which are implemented using diagnostics. since its between #ifdefs the diagnostic can often be hundreds or even thousands of lines which means the hover will basically never go away since the whole screen is a diagnostic

@SomeoneToIgnore
Copy link
Copy Markdown
Contributor

I wonder if an alternative approach could be something like #22644 ?
Surprised to see that not all popovers are delayed by default — and wondering if this solves most of the issues like #46841

@daydalek
Copy link
Copy Markdown
Contributor Author

I wonder if an alternative approach could be something like #22644 ? Surprised to see that not all popovers are delayed by default — and wondering if this solves most of the issues like #46841

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.

@Ahmad-Alsaleh
Copy link
Copy Markdown

Ahmad-Alsaleh commented Feb 16, 2026

I wonder if an alternative approach could be something like #22644 ? Surprised to see that not all popovers are delayed by default — and wondering if this solves most of the issues like #46841

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.

@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.

@daydalek
Copy link
Copy Markdown
Contributor Author

I wonder if an alternative approach could be something like #22644 ? Surprised to see that not all popovers are delayed by default — and wondering if this solves most of the issues like #46841

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.

@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.

@daydalek
Copy link
Copy Markdown
Contributor Author

daydalek commented Feb 18, 2026

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.mov

And If the cursor goes away, the hover disappears after a 300ms delay

2026-02-18.19.42.59.mov

As I know this is called editor.hover.sticky in VS Code , and our implementation is similar to VS Code's except the cursor in Zed usually travels a longer way than in VS Code because VS Code usually show the hover just around the cursor.

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.

@daydalek daydalek force-pushed the fix/multiline-hover-fix branch 2 times, most recently from 02ee2e0 to c4e8eee Compare February 21, 2026 10:33
@daydalek daydalek marked this pull request as draft February 21, 2026 10:55
@daydalek daydalek force-pushed the fix/multiline-hover-fix branch 4 times, most recently from 438ddc0 to 6588966 Compare February 21, 2026 12:06
@daydalek daydalek marked this pull request as ready for review February 21, 2026 12:07
@daydalek daydalek marked this pull request as draft February 21, 2026 14:21
@daydalek daydalek force-pushed the fix/multiline-hover-fix branch from 6588966 to c26e208 Compare February 21, 2026 14:34
@daydalek daydalek marked this pull request as ready for review February 21, 2026 14:34
@daydalek daydalek force-pushed the fix/multiline-hover-fix branch from c26e208 to 3410fe2 Compare February 22, 2026 04:19
@daydalek
Copy link
Copy Markdown
Contributor Author

daydalek commented Feb 23, 2026

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.mov

The 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.
@daydalek daydalek force-pushed the fix/multiline-hover-fix branch from 3410fe2 to 700cedd Compare February 24, 2026 07:56
Copy link
Copy Markdown
Contributor

@SomeoneToIgnore SomeoneToIgnore left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Image

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!

@daydalek
Copy link
Copy Markdown
Contributor Author

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
Image 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!

Thanks for the review and the suggestion!

Making the delay configurable definitely makes sense. I’m currently preparing for my graduate school entrance interview, so my time is a bit limited these weeks. I’ll look into this improvement in April and follow up with a separate PR if needed.

Thanks again for the testing and feedback!

@SomeoneToIgnore SomeoneToIgnore merged commit f713373 into zed-industries:main Mar 11, 2026
27 checks passed
@daydalek daydalek deleted the fix/multiline-hover-fix branch March 11, 2026 17:45
tommyming pushed a commit to tommyming/zed that referenced this pull request Mar 13, 2026
…-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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cla-signed The user has signed the Contributor License Agreement first contribution the author's first pull request to Zed. NOTE: the label application is automated via github actions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Diagnostic hover popover disappears when moving mouse cursor to the popover

4 participants