From 383ea8b546c3d497fefe53fd77e8a07453316cf4 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Fri, 7 Aug 2026 16:17:41 -0600 Subject: [PATCH] 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. --- crates/gpui/src/elements/div.rs | 132 +++++++++++++++++++++++++++++++- 1 file changed, 131 insertions(+), 1 deletion(-) diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index b9c1a96479..0fb4b202ed 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -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>>, + } + + impl Render for HoverListenerLayoutTestView { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> 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 {