@@ -1,12 +1,13 @@
use crate::{
ActiveDiagnostic, Anchor, AnchorRangeExt, DisplayPoint, DisplayRow, Editor, EditorSettings,
- EditorSnapshot, GlobalDiagnosticRenderer, HighlightKey, Hover,
+ EditorSnapshot, GlobalDiagnosticRenderer, GotoDefinitionKind, HighlightKey, Hover,
display_map::{InlayOffset, ToDisplayPoint, is_invisible},
- hover_links::{InlayHighlight, RangeInEditor},
+ hover_links::{HoverLink, InlayHighlight, RangeInEditor},
movement::TextLayoutDetails,
scroll::ScrollAmount,
};
use anyhow::Context as _;
+use collections::{HashMap, HashSet};
use gpui::{
AnyElement, AsyncWindowContext, Context, Entity, Focusable as _, FontWeight, Hsla,
InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, ScrollHandle, Size,
@@ -18,7 +19,7 @@ use language::{DiagnosticEntry, Language, LanguageRegistry};
use lsp::DiagnosticSeverity;
use markdown::{Markdown, MarkdownElement, MarkdownStyle};
use multi_buffer::{MultiBufferOffset, ToOffset, ToPoint};
-use project::{HoverBlock, HoverBlockKind, InlayHintLabelPart};
+use project::{HoverBlock, HoverBlockKind, InlayHintLabelPart, LocationLink};
use settings::Settings;
use std::{borrow::Cow, cell::RefCell};
use std::{ops::Range, sync::Arc, time::Duration};
@@ -187,6 +188,7 @@ pub fn hover_at_inlay(
scroll_handle,
keyboard_grace: Rc::new(RefCell::new(false)),
anchor: None,
+ type_definitions: Default::default(),
_subscription: subscription,
};
@@ -306,6 +308,9 @@ fn show_hover(
};
let hover_request = cx.update(|_, cx| provider.hover(&buffer, buffer_position, cx))?;
+ let type_def_request = cx.update(|_, cx| {
+ provider.definitions(&buffer, buffer_position, GotoDefinitionKind::Type, cx)
+ })?;
if let Some(delay) = delay {
delay.await;
@@ -438,6 +443,29 @@ fn show_hover(
} else {
Vec::new()
};
+
+ let type_definitions = if let Some(type_def_request) = type_def_request {
+ type_def_request.await.ok().flatten().unwrap_or_default()
+ } else {
+ Vec::new()
+ };
+ let type_def_map: HashMap<SharedString, LocationLink> = cx.update(|_, cx| {
+ let mut map = HashMap::default();
+ for link in type_definitions {
+ let name: String = link
+ .target
+ .buffer
+ .read(cx)
+ .text_for_range(link.target.range.clone())
+ .collect();
+ if !name.is_empty() {
+ map.insert(SharedString::from(name), link);
+ }
+ }
+ map
+ })?;
+ let type_def_map = Rc::new(type_def_map);
+
let snapshot = this.update_in(cx, |this, window, cx| this.snapshot(window, cx))?;
let mut hover_highlights = Vec::with_capacity(hovers_response.len());
let mut info_popovers = Vec::with_capacity(
@@ -466,6 +494,7 @@ fn show_hover(
scroll_handle,
keyboard_grace: Rc::new(RefCell::new(ignore_timeout)),
anchor: Some(anchor),
+ type_definitions: Default::default(),
_subscription: subscription,
})
}
@@ -507,6 +536,7 @@ fn show_hover(
scroll_handle,
keyboard_grace: Rc::new(RefCell::new(ignore_timeout)),
anchor: Some(anchor),
+ type_definitions: type_def_map.clone(),
_subscription: subscription,
});
}
@@ -772,6 +802,37 @@ pub fn open_markdown_url(link: SharedString, window: &mut Window, cx: &mut App)
cx.open_url(&link);
}
+fn navigate_to_hover_type(
+ editor: gpui::WeakEntity<Editor>,
+ type_definitions: &HashMap<SharedString, LocationLink>,
+ word: SharedString,
+ window: &mut Window,
+ cx: &mut App,
+) {
+ let Some(location_link) = type_definitions.get(&word).cloned() else {
+ return;
+ };
+ let Some(editor) = editor.upgrade() else {
+ return;
+ };
+ editor.update(cx, |editor, cx| {
+ let nav_entry = editor
+ .hover_state
+ .triggered_from
+ .and_then(|anchor| editor.navigation_entry(anchor, cx));
+ editor
+ .navigate_to_hover_links(
+ Some(GotoDefinitionKind::Type),
+ vec![HoverLink::Text(location_link)],
+ nav_entry,
+ false,
+ window,
+ cx,
+ )
+ .detach_and_log_err(cx);
+ });
+}
+
#[derive(Default)]
pub struct HoverState {
pub info_popovers: Vec<InfoPopover>,
@@ -887,6 +948,7 @@ pub struct InfoPopover {
pub scroll_handle: ScrollHandle,
pub keyboard_grace: Rc<RefCell<bool>>,
pub anchor: Option<Anchor>,
+ pub type_definitions: Rc<HashMap<SharedString, LocationLink>>,
_subscription: Option<Subscription>,
}
@@ -911,6 +973,10 @@ impl InfoPopover {
cx.stop_propagation();
})
.when_some(self.parsed_content.clone(), |this, markdown| {
+ let editor = cx.entity().downgrade();
+ let type_definitions = self.type_definitions.clone();
+ let clickable_words: HashSet<SharedString> =
+ type_definitions.keys().cloned().collect();
this.child(
div()
.id("info-md-container")
@@ -926,6 +992,16 @@ impl InfoPopover {
border: false,
})
.on_url_click(open_markdown_url)
+ .clickable_code_words(clickable_words)
+ .on_code_block_click(move |word, window, cx| {
+ navigate_to_hover_type(
+ editor.clone(),
+ &type_definitions,
+ word,
+ window,
+ cx,
+ );
+ })
.p_2(),
),
)
@@ -2005,4 +2081,513 @@ mod tests {
InlayOffset(MultiBufferOffset(104))..InlayOffset(MultiBufferOffset(108))
);
}
+
+ #[gpui::test]
+ async fn test_hover_popover_type_definitions_populated(cx: &mut gpui::TestAppContext) {
+ init_test(cx, |_| {});
+
+ let mut cx = EditorLspTestContext::new_rust(
+ lsp::ServerCapabilities {
+ hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
+ type_definition_provider: Some(lsp::TypeDefinitionProviderCapability::Simple(true)),
+ ..Default::default()
+ },
+ cx,
+ )
+ .await;
+
+ cx.set_state(indoc! {"
+ mod foo {
+ pub struct Baz;
+ }
+ mod bar {
+ pub struct Baz;
+ }
+ let vaˇriable: foo::Baz = foo::Baz;
+ "});
+
+ let hover_point = cx.display_point(indoc! {"
+ mod foo {
+ pub struct Baz;
+ }
+ mod bar {
+ pub struct Baz;
+ }
+ let vaˇriable: foo::Baz = foo::Baz;
+ "});
+
+ cx.update_editor(|editor, window, cx| {
+ let snapshot = editor.snapshot(window, cx);
+ let anchor = snapshot
+ .buffer_snapshot()
+ .anchor_before(hover_point.to_offset(&snapshot, Bias::Left));
+ hover_at(editor, Some(anchor), window, cx)
+ });
+
+ let symbol_range = cx.lsp_range(indoc! {"
+ mod foo {
+ pub struct Baz;
+ }
+ mod bar {
+ pub struct Baz;
+ }
+ let «variable»: foo::Baz = foo::Baz;
+ "});
+ let target_selection_range = cx.lsp_range(indoc! {"
+ mod foo {
+ pub struct «Baz»;
+ }
+ mod bar {
+ pub struct Baz;
+ }
+ let variable: foo::Baz = foo::Baz;
+ "});
+
+ cx.set_request_handler::<lsp::request::HoverRequest, _, _>(move |_, _, _| async move {
+ Ok(Some(lsp::Hover {
+ contents: lsp::HoverContents::Markup(lsp::MarkupContent {
+ kind: lsp::MarkupKind::Markdown,
+ value: "```rust\nBaz\n```".to_string(),
+ }),
+ range: Some(symbol_range),
+ }))
+ });
+
+ cx.set_request_handler::<lsp::request::GotoTypeDefinition, _, _>(
+ move |url, _, _| async move {
+ Ok(Some(lsp::GotoTypeDefinitionResponse::Link(vec![
+ lsp::LocationLink {
+ origin_selection_range: Some(symbol_range),
+ target_uri: url.clone(),
+ target_range: target_selection_range,
+ target_selection_range,
+ },
+ ])))
+ },
+ );
+
+ cx.background_executor
+ .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100));
+ cx.run_until_parked();
+
+ cx.editor(|editor, _, cx| {
+ assert!(editor.hover_state.visible());
+ assert_eq!(
+ editor.hover_state.info_popovers.len(),
+ 1,
+ "Expected exactly one hover popover"
+ );
+ let popover = &editor.hover_state.info_popovers[0];
+ assert_eq!(
+ popover.type_definitions.len(),
+ 1,
+ "Expected one type definition entry"
+ );
+ assert!(
+ popover.type_definitions.contains_key("Baz"),
+ "Expected type_definitions to contain 'Baz', got keys: {:?}",
+ popover.type_definitions.keys().collect::<Vec<_>>()
+ );
+
+ // Verify the hover text rendered correctly too
+ let rendered_text = popover.get_rendered_text(cx);
+ assert!(
+ rendered_text.contains("Baz"),
+ "Hover text should contain 'Baz', got: {rendered_text}"
+ );
+ });
+ }
+
+ #[gpui::test]
+ async fn test_hover_popover_navigate_to_type(cx: &mut gpui::TestAppContext) {
+ init_test(cx, |_| {});
+
+ let mut cx = EditorLspTestContext::new_rust(
+ lsp::ServerCapabilities {
+ hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
+ type_definition_provider: Some(lsp::TypeDefinitionProviderCapability::Simple(true)),
+ ..Default::default()
+ },
+ cx,
+ )
+ .await;
+
+ cx.set_state(indoc! {"
+ mod foo {
+ pub struct Baz;
+ }
+ mod bar {
+ pub struct Baz;
+ }
+ let vaˇriable: foo::Baz = foo::Baz;
+ "});
+
+ let hover_point = cx.display_point(indoc! {"
+ mod foo {
+ pub struct Baz;
+ }
+ mod bar {
+ pub struct Baz;
+ }
+ let vaˇriable: foo::Baz = foo::Baz;
+ "});
+
+ cx.update_editor(|editor, window, cx| {
+ let snapshot = editor.snapshot(window, cx);
+ let anchor = snapshot
+ .buffer_snapshot()
+ .anchor_before(hover_point.to_offset(&snapshot, Bias::Left));
+ hover_at(editor, Some(anchor), window, cx)
+ });
+
+ let symbol_range = cx.lsp_range(indoc! {"
+ mod foo {
+ pub struct Baz;
+ }
+ mod bar {
+ pub struct Baz;
+ }
+ let «variable»: foo::Baz = foo::Baz;
+ "});
+ // Point to foo::Baz specifically (line 2), not bar::Baz (line 5)
+ let target_selection_range = cx.lsp_range(indoc! {"
+ mod foo {
+ pub struct «Baz»;
+ }
+ mod bar {
+ pub struct Baz;
+ }
+ let variable: foo::Baz = foo::Baz;
+ "});
+
+ cx.set_request_handler::<lsp::request::HoverRequest, _, _>(move |_, _, _| async move {
+ Ok(Some(lsp::Hover {
+ contents: lsp::HoverContents::Markup(lsp::MarkupContent {
+ kind: lsp::MarkupKind::Markdown,
+ value: "```rust\nBaz\n```".to_string(),
+ }),
+ range: Some(symbol_range),
+ }))
+ });
+
+ cx.set_request_handler::<lsp::request::GotoTypeDefinition, _, _>(
+ move |url, _, _| async move {
+ Ok(Some(lsp::GotoTypeDefinitionResponse::Link(vec![
+ lsp::LocationLink {
+ origin_selection_range: Some(symbol_range),
+ target_uri: url.clone(),
+ target_range: target_selection_range,
+ target_selection_range,
+ },
+ ])))
+ },
+ );
+
+ cx.background_executor
+ .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100));
+ cx.run_until_parked();
+
+ // Verify the hover is visible and has the type definition
+ cx.editor(|editor, _, _| {
+ assert!(editor.hover_state.visible());
+ assert!(
+ editor.hover_state.info_popovers[0]
+ .type_definitions
+ .contains_key("Baz")
+ );
+ });
+
+ // Extract the data we need before navigating (navigate_to_hover_type
+ // internally calls editor.update(), so we can't call it from within
+ // update_editor without causing a re-entrant borrow panic).
+ let (type_definitions, editor_weak) = cx.editor(|editor, _, cx| {
+ (
+ editor.hover_state.info_popovers[0].type_definitions.clone(),
+ cx.entity().downgrade(),
+ )
+ });
+
+ let window_handle = cx.window;
+ cx.update_window(window_handle, |_, window, cx| {
+ navigate_to_hover_type(editor_weak, &type_definitions, "Baz".into(), window, cx);
+ })
+ .unwrap();
+ cx.run_until_parked();
+
+ // Verify cursor moved to foo::Baz definition, not bar::Baz
+ cx.assert_editor_state(indoc! {"
+ mod foo {
+ pub struct «Bazˇ»;
+ }
+ mod bar {
+ pub struct Baz;
+ }
+ let variable: foo::Baz = foo::Baz;
+ "});
+ }
+
+ #[gpui::test]
+ async fn test_hover_popover_multiple_type_definitions(cx: &mut gpui::TestAppContext) {
+ init_test(cx, |_| {});
+
+ let mut cx = EditorLspTestContext::new_rust(
+ lsp::ServerCapabilities {
+ hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
+ type_definition_provider: Some(lsp::TypeDefinitionProviderCapability::Simple(true)),
+ ..Default::default()
+ },
+ cx,
+ )
+ .await;
+
+ cx.set_state(indoc! {"
+ mod foo {
+ pub struct Baz;
+ }
+ mod bar {
+ pub struct Baz;
+ }
+ struct Wrapper<T, U>(T, U);
+ let vaˇriable = Wrapper(foo::Baz, bar::Baz);
+ "});
+
+ let hover_point = cx.display_point(indoc! {"
+ mod foo {
+ pub struct Baz;
+ }
+ mod bar {
+ pub struct Baz;
+ }
+ struct Wrapper<T, U>(T, U);
+ let vaˇriable = Wrapper(foo::Baz, bar::Baz);
+ "});
+
+ cx.update_editor(|editor, window, cx| {
+ let snapshot = editor.snapshot(window, cx);
+ let anchor = snapshot
+ .buffer_snapshot()
+ .anchor_before(hover_point.to_offset(&snapshot, Bias::Left));
+ hover_at(editor, Some(anchor), window, cx)
+ });
+
+ let symbol_range = cx.lsp_range(indoc! {"
+ mod foo {
+ pub struct Baz;
+ }
+ mod bar {
+ pub struct Baz;
+ }
+ struct Wrapper<T, U>(T, U);
+ let «variable» = Wrapper(foo::Baz, bar::Baz);
+ "});
+ let wrapper_range = cx.lsp_range(indoc! {"
+ mod foo {
+ pub struct Baz;
+ }
+ mod bar {
+ pub struct Baz;
+ }
+ struct «Wrapper»<T, U>(T, U);
+ let variable = Wrapper(foo::Baz, bar::Baz);
+ "});
+ let foo_baz_range = cx.lsp_range(indoc! {"
+ mod foo {
+ pub struct «Baz»;
+ }
+ mod bar {
+ pub struct Baz;
+ }
+ struct Wrapper<T, U>(T, U);
+ let variable = Wrapper(foo::Baz, bar::Baz);
+ "});
+ let bar_baz_range = cx.lsp_range(indoc! {"
+ mod foo {
+ pub struct Baz;
+ }
+ mod bar {
+ pub struct «Baz»;
+ }
+ struct Wrapper<T, U>(T, U);
+ let variable = Wrapper(foo::Baz, bar::Baz);
+ "});
+
+ cx.set_request_handler::<lsp::request::HoverRequest, _, _>(move |_, _, _| async move {
+ Ok(Some(lsp::Hover {
+ contents: lsp::HoverContents::Markup(lsp::MarkupContent {
+ kind: lsp::MarkupKind::Markdown,
+ value: "```rust\nWrapper\n```".to_string(),
+ }),
+ range: Some(symbol_range),
+ }))
+ });
+
+ cx.set_request_handler::<lsp::request::GotoTypeDefinition, _, _>(
+ move |url, _, _| async move {
+ Ok(Some(lsp::GotoTypeDefinitionResponse::Link(vec![
+ lsp::LocationLink {
+ origin_selection_range: Some(symbol_range),
+ target_uri: url.clone(),
+ target_range: wrapper_range,
+ target_selection_range: wrapper_range,
+ },
+ lsp::LocationLink {
+ origin_selection_range: Some(symbol_range),
+ target_uri: url.clone(),
+ target_range: foo_baz_range,
+ target_selection_range: foo_baz_range,
+ },
+ lsp::LocationLink {
+ origin_selection_range: Some(symbol_range),
+ target_uri: url.clone(),
+ target_range: bar_baz_range,
+ target_selection_range: bar_baz_range,
+ },
+ ])))
+ },
+ );
+
+ cx.background_executor
+ .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100));
+ cx.run_until_parked();
+
+ cx.editor(|editor, _, _| {
+ assert!(editor.hover_state.visible());
+ let popover = &editor.hover_state.info_popovers[0];
+ let type_defs = &popover.type_definitions;
+
+ // "Wrapper" is unique so it must be present
+ assert!(
+ type_defs.contains_key("Wrapper"),
+ "Expected 'Wrapper' in type_definitions, got keys: {:?}",
+ type_defs.keys().collect::<Vec<_>>()
+ );
+
+ // When multiple types share the same short name (e.g., foo::Baz and bar::Baz),
+ // only one entry survives in the HashMap. This is acceptable because the hover
+ // text would typically show qualified paths in this case. A future enhancement
+ // could use Vec<LocationLink> or leverage rust-analyzer's hover actions extension
+ // to handle this precisely.
+ assert!(
+ type_defs.contains_key("Baz"),
+ "Expected 'Baz' in type_definitions (one of the two should survive)"
+ );
+ assert_eq!(
+ type_defs.len(),
+ 2,
+ "Expected 2 entries (Wrapper + one Baz), got: {:?}",
+ type_defs.keys().collect::<Vec<_>>()
+ );
+ });
+
+ // Navigation to "Wrapper" should work correctly
+ let (type_definitions, editor_weak) = cx.editor(|editor, _, cx| {
+ (
+ editor.hover_state.info_popovers[0].type_definitions.clone(),
+ cx.entity().downgrade(),
+ )
+ });
+
+ let window_handle = cx.window;
+ cx.update_window(window_handle, |_, window, cx| {
+ navigate_to_hover_type(editor_weak, &type_definitions, "Wrapper".into(), window, cx);
+ })
+ .unwrap();
+ cx.run_until_parked();
+
+ cx.assert_editor_state(indoc! {"
+ mod foo {
+ pub struct Baz;
+ }
+ mod bar {
+ pub struct Baz;
+ }
+ struct «Wrapperˇ»<T, U>(T, U);
+ let variable = Wrapper(foo::Baz, bar::Baz);
+ "});
+ }
+
+ #[gpui::test]
+ async fn test_hover_popover_type_definition_request_error(cx: &mut gpui::TestAppContext) {
+ init_test(cx, |_| {});
+
+ let mut cx = EditorLspTestContext::new_rust(
+ lsp::ServerCapabilities {
+ hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
+ type_definition_provider: Some(lsp::TypeDefinitionProviderCapability::Simple(true)),
+ ..Default::default()
+ },
+ cx,
+ )
+ .await;
+
+ cx.set_state(indoc! {"
+ struct MyStruct;
+ let vaˇriable = MyStruct;
+ "});
+
+ let hover_point = cx.display_point(indoc! {"
+ struct MyStruct;
+ let vaˇriable = MyStruct;
+ "});
+
+ cx.update_editor(|editor, window, cx| {
+ let snapshot = editor.snapshot(window, cx);
+ let anchor = snapshot
+ .buffer_snapshot()
+ .anchor_before(hover_point.to_offset(&snapshot, Bias::Left));
+ hover_at(editor, Some(anchor), window, cx)
+ });
+
+ let symbol_range = cx.lsp_range(indoc! {"
+ struct MyStruct;
+ let «variable» = MyStruct;
+ "});
+
+ // Hover succeeds normally
+ cx.set_request_handler::<lsp::request::HoverRequest, _, _>(move |_, _, _| async move {
+ Ok(Some(lsp::Hover {
+ contents: lsp::HoverContents::Markup(lsp::MarkupContent {
+ kind: lsp::MarkupKind::Markdown,
+ value: "some basic docs".to_string(),
+ }),
+ range: Some(symbol_range),
+ }))
+ });
+
+ // Type definition request returns an error
+ cx.set_request_handler::<lsp::request::GotoTypeDefinition, _, _>(
+ move |_, _, _| async move { Err(anyhow::anyhow!("LSP server error: request failed")) },
+ );
+
+ cx.background_executor
+ .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100));
+ cx.run_until_parked();
+
+ // The hover popover should still be visible with the hover content,
+ // but type_definitions should be empty due to the error
+ cx.editor(|editor, _, cx| {
+ assert!(
+ editor.hover_state.visible(),
+ "Hover should still be visible even when type definition request fails"
+ );
+ assert_eq!(
+ editor.hover_state.info_popovers.len(),
+ 1,
+ "Expected exactly one hover popover"
+ );
+ let popover = &editor.hover_state.info_popovers[0];
+ assert!(
+ popover.type_definitions.is_empty(),
+ "type_definitions should be empty when the LSP request errors, got: {:?}",
+ popover.type_definitions.keys().collect::<Vec<_>>()
+ );
+
+ // The hover text should still render correctly
+ let rendered_text = popover.get_rendered_text(cx);
+ assert_eq!(
+ rendered_text, "some basic docs",
+ "Hover content should be unaffected by type definition error"
+ );
+ });
+ }
}
@@ -32,7 +32,7 @@ use gpui::{
MouseUpEvent, Point, ScrollHandle, Stateful, StrikethroughStyle, StyleRefinement, StyledText,
Task, TextLayout, TextRun, TextStyle, TextStyleRefinement, actions, img, point, quad,
};
-use language::{CharClassifier, Language, LanguageRegistry, Rope};
+use language::{CharClassifier, CharKind, Language, LanguageRegistry, Rope};
use parser::CodeBlockMetadata;
use parser::{MarkdownEvent, MarkdownTag, MarkdownTagEnd, parse_links_only, parse_markdown};
use pulldown_cmark::Alignment;
@@ -237,6 +237,7 @@ pub struct Markdown {
source: SharedString,
selection: Selection,
pressed_link: Option<RenderedLink>,
+ pressed_code_block_word: Option<SharedString>,
autoscroll_request: Option<usize>,
parsed_markdown: ParsedMarkdown,
images_by_source_offset: HashMap<usize, Arc<Image>>,
@@ -305,6 +306,7 @@ impl Markdown {
source,
selection: Selection::default(),
pressed_link: None,
+ pressed_code_block_word: None,
autoscroll_request: None,
should_reparse: false,
images_by_source_offset: Default::default(),
@@ -330,6 +332,7 @@ impl Markdown {
source,
selection: Selection::default(),
pressed_link: None,
+ pressed_code_block_word: None,
autoscroll_request: None,
should_reparse: false,
parsed_markdown: ParsedMarkdown::default(),
@@ -702,6 +705,8 @@ pub struct MarkdownElement {
style: MarkdownStyle,
code_block_renderer: CodeBlockRenderer,
on_url_click: Option<Box<dyn Fn(SharedString, &mut Window, &mut App)>>,
+ on_code_block_click: Option<Box<dyn Fn(SharedString, &mut Window, &mut App)>>,
+ clickable_code_words: Option<Rc<HashSet<SharedString>>>,
}
impl MarkdownElement {
@@ -715,6 +720,8 @@ impl MarkdownElement {
border: false,
},
on_url_click: None,
+ on_code_block_click: None,
+ clickable_code_words: None,
}
}
@@ -752,6 +759,19 @@ impl MarkdownElement {
self
}
+ pub fn on_code_block_click(
+ mut self,
+ handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
+ ) -> Self {
+ self.on_code_block_click = Some(Box::new(handler));
+ self
+ }
+
+ pub fn clickable_code_words(mut self, words: HashSet<SharedString>) -> Self {
+ self.clickable_code_words = Some(Rc::new(words));
+ self
+ }
+
fn paint_selection(
&self,
bounds: Bounds<Pixels>,
@@ -819,6 +839,15 @@ impl MarkdownElement {
}
}
+ fn is_clickable_code_word(
+ word: &SharedString,
+ clickable_code_words: &Option<Rc<HashSet<SharedString>>>,
+ ) -> bool {
+ clickable_code_words
+ .as_ref()
+ .map_or(true, |set| set.contains(word))
+ }
+
fn paint_mouse_listeners(
&mut self,
hitbox: &Hitbox,
@@ -830,14 +859,23 @@ impl MarkdownElement {
return;
}
+ let has_code_block_click = self.on_code_block_click.is_some();
+ let clickable_code_words = self.clickable_code_words.take();
let is_hovering_link = hitbox.is_hovered(window)
&& !self.markdown.read(cx).selection.pending
&& rendered_text
.link_for_position(window.mouse_position())
.is_some();
+ let is_hovering_code_block_word = has_code_block_click
+ && hitbox.is_hovered(window)
+ && !self.markdown.read(cx).selection.pending
+ && rendered_text
+ .code_block_word_for_position(window.mouse_position())
+ .filter(|word| Self::is_clickable_code_word(word, &clickable_code_words))
+ .is_some();
if !self.style.prevent_mouse_interaction {
- if is_hovering_link {
+ if is_hovering_link || is_hovering_code_block_word {
window.set_cursor_style(CursorStyle::PointingHand, hitbox);
} else {
window.set_cursor_style(CursorStyle::IBeam, hitbox);
@@ -845,6 +883,7 @@ impl MarkdownElement {
}
let on_open_url = self.on_url_click.take();
+ let on_code_block_click = self.on_code_block_click.take();
self.on_mouse_event(window, cx, {
let hitbox = hitbox.clone();
@@ -862,11 +901,20 @@ impl MarkdownElement {
self.on_mouse_event(window, cx, {
let rendered_text = rendered_text.clone();
let hitbox = hitbox.clone();
+ let clickable_code_words = clickable_code_words.clone();
move |markdown, event: &MouseDownEvent, phase, window, cx| {
if hitbox.is_hovered(window) {
if phase.bubble() {
if let Some(link) = rendered_text.link_for_position(event.position) {
markdown.pressed_link = Some(link.clone());
+ } else if has_code_block_click
+ && let Some(word) = rendered_text
+ .code_block_word_for_position(event.position)
+ .filter(|word| {
+ Self::is_clickable_code_word(word, &clickable_code_words)
+ })
+ {
+ markdown.pressed_code_block_word = Some(word);
} else {
let source_index =
match rendered_text.source_index_for_position(event.position) {
@@ -910,6 +958,7 @@ impl MarkdownElement {
} else if phase.capture() && event.button == MouseButton::Left {
markdown.selection = Selection::default();
markdown.pressed_link = None;
+ markdown.pressed_code_block_word = None;
cx.notify();
}
}
@@ -917,7 +966,8 @@ impl MarkdownElement {
self.on_mouse_event(window, cx, {
let rendered_text = rendered_text.clone();
let hitbox = hitbox.clone();
- let was_hovering_link = is_hovering_link;
+ let clickable_code_words = clickable_code_words.clone();
+ let was_hovering_clickable = is_hovering_link || is_hovering_code_block_word;
move |markdown, event: &MouseMoveEvent, phase, window, cx| {
if phase.capture() {
return;
@@ -933,9 +983,16 @@ impl MarkdownElement {
markdown.autoscroll_request = Some(source_index);
cx.notify();
} else {
- let is_hovering_link = hitbox.is_hovered(window)
- && rendered_text.link_for_position(event.position).is_some();
- if is_hovering_link != was_hovering_link {
+ let is_hovering_clickable = hitbox.is_hovered(window)
+ && (rendered_text.link_for_position(event.position).is_some()
+ || (has_code_block_click
+ && rendered_text
+ .code_block_word_for_position(event.position)
+ .filter(|word| {
+ Self::is_clickable_code_word(word, &clickable_code_words)
+ })
+ .is_some()));
+ if is_hovering_clickable != was_hovering_clickable {
cx.notify();
}
}
@@ -953,6 +1010,18 @@ impl MarkdownElement {
} else {
cx.open_url(&pressed_link.destination_url);
}
+ } else if let Some(pressed_word) = markdown.pressed_code_block_word.take()
+ && rendered_text
+ .code_block_word_for_position(event.position)
+ .filter(|word| {
+ Self::is_clickable_code_word(word, &clickable_code_words)
+ })
+ .as_ref()
+ == Some(&pressed_word)
+ {
+ if let Some(on_click) = on_code_block_click.as_ref() {
+ on_click(pressed_word, window, cx);
+ }
}
} else if markdown.selection.pending {
markdown.selection.pending = false;
@@ -2224,6 +2293,63 @@ impl RenderedText {
.iter()
.find(|link| link.source_range.contains(&source_index))
}
+
+ fn code_block_word_for_position(&self, position: Point<Pixels>) -> Option<SharedString> {
+ let source_index = self.source_index_for_position(position).ok()?;
+
+ for line in self.lines.iter() {
+ if source_index > line.source_end {
+ continue;
+ }
+
+ // Only return words for code block lines
+ if line.language.is_none() {
+ return None;
+ }
+
+ let line_rendered_start = line.source_mappings.first()?.rendered_index;
+ let rendered_index_in_line =
+ line.rendered_index_for_source_index(source_index) - line_rendered_start;
+ let text = line.layout.text();
+
+ let scope = line.language.as_ref().map(|l| l.default_scope());
+ let classifier = CharClassifier::new(scope);
+
+ // Check that we're on a word character
+ let char_at_cursor = text[rendered_index_in_line..].chars().next()?;
+ if classifier.kind(char_at_cursor) != CharKind::Word {
+ return None;
+ }
+
+ // Find word boundaries
+ let mut start = rendered_index_in_line;
+ for c in text[..rendered_index_in_line].chars().rev() {
+ if classifier.kind(c) == CharKind::Word {
+ start -= c.len_utf8();
+ } else {
+ break;
+ }
+ }
+
+ let mut end = rendered_index_in_line;
+ for c in text[rendered_index_in_line..].chars() {
+ if classifier.kind(c) == CharKind::Word {
+ end += c.len_utf8();
+ } else {
+ break;
+ }
+ }
+
+ let word = &text[start..end];
+ if word.is_empty() {
+ return None;
+ }
+
+ return Some(SharedString::from(word.to_string()));
+ }
+
+ None
+ }
}
#[cfg(test)]