Link previews in chat

This commit is contained in:
Conrad Irwin 2024-01-19 16:59:17 -07:00
parent 5dee8914ed
commit 23d991962a
6 changed files with 209 additions and 31 deletions

1
Cargo.lock generated
View file

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

View file

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

View file

@ -1,12 +1,18 @@
use crate::{ use crate::{
Bounds, DispatchPhase, Element, ElementId, HighlightStyle, IntoElement, LayoutId, ActiveTooltip, AnyTooltip, AnyView, Bounds, DispatchPhase, Element, ElementId, HighlightStyle,
MouseDownEvent, MouseUpEvent, Pixels, Point, SharedString, Size, TextRun, TextStyle, IntoElement, LayoutId, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point,
WhiteSpace, WindowContext, WrappedLine, SharedString, Size, TextRun, TextStyle, WhiteSpace, WindowContext, WrappedLine, TOOLTIP_DELAY,
}; };
use anyhow::anyhow; use anyhow::anyhow;
use parking_lot::{Mutex, MutexGuard}; use parking_lot::{Mutex, MutexGuard};
use smallvec::SmallVec; 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; use util::ResultExt;
impl Element for &'static str { impl Element for &'static str {
@ -289,6 +295,8 @@ pub struct InteractiveText {
text: StyledText, text: StyledText,
click_listener: click_listener:
Option<Box<dyn Fn(&[Range<usize>], InteractiveTextClickEvent, &mut WindowContext<'_>)>>, 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>>, clickable_ranges: Vec<Range<usize>>,
} }
@ -300,18 +308,25 @@ struct InteractiveTextClickEvent {
pub struct InteractiveTextState { pub struct InteractiveTextState {
text_state: TextState, text_state: TextState,
mouse_down_index: Rc<Cell<Option<usize>>>, 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 { impl InteractiveText {
pub fn new(id: impl Into<ElementId>, text: StyledText) -> Self { pub fn new(id: impl Into<ElementId>, text: StyledText) -> Self {
Self { Self {
element_id: id.into(), element_id: id.into(),
text, text,
click_listener: None, click_listener: None,
hover_listener: None,
tooltip_builder: None,
clickable_ranges: Vec::new(), 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( pub fn on_click(
mut self, mut self,
ranges: Vec<Range<usize>>, ranges: Vec<Range<usize>>,
@ -328,6 +343,25 @@ impl InteractiveText {
self.clickable_ranges = ranges; self.clickable_ranges = ranges;
self 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 { impl Element for InteractiveText {
@ -339,13 +373,18 @@ impl Element for InteractiveText {
cx: &mut WindowContext, cx: &mut WindowContext,
) -> (LayoutId, Self::State) { ) -> (LayoutId, Self::State) {
if let Some(InteractiveTextState { if let Some(InteractiveTextState {
mouse_down_index, .. mouse_down_index,
hovered_index,
active_tooltip,
..
}) = state }) = state
{ {
let (layout_id, text_state) = self.text.request_layout(None, cx); let (layout_id, text_state) = self.text.request_layout(None, cx);
let element_state = InteractiveTextState { let element_state = InteractiveTextState {
text_state, text_state,
mouse_down_index, mouse_down_index,
hovered_index,
active_tooltip,
}; };
(layout_id, element_state) (layout_id, element_state)
} else { } else {
@ -353,6 +392,8 @@ impl Element for InteractiveText {
let element_state = InteractiveTextState { let element_state = InteractiveTextState {
text_state, text_state,
mouse_down_index: Rc::default(), mouse_down_index: Rc::default(),
hovered_index: Rc::default(),
active_tooltip: Rc::default(),
}; };
(layout_id, element_state) (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) self.text.paint(bounds, &mut state.text_state, cx)
} }

View file

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

View file

@ -6,6 +6,7 @@ use gpui::{
use language::{HighlightId, Language, LanguageRegistry}; use language::{HighlightId, Language, LanguageRegistry};
use std::{ops::Range, sync::Arc}; use std::{ops::Range, sync::Arc};
use theme::ActiveTheme; use theme::ActiveTheme;
use ui::LinkPreview;
use util::RangeExt; use util::RangeExt;
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@ -84,6 +85,18 @@ impl RichText {
let link_urls = self.link_urls.clone(); let link_urls = self.link_urls.clone();
move |ix, cx| cx.open_url(&link_urls[ix]) 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() .into_any_element()
} }
} }

View file

@ -69,29 +69,74 @@ impl Tooltip {
impl Render for Tooltip { impl Render for Tooltip {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement { fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
let ui_font = ThemeSettings::get_global(cx).ui_font.family.clone(); tooltip_container(cx, |el, _| {
overlay().child( el.child(
// padding to avoid mouse cursor h_flex()
div().pl_2().pt_2p5().child( .gap_4()
v_flex() .child(self.title.clone())
.elevation_2(cx) .when_some(self.key_binding.clone(), |this, key_binding| {
.font(ui_font) this.justify_between().child(key_binding)
.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))
}), }),
), )
) .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),
)
})
} }
} }