Link previews in chat
This commit is contained in:
parent
5dee8914ed
commit
23d991962a
6 changed files with 209 additions and 31 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
@ -6144,6 +6144,7 @@ dependencies = [
|
||||||
"smol",
|
"smol",
|
||||||
"sum_tree",
|
"sum_tree",
|
||||||
"theme",
|
"theme",
|
||||||
|
"ui",
|
||||||
"util",
|
"util",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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),
|
||||||
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue