1use crate::{
2 ActiveDiagnostic, Anchor, AnchorRangeExt, DisplayPoint, DisplayRow, Editor, EditorSettings,
3 EditorSnapshot, GlobalDiagnosticRenderer, HighlightKey, Hover,
4 display_map::{InlayOffset, ToDisplayPoint, is_invisible},
5 editor_settings::EditorSettingsScrollbarProxy,
6 hover_links::{InlayHighlight, RangeInEditor},
7 movement::TextLayoutDetails,
8 scroll::ScrollAmount,
9};
10use anyhow::Context as _;
11use gpui::{
12 AnyElement, App, AsyncWindowContext, Bounds, Context, Entity, Focusable as _, FontWeight, Hsla,
13 InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, ScrollHandle, Size,
14 StatefulInteractiveElement, StyleRefinement, Styled, Subscription, Task, TextStyleRefinement,
15 Window, canvas, div, px,
16};
17use itertools::Itertools;
18use language::{DiagnosticEntry, Language, LanguageRegistry};
19use lsp::DiagnosticSeverity;
20use markdown::{CopyButtonVisibility, Markdown, MarkdownElement, MarkdownStyle};
21use multi_buffer::{MultiBufferOffset, ToOffset, ToPoint};
22use project::{HoverBlock, HoverBlockKind, InlayHintLabelPart};
23use settings::Settings;
24use std::{
25 borrow::Cow,
26 cell::{Cell, RefCell},
27};
28use std::{ops::Range, sync::Arc, time::Duration};
29use std::{path::PathBuf, rc::Rc};
30use theme_settings::ThemeSettings;
31use ui::{CopyButton, Scrollbars, WithScrollbar, prelude::*, theme_is_transparent};
32use url::Url;
33use util::TryFutureExt;
34use workspace::{OpenOptions, OpenVisible, Workspace};
35
36pub const MIN_POPOVER_CHARACTER_WIDTH: f32 = 20.;
37pub const MIN_POPOVER_LINE_HEIGHT: f32 = 4.;
38pub const POPOVER_RIGHT_OFFSET: Pixels = px(8.0);
39pub const HOVER_POPOVER_GAP: Pixels = px(10.);
40
41/// Bindable action which uses the most recent selection head to trigger a hover
42pub fn hover(editor: &mut Editor, _: &Hover, window: &mut Window, cx: &mut Context<Editor>) {
43 let head = editor.selections.newest_anchor().head();
44 show_hover(editor, head, true, window, cx);
45}
46
47/// The internal hover action dispatches between `show_hover` or `hide_hover`
48/// depending on whether a point to hover over is provided.
49pub fn hover_at(
50 editor: &mut Editor,
51 anchor: Option<Anchor>,
52 mouse_position: Option<gpui::Point<Pixels>>,
53 window: &mut Window,
54 cx: &mut Context<Editor>,
55) {
56 if EditorSettings::get_global(cx).hover_popover_enabled {
57 if show_keyboard_hover(editor, window, cx) {
58 return;
59 }
60
61 if let Some(anchor) = anchor {
62 editor.hover_state.hiding_delay_task = None;
63 editor.hover_state.closest_mouse_distance = None;
64 show_hover(editor, anchor, false, window, cx);
65 } else {
66 let settings = EditorSettings::get_global(cx);
67 if !settings.hover_popover_sticky {
68 hide_hover(editor, cx);
69 return;
70 }
71
72 let mut getting_closer = false;
73 if let Some(mouse_position) = mouse_position {
74 getting_closer = editor.hover_state.is_mouse_getting_closer(mouse_position);
75 }
76
77 // If we are moving away and a timer is already running, just let it count down.
78 if !getting_closer && editor.hover_state.hiding_delay_task.is_some() {
79 return;
80 }
81
82 // If we are moving closer, or if no timer is running at all, start/restart the timer.
83 let delay = Duration::from_millis(settings.hover_popover_hiding_delay.0);
84 let task = cx.spawn(async move |this, cx| {
85 cx.background_executor().timer(delay).await;
86 this.update(cx, |editor, cx| {
87 hide_hover(editor, cx);
88 })
89 .ok();
90 });
91 editor.hover_state.hiding_delay_task = Some(task);
92 }
93 }
94}
95
96pub fn show_keyboard_hover(
97 editor: &mut Editor,
98 window: &mut Window,
99 cx: &mut Context<Editor>,
100) -> bool {
101 if let Some(anchor) = editor.hover_state.info_popovers.iter().find_map(|p| {
102 if *p.keyboard_grace.borrow() {
103 p.anchor
104 } else {
105 None
106 }
107 }) {
108 show_hover(editor, anchor, false, window, cx);
109 return true;
110 }
111
112 if let Some(anchor) = editor
113 .hover_state
114 .diagnostic_popover
115 .as_ref()
116 .and_then(|d| {
117 if *d.keyboard_grace.borrow() {
118 Some(d.anchor)
119 } else {
120 None
121 }
122 })
123 {
124 show_hover(editor, anchor, false, window, cx);
125 return true;
126 }
127
128 false
129}
130
131pub struct InlayHover {
132 pub(crate) range: InlayHighlight,
133 pub tooltip: HoverBlock,
134}
135
136pub fn find_hovered_hint_part(
137 label_parts: Vec<InlayHintLabelPart>,
138 hint_start: InlayOffset,
139 hovered_offset: InlayOffset,
140) -> Option<(InlayHintLabelPart, Range<InlayOffset>)> {
141 if hovered_offset >= hint_start {
142 let mut offset_in_hint = hovered_offset - hint_start;
143 let mut part_start = hint_start;
144 for part in label_parts {
145 let part_len = part.value.len();
146 if offset_in_hint >= part_len {
147 offset_in_hint -= part_len;
148 part_start.0 += part_len;
149 } else {
150 let part_end = InlayOffset(part_start.0 + part_len);
151 return Some((part, part_start..part_end));
152 }
153 }
154 }
155 None
156}
157
158pub fn hover_at_inlay(
159 editor: &mut Editor,
160 inlay_hover: InlayHover,
161 window: &mut Window,
162 cx: &mut Context<Editor>,
163) {
164 if EditorSettings::get_global(cx).hover_popover_enabled {
165 if editor.pending_rename.is_some() {
166 return;
167 }
168
169 let Some(project) = editor.project.clone() else {
170 return;
171 };
172
173 if editor
174 .hover_state
175 .info_popovers
176 .iter()
177 .any(|InfoPopover { symbol_range, .. }| {
178 if let RangeInEditor::Inlay(range) = symbol_range
179 && range == &inlay_hover.range
180 {
181 // Hover triggered from same location as last time. Don't show again.
182 return true;
183 }
184 false
185 })
186 {
187 return;
188 }
189
190 let hover_popover_delay = EditorSettings::get_global(cx).hover_popover_delay.0;
191
192 editor.hover_state.hiding_delay_task = None;
193 editor.hover_state.closest_mouse_distance = None;
194
195 let task = cx.spawn_in(window, async move |this, cx| {
196 async move {
197 cx.background_executor()
198 .timer(Duration::from_millis(hover_popover_delay))
199 .await;
200 this.update(cx, |this, _| {
201 this.hover_state.diagnostic_popover = None;
202 })?;
203
204 let language_registry = project.read_with(cx, |p, _| p.languages().clone());
205 let blocks = vec![inlay_hover.tooltip];
206 let parsed_content =
207 parse_blocks(&blocks, Some(&language_registry), None, cx).await;
208
209 let scroll_handle = ScrollHandle::new();
210
211 let subscription = this
212 .update(cx, |_, cx| {
213 parsed_content.as_ref().map(|parsed_content| {
214 cx.observe(parsed_content, |_, _, cx| cx.notify())
215 })
216 })
217 .ok()
218 .flatten();
219
220 let hover_popover = InfoPopover {
221 symbol_range: RangeInEditor::Inlay(inlay_hover.range.clone()),
222 parsed_content,
223 scroll_handle,
224 keyboard_grace: Rc::new(RefCell::new(false)),
225 anchor: None,
226 last_bounds: Rc::new(Cell::new(None)),
227 _subscription: subscription,
228 };
229
230 this.update(cx, |this, cx| {
231 // TODO: no background highlights happen for inlays currently
232 this.hover_state.info_popovers = vec![hover_popover];
233 cx.notify();
234 })?;
235
236 anyhow::Ok(())
237 }
238 .log_err()
239 .await
240 });
241
242 editor.hover_state.info_task = Some(task);
243 }
244}
245
246/// Hides the type information popup.
247/// Triggered by the `Hover` action when the cursor is not over a symbol or when the
248/// selections changed.
249pub fn hide_hover(editor: &mut Editor, cx: &mut Context<Editor>) -> bool {
250 let info_popovers = editor.hover_state.info_popovers.drain(..);
251 let diagnostics_popover = editor.hover_state.diagnostic_popover.take();
252 let did_hide = info_popovers.count() > 0 || diagnostics_popover.is_some();
253
254 editor.hover_state.info_task = None;
255 editor.hover_state.triggered_from = None;
256 editor.hover_state.hiding_delay_task = None;
257 editor.hover_state.closest_mouse_distance = None;
258
259 editor.clear_background_highlights(HighlightKey::HoverState, cx);
260
261 if did_hide {
262 cx.notify();
263 }
264
265 did_hide
266}
267
268/// Queries the LSP and shows type info and documentation
269/// about the symbol the mouse is currently hovering over.
270/// Triggered by the `Hover` action when the cursor may be over a symbol.
271fn show_hover(
272 editor: &mut Editor,
273 anchor: Anchor,
274 ignore_timeout: bool,
275 window: &mut Window,
276 cx: &mut Context<Editor>,
277) -> Option<()> {
278 if editor.pending_rename.is_some() {
279 return None;
280 }
281
282 let snapshot = editor.snapshot(window, cx);
283
284 let (buffer_position, _) = editor
285 .buffer
286 .read(cx)
287 .snapshot(cx)
288 .anchor_to_buffer_anchor(anchor)?;
289 let buffer = editor.buffer.read(cx).buffer(buffer_position.buffer_id)?;
290
291 let language_registry = editor
292 .project()
293 .map(|project| project.read(cx).languages().clone());
294 let provider = editor.semantics_provider.clone()?;
295
296 editor.hover_state.hiding_delay_task = None;
297 editor.hover_state.closest_mouse_distance = None;
298
299 if !ignore_timeout {
300 if same_info_hover(editor, &snapshot, anchor)
301 || same_diagnostic_hover(editor, &snapshot, anchor)
302 || editor.hover_state.diagnostic_popover.is_some()
303 {
304 // Hover triggered from same location as last time. Don't show again.
305 return None;
306 } else {
307 hide_hover(editor, cx);
308 }
309 }
310
311 // Don't request again if the location is the same as the previous request
312 if let Some(triggered_from) = &editor.hover_state.triggered_from
313 && triggered_from
314 .cmp(&anchor, &snapshot.buffer_snapshot())
315 .is_eq()
316 {
317 return None;
318 }
319
320 let hover_popover_delay = EditorSettings::get_global(cx).hover_popover_delay.0;
321 let all_diagnostics_active = editor.active_diagnostics == ActiveDiagnostic::All;
322 let active_group_id = if let ActiveDiagnostic::Group(group) = &editor.active_diagnostics {
323 Some(group.group_id)
324 } else {
325 None
326 };
327
328 let renderer = GlobalDiagnosticRenderer::global(cx);
329 let task = cx.spawn_in(window, async move |this, cx| {
330 async move {
331 // If we need to delay, delay a set amount initially before making the lsp request
332 let delay = if ignore_timeout {
333 None
334 } else {
335 let lsp_request_early = hover_popover_delay / 2;
336 cx.background_executor()
337 .timer(Duration::from_millis(
338 hover_popover_delay - lsp_request_early,
339 ))
340 .await;
341
342 // Construct delay task to wait for later
343 let total_delay = Some(
344 cx.background_executor()
345 .timer(Duration::from_millis(lsp_request_early)),
346 );
347 total_delay
348 };
349
350 let hover_request = cx.update(|_, cx| provider.hover(&buffer, buffer_position, cx))?;
351
352 if let Some(delay) = delay {
353 delay.await;
354 }
355 let offset = anchor.to_offset(&snapshot.buffer_snapshot());
356 let local_diagnostic = if all_diagnostics_active {
357 None
358 } else {
359 snapshot
360 .buffer_snapshot()
361 .diagnostics_with_buffer_ids_in_range::<MultiBufferOffset>(offset..offset)
362 .filter(|(_, diagnostic)| {
363 Some(diagnostic.diagnostic.group_id) != active_group_id
364 })
365 // Find the entry with the most specific range
366 .min_by_key(|(_, entry)| entry.range.end - entry.range.start)
367 };
368
369 let diagnostic_popover = if let Some((buffer_id, local_diagnostic)) = local_diagnostic {
370 let group = snapshot
371 .buffer_snapshot()
372 .diagnostic_group(buffer_id, local_diagnostic.diagnostic.group_id)
373 .collect::<Vec<_>>();
374 let point_range = local_diagnostic
375 .range
376 .start
377 .to_point(&snapshot.buffer_snapshot())
378 ..local_diagnostic
379 .range
380 .end
381 .to_point(&snapshot.buffer_snapshot());
382 let markdown = cx.update(|_, cx| {
383 renderer
384 .as_ref()
385 .and_then(|renderer| {
386 renderer.render_hover(
387 group,
388 point_range,
389 buffer_id,
390 language_registry.clone(),
391 cx,
392 )
393 })
394 .context("no rendered diagnostic")
395 })??;
396
397 let (background_color, border_color) = cx.update(|_, cx| {
398 let status_colors = cx.theme().status();
399 match local_diagnostic.diagnostic.severity {
400 DiagnosticSeverity::ERROR => {
401 (status_colors.error_background, status_colors.error_border)
402 }
403 DiagnosticSeverity::WARNING => (
404 status_colors.warning_background,
405 status_colors.warning_border,
406 ),
407 DiagnosticSeverity::INFORMATION => {
408 (status_colors.info_background, status_colors.info_border)
409 }
410 DiagnosticSeverity::HINT => {
411 (status_colors.hint_background, status_colors.hint_border)
412 }
413 _ => (
414 status_colors.ignored_background,
415 status_colors.ignored_border,
416 ),
417 }
418 })?;
419
420 let subscription =
421 this.update(cx, |_, cx| cx.observe(&markdown, |_, _, cx| cx.notify()))?;
422
423 let local_diagnostic = DiagnosticEntry {
424 diagnostic: local_diagnostic.diagnostic.to_owned(),
425 range: snapshot
426 .buffer_snapshot()
427 .anchor_before(local_diagnostic.range.start)
428 ..snapshot
429 .buffer_snapshot()
430 .anchor_after(local_diagnostic.range.end),
431 };
432
433 let scroll_handle = ScrollHandle::new();
434
435 Some(DiagnosticPopover {
436 local_diagnostic,
437 markdown,
438 border_color,
439 scroll_handle,
440 background_color,
441 keyboard_grace: Rc::new(RefCell::new(ignore_timeout)),
442 anchor,
443 last_bounds: Rc::new(Cell::new(None)),
444 _subscription: subscription,
445 })
446 } else {
447 None
448 };
449
450 this.update(cx, |this, _| {
451 this.hover_state.diagnostic_popover = diagnostic_popover;
452 })?;
453
454 let invisible_char = if let Some(invisible) = snapshot
455 .buffer_snapshot()
456 .chars_at(anchor)
457 .next()
458 .filter(|&c| is_invisible(c))
459 {
460 let after = snapshot.buffer_snapshot().anchor_after(
461 anchor.to_offset(&snapshot.buffer_snapshot()) + invisible.len_utf8(),
462 );
463 Some((invisible, anchor..after))
464 } else if let Some(invisible) = snapshot
465 .buffer_snapshot()
466 .reversed_chars_at(anchor)
467 .next()
468 .filter(|&c| is_invisible(c))
469 {
470 let before = snapshot.buffer_snapshot().anchor_before(
471 anchor.to_offset(&snapshot.buffer_snapshot()) - invisible.len_utf8(),
472 );
473
474 Some((invisible, before..anchor))
475 } else {
476 None
477 };
478
479 let hovers_response = if let Some(hover_request) = hover_request {
480 hover_request.await.unwrap_or_default()
481 } else {
482 Vec::new()
483 };
484 let snapshot = this.update_in(cx, |this, window, cx| this.snapshot(window, cx))?;
485 let mut hover_highlights = Vec::with_capacity(hovers_response.len());
486 let mut info_popovers = Vec::with_capacity(
487 hovers_response.len() + if invisible_char.is_some() { 1 } else { 0 },
488 );
489
490 if let Some((invisible, range)) = invisible_char {
491 let blocks = vec![HoverBlock {
492 text: format!("Unicode character U+{:02X}", invisible as u32),
493 kind: HoverBlockKind::PlainText,
494 }];
495 let parsed_content =
496 parse_blocks(&blocks, language_registry.as_ref(), None, cx).await;
497 let scroll_handle = ScrollHandle::new();
498 let subscription = this
499 .update(cx, |_, cx| {
500 parsed_content.as_ref().map(|parsed_content| {
501 cx.observe(parsed_content, |_, _, cx| cx.notify())
502 })
503 })
504 .ok()
505 .flatten();
506 info_popovers.push(InfoPopover {
507 symbol_range: RangeInEditor::Text(range),
508 parsed_content,
509 scroll_handle,
510 keyboard_grace: Rc::new(RefCell::new(ignore_timeout)),
511 anchor: Some(anchor),
512 last_bounds: Rc::new(Cell::new(None)),
513 _subscription: subscription,
514 })
515 }
516
517 for hover_result in hovers_response {
518 // Create symbol range of anchors for highlighting and filtering of future requests.
519 let range = hover_result
520 .range
521 .and_then(|range| {
522 let range = snapshot
523 .buffer_snapshot()
524 .buffer_anchor_range_to_anchor_range(range)?;
525 Some(range)
526 })
527 .or_else(|| {
528 let snapshot = &snapshot.buffer_snapshot();
529 let range = snapshot.syntax_ancestor(anchor..anchor)?.1;
530 Some(snapshot.anchor_before(range.start)..snapshot.anchor_after(range.end))
531 })
532 .unwrap_or_else(|| anchor..anchor);
533
534 let blocks = hover_result.contents;
535 let language = hover_result.language;
536 let parsed_content =
537 parse_blocks(&blocks, language_registry.as_ref(), language, cx).await;
538 let scroll_handle = ScrollHandle::new();
539 hover_highlights.push(range.clone());
540 let subscription = this
541 .update(cx, |_, cx| {
542 parsed_content.as_ref().map(|parsed_content| {
543 cx.observe(parsed_content, |_, _, cx| cx.notify())
544 })
545 })
546 .ok()
547 .flatten();
548 info_popovers.push(InfoPopover {
549 symbol_range: RangeInEditor::Text(range),
550 parsed_content,
551 scroll_handle,
552 keyboard_grace: Rc::new(RefCell::new(ignore_timeout)),
553 anchor: Some(anchor),
554 last_bounds: Rc::new(Cell::new(None)),
555 _subscription: subscription,
556 });
557 }
558
559 this.update_in(cx, |editor, window, cx| {
560 if hover_highlights.is_empty() {
561 editor.clear_background_highlights(HighlightKey::HoverState, cx);
562 } else {
563 // Highlight the selected symbol using a background highlight
564 editor.highlight_background(
565 HighlightKey::HoverState,
566 &hover_highlights,
567 |_, theme| theme.colors().element_hover, // todo update theme
568 cx,
569 );
570 }
571
572 editor.hover_state.info_popovers = info_popovers;
573 cx.notify();
574 window.refresh();
575 })?;
576
577 anyhow::Ok(())
578 }
579 .log_err()
580 .await
581 });
582
583 editor.hover_state.info_task = Some(task);
584 None
585}
586
587fn same_info_hover(editor: &Editor, snapshot: &EditorSnapshot, anchor: Anchor) -> bool {
588 editor
589 .hover_state
590 .info_popovers
591 .iter()
592 .any(|InfoPopover { symbol_range, .. }| {
593 symbol_range
594 .as_text_range()
595 .map(|range| {
596 let hover_range = range.to_offset(&snapshot.buffer_snapshot());
597 let offset = anchor.to_offset(&snapshot.buffer_snapshot());
598 // LSP returns a hover result for the end index of ranges that should be hovered, so we need to
599 // use an inclusive range here to check if we should dismiss the popover
600 (hover_range.start..=hover_range.end).contains(&offset)
601 })
602 .unwrap_or(false)
603 })
604}
605
606fn same_diagnostic_hover(editor: &Editor, snapshot: &EditorSnapshot, anchor: Anchor) -> bool {
607 editor
608 .hover_state
609 .diagnostic_popover
610 .as_ref()
611 .map(|diagnostic| {
612 let hover_range = diagnostic
613 .local_diagnostic
614 .range
615 .to_offset(&snapshot.buffer_snapshot());
616 let offset = anchor.to_offset(&snapshot.buffer_snapshot());
617
618 // Here we do basically the same as in `same_info_hover`, see comment there for an explanation
619 (hover_range.start..=hover_range.end).contains(&offset)
620 })
621 .unwrap_or(false)
622}
623
624async fn parse_blocks(
625 blocks: &[HoverBlock],
626 language_registry: Option<&Arc<LanguageRegistry>>,
627 language: Option<Arc<Language>>,
628 cx: &mut AsyncWindowContext,
629) -> Option<Entity<Markdown>> {
630 let combined_text = blocks
631 .iter()
632 .map(|block| match &block.kind {
633 project::HoverBlockKind::PlainText | project::HoverBlockKind::Markdown => {
634 Cow::Borrowed(block.text.trim())
635 }
636 project::HoverBlockKind::Code { language } => {
637 Cow::Owned(format!("```{}\n{}\n```", language, block.text.trim()))
638 }
639 })
640 .join("\n\n");
641
642 cx.new_window_entity(|_window, cx| {
643 Markdown::new(
644 combined_text.into(),
645 language_registry.cloned(),
646 language.map(|language| language.name()),
647 cx,
648 )
649 })
650 .ok()
651}
652
653pub fn hover_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
654 let settings = ThemeSettings::get_global(cx);
655 let ui_font_family = settings.ui_font.family.clone();
656 let ui_font_features = settings.ui_font.features.clone();
657 let ui_font_fallbacks = settings.ui_font.fallbacks.clone();
658 let buffer_font_family = settings.buffer_font.family.clone();
659 let buffer_font_features = settings.buffer_font.features.clone();
660 let buffer_font_fallbacks = settings.buffer_font.fallbacks.clone();
661 let buffer_font_weight = settings.buffer_font.weight;
662
663 let mut base_text_style = window.text_style();
664 base_text_style.refine(&TextStyleRefinement {
665 font_family: Some(ui_font_family),
666 font_features: Some(ui_font_features),
667 font_fallbacks: ui_font_fallbacks,
668 color: Some(cx.theme().colors().editor_foreground),
669 ..Default::default()
670 });
671 MarkdownStyle {
672 base_text_style,
673 code_block: StyleRefinement::default()
674 .my(rems(1.))
675 .font_buffer(cx)
676 .font_features(buffer_font_features.clone())
677 .font_weight(buffer_font_weight),
678 inline_code: TextStyleRefinement {
679 background_color: Some(cx.theme().colors().background),
680 font_family: Some(buffer_font_family),
681 font_features: Some(buffer_font_features),
682 font_fallbacks: buffer_font_fallbacks,
683 font_weight: Some(buffer_font_weight),
684 ..Default::default()
685 },
686 rule_color: cx.theme().colors().border,
687 block_quote_border_color: Color::Muted.color(cx),
688 block_quote: TextStyleRefinement {
689 color: Some(Color::Muted.color(cx)),
690 ..Default::default()
691 },
692 link: TextStyleRefinement {
693 color: Some(cx.theme().colors().editor_foreground),
694 underline: Some(gpui::UnderlineStyle {
695 thickness: px(1.),
696 color: Some(cx.theme().colors().editor_foreground),
697 wavy: false,
698 }),
699 ..Default::default()
700 },
701 syntax: cx.theme().syntax().clone(),
702 selection_background_color: cx.theme().colors().element_selection_background,
703 heading: StyleRefinement::default()
704 .font_weight(FontWeight::BOLD)
705 .text_base()
706 .mt(rems(1.))
707 .mb_0(),
708 table_columns_min_size: true,
709 ..Default::default()
710 }
711}
712
713pub fn diagnostics_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
714 let settings = ThemeSettings::get_global(cx);
715 let ui_font_family = settings.ui_font.family.clone();
716 let ui_font_fallbacks = settings.ui_font.fallbacks.clone();
717 let ui_font_features = settings.ui_font.features.clone();
718 let buffer_font_family = settings.buffer_font.family.clone();
719 let buffer_font_features = settings.buffer_font.features.clone();
720 let buffer_font_fallbacks = settings.buffer_font.fallbacks.clone();
721
722 let mut base_text_style = window.text_style();
723 base_text_style.refine(&TextStyleRefinement {
724 font_family: Some(ui_font_family),
725 font_features: Some(ui_font_features),
726 font_fallbacks: ui_font_fallbacks,
727 color: Some(cx.theme().colors().editor_foreground),
728 ..Default::default()
729 });
730 MarkdownStyle {
731 base_text_style,
732 code_block: StyleRefinement::default().my(rems(1.)).font_buffer(cx),
733 inline_code: TextStyleRefinement {
734 background_color: Some(cx.theme().colors().editor_background.opacity(0.5)),
735 font_family: Some(buffer_font_family),
736 font_features: Some(buffer_font_features),
737 font_fallbacks: buffer_font_fallbacks,
738 ..Default::default()
739 },
740 rule_color: cx.theme().colors().border,
741 block_quote_border_color: Color::Muted.color(cx),
742 block_quote: TextStyleRefinement {
743 color: Some(Color::Muted.color(cx)),
744 ..Default::default()
745 },
746 link: TextStyleRefinement {
747 color: Some(cx.theme().colors().editor_foreground),
748 underline: Some(gpui::UnderlineStyle {
749 thickness: px(1.),
750 color: Some(cx.theme().colors().editor_foreground),
751 wavy: false,
752 }),
753 ..Default::default()
754 },
755 syntax: cx.theme().syntax().clone(),
756 selection_background_color: cx.theme().colors().element_selection_background,
757 height_is_multiple_of_line_height: true,
758 heading: StyleRefinement::default()
759 .font_weight(FontWeight::BOLD)
760 .text_base()
761 .mb_0(),
762 table_columns_min_size: true,
763 ..Default::default()
764 }
765}
766
767pub fn open_markdown_url(link: SharedString, window: &mut Window, cx: &mut App) {
768 if let Ok(uri) = Url::parse(&link)
769 && uri.scheme() == "file"
770 && let Some(workspace) = Workspace::for_window(window, cx)
771 {
772 workspace.update(cx, |workspace, cx| {
773 let task = workspace.open_abs_path(
774 PathBuf::from(uri.path()),
775 OpenOptions {
776 visible: Some(OpenVisible::None),
777 ..Default::default()
778 },
779 window,
780 cx,
781 );
782
783 cx.spawn_in(window, async move |_, cx| {
784 let item = task.await?;
785 // Ruby LSP uses URLs with #L1,1-4,4
786 // we'll just take the first number and assume it's a line number
787 let Some(fragment) = uri.fragment() else {
788 return anyhow::Ok(());
789 };
790 let mut accum = 0u32;
791 for c in fragment.chars() {
792 if c >= '0' && c <= '9' && accum < u32::MAX / 2 {
793 accum *= 10;
794 accum += c as u32 - '0' as u32;
795 } else if accum > 0 {
796 break;
797 }
798 }
799 if accum == 0 {
800 return Ok(());
801 }
802 let Some(editor) = cx.update(|_, cx| item.act_as::<Editor>(cx))? else {
803 return Ok(());
804 };
805 editor.update_in(cx, |editor, window, cx| {
806 editor.change_selections(Default::default(), window, cx, |selections| {
807 selections.select_ranges([
808 text::Point::new(accum - 1, 0)..text::Point::new(accum - 1, 0)
809 ]);
810 });
811 })
812 })
813 .detach_and_log_err(cx);
814 });
815 return;
816 }
817 cx.open_url(&link);
818}
819
820#[derive(Default)]
821pub struct HoverState {
822 pub info_popovers: Vec<InfoPopover>,
823 pub diagnostic_popover: Option<DiagnosticPopover>,
824 pub triggered_from: Option<Anchor>,
825 pub info_task: Option<Task<Option<()>>>,
826 pub closest_mouse_distance: Option<Pixels>,
827 pub hiding_delay_task: Option<Task<()>>,
828}
829
830impl HoverState {
831 pub fn visible(&self) -> bool {
832 !self.info_popovers.is_empty() || self.diagnostic_popover.is_some()
833 }
834
835 pub fn is_mouse_getting_closer(&mut self, mouse_position: gpui::Point<Pixels>) -> bool {
836 if !self.visible() {
837 return false;
838 }
839
840 let mut popover_bounds = Vec::new();
841 for info_popover in &self.info_popovers {
842 if let Some(bounds) = info_popover.last_bounds.get() {
843 popover_bounds.push(bounds);
844 }
845 }
846 if let Some(diagnostic_popover) = &self.diagnostic_popover {
847 if let Some(bounds) = diagnostic_popover.last_bounds.get() {
848 popover_bounds.push(bounds);
849 }
850 }
851
852 if popover_bounds.is_empty() {
853 return false;
854 }
855
856 let distance = popover_bounds
857 .iter()
858 .map(|bounds| self.distance_from_point_to_bounds(mouse_position, *bounds))
859 .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
860 .unwrap_or(px(f32::MAX));
861
862 if let Some(closest_distance) = self.closest_mouse_distance {
863 if distance > closest_distance + px(4.0) {
864 return false;
865 }
866 }
867
868 self.closest_mouse_distance =
869 Some(distance.min(self.closest_mouse_distance.unwrap_or(distance)));
870 true
871 }
872
873 fn distance_from_point_to_bounds(
874 &self,
875 point: gpui::Point<Pixels>,
876 bounds: Bounds<Pixels>,
877 ) -> Pixels {
878 let center_x = bounds.origin.x + bounds.size.width / 2.;
879 let center_y = bounds.origin.y + bounds.size.height / 2.;
880 let dx: f32 = ((point.x - center_x).abs() - bounds.size.width / 2.)
881 .max(px(0.0))
882 .into();
883 let dy: f32 = ((point.y - center_y).abs() - bounds.size.height / 2.)
884 .max(px(0.0))
885 .into();
886 px((dx.powi(2) + dy.powi(2)).sqrt())
887 }
888
889 pub(crate) fn render(
890 &mut self,
891 snapshot: &EditorSnapshot,
892 visible_rows: Range<DisplayRow>,
893 max_size: Size<Pixels>,
894 text_layout_details: &TextLayoutDetails,
895 window: &mut Window,
896 cx: &mut Context<Editor>,
897 ) -> Option<(DisplayPoint, Vec<AnyElement>)> {
898 if !self.visible() {
899 return None;
900 }
901 // If there is a diagnostic, position the popovers based on that.
902 // Otherwise use the start of the hover range
903 let anchor = self
904 .diagnostic_popover
905 .as_ref()
906 .map(|diagnostic_popover| &diagnostic_popover.local_diagnostic.range.start)
907 .or_else(|| {
908 self.info_popovers.iter().find_map(|info_popover| {
909 match &info_popover.symbol_range {
910 RangeInEditor::Text(range) => Some(&range.start),
911 RangeInEditor::Inlay(_) => None,
912 }
913 })
914 })
915 .or_else(|| {
916 self.info_popovers.iter().find_map(|info_popover| {
917 match &info_popover.symbol_range {
918 RangeInEditor::Text(_) => None,
919 RangeInEditor::Inlay(range) => Some(&range.inlay_position),
920 }
921 })
922 })?;
923 let mut point = anchor.to_display_point(&snapshot.display_snapshot);
924 // Clamp the point within the visible rows in case the popup source spans multiple lines
925 if visible_rows.end <= point.row() {
926 point = crate::movement::up_by_rows(
927 &snapshot.display_snapshot,
928 point,
929 1 + (point.row() - visible_rows.end).0,
930 text::SelectionGoal::None,
931 true,
932 text_layout_details,
933 )
934 .0;
935 } else if point.row() < visible_rows.start {
936 point = crate::movement::down_by_rows(
937 &snapshot.display_snapshot,
938 point,
939 (visible_rows.start - point.row()).0,
940 text::SelectionGoal::None,
941 true,
942 text_layout_details,
943 )
944 .0;
945 }
946
947 if !visible_rows.contains(&point.row()) {
948 log::error!("Hover popover point out of bounds after moving");
949 return None;
950 }
951
952 let mut elements = Vec::new();
953
954 if let Some(diagnostic_popover) = self.diagnostic_popover.as_ref() {
955 elements.push(diagnostic_popover.render(max_size, window, cx));
956 }
957 for info_popover in &mut self.info_popovers {
958 elements.push(info_popover.render(max_size, window, cx));
959 }
960
961 Some((point, elements))
962 }
963
964 pub fn focused(&self, window: &mut Window, cx: &mut Context<Editor>) -> bool {
965 let mut hover_popover_is_focused = false;
966 for info_popover in &self.info_popovers {
967 if let Some(markdown_view) = &info_popover.parsed_content
968 && markdown_view.focus_handle(cx).is_focused(window)
969 {
970 hover_popover_is_focused = true;
971 }
972 }
973 if let Some(diagnostic_popover) = &self.diagnostic_popover
974 && diagnostic_popover
975 .markdown
976 .focus_handle(cx)
977 .is_focused(window)
978 {
979 hover_popover_is_focused = true;
980 }
981 hover_popover_is_focused
982 }
983}
984
985pub struct InfoPopover {
986 pub symbol_range: RangeInEditor,
987 pub parsed_content: Option<Entity<Markdown>>,
988 pub scroll_handle: ScrollHandle,
989 pub keyboard_grace: Rc<RefCell<bool>>,
990 pub anchor: Option<Anchor>,
991 pub last_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
992 _subscription: Option<Subscription>,
993}
994
995impl InfoPopover {
996 pub(crate) fn render(
997 &mut self,
998 max_size: Size<Pixels>,
999 window: &mut Window,
1000 cx: &mut Context<Editor>,
1001 ) -> AnyElement {
1002 let keyboard_grace = Rc::clone(&self.keyboard_grace);
1003 let this = cx.entity().downgrade();
1004 let bounds_cell = self.last_bounds.clone();
1005 div()
1006 .id("info_popover")
1007 .occlude()
1008 .elevation_2(cx)
1009 .child(
1010 canvas(
1011 {
1012 move |bounds, _window, _cx| {
1013 bounds_cell.set(Some(bounds));
1014 }
1015 },
1016 |_, _, _, _| {},
1017 )
1018 .absolute()
1019 .size_full(),
1020 )
1021 // Prevent a mouse down/move on the popover from being propagated to the editor,
1022 // because that would dismiss the popover.
1023 .on_mouse_move({
1024 move |_, _, cx: &mut App| {
1025 this.update(cx, |editor, _| {
1026 editor.hover_state.closest_mouse_distance = Some(px(0.0));
1027 editor.hover_state.hiding_delay_task = None;
1028 })
1029 .ok();
1030 cx.stop_propagation()
1031 }
1032 })
1033 .on_mouse_down(MouseButton::Left, move |_, _, cx| {
1034 let mut keyboard_grace = keyboard_grace.borrow_mut();
1035 *keyboard_grace = false;
1036 cx.stop_propagation();
1037 })
1038 .when_some(self.parsed_content.clone(), |this, markdown| {
1039 this.child(
1040 div()
1041 .id("info-md-container")
1042 .overflow_y_scroll()
1043 .max_w(max_size.width)
1044 .max_h(max_size.height)
1045 .track_scroll(&self.scroll_handle)
1046 .child(
1047 MarkdownElement::new(markdown, hover_markdown_style(window, cx))
1048 .code_block_renderer(markdown::CodeBlockRenderer::Default {
1049 copy_button_visibility: CopyButtonVisibility::Hidden,
1050 border: false,
1051 })
1052 .on_url_click(open_markdown_url)
1053 .p_2(),
1054 ),
1055 )
1056 .custom_scrollbars(
1057 Scrollbars::for_settings::<EditorSettingsScrollbarProxy>()
1058 .tracked_scroll_handle(&self.scroll_handle),
1059 window,
1060 cx,
1061 )
1062 })
1063 .into_any_element()
1064 }
1065
1066 pub fn scroll(&self, amount: ScrollAmount, window: &mut Window, cx: &mut Context<Editor>) {
1067 let mut current = self.scroll_handle.offset();
1068 current.y -= amount.pixels(
1069 window.line_height(),
1070 self.scroll_handle.bounds().size.height - px(16.),
1071 ) / 2.0;
1072 cx.notify();
1073 self.scroll_handle.set_offset(current);
1074 }
1075}
1076
1077pub struct DiagnosticPopover {
1078 pub(crate) local_diagnostic: DiagnosticEntry<Anchor>,
1079 markdown: Entity<Markdown>,
1080 border_color: Hsla,
1081 background_color: Hsla,
1082 pub keyboard_grace: Rc<RefCell<bool>>,
1083 pub anchor: Anchor,
1084 pub last_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
1085 _subscription: Subscription,
1086 pub scroll_handle: ScrollHandle,
1087}
1088
1089impl DiagnosticPopover {
1090 pub fn render(
1091 &self,
1092 max_size: Size<Pixels>,
1093 window: &mut Window,
1094 cx: &mut Context<Editor>,
1095 ) -> AnyElement {
1096 let keyboard_grace = Rc::clone(&self.keyboard_grace);
1097 let this = cx.entity().downgrade();
1098 let bounds_cell = self.last_bounds.clone();
1099 div()
1100 .id("diagnostic")
1101 .occlude()
1102 .elevation_2_borderless(cx)
1103 .child(
1104 canvas(
1105 {
1106 move |bounds, _window, _cx| {
1107 bounds_cell.set(Some(bounds));
1108 }
1109 },
1110 |_, _, _, _| {},
1111 )
1112 .absolute()
1113 .size_full(),
1114 )
1115 // Don't draw the background color if the theme
1116 // allows transparent surfaces.
1117 .when(theme_is_transparent(cx), |this| {
1118 this.bg(gpui::transparent_black())
1119 })
1120 // Prevent a mouse move on the popover from being propagated to the editor,
1121 // because that would dismiss the popover.
1122 .on_mouse_move({
1123 let this = this.clone();
1124 move |_, _, cx: &mut App| {
1125 this.update(cx, |editor, _| {
1126 editor.hover_state.closest_mouse_distance = Some(px(0.0));
1127 editor.hover_state.hiding_delay_task = None;
1128 })
1129 .ok();
1130 cx.stop_propagation()
1131 }
1132 })
1133 // Prevent a mouse down on the popover from being propagated to the editor,
1134 // because that would move the cursor.
1135 .on_mouse_down(MouseButton::Left, move |_, _, cx| {
1136 let mut keyboard_grace = keyboard_grace.borrow_mut();
1137 *keyboard_grace = false;
1138 cx.stop_propagation();
1139 })
1140 .child(
1141 div()
1142 .relative()
1143 .py_1()
1144 .pl_2()
1145 .pr_8()
1146 .bg(self.background_color)
1147 .border_1()
1148 .border_color(self.border_color)
1149 .rounded_lg()
1150 .child(
1151 div()
1152 .id("diagnostic-content-container")
1153 .max_w(max_size.width)
1154 .max_h(max_size.height)
1155 .overflow_y_scroll()
1156 .track_scroll(&self.scroll_handle)
1157 .child(
1158 MarkdownElement::new(
1159 self.markdown.clone(),
1160 diagnostics_markdown_style(window, cx),
1161 )
1162 .code_block_renderer(markdown::CodeBlockRenderer::Default {
1163 copy_button_visibility: CopyButtonVisibility::Hidden,
1164 border: false,
1165 })
1166 .on_url_click(
1167 move |link, window, cx| {
1168 if let Some(renderer) = GlobalDiagnosticRenderer::global(cx)
1169 {
1170 this.update(cx, |this, cx| {
1171 renderer.as_ref().open_link(this, link, window, cx);
1172 })
1173 .ok();
1174 }
1175 },
1176 ),
1177 ),
1178 )
1179 .child(div().absolute().top_1().right_1().child({
1180 let message = self.local_diagnostic.diagnostic.message.clone();
1181 CopyButton::new("copy-diagnostic", message).tooltip_label("Copy Diagnostic")
1182 }))
1183 .custom_scrollbars(
1184 Scrollbars::for_settings::<EditorSettingsScrollbarProxy>()
1185 .tracked_scroll_handle(&self.scroll_handle),
1186 window,
1187 cx,
1188 ),
1189 )
1190 .into_any_element()
1191 }
1192}
1193
1194#[cfg(test)]
1195mod tests {
1196 use super::*;
1197 use crate::{
1198 PointForPosition,
1199 actions::ConfirmCompletion,
1200 editor_tests::{handle_completion_request, init_test},
1201 inlays::inlay_hints::tests::{cached_hint_labels, visible_hint_labels},
1202 test::editor_lsp_test_context::EditorLspTestContext,
1203 };
1204 use collections::BTreeSet;
1205 use gpui::App;
1206 use indoc::indoc;
1207 use markdown::parser::MarkdownEvent;
1208 use project::InlayId;
1209 use settings::InlayHintSettingsContent;
1210 use settings::{DelayMs, SettingsStore};
1211 use smol::stream::StreamExt;
1212 use std::sync::atomic;
1213 use std::sync::atomic::AtomicUsize;
1214 use text::Bias;
1215
1216 fn get_hover_popover_delay(cx: &gpui::TestAppContext) -> u64 {
1217 cx.read(|cx: &App| -> u64 { EditorSettings::get_global(cx).hover_popover_delay.0 })
1218 }
1219
1220 impl InfoPopover {
1221 fn get_rendered_text(&self, cx: &gpui::App) -> String {
1222 let mut rendered_text = String::new();
1223 if let Some(parsed_content) = self.parsed_content.clone() {
1224 let markdown = parsed_content.read(cx);
1225 let text = markdown.parsed_markdown().source().to_string();
1226 let data = markdown.parsed_markdown().events();
1227 let slice = data;
1228
1229 for (range, event) in slice.iter() {
1230 match event {
1231 MarkdownEvent::SubstitutedText(parsed) => {
1232 rendered_text.push_str(parsed.as_str())
1233 }
1234 MarkdownEvent::Text | MarkdownEvent::Code => {
1235 rendered_text.push_str(&text[range.clone()])
1236 }
1237 _ => {}
1238 }
1239 }
1240 }
1241 rendered_text
1242 }
1243 }
1244
1245 #[gpui::test]
1246 async fn test_mouse_hover_info_popover_with_autocomplete_popover(
1247 cx: &mut gpui::TestAppContext,
1248 ) {
1249 init_test(cx, |_| {});
1250
1251 let mut cx = EditorLspTestContext::new_rust(
1252 lsp::ServerCapabilities {
1253 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
1254 completion_provider: Some(lsp::CompletionOptions {
1255 trigger_characters: Some(vec![".".to_string(), ":".to_string()]),
1256 resolve_provider: Some(true),
1257 ..Default::default()
1258 }),
1259 ..Default::default()
1260 },
1261 cx,
1262 )
1263 .await;
1264 let counter = Arc::new(AtomicUsize::new(0));
1265 // Basic hover delays and then pops without moving the mouse
1266 cx.set_state(indoc! {"
1267 oneˇ
1268 two
1269 three
1270 fn test() { println!(); }
1271 "});
1272
1273 //prompt autocompletion menu
1274 cx.simulate_keystroke(".");
1275 handle_completion_request(
1276 indoc! {"
1277 one.|<>
1278 two
1279 three
1280 "},
1281 vec!["first_completion", "second_completion"],
1282 true,
1283 counter.clone(),
1284 &mut cx,
1285 )
1286 .await;
1287 cx.condition(|editor, _| editor.context_menu_visible()) // wait until completion menu is visible
1288 .await;
1289 assert_eq!(counter.load(atomic::Ordering::Acquire), 1); // 1 completion request
1290
1291 let hover_point = cx.display_point(indoc! {"
1292 one.
1293 two
1294 three
1295 fn test() { printˇln!(); }
1296 "});
1297 cx.update_editor(|editor, window, cx| {
1298 let snapshot = editor.snapshot(window, cx);
1299 let anchor = snapshot
1300 .buffer_snapshot()
1301 .anchor_before(hover_point.to_offset(&snapshot, Bias::Left));
1302 hover_at(editor, Some(anchor), None, window, cx)
1303 });
1304 assert!(!cx.editor(|editor, _window, _cx| editor.hover_state.visible()));
1305
1306 // After delay, hover should be visible.
1307 let symbol_range = cx.lsp_range(indoc! {"
1308 one.
1309 two
1310 three
1311 fn test() { «println!»(); }
1312 "});
1313 let mut requests =
1314 cx.set_request_handler::<lsp::request::HoverRequest, _, _>(move |_, _, _| async move {
1315 Ok(Some(lsp::Hover {
1316 contents: lsp::HoverContents::Markup(lsp::MarkupContent {
1317 kind: lsp::MarkupKind::Markdown,
1318 value: "some basic docs".to_string(),
1319 }),
1320 range: Some(symbol_range),
1321 }))
1322 });
1323 cx.background_executor
1324 .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100));
1325 requests.next().await;
1326
1327 cx.editor(|editor, _window, cx| {
1328 assert!(editor.hover_state.visible());
1329 assert_eq!(
1330 editor.hover_state.info_popovers.len(),
1331 1,
1332 "Expected exactly one hover but got: {:?}",
1333 editor.hover_state.info_popovers.len()
1334 );
1335 let rendered_text = editor
1336 .hover_state
1337 .info_popovers
1338 .first()
1339 .unwrap()
1340 .get_rendered_text(cx);
1341 assert_eq!(rendered_text, "some basic docs".to_string())
1342 });
1343
1344 // check that the completion menu is still visible and that there still has only been 1 completion request
1345 cx.editor(|editor, _, _| assert!(editor.context_menu_visible()));
1346 assert_eq!(counter.load(atomic::Ordering::Acquire), 1);
1347
1348 //apply a completion and check it was successfully applied
1349 let _apply_additional_edits = cx.update_editor(|editor, window, cx| {
1350 editor.context_menu_next(&Default::default(), window, cx);
1351 editor
1352 .confirm_completion(&ConfirmCompletion::default(), window, cx)
1353 .unwrap()
1354 });
1355 cx.assert_editor_state(indoc! {"
1356 one.second_completionˇ
1357 two
1358 three
1359 fn test() { println!(); }
1360 "});
1361
1362 // check that the completion menu is no longer visible and that there still has only been 1 completion request
1363 cx.editor(|editor, _, _| assert!(!editor.context_menu_visible()));
1364 assert_eq!(counter.load(atomic::Ordering::Acquire), 1);
1365
1366 //verify the information popover is still visible and unchanged
1367 cx.editor(|editor, _, cx| {
1368 assert!(editor.hover_state.visible());
1369 assert_eq!(
1370 editor.hover_state.info_popovers.len(),
1371 1,
1372 "Expected exactly one hover but got: {:?}",
1373 editor.hover_state.info_popovers.len()
1374 );
1375 let rendered_text = editor
1376 .hover_state
1377 .info_popovers
1378 .first()
1379 .unwrap()
1380 .get_rendered_text(cx);
1381
1382 assert_eq!(rendered_text, "some basic docs".to_string())
1383 });
1384
1385 // Mouse moved with no hover response dismisses
1386 let hover_point = cx.display_point(indoc! {"
1387 one.second_completionˇ
1388 two
1389 three
1390 fn teˇst() { println!(); }
1391 "});
1392 let mut request = cx
1393 .lsp
1394 .set_request_handler::<lsp::request::HoverRequest, _, _>(
1395 |_, _| async move { Ok(None) },
1396 );
1397 cx.update_editor(|editor, window, cx| {
1398 let snapshot = editor.snapshot(window, cx);
1399 let anchor = snapshot
1400 .buffer_snapshot()
1401 .anchor_before(hover_point.to_offset(&snapshot, Bias::Left));
1402 hover_at(editor, Some(anchor), None, window, cx)
1403 });
1404 cx.background_executor
1405 .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100));
1406 request.next().await;
1407
1408 // verify that the information popover is no longer visible
1409 cx.editor(|editor, _, _| {
1410 assert!(!editor.hover_state.visible());
1411 });
1412 }
1413
1414 #[gpui::test]
1415 async fn test_mouse_hover_info_popover(cx: &mut gpui::TestAppContext) {
1416 init_test(cx, |_| {});
1417
1418 let mut cx = EditorLspTestContext::new_rust(
1419 lsp::ServerCapabilities {
1420 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
1421 ..Default::default()
1422 },
1423 cx,
1424 )
1425 .await;
1426
1427 // Basic hover delays and then pops without moving the mouse
1428 cx.set_state(indoc! {"
1429 fn ˇtest() { println!(); }
1430 "});
1431 let hover_point = cx.display_point(indoc! {"
1432 fn test() { printˇln!(); }
1433 "});
1434
1435 cx.update_editor(|editor, window, cx| {
1436 let snapshot = editor.snapshot(window, cx);
1437 let anchor = snapshot
1438 .buffer_snapshot()
1439 .anchor_before(hover_point.to_offset(&snapshot, Bias::Left));
1440 hover_at(editor, Some(anchor), None, window, cx)
1441 });
1442 assert!(!cx.editor(|editor, _window, _cx| editor.hover_state.visible()));
1443
1444 // After delay, hover should be visible.
1445 let symbol_range = cx.lsp_range(indoc! {"
1446 fn test() { «println!»(); }
1447 "});
1448 let mut requests =
1449 cx.set_request_handler::<lsp::request::HoverRequest, _, _>(move |_, _, _| async move {
1450 Ok(Some(lsp::Hover {
1451 contents: lsp::HoverContents::Markup(lsp::MarkupContent {
1452 kind: lsp::MarkupKind::Markdown,
1453 value: "some basic docs".to_string(),
1454 }),
1455 range: Some(symbol_range),
1456 }))
1457 });
1458 cx.background_executor
1459 .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100));
1460 requests.next().await;
1461
1462 cx.editor(|editor, _, cx| {
1463 assert!(editor.hover_state.visible());
1464 assert_eq!(
1465 editor.hover_state.info_popovers.len(),
1466 1,
1467 "Expected exactly one hover but got: {:?}",
1468 editor.hover_state.info_popovers.len()
1469 );
1470 let rendered_text = editor
1471 .hover_state
1472 .info_popovers
1473 .first()
1474 .unwrap()
1475 .get_rendered_text(cx);
1476
1477 assert_eq!(rendered_text, "some basic docs".to_string())
1478 });
1479
1480 // Mouse moved with no hover response dismisses
1481 let hover_point = cx.display_point(indoc! {"
1482 fn teˇst() { println!(); }
1483 "});
1484 let mut request = cx
1485 .lsp
1486 .set_request_handler::<lsp::request::HoverRequest, _, _>(
1487 |_, _| async move { Ok(None) },
1488 );
1489 cx.update_editor(|editor, window, cx| {
1490 let snapshot = editor.snapshot(window, cx);
1491 let anchor = snapshot
1492 .buffer_snapshot()
1493 .anchor_before(hover_point.to_offset(&snapshot, Bias::Left));
1494 hover_at(editor, Some(anchor), None, window, cx)
1495 });
1496 cx.background_executor
1497 .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100));
1498 request.next().await;
1499 cx.editor(|editor, _, _| {
1500 assert!(!editor.hover_state.visible());
1501 });
1502 }
1503
1504 #[gpui::test]
1505 async fn test_keyboard_hover_info_popover(cx: &mut gpui::TestAppContext) {
1506 init_test(cx, |_| {});
1507
1508 let mut cx = EditorLspTestContext::new_rust(
1509 lsp::ServerCapabilities {
1510 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
1511 ..Default::default()
1512 },
1513 cx,
1514 )
1515 .await;
1516
1517 // Hover with keyboard has no delay
1518 cx.set_state(indoc! {"
1519 fˇn test() { println!(); }
1520 "});
1521 cx.update_editor(|editor, window, cx| hover(editor, &Hover, window, cx));
1522 let symbol_range = cx.lsp_range(indoc! {"
1523 «fn» test() { println!(); }
1524 "});
1525
1526 cx.editor(|editor, _window, _cx| {
1527 assert!(!editor.hover_state.visible());
1528
1529 assert_eq!(
1530 editor.hover_state.info_popovers.len(),
1531 0,
1532 "Expected no hovers but got but got: {:?}",
1533 editor.hover_state.info_popovers.len()
1534 );
1535 });
1536
1537 let mut requests =
1538 cx.set_request_handler::<lsp::request::HoverRequest, _, _>(move |_, _, _| async move {
1539 Ok(Some(lsp::Hover {
1540 contents: lsp::HoverContents::Markup(lsp::MarkupContent {
1541 kind: lsp::MarkupKind::Markdown,
1542 value: "some other basic docs".to_string(),
1543 }),
1544 range: Some(symbol_range),
1545 }))
1546 });
1547
1548 requests.next().await;
1549 cx.dispatch_action(Hover);
1550
1551 cx.condition(|editor, _| editor.hover_state.visible()).await;
1552 cx.editor(|editor, _, cx| {
1553 assert_eq!(
1554 editor.hover_state.info_popovers.len(),
1555 1,
1556 "Expected exactly one hover but got: {:?}",
1557 editor.hover_state.info_popovers.len()
1558 );
1559
1560 let rendered_text = editor
1561 .hover_state
1562 .info_popovers
1563 .first()
1564 .unwrap()
1565 .get_rendered_text(cx);
1566
1567 assert_eq!(rendered_text, "some other basic docs".to_string())
1568 });
1569 }
1570
1571 #[gpui::test]
1572 async fn test_empty_hovers_filtered(cx: &mut gpui::TestAppContext) {
1573 init_test(cx, |_| {});
1574
1575 let mut cx = EditorLspTestContext::new_rust(
1576 lsp::ServerCapabilities {
1577 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
1578 ..Default::default()
1579 },
1580 cx,
1581 )
1582 .await;
1583
1584 // Hover with keyboard has no delay
1585 cx.set_state(indoc! {"
1586 fˇn test() { println!(); }
1587 "});
1588 cx.update_editor(|editor, window, cx| hover(editor, &Hover, window, cx));
1589 let symbol_range = cx.lsp_range(indoc! {"
1590 «fn» test() { println!(); }
1591 "});
1592 cx.set_request_handler::<lsp::request::HoverRequest, _, _>(move |_, _, _| async move {
1593 Ok(Some(lsp::Hover {
1594 contents: lsp::HoverContents::Array(vec![
1595 lsp::MarkedString::String("regular text for hover to show".to_string()),
1596 lsp::MarkedString::String("".to_string()),
1597 lsp::MarkedString::LanguageString(lsp::LanguageString {
1598 language: "Rust".to_string(),
1599 value: "".to_string(),
1600 }),
1601 ]),
1602 range: Some(symbol_range),
1603 }))
1604 })
1605 .next()
1606 .await;
1607 cx.dispatch_action(Hover);
1608
1609 cx.condition(|editor, _| editor.hover_state.visible()).await;
1610 cx.editor(|editor, _, cx| {
1611 assert_eq!(
1612 editor.hover_state.info_popovers.len(),
1613 1,
1614 "Expected exactly one hover but got: {:?}",
1615 editor.hover_state.info_popovers.len()
1616 );
1617 let rendered_text = editor
1618 .hover_state
1619 .info_popovers
1620 .first()
1621 .unwrap()
1622 .get_rendered_text(cx);
1623
1624 assert_eq!(
1625 rendered_text,
1626 "regular text for hover to show".to_string(),
1627 "No empty string hovers should be shown"
1628 );
1629 });
1630 }
1631
1632 #[gpui::test]
1633 async fn test_line_ends_trimmed(cx: &mut gpui::TestAppContext) {
1634 init_test(cx, |_| {});
1635
1636 let mut cx = EditorLspTestContext::new_rust(
1637 lsp::ServerCapabilities {
1638 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
1639 ..Default::default()
1640 },
1641 cx,
1642 )
1643 .await;
1644
1645 // Hover with keyboard has no delay
1646 cx.set_state(indoc! {"
1647 fˇn test() { println!(); }
1648 "});
1649 cx.update_editor(|editor, window, cx| hover(editor, &Hover, window, cx));
1650 let symbol_range = cx.lsp_range(indoc! {"
1651 «fn» test() { println!(); }
1652 "});
1653
1654 let code_str = "\nlet hovered_point: Vector2F // size = 8, align = 0x4\n";
1655 let markdown_string = format!("\n```rust\n{code_str}```");
1656
1657 let closure_markdown_string = markdown_string.clone();
1658 cx.set_request_handler::<lsp::request::HoverRequest, _, _>(move |_, _, _| {
1659 let future_markdown_string = closure_markdown_string.clone();
1660 async move {
1661 Ok(Some(lsp::Hover {
1662 contents: lsp::HoverContents::Markup(lsp::MarkupContent {
1663 kind: lsp::MarkupKind::Markdown,
1664 value: future_markdown_string,
1665 }),
1666 range: Some(symbol_range),
1667 }))
1668 }
1669 })
1670 .next()
1671 .await;
1672
1673 cx.dispatch_action(Hover);
1674
1675 cx.condition(|editor, _| editor.hover_state.visible()).await;
1676 cx.editor(|editor, _, cx| {
1677 assert_eq!(
1678 editor.hover_state.info_popovers.len(),
1679 1,
1680 "Expected exactly one hover but got: {:?}",
1681 editor.hover_state.info_popovers.len()
1682 );
1683 let rendered_text = editor
1684 .hover_state
1685 .info_popovers
1686 .first()
1687 .unwrap()
1688 .get_rendered_text(cx);
1689
1690 assert_eq!(
1691 rendered_text, code_str,
1692 "Should not have extra line breaks at end of rendered hover"
1693 );
1694 });
1695 }
1696
1697 #[gpui::test]
1698 // https://github.com/zed-industries/zed/issues/15498
1699 async fn test_info_hover_with_hrs(cx: &mut gpui::TestAppContext) {
1700 init_test(cx, |_| {});
1701
1702 let mut cx = EditorLspTestContext::new_rust(
1703 lsp::ServerCapabilities {
1704 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
1705 ..Default::default()
1706 },
1707 cx,
1708 )
1709 .await;
1710
1711 cx.set_state(indoc! {"
1712 fn fuˇnc(abc def: i32) -> u32 {
1713 }
1714 "});
1715
1716 cx.lsp
1717 .set_request_handler::<lsp::request::HoverRequest, _, _>({
1718 |_, _| async move {
1719 Ok(Some(lsp::Hover {
1720 contents: lsp::HoverContents::Markup(lsp::MarkupContent {
1721 kind: lsp::MarkupKind::Markdown,
1722 value: indoc!(
1723 r#"
1724 ### function `errands_data_read`
1725
1726 ---
1727 → `char *`
1728 Function to read a file into a string
1729
1730 ---
1731 ```cpp
1732 static char *errands_data_read()
1733 ```
1734 "#
1735 )
1736 .to_string(),
1737 }),
1738 range: None,
1739 }))
1740 }
1741 });
1742 cx.update_editor(|editor, window, cx| hover(editor, &Default::default(), window, cx));
1743 cx.run_until_parked();
1744
1745 cx.update_editor(|editor, _, cx| {
1746 let popover = editor.hover_state.info_popovers.first().unwrap();
1747 let content = popover.get_rendered_text(cx);
1748
1749 assert!(content.contains("Function to read a file"));
1750 });
1751 }
1752
1753 #[gpui::test]
1754 async fn test_hover_inlay_label_parts(cx: &mut gpui::TestAppContext) {
1755 init_test(cx, |settings| {
1756 settings.defaults.inlay_hints = Some(InlayHintSettingsContent {
1757 show_value_hints: Some(true),
1758 enabled: Some(true),
1759 edit_debounce_ms: Some(0),
1760 scroll_debounce_ms: Some(0),
1761 show_type_hints: Some(true),
1762 show_parameter_hints: Some(true),
1763 show_other_hints: Some(true),
1764 show_background: Some(false),
1765 toggle_on_modifiers_press: None,
1766 })
1767 });
1768
1769 let mut cx = EditorLspTestContext::new_rust(
1770 lsp::ServerCapabilities {
1771 inlay_hint_provider: Some(lsp::OneOf::Right(
1772 lsp::InlayHintServerCapabilities::Options(lsp::InlayHintOptions {
1773 resolve_provider: Some(true),
1774 ..Default::default()
1775 }),
1776 )),
1777 ..Default::default()
1778 },
1779 cx,
1780 )
1781 .await;
1782
1783 cx.set_state(indoc! {"
1784 struct TestStruct;
1785
1786 // ==================
1787
1788 struct TestNewType<T>(T);
1789
1790 fn main() {
1791 let variableˇ = TestNewType(TestStruct);
1792 }
1793 "});
1794
1795 let hint_start_offset = cx.ranges(indoc! {"
1796 struct TestStruct;
1797
1798 // ==================
1799
1800 struct TestNewType<T>(T);
1801
1802 fn main() {
1803 let variableˇ = TestNewType(TestStruct);
1804 }
1805 "})[0]
1806 .start;
1807 let hint_position = cx.to_lsp(MultiBufferOffset(hint_start_offset));
1808 let new_type_target_range = cx.lsp_range(indoc! {"
1809 struct TestStruct;
1810
1811 // ==================
1812
1813 struct «TestNewType»<T>(T);
1814
1815 fn main() {
1816 let variable = TestNewType(TestStruct);
1817 }
1818 "});
1819 let struct_target_range = cx.lsp_range(indoc! {"
1820 struct «TestStruct»;
1821
1822 // ==================
1823
1824 struct TestNewType<T>(T);
1825
1826 fn main() {
1827 let variable = TestNewType(TestStruct);
1828 }
1829 "});
1830
1831 let uri = cx.buffer_lsp_url.clone();
1832 let new_type_label = "TestNewType";
1833 let struct_label = "TestStruct";
1834 let entire_hint_label = ": TestNewType<TestStruct>";
1835 let closure_uri = uri.clone();
1836 cx.lsp
1837 .set_request_handler::<lsp::request::InlayHintRequest, _, _>(move |params, _| {
1838 let task_uri = closure_uri.clone();
1839 async move {
1840 assert_eq!(params.text_document.uri, task_uri);
1841 Ok(Some(vec![lsp::InlayHint {
1842 position: hint_position,
1843 label: lsp::InlayHintLabel::LabelParts(vec![lsp::InlayHintLabelPart {
1844 value: entire_hint_label.to_string(),
1845 ..Default::default()
1846 }]),
1847 kind: Some(lsp::InlayHintKind::TYPE),
1848 text_edits: None,
1849 tooltip: None,
1850 padding_left: Some(false),
1851 padding_right: Some(false),
1852 data: None,
1853 }]))
1854 }
1855 })
1856 .next()
1857 .await;
1858 cx.background_executor.run_until_parked();
1859 cx.update_editor(|editor, _, cx| {
1860 let expected_layers = vec![entire_hint_label.to_string()];
1861 assert_eq!(expected_layers, cached_hint_labels(editor, cx));
1862 assert_eq!(expected_layers, visible_hint_labels(editor, cx));
1863 });
1864
1865 let inlay_range = cx
1866 .ranges(indoc! {"
1867 struct TestStruct;
1868
1869 // ==================
1870
1871 struct TestNewType<T>(T);
1872
1873 fn main() {
1874 let variable« »= TestNewType(TestStruct);
1875 }
1876 "})
1877 .first()
1878 .cloned()
1879 .unwrap();
1880 let new_type_hint_part_hover_position = cx.update_editor(|editor, window, cx| {
1881 let snapshot = editor.snapshot(window, cx);
1882 let previous_valid = MultiBufferOffset(inlay_range.start).to_display_point(&snapshot);
1883 let next_valid = MultiBufferOffset(inlay_range.end).to_display_point(&snapshot);
1884 assert_eq!(previous_valid.row(), next_valid.row());
1885 assert!(previous_valid.column() < next_valid.column());
1886 let exact_unclipped = DisplayPoint::new(
1887 previous_valid.row(),
1888 previous_valid.column()
1889 + (entire_hint_label.find(new_type_label).unwrap() + new_type_label.len() / 2)
1890 as u32,
1891 );
1892 PointForPosition {
1893 previous_valid,
1894 next_valid,
1895 exact_unclipped,
1896 column_overshoot_after_line_end: 0,
1897 }
1898 });
1899 cx.update_editor(|editor, window, cx| {
1900 editor.update_inlay_link_and_hover_points(
1901 &editor.snapshot(window, cx),
1902 new_type_hint_part_hover_position,
1903 None,
1904 true,
1905 false,
1906 window,
1907 cx,
1908 );
1909 });
1910
1911 let resolve_closure_uri = uri.clone();
1912 cx.lsp
1913 .set_request_handler::<lsp::request::InlayHintResolveRequest, _, _>(
1914 move |mut hint_to_resolve, _| {
1915 let mut resolved_hint_positions = BTreeSet::new();
1916 let task_uri = resolve_closure_uri.clone();
1917 async move {
1918 let inserted = resolved_hint_positions.insert(hint_to_resolve.position);
1919 assert!(inserted, "Hint {hint_to_resolve:?} was resolved twice");
1920
1921 // `: TestNewType<TestStruct>`
1922 hint_to_resolve.label = lsp::InlayHintLabel::LabelParts(vec![
1923 lsp::InlayHintLabelPart {
1924 value: ": ".to_string(),
1925 ..Default::default()
1926 },
1927 lsp::InlayHintLabelPart {
1928 value: new_type_label.to_string(),
1929 location: Some(lsp::Location {
1930 uri: task_uri.clone(),
1931 range: new_type_target_range,
1932 }),
1933 tooltip: Some(lsp::InlayHintLabelPartTooltip::String(format!(
1934 "A tooltip for `{new_type_label}`"
1935 ))),
1936 ..Default::default()
1937 },
1938 lsp::InlayHintLabelPart {
1939 value: "<".to_string(),
1940 ..Default::default()
1941 },
1942 lsp::InlayHintLabelPart {
1943 value: struct_label.to_string(),
1944 location: Some(lsp::Location {
1945 uri: task_uri,
1946 range: struct_target_range,
1947 }),
1948 tooltip: Some(lsp::InlayHintLabelPartTooltip::MarkupContent(
1949 lsp::MarkupContent {
1950 kind: lsp::MarkupKind::Markdown,
1951 value: format!("A tooltip for `{struct_label}`"),
1952 },
1953 )),
1954 ..Default::default()
1955 },
1956 lsp::InlayHintLabelPart {
1957 value: ">".to_string(),
1958 ..Default::default()
1959 },
1960 ]);
1961
1962 Ok(hint_to_resolve)
1963 }
1964 },
1965 )
1966 .next()
1967 .await;
1968 cx.background_executor.run_until_parked();
1969
1970 cx.update_editor(|editor, window, cx| {
1971 editor.update_inlay_link_and_hover_points(
1972 &editor.snapshot(window, cx),
1973 new_type_hint_part_hover_position,
1974 None,
1975 true,
1976 false,
1977 window,
1978 cx,
1979 );
1980 });
1981 cx.background_executor
1982 .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100));
1983 cx.background_executor.run_until_parked();
1984 cx.update_editor(|editor, _, cx| {
1985 let hover_state = &editor.hover_state;
1986 assert!(
1987 hover_state.diagnostic_popover.is_none() && hover_state.info_popovers.len() == 1
1988 );
1989 let popover = hover_state.info_popovers.first().unwrap();
1990 let buffer_snapshot = editor.buffer().update(cx, |buffer, cx| buffer.snapshot(cx));
1991 assert_eq!(
1992 popover.symbol_range,
1993 RangeInEditor::Inlay(InlayHighlight {
1994 inlay: InlayId::Hint(0),
1995 inlay_position: buffer_snapshot
1996 .anchor_after(MultiBufferOffset(inlay_range.start)),
1997 range: ": ".len()..": ".len() + new_type_label.len(),
1998 }),
1999 "Popover range should match the new type label part"
2000 );
2001 assert_eq!(
2002 popover.get_rendered_text(cx),
2003 format!("A tooltip for {new_type_label}"),
2004 );
2005 });
2006
2007 let struct_hint_part_hover_position = cx.update_editor(|editor, window, cx| {
2008 let snapshot = editor.snapshot(window, cx);
2009 let previous_valid = MultiBufferOffset(inlay_range.start).to_display_point(&snapshot);
2010 let next_valid = MultiBufferOffset(inlay_range.end).to_display_point(&snapshot);
2011 assert_eq!(previous_valid.row(), next_valid.row());
2012 assert!(previous_valid.column() < next_valid.column());
2013 let exact_unclipped = DisplayPoint::new(
2014 previous_valid.row(),
2015 previous_valid.column()
2016 + (entire_hint_label.find(struct_label).unwrap() + struct_label.len() / 2)
2017 as u32,
2018 );
2019 PointForPosition {
2020 previous_valid,
2021 next_valid,
2022 exact_unclipped,
2023 column_overshoot_after_line_end: 0,
2024 }
2025 });
2026 cx.update_editor(|editor, window, cx| {
2027 editor.update_inlay_link_and_hover_points(
2028 &editor.snapshot(window, cx),
2029 struct_hint_part_hover_position,
2030 None,
2031 true,
2032 false,
2033 window,
2034 cx,
2035 );
2036 });
2037 cx.background_executor
2038 .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100));
2039 cx.background_executor.run_until_parked();
2040 cx.update_editor(|editor, _, cx| {
2041 let hover_state = &editor.hover_state;
2042 assert!(
2043 hover_state.diagnostic_popover.is_none() && hover_state.info_popovers.len() == 1
2044 );
2045 let popover = hover_state.info_popovers.first().unwrap();
2046 let buffer_snapshot = editor.buffer().update(cx, |buffer, cx| buffer.snapshot(cx));
2047 assert_eq!(
2048 popover.symbol_range,
2049 RangeInEditor::Inlay(InlayHighlight {
2050 inlay: InlayId::Hint(0),
2051 inlay_position: buffer_snapshot
2052 .anchor_after(MultiBufferOffset(inlay_range.start)),
2053 range: ": ".len() + new_type_label.len() + "<".len()
2054 ..": ".len() + new_type_label.len() + "<".len() + struct_label.len(),
2055 }),
2056 "Popover range should match the struct label part"
2057 );
2058 assert_eq!(
2059 popover.get_rendered_text(cx),
2060 format!("A tooltip for {struct_label}"),
2061 "Rendered markdown element should remove backticks from text"
2062 );
2063 });
2064 }
2065
2066 #[test]
2067 fn test_find_hovered_hint_part_with_multibyte_characters() {
2068 use crate::display_map::InlayOffset;
2069 use multi_buffer::MultiBufferOffset;
2070 use project::InlayHintLabelPart;
2071
2072 // Test with multi-byte UTF-8 character "→" (3 bytes, 1 character)
2073 let label = "→ app/Livewire/UserProfile.php";
2074 let label_parts = vec![InlayHintLabelPart {
2075 value: label.to_string(),
2076 tooltip: None,
2077 location: None,
2078 }];
2079
2080 let hint_start = InlayOffset(MultiBufferOffset(100));
2081
2082 // Verify the label has more bytes than characters (due to "→")
2083 assert_eq!(label.len(), 32); // bytes
2084 assert_eq!(label.chars().count(), 30); // characters
2085
2086 // Test hovering at the last byte (should find the part)
2087 let last_byte_offset = InlayOffset(MultiBufferOffset(100 + label.len() - 1));
2088 let result = find_hovered_hint_part(label_parts.clone(), hint_start, last_byte_offset);
2089 assert!(
2090 result.is_some(),
2091 "Should find part when hovering at last byte"
2092 );
2093 let (part, range) = result.unwrap();
2094 assert_eq!(part.value, label);
2095 assert_eq!(range.start, hint_start);
2096 assert_eq!(range.end, InlayOffset(MultiBufferOffset(100 + label.len())));
2097
2098 // Test hovering at the first byte of "→" (byte 0)
2099 let first_byte_offset = InlayOffset(MultiBufferOffset(100));
2100 let result = find_hovered_hint_part(label_parts.clone(), hint_start, first_byte_offset);
2101 assert!(
2102 result.is_some(),
2103 "Should find part when hovering at first byte"
2104 );
2105
2106 // Test hovering in the middle of "→" (byte 1, still part of the arrow character)
2107 let mid_arrow_offset = InlayOffset(MultiBufferOffset(101));
2108 let result = find_hovered_hint_part(label_parts, hint_start, mid_arrow_offset);
2109 assert!(
2110 result.is_some(),
2111 "Should find part when hovering in middle of multi-byte char"
2112 );
2113
2114 // Test with multiple parts containing multi-byte characters
2115 // Part ranges are [start, end) - start inclusive, end exclusive
2116 // "→ " occupies bytes [0, 4), "path" occupies bytes [4, 8)
2117 let parts = vec![
2118 InlayHintLabelPart {
2119 value: "→ ".to_string(), // 4 bytes (3 + 1)
2120 tooltip: None,
2121 location: None,
2122 },
2123 InlayHintLabelPart {
2124 value: "path".to_string(), // 4 bytes
2125 tooltip: None,
2126 location: None,
2127 },
2128 ];
2129
2130 // Hover at byte 3 (last byte of "→ ", the space character)
2131 let arrow_last_byte = InlayOffset(MultiBufferOffset(100 + 3));
2132 let result = find_hovered_hint_part(parts.clone(), hint_start, arrow_last_byte);
2133 assert!(result.is_some(), "Should find first part at its last byte");
2134 let (part, range) = result.unwrap();
2135 assert_eq!(part.value, "→ ");
2136 assert_eq!(
2137 range,
2138 InlayOffset(MultiBufferOffset(100))..InlayOffset(MultiBufferOffset(104))
2139 );
2140
2141 // Hover at byte 4 (first byte of "path", at the boundary)
2142 let path_start_offset = InlayOffset(MultiBufferOffset(100 + 4));
2143 let result = find_hovered_hint_part(parts.clone(), hint_start, path_start_offset);
2144 assert!(result.is_some(), "Should find second part at boundary");
2145 let (part, _) = result.unwrap();
2146 assert_eq!(part.value, "path");
2147
2148 // Hover at byte 7 (last byte of "path")
2149 let path_end_offset = InlayOffset(MultiBufferOffset(100 + 7));
2150 let result = find_hovered_hint_part(parts, hint_start, path_end_offset);
2151 assert!(result.is_some(), "Should find second part at last byte");
2152 let (part, range) = result.unwrap();
2153 assert_eq!(part.value, "path");
2154 assert_eq!(
2155 range,
2156 InlayOffset(MultiBufferOffset(104))..InlayOffset(MultiBufferOffset(108))
2157 );
2158 }
2159
2160 #[gpui::test]
2161 async fn test_hover_popover_hiding_delay(cx: &mut gpui::TestAppContext) {
2162 init_test(cx, |_| {});
2163
2164 let custom_delay_ms = 500u64;
2165 cx.update(|cx| {
2166 cx.update_global::<SettingsStore, _>(|settings, cx| {
2167 settings.update_user_settings(cx, |settings| {
2168 settings.editor.hover_popover_sticky = Some(true);
2169 settings.editor.hover_popover_hiding_delay = Some(DelayMs(custom_delay_ms));
2170 });
2171 });
2172 });
2173
2174 let mut cx = EditorLspTestContext::new_rust(
2175 lsp::ServerCapabilities {
2176 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
2177 ..Default::default()
2178 },
2179 cx,
2180 )
2181 .await;
2182
2183 cx.set_state(indoc! {"
2184 fn ˇtest() { println!(); }
2185 "});
2186
2187 // Trigger hover on a symbol
2188 let hover_point = cx.display_point(indoc! {"
2189 fn test() { printˇln!(); }
2190 "});
2191 let symbol_range = cx.lsp_range(indoc! {"
2192 fn test() { «println!»(); }
2193 "});
2194 let mut requests =
2195 cx.set_request_handler::<lsp::request::HoverRequest, _, _>(move |_, _, _| async move {
2196 Ok(Some(lsp::Hover {
2197 contents: lsp::HoverContents::Markup(lsp::MarkupContent {
2198 kind: lsp::MarkupKind::Markdown,
2199 value: "some basic docs".to_string(),
2200 }),
2201 range: Some(symbol_range),
2202 }))
2203 });
2204 cx.update_editor(|editor, window, cx| {
2205 let snapshot = editor.snapshot(window, cx);
2206 let anchor = snapshot
2207 .buffer_snapshot()
2208 .anchor_before(hover_point.to_offset(&snapshot, Bias::Left));
2209 hover_at(editor, Some(anchor), None, window, cx)
2210 });
2211 cx.background_executor
2212 .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100));
2213 requests.next().await;
2214
2215 // Hover should be visible
2216 cx.editor(|editor, _, _| {
2217 assert!(editor.hover_state.visible());
2218 });
2219
2220 // Move mouse away (hover_at with None anchor triggers the hiding delay)
2221 cx.update_editor(|editor, window, cx| hover_at(editor, None, None, window, cx));
2222
2223 // Popover should still be visible before the custom hiding delay expires
2224 cx.background_executor
2225 .advance_clock(Duration::from_millis(custom_delay_ms - 100));
2226 cx.editor(|editor, _, _| {
2227 assert!(
2228 editor.hover_state.visible(),
2229 "Popover should remain visible before the hiding delay expires"
2230 );
2231 });
2232
2233 // After the full custom delay, the popover should be hidden
2234 cx.background_executor
2235 .advance_clock(Duration::from_millis(200));
2236 cx.editor(|editor, _, _| {
2237 assert!(
2238 !editor.hover_state.visible(),
2239 "Popover should be hidden after the hiding delay expires"
2240 );
2241 });
2242 }
2243
2244 #[gpui::test]
2245 async fn test_hover_popover_sticky_disabled(cx: &mut gpui::TestAppContext) {
2246 init_test(cx, |_| {});
2247
2248 cx.update(|cx| {
2249 cx.update_global::<SettingsStore, _>(|settings, cx| {
2250 settings.update_user_settings(cx, |settings| {
2251 settings.editor.hover_popover_sticky = Some(false);
2252 });
2253 });
2254 });
2255
2256 let mut cx = EditorLspTestContext::new_rust(
2257 lsp::ServerCapabilities {
2258 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
2259 ..Default::default()
2260 },
2261 cx,
2262 )
2263 .await;
2264
2265 cx.set_state(indoc! {"
2266 fn ˇtest() { println!(); }
2267 "});
2268
2269 // Trigger hover on a symbol
2270 let hover_point = cx.display_point(indoc! {"
2271 fn test() { printˇln!(); }
2272 "});
2273 let symbol_range = cx.lsp_range(indoc! {"
2274 fn test() { «println!»(); }
2275 "});
2276 let mut requests =
2277 cx.set_request_handler::<lsp::request::HoverRequest, _, _>(move |_, _, _| async move {
2278 Ok(Some(lsp::Hover {
2279 contents: lsp::HoverContents::Markup(lsp::MarkupContent {
2280 kind: lsp::MarkupKind::Markdown,
2281 value: "some basic docs".to_string(),
2282 }),
2283 range: Some(symbol_range),
2284 }))
2285 });
2286 cx.update_editor(|editor, window, cx| {
2287 let snapshot = editor.snapshot(window, cx);
2288 let anchor = snapshot
2289 .buffer_snapshot()
2290 .anchor_before(hover_point.to_offset(&snapshot, Bias::Left));
2291 hover_at(editor, Some(anchor), None, window, cx)
2292 });
2293 cx.background_executor
2294 .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100));
2295 requests.next().await;
2296
2297 // Hover should be visible
2298 cx.editor(|editor, _, _| {
2299 assert!(editor.hover_state.visible());
2300 });
2301
2302 // Move mouse away — with sticky disabled, hide immediately
2303 cx.update_editor(|editor, window, cx| hover_at(editor, None, None, window, cx));
2304
2305 // Popover should be hidden immediately without any delay
2306 cx.editor(|editor, _, _| {
2307 assert!(
2308 !editor.hover_state.visible(),
2309 "Popover should be hidden immediately when sticky is disabled"
2310 );
2311 });
2312 }
2313
2314 #[gpui::test]
2315 async fn test_hover_popover_hiding_delay_restarts_when_mouse_gets_closer(
2316 cx: &mut gpui::TestAppContext,
2317 ) {
2318 init_test(cx, |_| {});
2319
2320 let custom_delay_ms = 600u64;
2321 cx.update(|cx| {
2322 cx.update_global::<SettingsStore, _>(|settings, cx| {
2323 settings.update_user_settings(cx, |settings| {
2324 settings.editor.hover_popover_sticky = Some(true);
2325 settings.editor.hover_popover_hiding_delay = Some(DelayMs(custom_delay_ms));
2326 });
2327 });
2328 });
2329
2330 let mut cx = EditorLspTestContext::new_rust(
2331 lsp::ServerCapabilities {
2332 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
2333 ..Default::default()
2334 },
2335 cx,
2336 )
2337 .await;
2338
2339 cx.set_state(indoc! {"
2340 fn ˇtest() { println!(); }
2341 "});
2342
2343 let hover_point = cx.display_point(indoc! {"
2344 fn test() { printˇln!(); }
2345 "});
2346 let symbol_range = cx.lsp_range(indoc! {"
2347 fn test() { «println!»(); }
2348 "});
2349 let mut requests =
2350 cx.set_request_handler::<lsp::request::HoverRequest, _, _>(move |_, _, _| async move {
2351 Ok(Some(lsp::Hover {
2352 contents: lsp::HoverContents::Markup(lsp::MarkupContent {
2353 kind: lsp::MarkupKind::Markdown,
2354 value: "some basic docs".to_string(),
2355 }),
2356 range: Some(symbol_range),
2357 }))
2358 });
2359 cx.update_editor(|editor, window, cx| {
2360 let snapshot = editor.snapshot(window, cx);
2361 let anchor = snapshot
2362 .buffer_snapshot()
2363 .anchor_before(hover_point.to_offset(&snapshot, Bias::Left));
2364 hover_at(editor, Some(anchor), None, window, cx)
2365 });
2366 cx.background_executor
2367 .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100));
2368 requests.next().await;
2369
2370 cx.editor(|editor, _, _| {
2371 assert!(editor.hover_state.visible());
2372 });
2373
2374 cx.update_editor(|editor, _, _| {
2375 let popover = editor.hover_state.info_popovers.first().unwrap();
2376 popover.last_bounds.set(Some(Bounds {
2377 origin: gpui::Point {
2378 x: px(100.0),
2379 y: px(100.0),
2380 },
2381 size: Size {
2382 width: px(100.0),
2383 height: px(60.0),
2384 },
2385 }));
2386 });
2387
2388 let far_point = gpui::Point {
2389 x: px(260.0),
2390 y: px(130.0),
2391 };
2392 cx.update_editor(|editor, window, cx| hover_at(editor, None, Some(far_point), window, cx));
2393
2394 cx.background_executor
2395 .advance_clock(Duration::from_millis(400));
2396 cx.background_executor.run_until_parked();
2397
2398 let closer_point = gpui::Point {
2399 x: px(220.0),
2400 y: px(130.0),
2401 };
2402 cx.update_editor(|editor, window, cx| {
2403 hover_at(editor, None, Some(closer_point), window, cx)
2404 });
2405
2406 cx.background_executor
2407 .advance_clock(Duration::from_millis(250));
2408 cx.background_executor.run_until_parked();
2409
2410 cx.editor(|editor, _, _| {
2411 assert!(
2412 editor.hover_state.visible(),
2413 "Popover should remain visible because moving closer restarts the hiding timer"
2414 );
2415 });
2416
2417 cx.background_executor
2418 .advance_clock(Duration::from_millis(350));
2419 cx.background_executor.run_until_parked();
2420
2421 cx.editor(|editor, _, _| {
2422 assert!(
2423 !editor.hover_state.visible(),
2424 "Popover should hide after the restarted hiding timer expires"
2425 );
2426 });
2427 }
2428
2429 #[gpui::test]
2430 async fn test_hover_popover_cancel_hide_on_rehover(cx: &mut gpui::TestAppContext) {
2431 init_test(cx, |_| {});
2432
2433 let custom_delay_ms = 500u64;
2434 cx.update(|cx| {
2435 cx.update_global::<SettingsStore, _>(|settings, cx| {
2436 settings.update_user_settings(cx, |settings| {
2437 settings.editor.hover_popover_sticky = Some(true);
2438 settings.editor.hover_popover_hiding_delay = Some(DelayMs(custom_delay_ms));
2439 });
2440 });
2441 });
2442
2443 let mut cx = EditorLspTestContext::new_rust(
2444 lsp::ServerCapabilities {
2445 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
2446 ..Default::default()
2447 },
2448 cx,
2449 )
2450 .await;
2451
2452 cx.set_state(indoc! {"
2453 fn ˇtest() { println!(); }
2454 "});
2455
2456 let hover_point = cx.display_point(indoc! {"
2457 fn test() { printˇln!(); }
2458 "});
2459 let symbol_range = cx.lsp_range(indoc! {"
2460 fn test() { «println!»(); }
2461 "});
2462 let mut requests =
2463 cx.set_request_handler::<lsp::request::HoverRequest, _, _>(move |_, _, _| async move {
2464 Ok(Some(lsp::Hover {
2465 contents: lsp::HoverContents::Markup(lsp::MarkupContent {
2466 kind: lsp::MarkupKind::Markdown,
2467 value: "some basic docs".to_string(),
2468 }),
2469 range: Some(symbol_range),
2470 }))
2471 });
2472 cx.update_editor(|editor, window, cx| {
2473 let snapshot = editor.snapshot(window, cx);
2474 let anchor = snapshot
2475 .buffer_snapshot()
2476 .anchor_before(hover_point.to_offset(&snapshot, Bias::Left));
2477 hover_at(editor, Some(anchor), None, window, cx)
2478 });
2479 cx.background_executor
2480 .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100));
2481 requests.next().await;
2482
2483 cx.editor(|editor, _, _| {
2484 assert!(editor.hover_state.visible());
2485 });
2486
2487 // Move mouse away — starts the 500ms hide timer
2488 cx.update_editor(|editor, window, cx| hover_at(editor, None, None, window, cx));
2489
2490 cx.background_executor
2491 .advance_clock(Duration::from_millis(300));
2492 cx.background_executor.run_until_parked();
2493 cx.editor(|editor, _, _| {
2494 assert!(
2495 editor.hover_state.visible(),
2496 "Popover should still be visible before hiding delay expires"
2497 );
2498 });
2499
2500 // Move back to the symbol — should cancel the hiding timer
2501 cx.update_editor(|editor, window, cx| {
2502 let snapshot = editor.snapshot(window, cx);
2503 let anchor = snapshot
2504 .buffer_snapshot()
2505 .anchor_before(hover_point.to_offset(&snapshot, Bias::Left));
2506 hover_at(editor, Some(anchor), None, window, cx)
2507 });
2508
2509 // Advance past the original deadline — popover should still be visible
2510 // because re-hovering cleared the hiding_delay_task
2511 cx.background_executor
2512 .advance_clock(Duration::from_millis(300));
2513 cx.background_executor.run_until_parked();
2514 cx.editor(|editor, _, _| {
2515 assert!(
2516 editor.hover_state.visible(),
2517 "Popover should remain visible after re-hovering the symbol"
2518 );
2519 assert!(
2520 editor.hover_state.hiding_delay_task.is_none(),
2521 "Hiding delay task should have been cleared by re-hover"
2522 );
2523 });
2524
2525 // Move away again — starts a fresh 500ms timer
2526 cx.update_editor(|editor, window, cx| hover_at(editor, None, None, window, cx));
2527
2528 cx.background_executor
2529 .advance_clock(Duration::from_millis(custom_delay_ms + 100));
2530 cx.background_executor.run_until_parked();
2531 cx.editor(|editor, _, _| {
2532 assert!(
2533 !editor.hover_state.visible(),
2534 "Popover should hide after the new hiding timer expires"
2535 );
2536 });
2537 }
2538
2539 #[gpui::test]
2540 async fn test_hover_popover_enabled_false_ignores_sticky(cx: &mut gpui::TestAppContext) {
2541 init_test(cx, |_| {});
2542
2543 cx.update(|cx| {
2544 cx.update_global::<SettingsStore, _>(|settings, cx| {
2545 settings.update_user_settings(cx, |settings| {
2546 settings.editor.hover_popover_enabled = Some(false);
2547 settings.editor.hover_popover_sticky = Some(true);
2548 settings.editor.hover_popover_hiding_delay = Some(DelayMs(500));
2549 });
2550 });
2551 });
2552
2553 let mut cx = EditorLspTestContext::new_rust(
2554 lsp::ServerCapabilities {
2555 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
2556 ..Default::default()
2557 },
2558 cx,
2559 )
2560 .await;
2561
2562 cx.set_state(indoc! {"
2563 fn ˇtest() { println!(); }
2564 "});
2565
2566 let hover_point = cx.display_point(indoc! {"
2567 fn test() { printˇln!(); }
2568 "});
2569
2570 // Trigger hover_at — should be gated by hover_popover_enabled=false
2571 cx.update_editor(|editor, window, cx| {
2572 let snapshot = editor.snapshot(window, cx);
2573 let anchor = snapshot
2574 .buffer_snapshot()
2575 .anchor_before(hover_point.to_offset(&snapshot, Bias::Left));
2576 hover_at(editor, Some(anchor), None, window, cx)
2577 });
2578
2579 // No need to advance clock or wait for LSP — the gate should prevent any work
2580 cx.editor(|editor, _, _| {
2581 assert!(
2582 !editor.hover_state.visible(),
2583 "Popover should not appear when hover_popover_enabled is false"
2584 );
2585 assert!(
2586 editor.hover_state.info_task.is_none(),
2587 "No hover info task should be scheduled when hover is disabled"
2588 );
2589 assert!(
2590 editor.hover_state.triggered_from.is_none(),
2591 "No hover trigger should be recorded when hover is disabled"
2592 );
2593 });
2594 }
2595}