gpui: Long-press to activate tooltips (#63948)

Tooltips on web need long-press, rather than hover

---

Release Notes:

- N/A or Added/Fixed/Improved ...
This commit is contained in:
Cameron Mcloughlin authored and GitHub committed 2026-09-11 05:25:06 +00:00
1 parent 85ad201397
commit 9eb022bd23
2 files changed
+235 -64

No files matched your search

+232 -64
View File
@@ -20,11 +20,11 @@ use crate::{
Display, Element, ElementId, Entity, EntityId, ExternalDragPayload, ExternalDragPayloadSource,
FileDropEvent, FocusHandle, Global, GlobalElementId, Hitbox, HitboxBehavior, HitboxId,
InspectorElementId, IntoElement, IsZero, KeyContext, KeyDownEvent, KeyUpEvent, KeyboardButton,
KeyboardClickEvent, LayoutId, ModifiersChangedEvent, MouseButton, MouseClickEvent,
MouseDownEvent, MouseExitEvent, MouseMoveEvent, MousePressureEvent, MouseUpEvent,
OngoingScroll, Overflow, ParentElement, PinchEvent, Pixels, Point, Render, ScrollWheelEvent,
SharedString, Size, Style, StyleRefinement, Styled, Task, TooltipId, Visibility, Window,
WindowControlArea, point, px, size,
KeyboardClickEvent, LayoutId, LongPressEvent, ModifiersChangedEvent, MouseButton,
MouseClickEvent, MouseDownEvent, MouseExitEvent, MouseMoveEvent, MousePressureEvent,
MouseUpEvent, OngoingScroll, Overflow, ParentElement, PinchEvent, Pixels, Point, Render,
ScrollWheelEvent, SharedString, Size, Style, StyleRefinement, Styled, Task, TooltipId,
TouchPhase, Visibility, Window, WindowControlArea, point, px, size,
};
use collections::HashMap;
use gpui_util::ResultExt;
@@ -32,7 +32,7 @@ use refineable::Refineable;
use smallvec::SmallVec;
use std::{
any::{Any, TypeId},
cell::RefCell,
cell::{Cell, RefCell},
cmp::Ordering,
fmt::Debug,
marker::PhantomData,
@@ -699,7 +699,7 @@ impl Interactivity {
self.hover_listener_mode = mode;
}
/// Use the given callback to construct a new tooltip view when the mouse hovers over this element.
/// Constructs a tooltip when the element is hovered or long-pressed.
/// The imperative API equivalent to [`StatefulInteractiveElement::tooltip`].
pub fn tooltip(&mut self, build_tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static)
where
@@ -715,7 +715,7 @@ impl Interactivity {
});
}
/// Use the given callback to construct a new tooltip view when the mouse hovers over this element.
/// Constructs a tooltip when the element is hovered or long-pressed.
/// The tooltip itself is also hoverable and won't disappear when the user moves the mouse into
/// the tooltip. The imperative API equivalent to [`StatefulInteractiveElement::hoverable_tooltip`].
pub fn hoverable_tooltip(
@@ -734,7 +734,9 @@ impl Interactivity {
});
}
/// Set the delay before this element's tooltip is shown.
/// Sets the delay before this element's tooltip is shown on hover.
///
/// Touch long presses show the tooltip immediately once the gesture is recognized.
/// The imperative API equivalent to [`StatefulInteractiveElement::tooltip_show_delay`].
pub fn tooltip_show_delay(&mut self, delay: Duration) {
self.tooltip_show_delay = Some(delay);
@@ -1670,7 +1672,7 @@ pub trait StatefulInteractiveElement: InteractiveElement {
self
}
/// Use the given callback to construct a new tooltip view when the mouse hovers over this element.
/// Constructs a tooltip when the element is hovered or long-pressed.
/// The fluent API equivalent to [`Interactivity::tooltip`].
fn tooltip(mut self, build_tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static) -> Self
where
@@ -1680,7 +1682,7 @@ pub trait StatefulInteractiveElement: InteractiveElement {
self
}
/// Use the given callback to construct a new tooltip view when the mouse hovers over this element.
/// Constructs a tooltip when the element is hovered or long-pressed.
/// The tooltip itself is also hoverable and won't disappear when the user moves the mouse into
/// the tooltip. The fluent API equivalent to [`Interactivity::hoverable_tooltip`].
fn hoverable_tooltip(
@@ -1694,7 +1696,9 @@ pub trait StatefulInteractiveElement: InteractiveElement {
self
}
/// Set the delay before this element's tooltip is shown.
/// Sets the delay before this element's tooltip is shown on hover.
///
/// Touch long presses show the tooltip immediately once the gesture is recognized.
/// The fluent API equivalent to [`Interactivity::tooltip_show_delay`].
fn tooltip_show_delay(mut self, delay: Duration) -> Self
where
@@ -2364,6 +2368,11 @@ impl Interactivity {
self.tooltip_id = set_tooltip_on_window(active_tooltip, window);
} else {
// If there is no longer a tooltip builder, remove the active tooltip.
if let Some(long_press_tooltip_active) =
element_state.long_press_tooltip_active.as_ref()
{
long_press_tooltip_active.set(false);
}
element_state.active_tooltip.take();
}
}
@@ -3180,6 +3189,10 @@ impl Interactivity {
.pending_mouse_down
.get_or_insert_with(Default::default)
.clone();
let long_press_tooltip_active = element_state
.long_press_tooltip_active
.get_or_insert_with(Default::default)
.clone();
let tooltip_is_hoverable = tooltip_builder.hoverable;
let build_tooltip = Rc::new(move |window: &mut Window, cx: &mut App| {
@@ -3207,6 +3220,7 @@ impl Interactivity {
build_tooltip,
check_is_hovered,
check_is_hovered_during_prepaint,
long_press_tooltip_active,
self.tooltip_show_delay,
window,
);
@@ -3605,6 +3619,7 @@ pub struct InteractiveElementState {
pub(crate) scroll_offset: Option<Rc<RefCell<Point<Pixels>>>>,
ongoing_scroll: Option<Rc<RefCell<OngoingScroll>>>,
pub(crate) active_tooltip: Option<Rc<RefCell<Option<ActiveTooltip>>>>,
long_press_tooltip_active: Option<Rc<Cell<bool>>>,
}
/// Whether or not the element or a group that contains it is clicked by the mouse.
@@ -3696,6 +3711,7 @@ pub(crate) fn register_tooltip_mouse_handlers(
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>,
long_press_tooltip_active: Rc<Cell<bool>>,
show_delay: Option<Duration>,
window: &mut Window,
) {
@@ -3706,6 +3722,7 @@ pub(crate) fn register_tooltip_mouse_handlers(
let active_tooltip = active_tooltip.clone();
let build_tooltip = build_tooltip.clone();
let check_is_hovered = check_is_hovered.clone();
let check_is_hovered_during_prepaint = check_is_hovered_during_prepaint.clone();
move |_: &MouseMoveEvent, phase, window, cx| {
handle_tooltip_mouse_move(
&active_tooltip,
@@ -3731,6 +3748,44 @@ pub(crate) fn register_tooltip_mouse_handlers(
}
});
window.on_mouse_event({
let active_tooltip = active_tooltip.clone();
let build_tooltip = build_tooltip.clone();
let check_is_hovered_during_prepaint = check_is_hovered_during_prepaint.clone();
move |event: &LongPressEvent, phase, window, cx| {
if !phase.bubble() {
return;
}
match event.phase {
TouchPhase::Started if !window.default_prevented() && check_is_hovered(window) => {
if show_tooltip(
&active_tooltip,
&build_tooltip,
&check_is_hovered_during_prepaint,
Some(long_press_tooltip_active.clone()),
window,
cx,
) {
long_press_tooltip_active.set(true);
window.prevent_default();
cx.stop_propagation();
}
}
TouchPhase::Ended | TouchPhase::Cancelled
if long_press_tooltip_active.replace(false) =>
{
clear_active_tooltip(&active_tooltip, window);
cx.stop_propagation();
}
TouchPhase::Started
| TouchPhase::Moved
| TouchPhase::Ended
| TouchPhase::Cancelled => {}
}
}
});
window.on_mouse_event({
let active_tooltip = active_tooltip.clone();
move |_: &ScrollWheelEvent, _phase, window: &mut Window, _cx| {
@@ -3830,36 +3885,14 @@ fn handle_tooltip_mouse_move(
return;
};
cx.update(|window, cx| {
let new_tooltip =
build_tooltip(window, cx).map(|(view, tooltip_is_hoverable)| {
let weak_active_tooltip = Rc::downgrade(&active_tooltip);
ActiveTooltip::Visible {
tooltip: AnyTooltip {
view,
mouse_position: window.mouse_position(),
check_visible_and_update: Rc::new(
move |tooltip_bounds, window, cx| {
let Some(active_tooltip) =
weak_active_tooltip.upgrade()
else {
return false;
};
handle_tooltip_check_visible_and_update(
&active_tooltip,
tooltip_is_hoverable,
&check_is_hovered_during_prepaint,
tooltip_bounds,
window,
cx,
)
},
),
},
is_hoverable: tooltip_is_hoverable,
}
});
*active_tooltip.borrow_mut() = new_tooltip;
window.refresh();
show_tooltip(
&active_tooltip,
&build_tooltip,
&check_is_hovered_during_prepaint,
None,
window,
cx,
);
})
.ok();
}
@@ -3874,6 +3907,50 @@ fn handle_tooltip_mouse_move(
}
}
fn show_tooltip(
active_tooltip: &Rc<RefCell<Option<ActiveTooltip>>>,
build_tooltip: &Rc<dyn Fn(&mut Window, &mut App) -> Option<(AnyView, bool)>>,
check_is_hovered_during_prepaint: &Rc<dyn Fn(&Window) -> bool>,
long_press_tooltip_active: Option<Rc<Cell<bool>>>,
window: &mut Window,
cx: &mut App,
) -> bool {
let new_tooltip = build_tooltip(window, cx).map(|(view, tooltip_is_hoverable)| {
let weak_active_tooltip = Rc::downgrade(active_tooltip);
let check_is_hovered_during_prepaint = check_is_hovered_during_prepaint.clone();
ActiveTooltip::Visible {
tooltip: AnyTooltip {
view,
mouse_position: window.mouse_position(),
check_visible_and_update: Rc::new(move |tooltip_bounds, window, cx| {
if long_press_tooltip_active
.as_ref()
.is_some_and(|active| active.get())
{
return true;
}
let Some(active_tooltip) = weak_active_tooltip.upgrade() else {
return false;
};
handle_tooltip_check_visible_and_update(
&active_tooltip,
tooltip_is_hoverable,
&check_is_hovered_during_prepaint,
tooltip_bounds,
window,
cx,
)
}),
},
is_hoverable: tooltip_is_hoverable,
}
});
let did_show = new_tooltip.is_some();
*active_tooltip.borrow_mut() = new_tooltip;
window.refresh();
did_show
}
/// Returns a callback which will be called by window prepaint to update tooltip visibility. The
/// purpose of doing this logic here instead of the mouse move handler is that the mouse move
/// handler won't get called when the element is not painted (e.g. via use of `visible_on_hover`).
@@ -4361,8 +4438,8 @@ impl ScrollHandle {
mod tests {
use super::*;
use crate::{
AnyWindowHandle, AppContext as _, Context, InputEvent, Keystroke, MouseMoveEvent,
TestAppContext, canvas, util::FluentBuilder as _,
AnyWindowHandle, AppContext as _, Context, GestureTuning, InputEvent, Keystroke,
MouseMoveEvent, TestAppContext, TouchEvent, TouchId, canvas, util::FluentBuilder as _,
};
use std::{cell::Cell, rc::Weak};
@@ -4837,12 +4914,123 @@ mod tests {
assert_eq!(handle.offset().y, px(-25.));
}
#[test]
fn long_press_shows_tooltip_until_touch_ends() {
let (mut test_app, any_window, captured_active_tooltip) = create_tooltip_owner_test(None);
let active_tooltip = captured_active_tooltip
.borrow()
.clone()
.unwrap()
.upgrade()
.unwrap();
let touch_id = TouchId(1);
let touch_position = point(px(49.), px(10.));
test_app
.update_window(any_window, |_, window, cx| {
window.dispatch_event(
TouchEvent {
id: touch_id,
phase: TouchPhase::Started,
position: touch_position,
predicted_position: None,
force: None,
}
.to_platform_input(),
cx,
);
})
.unwrap();
test_app
.dispatcher
.advance_clock(GestureTuning::default().long_press_duration + Duration::from_millis(1));
test_app.run_until_parked();
assert!(matches!(
active_tooltip.borrow().as_ref(),
Some(ActiveTooltip::Visible { .. })
));
let moved_position = point(px(55.), px(10.));
test_app
.update_window(any_window, |_, window, cx| {
window.dispatch_event(
TouchEvent {
id: touch_id,
phase: TouchPhase::Moved,
position: moved_position,
predicted_position: None,
force: None,
}
.to_platform_input(),
cx,
);
window.draw(cx).clear(cx);
})
.unwrap();
assert!(matches!(
active_tooltip.borrow().as_ref(),
Some(ActiveTooltip::Visible { .. })
));
test_app
.update_window(any_window, |_, window, cx| {
window.dispatch_event(
TouchEvent {
id: touch_id,
phase: TouchPhase::Ended,
position: moved_position,
predicted_position: None,
force: None,
}
.to_platform_input(),
cx,
);
})
.unwrap();
assert!(active_tooltip.borrow().is_none());
}
fn setup_tooltip_owner_test(
show_delay_override: Option<Duration>,
) -> (
TestAppContext,
crate::AnyWindowHandle,
CapturedActiveTooltip,
) {
let (mut test_app, any_window, captured_active_tooltip) =
create_tooltip_owner_test(show_delay_override);
test_app
.update_window(any_window, |_, window, cx| {
window.dispatch_event(
MouseMoveEvent {
position: point(px(10.), px(10.)),
modifiers: Default::default(),
pressed_button: None,
}
.to_platform_input(),
cx,
);
})
.unwrap();
test_app
.update_window(any_window, |_, window, cx| {
window.draw(cx).clear(cx);
})
.unwrap();
(test_app, any_window, captured_active_tooltip)
}
fn create_tooltip_owner_test(
show_delay_override: Option<Duration>,
) -> (
TestAppContext,
crate::AnyWindowHandle,
CapturedActiveTooltip,
) {
let mut test_app = TestAppContext::single();
let captured_active_tooltip: CapturedActiveTooltip = Rc::new(RefCell::new(None));
@@ -4855,26 +5043,6 @@ mod tests {
});
let any_window = window.into();
test_app
.update_window(any_window, |_, window, cx| {
window.draw(cx).clear(cx);
})
.unwrap();
test_app
.update_window(any_window, |_, window, cx| {
window.dispatch_event(
MouseMoveEvent {
position: point(px(10.), px(10.)),
modifiers: Default::default(),
pressed_button: None,
}
.to_platform_input(),
cx,
);
})
.unwrap();
test_app
.update_window(any_window, |_, window, cx| {
window.draw(cx).clear(cx);
+3
View File
@@ -1000,6 +1000,7 @@ pub struct InteractiveTextState {
mouse_down_index: Rc<Cell<Option<usize>>>,
hovered_index: Rc<Cell<Option<usize>>>,
active_tooltip: Rc<RefCell<Option<ActiveTooltip>>>,
long_press_tooltip_active: Rc<Cell<bool>>,
}
/// InteractiveTest is a wrapper around StyledText that adds mouse interactions.
@@ -1107,6 +1108,7 @@ impl Element for InteractiveText {
set_tooltip_on_window(&interactive_state.active_tooltip, window);
} else {
// If there is no longer a tooltip builder, remove the active tooltip.
interactive_state.long_press_tooltip_active.set(false);
interactive_state.active_tooltip.take();
}
}
@@ -1255,6 +1257,7 @@ impl Element for InteractiveText {
build_tooltip,
check_is_hovered,
check_is_hovered_during_prepaint,
interactive_state.long_press_tooltip_active.clone(),
None,
window,
);