Link previews in chat

Conrad Irwin created

Change summary

Cargo.lock                          |   1 
crates/gpui/src/elements/div.rs     |   6 
crates/gpui/src/elements/text.rs    | 128 +++++++++++++++++++++++++++++-
crates/rich_text/Cargo.toml         |   1 
crates/rich_text/src/rich_text.rs   |  13 +++
crates/ui/src/components/tooltip.rs |  91 ++++++++++++++++-----
6 files changed, 209 insertions(+), 31 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -6144,6 +6144,7 @@ dependencies = [
  "smol",
  "sum_tree",
  "theme",
+ "ui",
  "util",
 ]
 

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

@@ -24,7 +24,7 @@ use taffy::style::Overflow;
 use util::ResultExt;
 
 const DRAG_THRESHOLD: f64 = 2.;
-const TOOLTIP_DELAY: Duration = Duration::from_millis(500);
+pub(crate) const TOOLTIP_DELAY: Duration = Duration::from_millis(500);
 
 pub struct GroupStyle {
     pub group: SharedString,
@@ -1718,8 +1718,8 @@ pub struct InteractiveElementState {
 }
 
 pub struct ActiveTooltip {
-    tooltip: Option<AnyTooltip>,
-    _task: Option<Task<()>>,
+    pub(crate) tooltip: Option<AnyTooltip>,
+    pub(crate) _task: Option<Task<()>>,
 }
 
 /// Whether or not the element or a group that contains it is clicked by the mouse.

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

@@ -1,12 +1,18 @@
 use crate::{
-    Bounds, DispatchPhase, Element, ElementId, HighlightStyle, IntoElement, LayoutId,
-    MouseDownEvent, MouseUpEvent, Pixels, Point, SharedString, Size, TextRun, TextStyle,
-    WhiteSpace, WindowContext, WrappedLine,
+    ActiveTooltip, AnyTooltip, AnyView, Bounds, DispatchPhase, Element, ElementId, HighlightStyle,
+    IntoElement, LayoutId, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point,
+    SharedString, Size, TextRun, TextStyle, WhiteSpace, WindowContext, WrappedLine, TOOLTIP_DELAY,
 };
 use anyhow::anyhow;
 use parking_lot::{Mutex, MutexGuard};
 use smallvec::SmallVec;
-use std::{cell::Cell, mem, ops::Range, rc::Rc, sync::Arc};
+use std::{
+    cell::{Cell, RefCell},
+    mem,
+    ops::Range,
+    rc::Rc,
+    sync::Arc,
+};
 use util::ResultExt;
 
 impl Element for &'static str {
@@ -289,6 +295,8 @@ pub struct InteractiveText {
     text: StyledText,
     click_listener:
         Option<Box<dyn Fn(&[Range<usize>], InteractiveTextClickEvent, &mut WindowContext<'_>)>>,
+    hover_listener: Option<Box<dyn Fn(Option<usize>, MouseMoveEvent, &mut WindowContext<'_>)>>,
+    tooltip_builder: Option<Rc<dyn Fn(usize, &mut WindowContext<'_>) -> Option<AnyView>>>,
     clickable_ranges: Vec<Range<usize>>,
 }
 
@@ -300,18 +308,25 @@ struct InteractiveTextClickEvent {
 pub struct InteractiveTextState {
     text_state: TextState,
     mouse_down_index: Rc<Cell<Option<usize>>>,
+    hovered_index: Rc<Cell<Option<usize>>>,
+    active_tooltip: Rc<RefCell<Option<ActiveTooltip>>>,
 }
 
+/// InteractiveTest is a wrapper around StyledText that adds mouse interactions.
 impl InteractiveText {
     pub fn new(id: impl Into<ElementId>, text: StyledText) -> Self {
         Self {
             element_id: id.into(),
             text,
             click_listener: None,
+            hover_listener: None,
+            tooltip_builder: None,
             clickable_ranges: Vec::new(),
         }
     }
 
+    /// on_click is called when the user clicks on one of the given ranges, passing the index of
+    /// the clicked range.
     pub fn on_click(
         mut self,
         ranges: Vec<Range<usize>>,
@@ -328,6 +343,25 @@ impl InteractiveText {
         self.clickable_ranges = ranges;
         self
     }
+
+    /// on_hover is called when the mouse moves over a character within the text, passing the
+    /// index of the hovered character, or None if the mouse leaves the text.
+    pub fn on_hover(
+        mut self,
+        listener: impl Fn(Option<usize>, MouseMoveEvent, &mut WindowContext<'_>) + 'static,
+    ) -> Self {
+        self.hover_listener = Some(Box::new(listener));
+        self
+    }
+
+    /// tooltip lets you specify a tooltip for a given character index in the string.
+    pub fn tooltip(
+        mut self,
+        builder: impl Fn(usize, &mut WindowContext<'_>) -> Option<AnyView> + 'static,
+    ) -> Self {
+        self.tooltip_builder = Some(Rc::new(builder));
+        self
+    }
 }
 
 impl Element for InteractiveText {
@@ -339,13 +373,18 @@ impl Element for InteractiveText {
         cx: &mut WindowContext,
     ) -> (LayoutId, Self::State) {
         if let Some(InteractiveTextState {
-            mouse_down_index, ..
+            mouse_down_index,
+            hovered_index,
+            active_tooltip,
+            ..
         }) = state
         {
             let (layout_id, text_state) = self.text.request_layout(None, cx);
             let element_state = InteractiveTextState {
                 text_state,
                 mouse_down_index,
+                hovered_index,
+                active_tooltip,
             };
             (layout_id, element_state)
         } else {
@@ -353,6 +392,8 @@ impl Element for InteractiveText {
             let element_state = InteractiveTextState {
                 text_state,
                 mouse_down_index: Rc::default(),
+                hovered_index: Rc::default(),
+                active_tooltip: Rc::default(),
             };
             (layout_id, element_state)
         }
@@ -408,6 +449,83 @@ impl Element for InteractiveText {
                 });
             }
         }
+        if let Some(hover_listener) = self.hover_listener.take() {
+            let text_state = state.text_state.clone();
+            let hovered_index = state.hovered_index.clone();
+            cx.on_mouse_event(move |event: &MouseMoveEvent, phase, cx| {
+                if phase == DispatchPhase::Bubble {
+                    let current = hovered_index.get();
+                    let updated = text_state.index_for_position(bounds, event.position);
+                    if current != updated {
+                        hovered_index.set(updated);
+                        hover_listener(updated, event.clone(), cx);
+                        cx.refresh();
+                    }
+                }
+            });
+        }
+        if let Some(tooltip_builder) = self.tooltip_builder.clone() {
+            let active_tooltip = state.active_tooltip.clone();
+            let pending_mouse_down = state.mouse_down_index.clone();
+            let text_state = state.text_state.clone();
+
+            cx.on_mouse_event(move |event: &MouseMoveEvent, phase, cx| {
+                let position = text_state.index_for_position(bounds, event.position);
+                let is_hovered = position.is_some() && pending_mouse_down.get().is_none();
+                if !is_hovered {
+                    active_tooltip.take();
+                    return;
+                }
+                let position = position.unwrap();
+
+                if phase != DispatchPhase::Bubble {
+                    return;
+                }
+
+                if active_tooltip.borrow().is_none() {
+                    let task = cx.spawn({
+                        let active_tooltip = active_tooltip.clone();
+                        let tooltip_builder = tooltip_builder.clone();
+
+                        move |mut cx| async move {
+                            cx.background_executor().timer(TOOLTIP_DELAY).await;
+                            cx.update(|_, cx| {
+                                let new_tooltip =
+                                    tooltip_builder(position, cx).map(|tooltip| ActiveTooltip {
+                                        tooltip: Some(AnyTooltip {
+                                            view: tooltip,
+                                            cursor_offset: cx.mouse_position(),
+                                        }),
+                                        _task: None,
+                                    });
+                                *active_tooltip.borrow_mut() = new_tooltip;
+                                cx.refresh();
+                            })
+                            .ok();
+                        }
+                    });
+                    *active_tooltip.borrow_mut() = Some(ActiveTooltip {
+                        tooltip: None,
+                        _task: Some(task),
+                    });
+                }
+            });
+
+            let active_tooltip = state.active_tooltip.clone();
+            cx.on_mouse_event(move |_: &MouseDownEvent, _, _| {
+                active_tooltip.take();
+            });
+
+            if let Some(tooltip) = state
+                .active_tooltip
+                .clone()
+                .borrow()
+                .as_ref()
+                .and_then(|at| at.tooltip.clone())
+            {
+                cx.set_tooltip(tooltip);
+            }
+        }
 
         self.text.paint(bounds, &mut state.text_state, cx)
     }

crates/rich_text/Cargo.toml 🔗

@@ -21,6 +21,7 @@ sum_tree = { path = "../sum_tree" }
 theme = { path = "../theme" }
 language = { path = "../language" }
 util = { path = "../util" }
+ui = { path = "../ui" }
 anyhow.workspace = true
 futures.workspace = true
 lazy_static.workspace = true

crates/rich_text/src/rich_text.rs 🔗

@@ -6,6 +6,7 @@ use gpui::{
 use language::{HighlightId, Language, LanguageRegistry};
 use std::{ops::Range, sync::Arc};
 use theme::ActiveTheme;
+use ui::LinkPreview;
 use util::RangeExt;
 
 #[derive(Debug, Clone, PartialEq, Eq)]
@@ -84,6 +85,18 @@ impl RichText {
             let link_urls = self.link_urls.clone();
             move |ix, cx| cx.open_url(&link_urls[ix])
         })
+        .tooltip({
+            let link_ranges = self.link_ranges.clone();
+            let link_urls = self.link_urls.clone();
+            move |idx, cx| {
+                for (ix, range) in link_ranges.iter().enumerate() {
+                    if range.contains(&idx) {
+                        return Some(LinkPreview::new(&link_urls[ix], cx));
+                    }
+                }
+                None
+            }
+        })
         .into_any_element()
     }
 }

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

@@ -69,29 +69,74 @@ impl Tooltip {
 
 impl Render for Tooltip {
     fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
-        let ui_font = ThemeSettings::get_global(cx).ui_font.family.clone();
-        overlay().child(
-            // padding to avoid 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()
-                    .child(
-                        h_flex()
-                            .gap_4()
-                            .child(self.title.clone())
-                            .when_some(self.key_binding.clone(), |this, key_binding| {
-                                this.justify_between().child(key_binding)
-                            }),
-                    )
-                    .when_some(self.meta.clone(), |this, meta| {
-                        this.child(Label::new(meta).size(LabelSize::Small).color(Color::Muted))
+        tooltip_container(cx, |el, _| {
+            el.child(
+                h_flex()
+                    .gap_4()
+                    .child(self.title.clone())
+                    .when_some(self.key_binding.clone(), |this, key_binding| {
+                        this.justify_between().child(key_binding)
                     }),
-            ),
-        )
+            )
+            .when_some(self.meta.clone(), |this, meta| {
+                this.child(Label::new(meta).size(LabelSize::Small).color(Color::Muted))
+            })
+        })
+    }
+}
+
+fn tooltip_container<V>(
+    cx: &mut ViewContext<V>,
+    f: impl FnOnce(Div, &mut ViewContext<V>) -> Div,
+) -> impl IntoElement {
+    let ui_font = ThemeSettings::get_global(cx).ui_font.family.clone();
+    overlay().child(
+        // padding to avoid 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)),
+        ),
+    )
+}
+
+pub struct LinkPreview {
+    link: SharedString,
+}
+
+impl LinkPreview {
+    pub fn new(url: &str, cx: &mut WindowContext) -> AnyView {
+        let mut wrapped_url = String::new();
+        for (i, ch) in url.chars().enumerate() {
+            if i == 500 {
+                wrapped_url.push('…');
+                break;
+            }
+            if i % 100 == 0 && i != 0 {
+                wrapped_url.push('\n');
+            }
+            wrapped_url.push(ch);
+        }
+        cx.new_view(|_cx| LinkPreview {
+            link: wrapped_url.into(),
+        })
+        .into()
+    }
+}
+
+impl Render for LinkPreview {
+    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
+        tooltip_container(cx, |el, _| {
+            el.child(
+                Label::new(self.link.clone())
+                    .size(LabelSize::XSmall)
+                    .color(Color::Muted),
+            )
+        })
     }
 }