gpui: Fix stuck tooltips after mouse leaves origin (#58134)

Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [ ] Unsafe blocks (if any) have justifying comments
- [ ] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Release Notes:

- N/A

I encountered this bug while building my app with GPUI where tooltips
were getting stuck when i move my mouse away from the origin element.

Zed kinda gets away with it because tab close buttons use
`visible_on_hover`, so when the mouse leaves the tab, close button
disappears. This triggers a redraw which makes GPUI recheck and hide the
tooltip.

In the "Before" version i had to trigger the recheck by moving my mouse
over an element with hover styles to make the tooltip disappear.

### Before


https://github.com/user-attachments/assets/f405620f-6d6d-4a24-8992-214180bc7b76

### After


https://github.com/user-attachments/assets/2e41fbca-af61-4f34-bd5d-ab288f34e73a
This commit is contained in:
Mayank Verma authored and GitHub committed 2026-06-01 06:47:23 +00:00
1 parent 2e5d04e62f
commit ea4bf744d0
1 file changed
+61 -4
+61 -4
View File
@@ -18,7 +18,7 @@
use crate::PinchEvent;
use crate::{
Action, AnyDrag, AnyElement, AnyTooltip, AnyView, App, Bounds, ClickEvent, DispatchPhase,
Display, Element, ElementId, Entity, FocusHandle, Global, GlobalElementId, Hitbox,
Display, Element, ElementId, Entity, EntityId, FocusHandle, Global, GlobalElementId, Hitbox,
HitboxBehavior, HitboxId, InspectorElementId, IntoElement, IsZero, KeyContext, KeyDownEvent,
KeyUpEvent, KeyboardButton, KeyboardClickEvent, LayoutId, ModifiersChangedEvent, MouseButton,
MouseClickEvent, MouseDownEvent, MouseMoveEvent, MousePressureEvent, MouseUpEvent, Overflow,
@@ -3195,6 +3195,8 @@ pub(crate) fn register_tooltip_mouse_handlers(
check_is_hovered_during_prepaint: Rc<dyn Fn(&Window) -> bool>,
window: &mut Window,
) {
let current_view = window.current_view();
window.on_mouse_event({
let active_tooltip = active_tooltip.clone();
let build_tooltip = build_tooltip.clone();
@@ -3205,6 +3207,8 @@ pub(crate) fn register_tooltip_mouse_handlers(
&build_tooltip,
&check_is_hovered,
&check_is_hovered_during_prepaint,
tooltip_id,
current_view,
phase,
window,
cx,
@@ -3247,6 +3251,8 @@ fn handle_tooltip_mouse_move(
build_tooltip: &Rc<dyn Fn(&mut Window, &mut App) -> Option<(AnyView, bool)>>,
check_is_hovered: &Rc<dyn Fn(&Window) -> bool>,
check_is_hovered_during_prepaint: &Rc<dyn Fn(&Window) -> bool>,
tooltip_id: Option<TooltipId>,
current_view: EntityId,
phase: DispatchPhase,
window: &mut Window,
cx: &mut App,
@@ -3257,6 +3263,7 @@ fn handle_tooltip_mouse_move(
None,
CancelShow,
ScheduleShow,
CheckVisible,
}
let action = match active_tooltip.borrow().as_ref() {
@@ -3276,9 +3283,26 @@ fn handle_tooltip_mouse_move(
Action::CancelShow
}
}
// These are handled in check_visible_and_update.
Some(ActiveTooltip::Visible { .. }) | Some(ActiveTooltip::WaitingForHide { .. }) => {
Action::None
Some(ActiveTooltip::Visible { is_hoverable, .. }) => {
if phase.capture()
&& !check_is_hovered(window)
&& (!*is_hoverable
|| !tooltip_id.is_some_and(|tooltip_id| tooltip_id.is_hovered(window)))
{
Action::CheckVisible
} else {
Action::None
}
}
Some(ActiveTooltip::WaitingForHide { .. }) => {
if phase.capture()
&& (check_is_hovered(window)
|| tooltip_id.is_some_and(|tooltip_id| tooltip_id.is_hovered(window)))
{
Action::CheckVisible
} else {
Action::None
}
}
};
@@ -3339,6 +3363,7 @@ fn handle_tooltip_mouse_move(
_task: delayed_show_task,
});
}
Action::CheckVisible => cx.notify(current_view),
}
}
@@ -4055,4 +4080,36 @@ mod tests {
assert!(weak_active_tooltip.upgrade().is_none());
}
#[test]
fn tooltip_hides_after_mouse_leaves_origin() {
let (mut test_app, any_window, captured_active_tooltip) = setup_tooltip_owner_test();
let weak_active_tooltip = captured_active_tooltip.borrow().clone().unwrap();
let active_tooltip = weak_active_tooltip.upgrade().unwrap();
test_app.dispatcher.advance_clock(TOOLTIP_SHOW_DELAY);
test_app.run_until_parked();
assert!(matches!(
active_tooltip.borrow().as_ref(),
Some(ActiveTooltip::Visible { .. })
));
test_app
.update_window(any_window, |_, window, cx| {
window.dispatch_event(
MouseMoveEvent {
position: point(px(75.), px(75.)),
modifiers: Default::default(),
pressed_button: None,
}
.to_platform_input(),
cx,
);
})
.unwrap();
assert!(active_tooltip.borrow().is_none());
}
}