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",
"sum_tree",
"theme",
"ui",
"util",
]

View file

@ -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.

View file

@ -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)
}

View file

@ -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

View file

@ -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()
}
}

View file

@ -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),
)
})
}
}