Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"examples/system_monitor",
"examples/focus_trap",
"examples/tooltip_top_edge",
"examples/link_tooltip",
"examples/sidebar",
"examples/text_max_lines",
"examples/text_selection",
Expand All @@ -44,7 +45,7 @@
resolver = "2"

[workspace.package]
publish = false

Check warning on line 48 in Cargo.toml

View workflow job for this annotation

GitHub Actions / GPUI Fast (WebAssembly)

unused field `publish` in `workspace.package`
edition = "2024"

[workspace.dependencies]
Expand All @@ -54,9 +55,9 @@
gpui-component-macros = { path = "crates/component-macros", version = "0.9.0" }
gpui-kit-assets = { path = "crates/assets", version = "0.9.0" }
gpui-fps = { path = "crates/fps", version = "0.9.0" }
gpui-shell = { path = "crates/shell", version = "0.9.0" }

Check warning on line 58 in Cargo.toml

View workflow job for this annotation

GitHub Actions / GPUI Fast (WebAssembly)

unused workspace dependency `gpui-shell`
gpui-webview = { path = "crates/webview", version = "0.9.0" }
story = { path = "crates/story" }

Check warning on line 60 in Cargo.toml

View workflow job for this annotation

GitHub Actions / GPUI Fast (WebAssembly)

unused workspace dependency `story`

# GPUI comes from the `gpui-pre-*` snapshots of Zed's crates, and any snapshot
# may change GPUI's API. These requirements are what the published gpui-kit
Expand Down Expand Up @@ -113,9 +114,9 @@
] }
two-face = { version = "0.4.5", default-features = false, features = ["syntect-fancy"] }
tracing = "0.1.41"
wasm-bindgen = "0.2.113"

Check warning on line 117 in Cargo.toml

View workflow job for this annotation

GitHub Actions / GPUI Fast (WebAssembly)

unused workspace dependency `wasm-bindgen`
# Use errno stub for WASM
errno = { version = "0.3.14", default-features = false }

Check warning on line 119 in Cargo.toml

View workflow job for this annotation

GitHub Actions / GPUI Fast (WebAssembly)

unused workspace dependency `errno`

[workspace.dependencies.windows]
features = ["Wdk", "Wdk_System", "Wdk_System_SystemServices"]
Expand Down
100 changes: 97 additions & 3 deletions crates/base/src/text/inline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -368,6 +368,7 @@ pub(super) struct Inline {
/// The start of a pending reveal, when it is in this text.
reveal: Option<RevealAt>,
link_click_handler: Option<Arc<LinkClickHandlerFn>>,
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<TextRun>, TextStyle)>,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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);
}
Expand Down
6 changes: 6 additions & 0 deletions crates/base/src/text/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,9 @@ pub struct TextViewState {
pub(super) table_actions: Option<std::sync::Arc<TableActionsFn>>,
pub(super) image_source: Option<std::sync::Arc<super::text_view::ImageSourceFn>>,
pub(super) link_click_handler: Option<std::sync::Arc<LinkClickHandlerFn>>,
pub(super) link_tooltip: Option<std::sync::Arc<super::text_view::LinkTooltipFn>>,
pub(super) hovered_link_tooltip: Option<(SharedString, gpui::AnyView)>,
pub(super) link_tooltip_target: Option<(usize, SharedString)>,
pub(super) markdown_extensions: Arc<MarkdownExtensions>,

pub(super) is_selecting: bool,
Expand Down Expand Up @@ -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,
Expand Down
128 changes: 127 additions & 1 deletion crates/base/src/text/text_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<Pixels>, &mut Window, &mut App);
Expand Down Expand Up @@ -168,6 +171,7 @@ pub struct TextView {
code_block_highlighter: Option<Arc<CodeBlockHighlighterFn>>,
table_actions: Option<Arc<TableActionsFn>>,
link_click_handler: Option<Arc<LinkClickHandlerFn>>,
link_tooltip: Option<Arc<LinkTooltipFn>>,
image_source: Option<Arc<ImageSourceFn>>,
reveal_handler: Option<Rc<RevealHandlerFn>>,
markdown_extensions: Arc<MarkdownExtensions>,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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<F>(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`].
///
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<Self>) -> impl IntoElement {
div()
.debug_selector(|| "link-destination-preview".into())
.w(px(180.))
.h(px(24.))
.child("/tmp/report.json")
}
}
struct Root {
view: Entity<TextViewState>,
covered: bool,
}
impl Render for Root {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> 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);
Expand Down
Loading
Loading