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