Cargo.lock 🔗
@@ -6144,6 +6144,7 @@ dependencies = [
"smol",
"sum_tree",
"theme",
+ "ui",
"util",
]
Conrad Irwin created
Cargo.lock | 1
crates/gpui/src/elements/div.rs | 6
crates/gpui/src/elements/text.rs | 128 +++++++++++++++++++++++++++++-
crates/rich_text/Cargo.toml | 1
crates/rich_text/src/rich_text.rs | 13 +++
crates/ui/src/components/tooltip.rs | 91 ++++++++++++++++-----
6 files changed, 209 insertions(+), 31 deletions(-)
@@ -6144,6 +6144,7 @@ dependencies = [
"smol",
"sum_tree",
"theme",
+ "ui",
"util",
]
@@ -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.
@@ -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)
}
@@ -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
@@ -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()
}
}
@@ -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),
+ )
+ })
}
}