Introduce a new `Tooltip` element and a `with_tooltip` helper

Antonio Scandurra created

Change summary

crates/gpui/src/elements.rs                     | 15 +++
crates/gpui/src/elements/mouse_event_handler.rs | 12 ++
crates/gpui/src/elements/tooltip.rs             | 84 ++++++++++++++----
crates/gpui/src/presenter.rs                    | 22 +++-
crates/gpui/src/scene.rs                        |  2 
5 files changed, 108 insertions(+), 27 deletions(-)

Detailed changes

crates/gpui/src/elements.rs 🔗

@@ -31,7 +31,8 @@ use crate::{
         rect::RectF,
         vector::{vec2f, Vector2F},
     },
-    json, DebugContext, Event, EventContext, LayoutContext, PaintContext, SizeConstraint,
+    json, DebugContext, Event, EventContext, LayoutContext, PaintContext, RenderContext,
+    SizeConstraint, View,
 };
 use core::panic;
 use json::ToJson;
@@ -155,6 +156,18 @@ pub trait Element {
     {
         FlexItem::new(self.boxed()).float()
     }
+
+    fn with_tooltip<T: View>(
+        self,
+        id: usize,
+        tooltip: ElementBox,
+        cx: &mut RenderContext<T>,
+    ) -> Tooltip
+    where
+        Self: 'static + Sized,
+    {
+        Tooltip::new(id, self.boxed(), tooltip, cx)
+    }
 }
 
 pub enum Lifecycle<T: Element> {

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

@@ -25,6 +25,7 @@ pub struct MouseEventHandler {
     mouse_down_out: Option<Rc<dyn Fn(Vector2F, &mut EventContext)>>,
     right_mouse_down_out: Option<Rc<dyn Fn(Vector2F, &mut EventContext)>>,
     drag: Option<Rc<dyn Fn(Vector2F, &mut EventContext)>>,
+    hover: Option<Rc<dyn Fn(Vector2F, bool, &mut EventContext)>>,
     padding: Padding,
 }
 
@@ -47,6 +48,7 @@ impl MouseEventHandler {
             mouse_down_out: None,
             right_mouse_down_out: None,
             drag: None,
+            hover: None,
             padding: Default::default(),
         }
     }
@@ -109,6 +111,14 @@ impl MouseEventHandler {
         self
     }
 
+    pub fn on_hover(
+        mut self,
+        handler: impl Fn(Vector2F, bool, &mut EventContext) + 'static,
+    ) -> Self {
+        self.hover = Some(Rc::new(handler));
+        self
+    }
+
     pub fn with_padding(mut self, padding: Padding) -> Self {
         self.padding = padding;
         self
@@ -153,7 +163,7 @@ impl Element for MouseEventHandler {
             view_id: cx.current_view_id(),
             discriminant: Some((self.tag, self.id)),
             bounds: self.hit_bounds(bounds),
-            hover: None,
+            hover: self.hover.clone(),
             click: self.click.clone(),
             mouse_down: self.mouse_down.clone(),
             right_click: self.right_click.clone(),

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

@@ -1,39 +1,78 @@
-use super::{ContainerStyle, Element, ElementBox};
+use std::{
+    cell::{Cell, RefCell},
+    rc::Rc,
+    time::Duration,
+};
+
+use super::{Element, ElementBox, MouseEventHandler};
 use crate::{
     geometry::{rect::RectF, vector::Vector2F},
-    json::{json, ToJson},
-    ElementStateHandle, LayoutContext, PaintContext, RenderContext, SizeConstraint, View,
+    json::json,
+    ElementStateHandle, LayoutContext, PaintContext, RenderContext, SizeConstraint, Task, View,
 };
 
+const DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(500);
+
 pub struct Tooltip {
-    state: ElementStateHandle<TooltipState>,
     child: ElementBox,
-    style: ContainerStyle,
-    text: String,
+    tooltip: Option<ElementBox>,
+    state: ElementStateHandle<Rc<TooltipState>>,
 }
 
 #[derive(Default)]
-struct TooltipState {}
+struct TooltipState {
+    visible: Cell<bool>,
+    position: Cell<Vector2F>,
+    debounce: RefCell<Option<Task<()>>>,
+}
 
 impl Tooltip {
     pub fn new<T: View>(
         id: usize,
         child: ElementBox,
-        text: String,
+        tooltip: ElementBox,
         cx: &mut RenderContext<T>,
     ) -> Self {
+        let state_handle = cx.element_state::<TooltipState, Rc<TooltipState>>(id);
+        let state = state_handle.read(cx).clone();
+        let tooltip = if state.visible.get() {
+            Some(tooltip)
+        } else {
+            None
+        };
+        let child = MouseEventHandler::new::<Self, _, _>(id, cx, |_, _| child)
+            .on_hover(move |position, hover, cx| {
+                let window_id = cx.window_id();
+                if let Some(view_id) = cx.view_id() {
+                    if hover {
+                        if !state.visible.get() {
+                            state.position.set(position);
+
+                            let mut debounce = state.debounce.borrow_mut();
+                            if debounce.is_none() {
+                                *debounce = Some(cx.spawn({
+                                    let state = state.clone();
+                                    |mut cx| async move {
+                                        cx.background().timer(DEBOUNCE_TIMEOUT).await;
+                                        state.visible.set(true);
+                                        cx.update(|cx| cx.notify_view(window_id, view_id));
+                                    }
+                                }));
+                            }
+                        }
+                    } else {
+                        state.visible.set(false);
+                        state.debounce.take();
+                    }
+                }
+            })
+            .boxed();
         Self {
-            state: cx.element_state::<Self, _>(id),
             child,
-            text,
-            style: Default::default(),
+            tooltip,
+            state: state_handle,
         }
     }
-
-    pub fn with_style(mut self, style: ContainerStyle) -> Self {
-        self.style = style;
-        self
-    }
 }
 
 impl Element for Tooltip {
@@ -46,6 +85,9 @@ impl Element for Tooltip {
         cx: &mut LayoutContext,
     ) -> (Vector2F, Self::LayoutState) {
         let size = self.child.layout(constraint, cx);
+        if let Some(tooltip) = self.tooltip.as_mut() {
+            tooltip.layout(SizeConstraint::new(Vector2F::zero(), cx.window_size), cx);
+        }
         (size, ())
     }
 
@@ -57,6 +99,13 @@ impl Element for Tooltip {
         cx: &mut PaintContext,
     ) {
         self.child.paint(bounds.origin(), visible_bounds, cx);
+        if let Some(tooltip) = self.tooltip.as_mut() {
+            let origin = self.state.read(cx).position.get();
+            let size = tooltip.size();
+            cx.scene.push_stacking_context(None);
+            tooltip.paint(origin, RectF::new(origin, size), cx);
+            cx.scene.pop_stacking_context();
+        }
     }
 
     fn dispatch_event(
@@ -80,8 +129,7 @@ impl Element for Tooltip {
     ) -> serde_json::Value {
         json!({
             "child": self.child.debug(cx),
-            "style": self.style.to_json(),
-            "text": &self.text,
+            "tooltip": self.tooltip.as_ref().map(|t| t.debug(cx)),
         })
     }
 }

crates/gpui/src/presenter.rs 🔗

@@ -311,7 +311,7 @@ impl Presenter {
                                 if let Some(region_id) = region.id() {
                                     if !self.hovered_region_ids.contains(&region_id) {
                                         invalidated_views.push(region.view_id);
-                                        hovered_regions.push(region.clone());
+                                        hovered_regions.push((region.clone(), position));
                                         self.hovered_region_ids.insert(region_id);
                                     }
                                 }
@@ -319,7 +319,7 @@ impl Presenter {
                                 if let Some(region_id) = region.id() {
                                     if self.hovered_region_ids.contains(&region_id) {
                                         invalidated_views.push(region.view_id);
-                                        unhovered_regions.push(region.clone());
+                                        unhovered_regions.push((region.clone(), position));
                                         self.hovered_region_ids.remove(&region_id);
                                     }
                                 }
@@ -348,20 +348,20 @@ impl Presenter {
 
             let mut event_cx = self.build_event_context(cx);
             let mut handled = false;
-            for unhovered_region in unhovered_regions {
+            for (unhovered_region, position) in unhovered_regions {
                 handled = true;
                 if let Some(hover_callback) = unhovered_region.hover {
                     event_cx.with_current_view(unhovered_region.view_id, |event_cx| {
-                        hover_callback(false, event_cx);
+                        hover_callback(position, false, event_cx);
                     })
                 }
             }
 
-            for hovered_region in hovered_regions {
+            for (hovered_region, position) in hovered_regions {
                 handled = true;
                 if let Some(hover_callback) = hovered_region.hover {
                     event_cx.with_current_view(hovered_region.view_id, |event_cx| {
-                        hover_callback(true, event_cx);
+                        hover_callback(position, true, event_cx);
                     })
                 }
             }
@@ -449,6 +449,7 @@ impl Presenter {
             view_stack: Default::default(),
             invalidated_views: Default::default(),
             notify_count: 0,
+            window_id: self.window_id,
             app: cx,
         }
     }
@@ -626,6 +627,7 @@ pub struct EventContext<'a> {
     pub font_cache: &'a FontCache,
     pub text_layout_cache: &'a TextLayoutCache,
     pub app: &'a mut MutableAppContext,
+    pub window_id: usize,
     pub notify_count: usize,
     view_stack: Vec<usize>,
     invalidated_views: HashSet<usize>,
@@ -653,6 +655,14 @@ impl<'a> EventContext<'a> {
         result
     }
 
+    pub fn window_id(&self) -> usize {
+        self.window_id
+    }
+
+    pub fn view_id(&self) -> Option<usize> {
+        self.view_stack.last().copied()
+    }
+
     pub fn dispatch_any_action(&mut self, action: Box<dyn Action>) {
         self.dispatched_actions.push(DispatchDirective {
             dispatcher_view_id: self.view_stack.last().copied(),

crates/gpui/src/scene.rs 🔗

@@ -49,7 +49,7 @@ pub struct MouseRegion {
     pub view_id: usize,
     pub discriminant: Option<(TypeId, usize)>,
     pub bounds: RectF,
-    pub hover: Option<Rc<dyn Fn(bool, &mut EventContext)>>,
+    pub hover: Option<Rc<dyn Fn(Vector2F, bool, &mut EventContext)>>,
     pub mouse_down: Option<Rc<dyn Fn(Vector2F, &mut EventContext)>>,
     pub click: Option<Rc<dyn Fn(Vector2F, usize, &mut EventContext)>>,
     pub right_mouse_down: Option<Rc<dyn Fn(Vector2F, &mut EventContext)>>,