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:
1 parent
9441364cf1
commit
a47324bb2b
1 file changed
+168
-4
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user