From ba285bbfec851f8be707432b0fbc5c35c86cc68e Mon Sep 17 00:00:00 2001 From: iam4x Date: Wed, 4 Nov 2026 13:25:58 +0400 Subject: [PATCH] text: Add pointer-following link tooltips --- Cargo.lock | 7 ++ Cargo.toml | 1 + crates/base/src/text/inline.rs | 100 +++++++++++++++++++++- crates/base/src/text/state.rs | 6 ++ crates/base/src/text/text_view.rs | 128 +++++++++++++++++++++++++++- crates/component/src/text/compat.rs | 11 ++- examples/link_tooltip/Cargo.toml | 14 +++ examples/link_tooltip/src/main.rs | 61 +++++++++++++ 8 files changed, 323 insertions(+), 5 deletions(-) create mode 100644 examples/link_tooltip/Cargo.toml create mode 100644 examples/link_tooltip/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index e0917cb21c..3f84bc11ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2889,6 +2889,13 @@ dependencies = [ "gpui-kit", ] +[[package]] +name = "example-link-tooltip" +version = "0.9.0" +dependencies = [ + "gpui-kit", +] + [[package]] name = "example-markdown" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index f5163b38fe..aa6246d223 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ members = [ "examples/system_monitor", "examples/focus_trap", "examples/tooltip_top_edge", + "examples/link_tooltip", "examples/sidebar", "examples/text_max_lines", "examples/text_selection", diff --git a/crates/base/src/text/inline.rs b/crates/base/src/text/inline.rs index bc900b6f75..af91162820 100644 --- a/crates/base/src/text/inline.rs +++ b/crates/base/src/text/inline.rs @@ -16,7 +16,7 @@ use std::{ }; use gpui::{ - App, BorderStyle, Bounds, ClickEvent, CursorStyle, Edges, Element, ElementId, + AnyTooltip, App, BorderStyle, Bounds, ClickEvent, CursorStyle, Edges, Element, ElementId, ForegroundExecutor, Global, GlobalElementId, Half, HighlightStyle, Hitbox, HitboxBehavior, Hsla, InspectorElementId, IntoElement, LayoutId, MouseButton, MouseClickEvent, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, SharedString, StyleRefinement, StyledText, Task, @@ -368,6 +368,7 @@ pub(super) struct Inline { /// The start of a pending reveal, when it is in this text. reveal: Option, link_click_handler: Option>, + link_tooltip: bool, /// What this frame's layout was shaped with, to hand the shaped text to /// the next frame (see [`RetainedLayout`]). retained_key: Option<(Vec, TextStyle)>, @@ -754,6 +755,7 @@ impl Inline { range_backgrounds: Vec::new(), reveal: None, link_click_handler, + link_tooltip: false, retained_key: None, handed_over: false, editor_piece: None, @@ -872,6 +874,62 @@ impl Inline { None } + fn prepaint_link_tooltip(&mut self, hitbox: &Hitbox, window: &mut Window, cx: &mut App) { + let Some(view) = GlobalState::global(cx).text_view_state() else { + return; + }; + let Some(builder) = view.read(cx).link_tooltip.clone() else { + return; + }; + self.link_tooltip = !self.links.is_empty(); + // Hit testing is unavailable during prepaint, as with native GPUI tooltips. + let position = window.mouse_position(); + if !self.link_tooltip + || window.last_input_was_keyboard() + || !hitbox.bounds.contains(&position) + || !hitbox.content_mask.bounds.contains(&position) + { + return; + } + let Some(link) = Self::link_for_position(self.styled_text.layout(), &self.links, position) + else { + return; + }; + let target = (state_key(&self.state), link.url.clone()); + if view.read(cx).link_tooltip_target.as_ref() != Some(&target) { + return; + } + let cached = view + .read(cx) + .hovered_link_tooltip + .as_ref() + .filter(|(url, _)| *url == link.url) + .map(|(_, tooltip)| tooltip.clone()); + let tooltip = match cached { + Some(tooltip) => tooltip, + None => { + let tooltip = builder(&link.url, window, cx); + view.update(cx, |state, _| { + state.hovered_link_tooltip = Some((link.url.clone(), tooltip.clone())); + }); + tooltip + } + }; + let bounds = hitbox.bounds.intersect(&hitbox.content_mask.bounds); + let layout = self.styled_text.layout().clone(); + let links = self.links.clone(); + window.set_tooltip(AnyTooltip { + view: tooltip, + mouse_position: position, + check_visible_and_update: Rc::new(move |_, window, _| { + let position = window.mouse_position(); + !window.last_input_was_keyboard() + && bounds.contains(&position) + && Self::link_for_position(&layout, &links, position).is_some() + }), + }); + } + /// Get the range of the link at given mouse position. fn link_range_for_position( layout: &TextLayout, @@ -1236,6 +1294,7 @@ impl Element for Inline { self.request_reveal(window); let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal); + self.prepaint_link_tooltip(&hitbox, window, cx); self.retain_styled_text(cx); hitbox } @@ -1307,6 +1366,34 @@ impl Element for Inline { window.set_cursor_style(CursorStyle::PointingHand, &hitbox); } + if self.link_tooltip + && let Some(view) = GlobalState::global(cx).text_view_state() + { + let key = state_key(&self.state); + let target = hovered_link.as_ref().and_then(|range| { + self.links + .iter() + .find(|(candidate, _)| candidate == range) + .map(|(_, link)| (key, link.url.clone())) + }); + let changed = view.update(cx, |state, _| { + let owns_target = state + .link_tooltip_target + .as_ref() + .is_some_and(|(owner, _)| *owner == key); + if (target.is_some() || owns_target) && state.link_tooltip_target != target { + state.link_tooltip_target = target; + true + } else { + false + } + }); + if changed { + cx.notify(current_view); + window.request_animation_frame(); + } + } + if let Some(selection) = &state.selection { let color = GlobalState::global(cx) .text_view_state() @@ -1449,15 +1536,22 @@ impl Element for Inline { let hitbox = hitbox.clone(); let text_layout = text_layout.clone(); let links = self.links.clone(); + let link_tooltip = self.link_tooltip; let mut hovered_link = hovered_link; move |event: &MouseMoveEvent, phase, window, cx| { - if !phase.bubble() || !hitbox.is_hovered(window) { + if !phase.bubble() { + return; + } + if !hitbox.is_hovered(window) { + if link_tooltip && hovered_link.take().is_some() { + cx.notify(current_view); + } return; } let updated = Self::link_range_for_position(&text_layout, &links, event.position); - if hovered_link != updated { + if hovered_link != updated || (link_tooltip && updated.is_some()) { hovered_link = updated; cx.notify(current_view); } diff --git a/crates/base/src/text/state.rs b/crates/base/src/text/state.rs index 82f0fbce22..016d4e80f4 100644 --- a/crates/base/src/text/state.rs +++ b/crates/base/src/text/state.rs @@ -131,6 +131,9 @@ pub struct TextViewState { pub(super) table_actions: Option>, pub(super) image_source: Option>, pub(super) link_click_handler: Option>, + pub(super) link_tooltip: Option>, + pub(super) hovered_link_tooltip: Option<(SharedString, gpui::AnyView)>, + pub(super) link_tooltip_target: Option<(usize, SharedString)>, pub(super) markdown_extensions: Arc, pub(super) is_selecting: bool, @@ -236,6 +239,9 @@ impl TextViewState { code_block_highlighter: None, table_actions: None, link_click_handler: None, + link_tooltip: None, + hovered_link_tooltip: None, + link_tooltip_target: None, image_source: None, markdown_extensions: Arc::default(), is_selecting: false, diff --git a/crates/base/src/text/text_view.rs b/crates/base/src/text/text_view.rs index 21cc84ade6..b720c2095d 100644 --- a/crates/base/src/text/text_view.rs +++ b/crates/base/src/text/text_view.rs @@ -2,7 +2,7 @@ use std::{ops::Range, rc::Rc, sync::Arc}; use gpui::prelude::FluentBuilder as _; use gpui::{ - AnyElement, App, Bounds, ClickEvent, ContentMask, Element, ElementId, Entity, Global, + AnyElement, AnyView, App, Bounds, ClickEvent, ContentMask, Element, ElementId, Entity, Global, GlobalElementId, Hitbox, HitboxBehavior, InspectorElementId, InteractiveElement, IntoElement, LayoutId, MouseButton, ParentElement, Pixels, Refineable as _, SharedString, StyleRefinement, Styled, Window, div, point, px, @@ -109,6 +109,9 @@ pub(crate) type ImageSourceFn = dyn Fn(&gpui::SharedUri) -> gpui::ImageSource + pub(crate) type LinkClickHandlerFn = dyn Fn(&SharedString, &ClickEvent, &mut Window, &mut App) + Send + Sync; +pub(crate) type LinkTooltipFn = + dyn Fn(&SharedString, &mut Window, &mut App) -> AnyView + Send + Sync; + /// Kept by the element only, so unlike the handlers the state carries, it /// may hold a `ScrollHandle`. pub(crate) type RevealHandlerFn = dyn Fn(Bounds, &mut Window, &mut App); @@ -168,6 +171,7 @@ pub struct TextView { code_block_highlighter: Option>, table_actions: Option>, link_click_handler: Option>, + link_tooltip: Option>, image_source: Option>, reveal_handler: Option>, markdown_extensions: Arc, @@ -218,6 +222,7 @@ impl TextView { code_block_highlighter: None, table_actions: None, link_click_handler: None, + link_tooltip: None, image_source: None, reveal_handler: None, markdown_extensions: Arc::default(), @@ -245,6 +250,7 @@ impl TextView { code_block_highlighter: None, table_actions: None, link_click_handler: None, + link_tooltip: None, image_source: None, reveal_handler: None, markdown_extensions: Arc::default(), @@ -272,6 +278,7 @@ impl TextView { code_block_highlighter: None, table_actions: None, link_click_handler: None, + link_tooltip: None, image_source: None, reveal_handler: None, markdown_extensions: Arc::default(), @@ -448,6 +455,18 @@ impl TextView { self } + /// Show a tooltip that follows the pointer while it is over a link. + /// + /// The builder receives the link's URL. The view caches the tooltip + /// until a different destination is shown. + pub fn link_tooltip(mut self, builder: F) -> Self + where + F: Fn(&SharedString, &mut Window, &mut App) -> AnyView + Send + Sync + 'static, + { + self.link_tooltip = Some(Arc::new(builder)); + self + } + /// Scroll a container that does not follow scroll requests to the line /// of [`TextViewState::reveal_range`]. /// @@ -754,6 +773,7 @@ impl Element for TextView { state.code_block_highlighter = code_block_highlighter; state.table_actions = self.table_actions.clone(); state.link_click_handler = self.link_click_handler.clone(); + state.link_tooltip = self.link_tooltip.clone(); state.image_source = self.image_source.clone(); state.set_markdown_extensions(self.markdown_extensions.clone(), cx); if let Some(motion) = &self.motion { @@ -3302,6 +3322,112 @@ mod tests { ); } + #[gpui::test] + fn link_tooltip_follows_the_pointer_and_clears_on_exit(cx: &mut TestAppContext) { + struct Preview; + impl Render for Preview { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + .debug_selector(|| "link-destination-preview".into()) + .w(px(180.)) + .h(px(24.)) + .child("/tmp/report.json") + } + } + struct Root { + view: Entity, + covered: bool, + } + impl Render for Root { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + .relative() + .child( + TextView::new(&self.view) + .w(px(160.)) + .link_tooltip(|_, _, cx| cx.new(|_| Preview).into()), + ) + .when(self.covered, |view| { + view.child(div().absolute().top_0().left_0().size_full().occlude()) + }) + } + } + cx.update(crate::init); + let (root, cx) = cx.add_window_view(|_, cx| Root { + covered: true, + view: cx.new(|cx| { + TextViewState::markdown("[preview destination with a label that wraps over several lines](file:///tmp/report.json)", cx) + }), + }); + cx.run_until_parked(); + cx.update(|window, cx| window.draw(cx).clear(cx)); + let rows = root.read_with(cx, |root, cx| { + root.view.read(cx).selection_adapter.text_bounds() + }); + assert!(rows.len() > 1, "the native link label wraps"); + let bounds = rows[0]; + let start = point(bounds.left() + px(2.), bounds.center().y); + cx.update(|window, cx| window.simulate_mouse_move(start, cx)); + cx.run_until_parked(); + cx.update(|window, cx| window.draw(cx).clear(cx)); + assert!( + cx.debug_bounds("link-destination-preview").is_none(), + "an occluded link cannot activate a preview" + ); + root.update(cx, |root, cx| { + root.covered = false; + cx.notify(); + }); + cx.run_until_parked(); + cx.update(|window, cx| window.draw(cx).clear(cx)); + assert_eq!( + cx.update(|window, cx| window.simulate_next_frame(cx)), + 1, + "a new hover requests the frame that shows its preview", + ); + cx.run_until_parked(); + cx.update(|window, cx| window.draw(cx).clear(cx)); + let first = cx + .debug_bounds("link-destination-preview") + .expect("hovering a native link paints its destination preview"); + assert_eq!( + cx.update(|window, cx| window.simulate_next_frame(cx)), + 0, + "an idle preview does not request more frames", + ); + cx.update(|window, cx| window.simulate_mouse_move(start + point(px(20.), px(0.)), cx)); + cx.run_until_parked(); + cx.update(|window, cx| window.draw(cx).clear(cx)); + let second = cx.debug_bounds("link-destination-preview").unwrap(); + assert_eq!(second.left() - first.left(), px(20.)); + assert_eq!(second.top(), first.top()); + cx.update(|window, cx| window.simulate_mouse_move(rows[1].center(), cx)); + cx.run_until_parked(); + cx.update(|window, cx| window.draw(cx).clear(cx)); + assert!(cx.debug_bounds("link-destination-preview").is_some()); + root.update(cx, |root, cx| { + root.covered = true; + cx.notify(); + }); + cx.run_until_parked(); + cx.update(|window, cx| window.draw(cx).clear(cx)); + assert!( + cx.debug_bounds("link-destination-preview").is_none(), + "covering an active link clears its preview" + ); + root.update(cx, |root, cx| { + root.covered = false; + cx.notify(); + }); + cx.run_until_parked(); + cx.update(|window, cx| { + window.simulate_mouse_move(point(bounds.right() + px(30.), bounds.center().y), cx) + }); + cx.run_until_parked(); + cx.update(|window, cx| window.draw(cx).clear(cx)); + assert!(cx.debug_bounds("link-destination-preview").is_none()); + } + #[gpui::test] fn markdown_link_opens_url_without_handler(cx: &mut TestAppContext) { cx.update(crate::init); diff --git a/crates/component/src/text/compat.rs b/crates/component/src/text/compat.rs index c9b8978c91..1bea0759ab 100644 --- a/crates/component/src/text/compat.rs +++ b/crates/component/src/text/compat.rs @@ -1,5 +1,5 @@ use gpui::{ - AnyElement, App, Bounds, ClickEvent, Element, ElementId, Entity, GlobalElementId, + AnyElement, AnyView, App, Bounds, ClickEvent, Element, ElementId, Entity, GlobalElementId, HighlightStyle, InspectorElementId, IntoElement, LayoutId, Pixels, Refineable as _, RenderOnce, SharedString, StyleRefinement, Styled, Window, }; @@ -185,6 +185,15 @@ impl TextView { self.inner = self.inner.on_link_click(f); self } + /// Shows an application-supplied tooltip beside the pointer over a link. + /// The tooltip is cached until a different destination is shown. + pub fn link_tooltip(mut self, builder: F) -> Self + where + F: Fn(&SharedString, &mut Window, &mut App) -> AnyView + Send + Sync + 'static, + { + self.inner = self.inner.link_tooltip(builder); + self + } /// Scrolls a container that ignores scroll requests to the line of /// `TextViewState::reveal_range`, with the line's window bounds. pub fn on_reveal(mut self, f: F) -> Self diff --git a/examples/link_tooltip/Cargo.toml b/examples/link_tooltip/Cargo.toml new file mode 100644 index 0000000000..ceb43f16a1 --- /dev/null +++ b/examples/link_tooltip/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "example-link-tooltip" +version = "0.9.0" +publish = false +edition.workspace = true + +[dependencies] +gpui-kit.workspace = true + +[lints] +workspace = true + +[features] +gpui-fast = ["gpui-kit/gpui-fast"] diff --git a/examples/link_tooltip/src/main.rs b/examples/link_tooltip/src/main.rs new file mode 100644 index 0000000000..00d8e9134d --- /dev/null +++ b/examples/link_tooltip/src/main.rs @@ -0,0 +1,61 @@ +use gpui_kit::component::{ + ActiveTheme as _, + text::{TextView, TextViewState}, + tooltip::Tooltip, +}; +use gpui_kit::{prelude::FluentBuilder as _, *}; + +struct LinkTooltipExample { + text: Entity, + show_preview: bool, +} + +impl Render for LinkTooltipExample { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .size_full() + .p_6() + .bg(cx.theme().background) + .text_color(cx.theme().foreground) + .child(TextView::new(&self.text).w_full().selectable(true).when( + self.show_preview, + |view| { + view.link_tooltip(|url, window, cx| { + let destination = url.clone(); + Tooltip::element(move |_, _| { + div().max_w(rems(32.)).child(destination.clone()) + }) + .build(window, cx) + }) + }, + )) + } +} + +fn main() { + let show_preview = !std::env::args().any(|arg| arg == "--no-preview"); + gpui_kit::application() + .with_assets(gpui_kit::assets::Assets) + .run(move |cx| { + gpui_kit::init(cx); + let options = WindowOptions { + app_id: Some("gpui-link-tooltip-example".into()), + window_bounds: Some(WindowBounds::centered(size(px(720.), px(360.)), cx)), + titlebar: Some(TitlebarOptions { + title: Some("Link previews".into()), + ..Default::default() + }), + ..Default::default() + }; + gpui_kit::open_window(options, cx, |_, cx| { + cx.new(|cx| LinkTooltipExample { + show_preview, + text: cx.new(|cx| TextViewState::markdown( + "# Reports\n\nMove the pointer along a link to inspect its destination.\n\n[Cleanup report](file:///home/demo/reports/cleanup-report.json)\n\n[Review the complete report with a link label that wraps across multiple lines and remains selectable](https://example.com/reports/2026/11/cleanup-report.json)\n\n[GPUI Kit documentation](https://gpui-kit.com/docs/getting-started)", + cx, + )), + }) + }) + .expect("open link preview example"); + }); +}