From 9eb022bd235d177d9caee3c621e39a9a34828110 Mon Sep 17 00:00:00 2001 From: Cameron Mcloughlin Date: Fri, 11 Sep 2026 05:25:06 +0000 Subject: [PATCH] gpui: Long-press to activate tooltips (#63948) Tooltips on web need long-press, rather than hover --- Release Notes: - N/A or Added/Fixed/Improved ... --- crates/gpui/src/elements/div.rs | 296 ++++++++++++++++++++++++------- crates/gpui/src/elements/text.rs | 3 + 2 files changed, 235 insertions(+), 64 deletions(-) diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 72430b4546..126c406c78 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -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>>>, ongoing_scroll: Option>>, pub(crate) active_tooltip: Option>>>, + long_press_tooltip_active: Option>>, } /// 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 Option<(AnyView, bool)>>, check_is_hovered: Rc bool>, check_is_hovered_during_prepaint: Rc bool>, + long_press_tooltip_active: Rc>, show_delay: Option, 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>>, + build_tooltip: &Rc Option<(AnyView, bool)>>, + check_is_hovered_during_prepaint: &Rc bool>, + long_press_tooltip_active: Option>>, + 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, ) -> ( 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, + ) -> ( + 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); diff --git a/crates/gpui/src/elements/text.rs b/crates/gpui/src/elements/text.rs index 3470ac94e2..b3e5a35175 100644 --- a/crates/gpui/src/elements/text.rs +++ b/crates/gpui/src/elements/text.rs @@ -1000,6 +1000,7 @@ pub struct InteractiveTextState { mouse_down_index: Rc>>, hovered_index: Rc>>, active_tooltip: Rc>>, + long_press_tooltip_active: Rc>, } /// 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, );