gpui: Fix hover state not clearing when mouse leaves window (#60275)

# Objective

Fix two gaps in element hover tracking at window boundaries. Hover was
only re-evaluated on `MouseMove`, so when the pointer left the window no
event fired `on_hover(false)` and the element stayed hovered.
Symmetrically on Wayland, no `Motion` follows `Enter` until the pointer
moves again, so hover was not established at the entry pixel. Both cases
are easy to miss since most hover-styled elements don't sit flush
against the window edge, but they surfaced while implementing
layer_shell popups with input_regions, which should close when stop
hovering.

## Solution

The hover compare-and-fire logic in `div` is refactored into a shared
`update_hover` closure, and a second listener on `MouseExitEvent` clears
hover when the pointer leaves the window. It clears unconditionally
because `MouseExited` doesn't update the tracked mouse position, so a
hit test during that dispatch would still report the element as hovered.

On Wayland, a `MouseMove` is synthesized at the entry position on
`wl_pointer.enter`, mirroring the `MouseExited` already dispatched on
`Leave`.

## Testing

Tested manually on Wayland/Linux: hover on a window-edge element clears
when the pointer leaves the window, and hover is established immediately
when the pointer enters a surface with an element under the entry pixel.

Not tested on other platforms. The `div` change relies on each
platform's existing `MouseExited` dispatch: macOS and X11 emit it, so
they get the exit fix too. Windows never dispatches `MouseExited`
(`WM_MOUSELEAVE` only flips the window-level hover flag), so the
stuck-hover case might remain there, unchanged from before.

Before:


https://github.com/user-attachments/assets/6af83bb3-de9d-40e2-a64c-bdefc98fc96d

After:


https://github.com/user-attachments/assets/721a9b5c-108a-499f-867e-da111836a34a


Here is an example application to test this:

```rs
#![cfg_attr(target_family = "wasm", no_main)]

use gpui::{
    App, Bounds, Context, Window, WindowBounds, WindowOptions, div, prelude::*, px, rgb, size,
};
use gpui_platform::application;

struct HoverExit {
    hovered: bool,
}

impl Render for HoverExit {
    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        // Fills the whole window so its edge is the window edge: moving the mouse
        // out of the window is what exercises the MouseExited path.
        div()
            .id("hover-exit")
            .size_full()
            .flex()
            .justify_center()
            .items_center()
            .text_xl()
            .text_color(rgb(0xffffff))
            .bg(if self.hovered {
                rgb(0x585f58)
            } else {
                rgb(0x505050)
            })
            .child(if self.hovered { "HOVERED" } else { "not hovered" })
            .on_hover(cx.listener(|this, hovered, _, cx| {
                this.hovered = *hovered;
                cx.notify();
            }))
    }
}

fn run_example() {
    application().run(|cx: &mut App| {
        let bounds = Bounds::centered(None, size(px(240.), px(160.0)), cx);
        cx.open_window(
            WindowOptions {
                window_bounds: Some(WindowBounds::Windowed(bounds)),
                app_id: Some("gpui-hover-exit".to_string()),
                ..Default::default()
            },
            |_, cx| cx.new(|_| HoverExit { hovered: false }),
        )
        .unwrap();
        cx.activate(true);
    });
}

#[cfg(not(target_family = "wasm"))]
fn main() {
    run_example();
}

#[cfg(target_family = "wasm")]
#[wasm_bindgen::prelude::wasm_bindgen(start)]
pub fn start() {
    gpui_platform::web_init();
    run_example();
}
```


## 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 adheres to Zed's UI standards
([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
and
[icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md)
guidelines)
- [ ] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

---

Release Notes:

- Fixed element hover state not clearing when the mouse leaves the
window
This commit is contained in:
Philipp Schaffrath 2026-07-08 22:27:47 +02:00 committed by GitHub
parent 23c0080d1d
commit 664b7ecb40
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 33 additions and 12 deletions

View file

@ -2860,7 +2860,6 @@ impl Interactivity {
}
if let Some(hover_listener) = self.hover_listener.take() {
let hitbox = hitbox.clone();
let was_hovered = element_state
.hover_listener_state
.get_or_insert_with(Default::default)
@ -2869,22 +2868,35 @@ impl Interactivity {
.pending_mouse_down
.get_or_insert_with(Default::default)
.clone();
window.on_mouse_event(move |_: &MouseMoveEvent, phase, window, cx| {
if phase != DispatchPhase::Bubble {
return;
}
let is_hovered = has_mouse_down.borrow().is_none()
&& !cx.has_active_drag()
&& hitbox.is_hovered(window);
let hover_listener = Rc::new(hover_listener);
let update_hover = move |is_hovered: bool, window: &mut Window, cx: &mut App| {
let mut was_hovered = was_hovered.borrow_mut();
if is_hovered != *was_hovered {
*was_hovered = is_hovered;
drop(was_hovered);
hover_listener(&is_hovered, window, cx);
}
};
window.on_mouse_event({
let update_hover = update_hover.clone();
let hitbox = hitbox.clone();
move |_: &MouseMoveEvent, phase, window, cx| {
if phase == DispatchPhase::Bubble {
let is_hovered = has_mouse_down.borrow().is_none()
&& !cx.has_active_drag()
&& hitbox.is_hovered(window);
update_hover(is_hovered, window, cx);
}
}
});
// The pointer can leave the window without a final MouseMove, so also
// clear hover on MouseExited.
window.on_mouse_event(move |_: &MouseExitEvent, phase, window, cx| {
if phase == DispatchPhase::Bubble {
update_hover(false, window, cx);
}
});
}

View file

@ -1884,8 +1884,9 @@ impl Dispatch<wl_pointer::WlPointer, ()> for WaylandClientStatePtr {
surface_y,
..
} => {
let position = point(px(surface_x as f32), px(surface_y as f32));
state.serial_tracker.update(SerialKind::MouseEnter, serial);
state.mouse_location = Some(point(px(surface_x as f32), px(surface_y as f32)));
state.mouse_location = Some(position);
state.button_pressed = None;
if let Some(window) = get_window(&mut state, &surface.id()) {
@ -1908,8 +1909,16 @@ impl Dispatch<wl_pointer::WlPointer, ()> for WaylandClientStatePtr {
);
}
}
let modifiers = state.modifiers;
drop(state);
window.set_hovered(true);
// No Motion follows Enter unless the pointer keeps moving, so synthesize
// a MouseMove to establish hover at the entry position.
window.handle_input(PlatformInput::MouseMove(MouseMoveEvent {
position,
pressed_button: None,
modifiers,
}));
}
}
wl_pointer::Event::Leave { .. } => {