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 {