diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 7f242a91ec..444efa4cf6 100644 --- a/crates/editor/src/element.rs +++ b/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(), diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 0aa160297a..87225e1972 100644 --- a/crates/gpui/src/app.rs +++ b/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, + /// The absolute position of the mouse when the tooltip was deployed. + pub mouse_position: Point, } /// A keystroke event, and potentially the associated action diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index b6c676cdb9..708aea464a 100644 --- a/crates/gpui/src/elements/div.rs +++ b/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; type CanDropPredicate = Box bool + 'static>; -pub(crate) type TooltipBuilder = Rc AnyView + 'static>; +pub(crate) struct TooltipBuilder { + build: Rc AnyView + 'static>, + hoverable: bool, +} pub(crate) type KeyDownListener = Box; @@ -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, + pub(crate) tooltip_id: Option, pub(crate) content_size: Size, pub(crate) key_context: Option, 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(); + } + } } }) } diff --git a/crates/gpui/src/elements/text.rs b/crates/gpui/src/elements/text.rs index 735e8dc858..4645404c29 100644 --- a/crates/gpui/src/elements/text.rs +++ b/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, } diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index ee2b4fdeb1..c6a0860656 100644 --- a/crates/gpui/src/window.rs +++ b/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, next_frame_callbacks: Rc>>, pub(crate) dirty_views: FxHashSet, pub(crate) focus_handles: Arc>>, @@ -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(), diff --git a/crates/gpui/src/window/element_cx.rs b/crates/gpui/src/window/element_cx.rs index 356a05a433..4a334ea9dc 100644 --- a/crates/gpui/src/window/element_cx.rs +++ b/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, +} + +#[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>, pub(crate) element_offset_stack: Vec>, pub(crate) input_handlers: Vec>, - pub(crate) tooltip_requests: Vec>, + pub(crate) tooltip_requests: Vec>, pub(crate) cursor_styles: Vec, #[cfg(any(test, feature = "test-support"))] pub(crate) debug_bounds: FxHashMap>, @@ -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 { + 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 diff --git a/crates/ui/src/components/tooltip.rs b/crates/ui/src/components/tooltip.rs index 293b1ba3fb..1ce25129ff 100644 --- a/crates/ui/src/components/tooltip.rs +++ b/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( f: impl FnOnce(Div, &mut ViewContext) -> 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)), ) }