gpui: Reconcile hover listeners after layout changes (#62335)

GPUI's declarative hover styles are recomputed from the current frame's
hit test, but `on_hover` listeners previously updated only in response
to mouse movement or the pointer leaving the window. When layout moved a
different element beneath a stationary pointer, the new element looked
hovered without receiving its hover-start callback.

This change reconciles each hover listener against the current hit test
during painting and defers transitions until after the paint cycle, when
application callbacks can safely update state. Reconciliation pauses
while a mouse press is pending so hover-only controls remain mounted
between mouse-down and mouse-up.

The regression coverage moves an element beneath and away from a
stationary pointer and verifies both transitions. It also verifies that
repainting during a stationary mouse press does not generate a spurious
hover exit.

Testing:

- `cargo test -p gpui --lib`
- `cargo fmt --check -p gpui`
- `cargo check -p delta --bin delta` with Delta temporarily patched to
the local GPUI checkout

Release Notes:

- Fixed hover interactions not updating when interface elements move
beneath a stationary pointer.
This commit is contained in:
Conrad Irwin authored and GitHub committed 2026-08-07 22:17:41 +00:00
1 parent c5f035e1bd
commit 383ea8b546
1 file changed
+131 -1
+131 -1
View File
@@ -644,6 +644,7 @@ impl Interactivity {
/// Bind the given callback on the hover start and end events of this element. Note that the boolean
/// passed to the callback is true when the hover starts and false when it ends.
/// Transitions caused by layout changes under a stationary mouse also invoke the callback.
/// The imperative API equivalent to [`StatefulInteractiveElement::on_hover`].
///
/// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback.
@@ -1587,6 +1588,7 @@ pub trait StatefulInteractiveElement: InteractiveElement {
/// Bind the given callback on the hover start and end events of this element. Note that the boolean
/// passed to the callback is true when the hover starts and false when it ends.
/// Transitions caused by layout changes under a stationary mouse also invoke the callback.
/// The fluent API equivalent to [`Interactivity::on_hover`].
///
/// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback.
@@ -3009,8 +3011,9 @@ impl Interactivity {
.get_or_insert_with(Default::default)
.clone();
let hover_listener = Rc::new(hover_listener);
let hover_listener_state = was_hovered.clone();
let update_hover = move |is_hovered: bool, window: &mut Window, cx: &mut App| {
let mut was_hovered = was_hovered.borrow_mut();
let mut was_hovered = hover_listener_state.borrow_mut();
if is_hovered != *was_hovered {
*was_hovered = is_hovered;
drop(was_hovered);
@@ -3018,6 +3021,16 @@ impl Interactivity {
}
};
if has_mouse_down.borrow().is_none() {
let is_hovered = !cx.has_active_drag() && hitbox.is_hovered(window);
if is_hovered != *was_hovered.borrow() {
let update_hover = update_hover.clone();
window.defer(cx, move |window, cx| {
update_hover(is_hovered, window, cx);
});
}
}
window.on_mouse_event({
let update_hover = update_hover.clone();
let hitbox = hitbox.clone();
@@ -4332,6 +4345,123 @@ mod tests {
assert_eq!(stateful_width.get(), px(10.));
}
struct HoverListenerLayoutTestView {
target_left: Pixels,
hover_transitions: Rc<RefCell<Vec<bool>>>,
}
impl Render for HoverListenerLayoutTestView {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
let hover_transitions = self.hover_transitions.clone();
div().relative().size_full().child(
div()
.id("hover-target")
.absolute()
.left(self.target_left)
.top_0()
.size(px(20.))
.on_click(|_, _, _| {})
.on_hover(move |is_hovered, _, _| {
hover_transitions.borrow_mut().push(*is_hovered);
}),
)
}
}
#[gpui::test]
fn hover_listeners_update_when_layout_changes_under_stationary_mouse(cx: &mut TestAppContext) {
let hover_transitions = Rc::new(RefCell::new(Vec::new()));
let window = cx.add_window({
let hover_transitions = hover_transitions.clone();
move |_, _| HoverListenerLayoutTestView {
target_left: px(40.),
hover_transitions,
}
});
let any_window = AnyWindowHandle::from(window);
cx.update_window(any_window, |_, window, cx| {
window.draw(cx).clear(cx);
window.simulate_mouse_move(point(px(10.), px(10.)), cx);
})
.unwrap();
assert!(hover_transitions.borrow().is_empty());
window
.update(cx, |view, _, cx| {
view.target_left = px(0.);
cx.notify();
})
.unwrap();
cx.update_window(any_window, |_, window, cx| window.draw(cx).clear(cx))
.unwrap();
assert_eq!(*hover_transitions.borrow(), [true]);
window
.update(cx, |view, _, cx| {
view.target_left = px(40.);
cx.notify();
})
.unwrap();
cx.update_window(any_window, |_, window, cx| window.draw(cx).clear(cx))
.unwrap();
assert_eq!(*hover_transitions.borrow(), [true, false]);
}
#[gpui::test]
fn hover_listeners_remain_hovered_during_stationary_mouse_press(cx: &mut TestAppContext) {
let hover_transitions = Rc::new(RefCell::new(Vec::new()));
let window = cx.add_window({
let hover_transitions = hover_transitions.clone();
move |_, _| HoverListenerLayoutTestView {
target_left: px(0.),
hover_transitions,
}
});
let any_window = AnyWindowHandle::from(window);
let mouse_position = point(px(10.), px(10.));
cx.update_window(any_window, |_, window, cx| {
window.draw(cx).clear(cx);
window.simulate_mouse_move(mouse_position, cx);
})
.unwrap();
assert_eq!(*hover_transitions.borrow(), [true]);
cx.update_window(any_window, |_, window, cx| {
window.dispatch_event(
MouseDownEvent {
position: mouse_position,
button: MouseButton::Left,
modifiers: Default::default(),
click_count: 1,
first_mouse: false,
}
.to_platform_input(),
cx,
);
window.draw(cx).clear(cx);
})
.unwrap();
assert_eq!(*hover_transitions.borrow(), [true]);
cx.update_window(any_window, |_, window, cx| {
window.dispatch_event(
MouseUpEvent {
position: mouse_position,
button: MouseButton::Left,
modifiers: Default::default(),
click_count: 1,
}
.to_platform_input(),
cx,
);
window.draw(cx).clear(cx);
})
.unwrap();
assert_eq!(*hover_transitions.borrow(), [true]);
}
struct TestTooltipView;
impl Render for TestTooltipView {