Allow hovering over tooltips in `git blame` sidebar (#10466)

Thorsten Ball and Antonio created

This introduces a new API on `StatefulInteractiveElement` to create a
tooltip that can be hovered, scrolled inside, and clicked:
`.hoverable_tooltip`.

Right now we only use it in the `git blame` gutter, but the plan is to
use the new hover/click/scroll behavior in #10398 to introduce new
git-blame-tooltips.

Release Notes:

- N/A

---------

Co-authored-by: Antonio <antonio@zed.dev>

Change summary

crates/editor/src/element.rs         |   2 
crates/gpui/src/app.rs               |   4 
crates/gpui/src/elements/div.rs      |  89 +++++++++++++++++++---
crates/gpui/src/elements/text.rs     |   2 
crates/gpui/src/window.rs            |   4 +
crates/gpui/src/window/element_cx.rs | 116 ++++++++++++++++++++++++-----
crates/ui/src/components/tooltip.rs  |  25 +++---
7 files changed, 190 insertions(+), 52 deletions(-)

Detailed changes

crates/editor/src/element.rs 🔗

@@ -2969,7 +2969,7 @@ fn render_blame_entry(
                 cx.open_url(url.as_str())
             })
         })
-        .tooltip(move |cx| {
+        .hoverable_tooltip(move |cx| {
             BlameEntryTooltip::new(
                 sha_color.cursor,
                 commit_message.clone(),

crates/gpui/src/app.rs 🔗

@@ -1416,8 +1416,8 @@ pub struct AnyTooltip {
     /// The view used to display the tooltip
     pub view: AnyView,
 
-    /// The offset from the cursor to use, relative to the parent view
-    pub cursor_offset: Point<Pixels>,
+    /// The absolute position of the mouse when the tooltip was deployed.
+    pub mouse_position: Point<Pixels>,
 }
 
 /// A keystroke event, and potentially the associated action

crates/gpui/src/elements/div.rs 🔗

@@ -21,7 +21,7 @@ use crate::{
     HitboxId, IntoElement, IsZero, KeyContext, KeyDownEvent, KeyUpEvent, LayoutId,
     ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent,
     ParentElement, Pixels, Point, Render, ScrollWheelEvent, SharedString, Size, Style,
-    StyleRefinement, Styled, Task, View, Visibility, WindowContext,
+    StyleRefinement, Styled, Task, TooltipId, View, Visibility, WindowContext,
 };
 use collections::HashMap;
 use refineable::Refineable;
@@ -483,7 +483,29 @@ impl Interactivity {
             self.tooltip_builder.is_none(),
             "calling tooltip more than once on the same element is not supported"
         );
-        self.tooltip_builder = Some(Rc::new(build_tooltip));
+        self.tooltip_builder = Some(TooltipBuilder {
+            build: Rc::new(build_tooltip),
+            hoverable: false,
+        });
+    }
+
+    /// Use the given callback to construct a new tooltip view when the mouse hovers over this element.
+    /// The tooltip itself is also hoverable and won't disappear when the user moves the mouse into
+    /// the tooltip. The imperative API equivalent to [`InteractiveElement::hoverable_tooltip`]
+    pub fn hoverable_tooltip(
+        &mut self,
+        build_tooltip: impl Fn(&mut WindowContext) -> AnyView + 'static,
+    ) where
+        Self: Sized,
+    {
+        debug_assert!(
+            self.tooltip_builder.is_none(),
+            "calling tooltip more than once on the same element is not supported"
+        );
+        self.tooltip_builder = Some(TooltipBuilder {
+            build: Rc::new(build_tooltip),
+            hoverable: true,
+        });
     }
 
     /// Block the mouse from interacting with this element or any of its children
@@ -973,6 +995,20 @@ pub trait StatefulInteractiveElement: InteractiveElement {
         self.interactivity().tooltip(build_tooltip);
         self
     }
+
+    /// Use the given callback to construct a new tooltip view when the mouse hovers over this element.
+    /// 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(
+        mut self,
+        build_tooltip: impl Fn(&mut WindowContext) -> AnyView + 'static,
+    ) -> Self
+    where
+        Self: Sized,
+    {
+        self.interactivity().hoverable_tooltip(build_tooltip);
+        self
+    }
 }
 
 /// A trait for providing focus related APIs to interactive elements
@@ -1015,7 +1051,10 @@ type DropListener = Box<dyn Fn(&dyn Any, &mut WindowContext) + 'static>;
 
 type CanDropPredicate = Box<dyn Fn(&dyn Any, &mut WindowContext) -> bool + 'static>;
 
-pub(crate) type TooltipBuilder = Rc<dyn Fn(&mut WindowContext) -> AnyView + 'static>;
+pub(crate) struct TooltipBuilder {
+    build: Rc<dyn Fn(&mut WindowContext) -> AnyView + 'static>,
+    hoverable: bool,
+}
 
 pub(crate) type KeyDownListener =
     Box<dyn Fn(&KeyDownEvent, DispatchPhase, &mut WindowContext) + 'static>;
@@ -1188,6 +1227,7 @@ pub struct Interactivity {
     /// Whether the element was hovered. This will only be present after paint if an hitbox
     /// was created for the interactive element.
     pub hovered: Option<bool>,
+    pub(crate) tooltip_id: Option<TooltipId>,
     pub(crate) content_size: Size<Pixels>,
     pub(crate) key_context: Option<KeyContext>,
     pub(crate) focusable: bool,
@@ -1321,7 +1361,7 @@ impl Interactivity {
                     if let Some(active_tooltip) = element_state.active_tooltip.as_ref() {
                         if let Some(active_tooltip) = active_tooltip.borrow().as_ref() {
                             if let Some(tooltip) = active_tooltip.tooltip.clone() {
-                                cx.set_tooltip(tooltip);
+                                self.tooltip_id = Some(cx.set_tooltip(tooltip));
                             }
                         }
                     }
@@ -1806,6 +1846,7 @@ impl Interactivity {
             }
 
             if let Some(tooltip_builder) = self.tooltip_builder.take() {
+                let tooltip_is_hoverable = tooltip_builder.hoverable;
                 let active_tooltip = element_state
                     .active_tooltip
                     .get_or_insert_with(Default::default)
@@ -1818,11 +1859,17 @@ impl Interactivity {
                 cx.on_mouse_event({
                     let active_tooltip = active_tooltip.clone();
                     let hitbox = hitbox.clone();
+                    let tooltip_id = self.tooltip_id;
                     move |_: &MouseMoveEvent, phase, cx| {
                         let is_hovered =
                             pending_mouse_down.borrow().is_none() && hitbox.is_hovered(cx);
-                        if !is_hovered {
-                            active_tooltip.borrow_mut().take();
+                        let tooltip_is_hovered =
+                            tooltip_id.map_or(false, |tooltip_id| tooltip_id.is_hovered(cx));
+                        if !is_hovered && (!tooltip_is_hoverable || !tooltip_is_hovered) {
+                            if active_tooltip.borrow_mut().take().is_some() {
+                                cx.refresh();
+                            }
+
                             return;
                         }
 
@@ -1833,15 +1880,14 @@ impl Interactivity {
                         if active_tooltip.borrow().is_none() {
                             let task = cx.spawn({
                                 let active_tooltip = active_tooltip.clone();
-                                let tooltip_builder = tooltip_builder.clone();
-
+                                let build_tooltip = tooltip_builder.build.clone();
                                 move |mut cx| async move {
                                     cx.background_executor().timer(TOOLTIP_DELAY).await;
                                     cx.update(|cx| {
                                         active_tooltip.borrow_mut().replace(ActiveTooltip {
                                             tooltip: Some(AnyTooltip {
-                                                view: tooltip_builder(cx),
-                                                cursor_offset: cx.mouse_position(),
+                                                view: build_tooltip(cx),
+                                                mouse_position: cx.mouse_position(),
                                             }),
                                             _task: None,
                                         });
@@ -1860,15 +1906,30 @@ impl Interactivity {
 
                 cx.on_mouse_event({
                     let active_tooltip = active_tooltip.clone();
-                    move |_: &MouseDownEvent, _, _| {
-                        active_tooltip.borrow_mut().take();
+                    let tooltip_id = self.tooltip_id;
+                    move |_: &MouseDownEvent, _, cx| {
+                        let tooltip_is_hovered =
+                            tooltip_id.map_or(false, |tooltip_id| tooltip_id.is_hovered(cx));
+
+                        if !tooltip_is_hoverable || !tooltip_is_hovered {
+                            if active_tooltip.borrow_mut().take().is_some() {
+                                cx.refresh();
+                            }
+                        }
                     }
                 });
 
                 cx.on_mouse_event({
                     let active_tooltip = active_tooltip.clone();
-                    move |_: &ScrollWheelEvent, _, _| {
-                        active_tooltip.borrow_mut().take();
+                    let tooltip_id = self.tooltip_id;
+                    move |_: &ScrollWheelEvent, _, cx| {
+                        let tooltip_is_hovered =
+                            tooltip_id.map_or(false, |tooltip_id| tooltip_id.is_hovered(cx));
+                        if !tooltip_is_hoverable || !tooltip_is_hovered {
+                            if active_tooltip.borrow_mut().take().is_some() {
+                                cx.refresh();
+                            }
+                        }
                     }
                 })
             }

crates/gpui/src/elements/text.rs 🔗

@@ -553,7 +553,7 @@ impl Element for InteractiveText {
                                                 ActiveTooltip {
                                                     tooltip: Some(AnyTooltip {
                                                         view: tooltip,
-                                                        cursor_offset: cx.mouse_position(),
+                                                        mouse_position: cx.mouse_position(),
                                                     }),
                                                     _task: None,
                                                 }

crates/gpui/src/window.rs 🔗

@@ -287,6 +287,8 @@ pub struct Window {
     pub(crate) rendered_frame: Frame,
     pub(crate) next_frame: Frame,
     pub(crate) next_hitbox_id: HitboxId,
+    pub(crate) next_tooltip_id: TooltipId,
+    pub(crate) tooltip_bounds: Option<TooltipBounds>,
     next_frame_callbacks: Rc<RefCell<Vec<FrameCallback>>>,
     pub(crate) dirty_views: FxHashSet<EntityId>,
     pub(crate) focus_handles: Arc<RwLock<SlotMap<FocusId, AtomicUsize>>>,
@@ -551,6 +553,8 @@ impl Window {
             next_frame: Frame::new(DispatchTree::new(cx.keymap.clone(), cx.actions.clone())),
             next_frame_callbacks,
             next_hitbox_id: HitboxId::default(),
+            next_tooltip_id: TooltipId::default(),
+            tooltip_bounds: None,
             dirty_views: FxHashSet::default(),
             focus_handles: Arc::new(RwLock::new(SlotMap::with_key())),
             focus_listeners: SubscriberSet::new(),

crates/gpui/src/window/element_cx.rs 🔗

@@ -15,7 +15,7 @@
 use std::{
     any::{Any, TypeId},
     borrow::{Borrow, BorrowMut, Cow},
-    mem,
+    cmp, mem,
     ops::Range,
     rc::Rc,
     sync::Arc,
@@ -28,17 +28,18 @@ use futures::{future::Shared, FutureExt};
 #[cfg(target_os = "macos")]
 use media::core_video::CVImageBuffer;
 use smallvec::SmallVec;
+use util::post_inc;
 
 use crate::{
-    hash, prelude::*, size, AnyElement, AnyTooltip, AppContext, Asset, AvailableSpace, Bounds,
-    BoxShadow, ContentMask, Corners, CursorStyle, DevicePixels, DispatchNodeId, DispatchPhase,
-    DispatchTree, DrawPhase, ElementId, ElementStateBox, EntityId, FocusHandle, FocusId, FontId,
-    GlobalElementId, GlyphId, Hsla, ImageData, InputHandler, IsZero, KeyContext, KeyEvent,
-    LayoutId, LineLayoutIndex, ModifiersChangedEvent, MonochromeSprite, MouseEvent, PaintQuad,
-    Path, Pixels, PlatformInputHandler, Point, PolychromeSprite, Quad, RenderGlyphParams,
-    RenderImageParams, RenderSvgParams, Scene, Shadow, SharedString, Size, StrikethroughStyle,
-    Style, Task, TextStyleRefinement, TransformationMatrix, Underline, UnderlineStyle, Window,
-    WindowContext, SUBPIXEL_VARIANTS,
+    hash, point, prelude::*, px, size, AnyElement, AnyTooltip, AppContext, Asset, AvailableSpace,
+    Bounds, BoxShadow, ContentMask, Corners, CursorStyle, DevicePixels, DispatchNodeId,
+    DispatchPhase, DispatchTree, DrawPhase, ElementId, ElementStateBox, EntityId, FocusHandle,
+    FocusId, FontId, GlobalElementId, GlyphId, Hsla, ImageData, InputHandler, IsZero, KeyContext,
+    KeyEvent, LayoutId, LineLayoutIndex, ModifiersChangedEvent, MonochromeSprite, MouseEvent,
+    PaintQuad, Path, Pixels, PlatformInputHandler, Point, PolychromeSprite, Quad,
+    RenderGlyphParams, RenderImageParams, RenderSvgParams, Scene, Shadow, SharedString, Size,
+    StrikethroughStyle, Style, Task, TextStyleRefinement, TransformationMatrix, Underline,
+    UnderlineStyle, Window, WindowContext, SUBPIXEL_VARIANTS,
 };
 
 pub(crate) type AnyMouseListener =
@@ -84,6 +85,33 @@ impl Hitbox {
 #[derive(Default, Eq, PartialEq)]
 pub(crate) struct HitTest(SmallVec<[HitboxId; 8]>);
 
+/// An identifier for a tooltip.
+#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)]
+pub struct TooltipId(usize);
+
+impl TooltipId {
+    /// Checks if the tooltip is currently hovered.
+    pub fn is_hovered(&self, cx: &WindowContext) -> bool {
+        cx.window
+            .tooltip_bounds
+            .as_ref()
+            .map_or(false, |tooltip_bounds| {
+                tooltip_bounds.id == *self && tooltip_bounds.bounds.contains(&cx.mouse_position())
+            })
+    }
+}
+
+pub(crate) struct TooltipBounds {
+    id: TooltipId,
+    bounds: Bounds<Pixels>,
+}
+
+#[derive(Clone)]
+pub(crate) struct TooltipRequest {
+    id: TooltipId,
+    tooltip: AnyTooltip,
+}
+
 pub(crate) struct DeferredDraw {
     priority: usize,
     parent_node: DispatchNodeId,
@@ -108,7 +136,7 @@ pub(crate) struct Frame {
     pub(crate) content_mask_stack: Vec<ContentMask<Pixels>>,
     pub(crate) element_offset_stack: Vec<Point<Pixels>>,
     pub(crate) input_handlers: Vec<Option<PlatformInputHandler>>,
-    pub(crate) tooltip_requests: Vec<Option<AnyTooltip>>,
+    pub(crate) tooltip_requests: Vec<Option<TooltipRequest>>,
     pub(crate) cursor_styles: Vec<CursorStyleRequest>,
     #[cfg(any(test, feature = "test-support"))]
     pub(crate) debug_bounds: FxHashMap<String, Bounds<Pixels>>,
@@ -364,6 +392,7 @@ impl<'a> VisualContext for ElementContext<'a> {
 impl<'a> ElementContext<'a> {
     pub(crate) fn draw_roots(&mut self) {
         self.window.draw_phase = DrawPhase::Layout;
+        self.window.tooltip_bounds.take();
 
         // Layout all root elements.
         let mut root_element = self.window.root_view.as_ref().unwrap().clone().into_any();
@@ -388,14 +417,8 @@ impl<'a> ElementContext<'a> {
             element.layout(offset, AvailableSpace::min_size(), self);
             active_drag_element = Some(element);
             self.app.active_drag = Some(active_drag);
-        } else if let Some(tooltip_request) =
-            self.window.next_frame.tooltip_requests.last().cloned()
-        {
-            let tooltip_request = tooltip_request.unwrap();
-            let mut element = tooltip_request.view.clone().into_any();
-            let offset = tooltip_request.cursor_offset;
-            element.layout(offset, AvailableSpace::min_size(), self);
-            tooltip_element = Some(element);
+        } else {
+            tooltip_element = self.layout_tooltip();
         }
 
         self.window.mouse_hit_test = self.window.next_frame.hit_test(self.window.mouse_position);
@@ -415,6 +438,52 @@ impl<'a> ElementContext<'a> {
         }
     }
 
+    fn layout_tooltip(&mut self) -> Option<AnyElement> {
+        let tooltip_request = self.window.next_frame.tooltip_requests.last().cloned()?;
+        let tooltip_request = tooltip_request.unwrap();
+        let mut element = tooltip_request.tooltip.view.clone().into_any();
+        let mouse_position = tooltip_request.tooltip.mouse_position;
+        let tooltip_size = element.measure(AvailableSpace::min_size(), self);
+
+        let mut tooltip_bounds = Bounds::new(mouse_position + point(px(1.), px(1.)), tooltip_size);
+        let window_bounds = Bounds {
+            origin: Point::default(),
+            size: self.viewport_size(),
+        };
+
+        if tooltip_bounds.right() > window_bounds.right() {
+            let new_x = mouse_position.x - tooltip_bounds.size.width - px(1.);
+            if new_x >= Pixels::ZERO {
+                tooltip_bounds.origin.x = new_x;
+            } else {
+                tooltip_bounds.origin.x = cmp::max(
+                    Pixels::ZERO,
+                    tooltip_bounds.origin.x - tooltip_bounds.right() - window_bounds.right(),
+                );
+            }
+        }
+
+        if tooltip_bounds.bottom() > window_bounds.bottom() {
+            let new_y = mouse_position.y - tooltip_bounds.size.height - px(1.);
+            if new_y >= Pixels::ZERO {
+                tooltip_bounds.origin.y = new_y;
+            } else {
+                tooltip_bounds.origin.y = cmp::max(
+                    Pixels::ZERO,
+                    tooltip_bounds.origin.y - tooltip_bounds.bottom() - window_bounds.bottom(),
+                );
+            }
+        }
+
+        self.with_absolute_element_offset(tooltip_bounds.origin, |cx| element.after_layout(cx));
+
+        self.window.tooltip_bounds = Some(TooltipBounds {
+            id: tooltip_request.id,
+            bounds: tooltip_bounds,
+        });
+        Some(element)
+    }
+
     fn layout_deferred_draws(&mut self, deferred_draw_indices: &[usize]) {
         assert_eq!(self.window.element_id_stack.len(), 0);
 
@@ -604,8 +673,13 @@ impl<'a> ElementContext<'a> {
     }
 
     /// Sets a tooltip to be rendered for the upcoming frame
-    pub fn set_tooltip(&mut self, tooltip: AnyTooltip) {
-        self.window.next_frame.tooltip_requests.push(Some(tooltip));
+    pub fn set_tooltip(&mut self, tooltip: AnyTooltip) -> TooltipId {
+        let id = TooltipId(post_inc(&mut self.window.next_tooltip_id.0));
+        self.window
+            .next_frame
+            .tooltip_requests
+            .push(Some(TooltipRequest { id, tooltip }));
+        id
     }
 
     /// Pushes the given element id onto the global stack and invokes the given closure

crates/ui/src/components/tooltip.rs 🔗

@@ -1,4 +1,4 @@
-use gpui::{anchored, Action, AnyView, IntoElement, Render, VisualContext};
+use gpui::{Action, AnyView, IntoElement, Render, VisualContext};
 use settings::Settings;
 use theme::ThemeSettings;
 
@@ -90,18 +90,17 @@ pub fn tooltip_container<V>(
     f: impl FnOnce(Div, &mut ViewContext<V>) -> Div,
 ) -> impl IntoElement {
     let ui_font = ThemeSettings::get_global(cx).ui_font.family.clone();
-    // padding to avoid mouse cursor
-    anchored().child(
-        div().pl_2().pt_2p5().child(
-            v_flex()
-                .elevation_2(cx)
-                .font(ui_font)
-                .text_ui()
-                .text_color(cx.theme().colors().text)
-                .py_1()
-                .px_2()
-                .map(|el| f(el, cx)),
-        ),
+
+    // padding to avoid tooltip appearing right below the mouse cursor
+    div().pl_2().pt_2p5().child(
+        v_flex()
+            .elevation_2(cx)
+            .font(ui_font)
+            .text_ui()
+            .text_color(cx.theme().colors().text)
+            .py_1()
+            .px_2()
+            .map(|el| f(el, cx)),
     )
 }