gpui: Add an option to keep hover listeners active after keyboard input (#63614)

While working on https://github.com/zed-industries/zed/pull/63343, I ran
into a problem with the pending key binding indicator. Hovering pauses
its countdown, but pressing the next key made `on_hover` report false
even though the mouse had not moved. The countdown then resumed while
the pointer was still over the indicator.

`on_hover` currently follows GPUI's normal hover rules. GPUI clears
hover after keyboard input so the item under a stationary mouse does not
interfere with keyboard navigation, for example list popover navigation
with keyboard.

This PR adds `HoverListenerMode` and `hover_listener_mode`. Callers that
need to track the pointer across key presses can opt in like this:

```rs
div()
    .hover_listener_mode(HoverListenerMode::InputModalityIndependent)
    .on_hover(|is_hovered, window, cx| {
          // ...
      })
```

`InputModalityAware` remains the default.

Release Notes:

- N/A
This commit is contained in:
Smit Barmase authored and GitHub committed 2026-09-03 09:35:52 +00:00
1 parent 9441364cf1
commit a47324bb2b
1 file changed
+168 -4
+168 -4
View File
@@ -649,6 +649,10 @@ 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.
///
/// By default, keyboard input suppresses hover until the next mouse move, mouse down, or touch. Set
/// [`HoverListenerMode::InputModalityIndependent`] with [`Self::hover_listener_mode`] to
/// continue hit-testing hover after keyboard input.
/// 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.
@@ -663,6 +667,16 @@ impl Interactivity {
self.hover_listener = Some(Box::new(listener));
}
/// Sets how [`Self::on_hover`] responds to key presses while the mouse is stationary.
/// This affects only the hover listener, not hover styles or tooltips. The imperative API
/// equivalent to [`StatefulInteractiveElement::hover_listener_mode`].
pub fn hover_listener_mode(&mut self, mode: HoverListenerMode)
where
Self: Sized,
{
self.hover_listener_mode = mode;
}
/// Use the given callback to construct a new tooltip view when the mouse hovers over this element.
/// The imperative API equivalent to [`StatefulInteractiveElement::tooltip`].
pub fn tooltip(&mut self, build_tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static)
@@ -1593,6 +1607,10 @@ 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.
///
/// By default, keyboard input suppresses hover until the next mouse move, mouse down, or touch. Set
/// [`HoverListenerMode::InputModalityIndependent`] with [`Self::hover_listener_mode`] to
/// continue hit-testing hover after keyboard input.
/// 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.
@@ -1604,6 +1622,17 @@ pub trait StatefulInteractiveElement: InteractiveElement {
self
}
/// Sets how [`Self::on_hover`] responds to key presses while the mouse is stationary.
/// This affects only the hover listener, not hover styles or tooltips. The fluent API
/// equivalent to [`Interactivity::hover_listener_mode`].
fn hover_listener_mode(mut self, mode: HoverListenerMode) -> Self
where
Self: Sized,
{
self.interactivity().hover_listener_mode(mode);
self
}
/// Use the given callback to construct a new tooltip view when the mouse hovers over this element.
/// The fluent API equivalent to [`Interactivity::tooltip`].
fn tooltip(mut self, build_tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static) -> Self
@@ -1658,6 +1687,29 @@ pub(crate) type PinchListener =
pub(crate) type ClickListener = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
/// Controls how [`StatefulInteractiveElement::on_hover`] responds to key presses while the mouse
/// is stationary.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum HoverListenerMode {
/// Use input-modality-aware hit testing. Keyboard input suppresses hover until the mouse moves
/// again, unless pointer capture or an active mouse-down interaction keeps the listener hovered.
#[default]
InputModalityAware,
/// Use hit testing even when the last input was from the keyboard. This changes only
/// keyboard-modality filtering; all other [`StatefulInteractiveElement::on_hover`] behavior
/// remains unchanged.
InputModalityIndependent,
}
impl HoverListenerMode {
fn is_hovered(self, hitbox: &Hitbox, window: &Window) -> bool {
match self {
Self::InputModalityAware => hitbox.is_hovered(window),
Self::InputModalityIndependent => hitbox.id.is_hovered_ignoring_last_input(window),
}
}
}
pub(crate) struct DragListener {
value: Arc<dyn Any>,
render: Box<dyn Fn(&dyn Any, Point<Pixels>, &mut Window, &mut App) -> AnyView + 'static>,
@@ -2079,6 +2131,7 @@ pub struct Interactivity {
pub(crate) aux_click_listeners: Vec<ClickListener>,
pub(crate) drag_listener: Option<DragListener>,
pub(crate) hover_listener: Option<Box<dyn Fn(&bool, &mut Window, &mut App)>>,
pub(crate) hover_listener_mode: HoverListenerMode,
pub(crate) tooltip_builder: Option<TooltipBuilder>,
pub(crate) tooltip_show_delay: Option<Duration>,
pub(crate) window_control: Option<WindowControlArea>,
@@ -3034,9 +3087,11 @@ impl Interactivity {
hover_listener(&is_hovered, window, cx);
}
};
let hover_listener_mode = self.hover_listener_mode;
if has_mouse_down.borrow().is_none() {
let is_hovered = !cx.has_active_drag() && hitbox.is_hovered(window);
let is_hovered =
!cx.has_active_drag() && hover_listener_mode.is_hovered(hitbox, window);
if is_hovered != *was_hovered.borrow() {
let update_hover = update_hover.clone();
window.defer(cx, move |window, cx| {
@@ -3052,7 +3107,7 @@ impl Interactivity {
if phase == DispatchPhase::Bubble {
let is_hovered = has_mouse_down.borrow().is_none()
&& !cx.has_active_drag()
&& hitbox.is_hovered(window);
&& hover_listener_mode.is_hovered(&hitbox, window);
update_hover(is_hovered, window, cx);
}
}
@@ -4385,7 +4440,9 @@ mod tests {
}
#[gpui::test]
fn hover_listeners_update_when_layout_changes_under_stationary_mouse(cx: &mut TestAppContext) {
fn default_hover_listener_updates_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();
@@ -4425,7 +4482,114 @@ mod tests {
}
#[gpui::test]
fn hover_listeners_remain_hovered_during_stationary_mouse_press(cx: &mut TestAppContext) {
fn default_hover_listener_ends_after_key_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);
cx.update_window(any_window, |_, window, cx| {
window.draw(cx).clear(cx);
window.simulate_mouse_move(point(px(10.), px(10.)), cx);
})
.unwrap();
assert_eq!(*hover_transitions.borrow(), [true]);
key_down(cx, any_window, "a");
cx.update_window(any_window, |_, window, cx| window.draw(cx).clear(cx))
.unwrap();
assert_eq!(*hover_transitions.borrow(), [true, false]);
}
struct HoverListenerModeLayoutTestView {
target_left: Pixels,
hover_transitions: Rc<RefCell<Vec<bool>>>,
}
impl Render for HoverListenerModeLayoutTestView {
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.))
.hover_listener_mode(HoverListenerMode::InputModalityIndependent)
.on_hover(move |is_hovered, _, _| {
hover_transitions.borrow_mut().push(*is_hovered);
}),
)
}
}
#[gpui::test]
fn input_modality_independent_hover_listener_updates_after_key_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 |_, _| HoverListenerModeLayoutTestView {
target_left: px(40.),
hover_transitions,
}
});
let any_window = AnyWindowHandle::from(window);
let pointer_position = point(px(10.), px(10.));
cx.update_window(any_window, |_, window, cx| {
window.draw(cx).clear(cx);
window.simulate_mouse_move(pointer_position, cx);
})
.unwrap();
assert!(hover_transitions.borrow().is_empty());
key_down(cx, any_window, "a");
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]);
key_down(cx, any_window, "b");
cx.update_window(any_window, |_, window, cx| window.draw(cx).clear(cx))
.unwrap();
assert_eq!(*hover_transitions.borrow(), [true]);
cx.update_window(any_window, |_, window, cx| {
window.simulate_mouse_move(point(px(30.), px(10.)), cx);
})
.unwrap();
assert_eq!(*hover_transitions.borrow(), [true, false]);
cx.update_window(any_window, |_, window, cx| {
window.simulate_mouse_move(pointer_position, cx);
window.dispatch_event(
MouseExitEvent {
position: pointer_position,
..Default::default()
}
.to_platform_input(),
cx,
);
})
.unwrap();
assert_eq!(*hover_transitions.borrow(), [true, false, true, false]);
}
#[gpui::test]
fn default_hover_listener_remains_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();