From a47324bb2be2abf6b749fb5e2425044e940ffa03 Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Thu, 3 Sep 2026 09:35:52 +0000 Subject: [PATCH] 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 --- crates/gpui/src/elements/div.rs | 172 +++++++++++++++++++++++++++++++- 1 file changed, 168 insertions(+), 4 deletions(-) diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 8a6da212d6..f9208d04ad 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -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; +/// 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, render: Box, &mut Window, &mut App) -> AnyView + 'static>, @@ -2079,6 +2131,7 @@ pub struct Interactivity { pub(crate) aux_click_listeners: Vec, pub(crate) drag_listener: Option, pub(crate) hover_listener: Option>, + pub(crate) hover_listener_mode: HoverListenerMode, pub(crate) tooltip_builder: Option, pub(crate) tooltip_show_delay: Option, pub(crate) window_control: Option, @@ -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>>, + } + + impl Render for HoverListenerModeLayoutTestView { + 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.)) + .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();