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 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_position, _) = editor
279 .buffer
280 .read(cx)
281 .snapshot(cx)
282 .anchor_to_buffer_anchor(anchor)?;
283 let buffer = editor.buffer.read(cx).buffer(buffer_position.buffer_id)?;
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 .buffer_anchor_range_to_anchor_range(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_visibility: CopyButtonVisibility::Hidden,
1044 border: false,
1045 })
1046 .on_url_click(open_markdown_url)
1047 .p_2(),
1048 ),
1049 )
1050 .custom_scrollbars(
1051 Scrollbars::for_settings::<EditorSettingsScrollbarProxy>()
1052 .tracked_scroll_handle(&self.scroll_handle),
1053 window,
1054 cx,
1055 )
1056 })
1057 .into_any_element()
1058 }
1059
1060 pub fn scroll(&self, amount: ScrollAmount, window: &mut Window, cx: &mut Context<Editor>) {
1061 let mut current = self.scroll_handle.offset();
1062 current.y -= amount.pixels(
1063 window.line_height(),
1064 self.scroll_handle.bounds().size.height - px(16.),
1065 ) / 2.0;
1066 cx.notify();
1067 self.scroll_handle.set_offset(current);
1068 }
1069}
1070
1071pub struct DiagnosticPopover {
1072 pub(crate) local_diagnostic: DiagnosticEntry<Anchor>,
1073 markdown: Entity<Markdown>,
1074 border_color: Hsla,
1075 background_color: Hsla,
1076 pub keyboard_grace: Rc<RefCell<bool>>,
1077 pub anchor: Anchor,
1078 pub last_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
1079 _subscription: Subscription,
1080 pub scroll_handle: ScrollHandle,
1081}
1082
1083impl DiagnosticPopover {
1084 pub fn render(
1085 &self,
1086 max_size: Size<Pixels>,
1087 window: &mut Window,
1088 cx: &mut Context<Editor>,
1089 ) -> AnyElement {
1090 let keyboard_grace = Rc::clone(&self.keyboard_grace);
1091 let this = cx.entity().downgrade();
1092 let bounds_cell = self.last_bounds.clone();
1093 div()
1094 .id("diagnostic")
1095 .occlude()
1096 .elevation_2_borderless(cx)
1097 .child(
1098 canvas(
1099 {
1100 move |bounds, _window, _cx| {
1101 bounds_cell.set(Some(bounds));
1102 }
1103 },
1104 |_, _, _, _| {},
1105 )
1106 .absolute()
1107 .size_full(),
1108 )
1109 // Don't draw the background color if the theme
1110 // allows transparent surfaces.
1111 .when(theme_is_transparent(cx), |this| {
1112 this.bg(gpui::transparent_black())
1113 })
1114 // Prevent a mouse move on the popover from being propagated to the editor,
1115 // because that would dismiss the popover.
1116 .on_mouse_move({
1117 let this = this.clone();
1118 move |_, _, cx: &mut App| {
1119 this.update(cx, |editor, _| {
1120 editor.hover_state.closest_mouse_distance = Some(px(0.0));
1121 editor.hover_state.hiding_delay_task = None;
1122 })
1123 .ok();
1124 cx.stop_propagation()
1125 }
1126 })
1127 // Prevent a mouse down on the popover from being propagated to the editor,
1128 // because that would move the cursor.
1129 .on_mouse_down(MouseButton::Left, move |_, _, cx| {
1130 let mut keyboard_grace = keyboard_grace.borrow_mut();
1131 *keyboard_grace = false;
1132 cx.stop_propagation();
1133 })
1134 .child(
1135 div()
1136 .relative()
1137 .py_1()
1138 .pl_2()
1139 .pr_8()
1140 .bg(self.background_color)
1141 .border_1()
1142 .border_color(self.border_color)
1143 .rounded_lg()
1144 .child(
1145 div()
1146 .id("diagnostic-content-container")
1147 .max_w(max_size.width)
1148 .max_h(max_size.height)
1149 .overflow_y_scroll()
1150 .track_scroll(&self.scroll_handle)
1151 .child(
1152 MarkdownElement::new(
1153 self.markdown.clone(),
1154 diagnostics_markdown_style(window, cx),
1155 )
1156 .code_block_renderer(markdown::CodeBlockRenderer::Default {
1157 copy_button_visibility: CopyButtonVisibility::Hidden,
1158 border: false,
1159 })
1160 .on_url_click(
1161 move |link, window, cx| {
1162 if let Some(renderer) = GlobalDiagnosticRenderer::global(cx)
1163 {
1164 this.update(cx, |this, cx| {
1165 renderer.as_ref().open_link(this, link, window, cx);
1166 })
1167 .ok();
1168 }
1169 },
1170 ),
1171 ),
1172 )
1173 .child(div().absolute().top_1().right_1().child({
1174 let message = self.local_diagnostic.diagnostic.message.clone();
1175 CopyButton::new("copy-diagnostic", message).tooltip_label("Copy Diagnostic")
1176 }))
1177 .custom_scrollbars(
1178 Scrollbars::for_settings::<EditorSettingsScrollbarProxy>()
1179 .tracked_scroll_handle(&self.scroll_handle),
1180 window,
1181 cx,
1182 ),
1183 )
1184 .into_any_element()
1185 }
1186}
1187
1188#[cfg(test)]
1189mod tests {
1190 use super::*;
1191 use crate::{
1192 PointForPosition,
1193 actions::ConfirmCompletion,
1194 editor_tests::{handle_completion_request, init_test},
1195 inlays::inlay_hints::tests::{cached_hint_labels, visible_hint_labels},
1196 test::editor_lsp_test_context::EditorLspTestContext,
1197 };
1198 use collections::BTreeSet;
1199 use gpui::App;
1200 use indoc::indoc;
1201 use markdown::parser::MarkdownEvent;
1202 use project::InlayId;
1203 use settings::InlayHintSettingsContent;
1204 use smol::stream::StreamExt;
1205 use std::sync::atomic;
1206 use std::sync::atomic::AtomicUsize;
1207 use text::Bias;
1208
1209 fn get_hover_popover_delay(cx: &gpui::TestAppContext) -> u64 {
1210 cx.read(|cx: &App| -> u64 { EditorSettings::get_global(cx).hover_popover_delay.0 })
1211 }
1212
1213 impl InfoPopover {
1214 fn get_rendered_text(&self, cx: &gpui::App) -> String {
1215 let mut rendered_text = String::new();
1216 if let Some(parsed_content) = self.parsed_content.clone() {
1217 let markdown = parsed_content.read(cx);
1218 let text = markdown.parsed_markdown().source().to_string();
1219 let data = markdown.parsed_markdown().events();
1220 let slice = data;
1221
1222 for (range, event) in slice.iter() {
1223 match event {
1224 MarkdownEvent::SubstitutedText(parsed) => {
1225 rendered_text.push_str(parsed.as_str())
1226 }
1227 MarkdownEvent::Text | MarkdownEvent::Code => {
1228 rendered_text.push_str(&text[range.clone()])
1229 }
1230 _ => {}
1231 }
1232 }
1233 }
1234 rendered_text
1235 }
1236 }
1237
1238 #[gpui::test]
1239 async fn test_mouse_hover_info_popover_with_autocomplete_popover(
1240 cx: &mut gpui::TestAppContext,
1241 ) {
1242 init_test(cx, |_| {});
1243
1244 let mut cx = EditorLspTestContext::new_rust(
1245 lsp::ServerCapabilities {
1246 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
1247 completion_provider: Some(lsp::CompletionOptions {
1248 trigger_characters: Some(vec![".".to_string(), ":".to_string()]),
1249 resolve_provider: Some(true),
1250 ..Default::default()
1251 }),
1252 ..Default::default()
1253 },
1254 cx,
1255 )
1256 .await;
1257 let counter = Arc::new(AtomicUsize::new(0));
1258 // Basic hover delays and then pops without moving the mouse
1259 cx.set_state(indoc! {"
1260 oneˇ
1261 two
1262 three
1263 fn test() { println!(); }
1264 "});
1265
1266 //prompt autocompletion menu
1267 cx.simulate_keystroke(".");
1268 handle_completion_request(
1269 indoc! {"
1270 one.|<>
1271 two
1272 three
1273 "},
1274 vec!["first_completion", "second_completion"],
1275 true,
1276 counter.clone(),
1277 &mut cx,
1278 )
1279 .await;
1280 cx.condition(|editor, _| editor.context_menu_visible()) // wait until completion menu is visible
1281 .await;
1282 assert_eq!(counter.load(atomic::Ordering::Acquire), 1); // 1 completion request
1283
1284 let hover_point = cx.display_point(indoc! {"
1285 one.
1286 two
1287 three
1288 fn test() { printˇln!(); }
1289 "});
1290 cx.update_editor(|editor, window, cx| {
1291 let snapshot = editor.snapshot(window, cx);
1292 let anchor = snapshot
1293 .buffer_snapshot()
1294 .anchor_before(hover_point.to_offset(&snapshot, Bias::Left));
1295 hover_at(editor, Some(anchor), None, window, cx)
1296 });
1297 assert!(!cx.editor(|editor, _window, _cx| editor.hover_state.visible()));
1298
1299 // After delay, hover should be visible.
1300 let symbol_range = cx.lsp_range(indoc! {"
1301 one.
1302 two
1303 three
1304 fn test() { «println!»(); }
1305 "});
1306 let mut requests =
1307 cx.set_request_handler::<lsp::request::HoverRequest, _, _>(move |_, _, _| async move {
1308 Ok(Some(lsp::Hover {
1309 contents: lsp::HoverContents::Markup(lsp::MarkupContent {
1310 kind: lsp::MarkupKind::Markdown,
1311 value: "some basic docs".to_string(),
1312 }),
1313 range: Some(symbol_range),
1314 }))
1315 });
1316 cx.background_executor
1317 .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100));
1318 requests.next().await;
1319
1320 cx.editor(|editor, _window, cx| {
1321 assert!(editor.hover_state.visible());
1322 assert_eq!(
1323 editor.hover_state.info_popovers.len(),
1324 1,
1325 "Expected exactly one hover but got: {:?}",
1326 editor.hover_state.info_popovers.len()
1327 );
1328 let rendered_text = editor
1329 .hover_state
1330 .info_popovers
1331 .first()
1332 .unwrap()
1333 .get_rendered_text(cx);
1334 assert_eq!(rendered_text, "some basic docs".to_string())
1335 });
1336
1337 // check that the completion menu is still visible and that there still has only been 1 completion request
1338 cx.editor(|editor, _, _| assert!(editor.context_menu_visible()));
1339 assert_eq!(counter.load(atomic::Ordering::Acquire), 1);
1340
1341 //apply a completion and check it was successfully applied
1342 let _apply_additional_edits = cx.update_editor(|editor, window, cx| {
1343 editor.context_menu_next(&Default::default(), window, cx);
1344 editor
1345 .confirm_completion(&ConfirmCompletion::default(), window, cx)
1346 .unwrap()
1347 });
1348 cx.assert_editor_state(indoc! {"
1349 one.second_completionˇ
1350 two
1351 three
1352 fn test() { println!(); }
1353 "});
1354
1355 // check that the completion menu is no longer visible and that there still has only been 1 completion request
1356 cx.editor(|editor, _, _| assert!(!editor.context_menu_visible()));
1357 assert_eq!(counter.load(atomic::Ordering::Acquire), 1);
1358
1359 //verify the information popover is still visible and unchanged
1360 cx.editor(|editor, _, cx| {
1361 assert!(editor.hover_state.visible());
1362 assert_eq!(
1363 editor.hover_state.info_popovers.len(),
1364 1,
1365 "Expected exactly one hover but got: {:?}",
1366 editor.hover_state.info_popovers.len()
1367 );
1368 let rendered_text = editor
1369 .hover_state
1370 .info_popovers
1371 .first()
1372 .unwrap()
1373 .get_rendered_text(cx);
1374
1375 assert_eq!(rendered_text, "some basic docs".to_string())
1376 });
1377
1378 // Mouse moved with no hover response dismisses
1379 let hover_point = cx.display_point(indoc! {"
1380 one.second_completionˇ
1381 two
1382 three
1383 fn teˇst() { println!(); }
1384 "});
1385 let mut request = cx
1386 .lsp
1387 .set_request_handler::<lsp::request::HoverRequest, _, _>(
1388 |_, _| async move { Ok(None) },
1389 );
1390 cx.update_editor(|editor, window, cx| {
1391 let snapshot = editor.snapshot(window, cx);
1392 let anchor = snapshot
1393 .buffer_snapshot()
1394 .anchor_before(hover_point.to_offset(&snapshot, Bias::Left));
1395 hover_at(editor, Some(anchor), None, window, cx)
1396 });
1397 cx.background_executor
1398 .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100));
1399 request.next().await;
1400
1401 // verify that the information popover is no longer visible
1402 cx.editor(|editor, _, _| {
1403 assert!(!editor.hover_state.visible());
1404 });
1405 }
1406
1407 #[gpui::test]
1408 async fn test_mouse_hover_info_popover(cx: &mut gpui::TestAppContext) {
1409 init_test(cx, |_| {});
1410
1411 let mut cx = EditorLspTestContext::new_rust(
1412 lsp::ServerCapabilities {
1413 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
1414 ..Default::default()
1415 },
1416 cx,
1417 )
1418 .await;
1419
1420 // Basic hover delays and then pops without moving the mouse
1421 cx.set_state(indoc! {"
1422 fn ˇtest() { println!(); }
1423 "});
1424 let hover_point = cx.display_point(indoc! {"
1425 fn test() { printˇln!(); }
1426 "});
1427
1428 cx.update_editor(|editor, window, cx| {
1429 let snapshot = editor.snapshot(window, cx);
1430 let anchor = snapshot
1431 .buffer_snapshot()
1432 .anchor_before(hover_point.to_offset(&snapshot, Bias::Left));
1433 hover_at(editor, Some(anchor), None, window, cx)
1434 });
1435 assert!(!cx.editor(|editor, _window, _cx| editor.hover_state.visible()));
1436
1437 // After delay, hover should be visible.
1438 let symbol_range = cx.lsp_range(indoc! {"
1439 fn test() { «println!»(); }
1440 "});
1441 let mut requests =
1442 cx.set_request_handler::<lsp::request::HoverRequest, _, _>(move |_, _, _| async move {
1443 Ok(Some(lsp::Hover {
1444 contents: lsp::HoverContents::Markup(lsp::MarkupContent {
1445 kind: lsp::MarkupKind::Markdown,
1446 value: "some basic docs".to_string(),
1447 }),
1448 range: Some(symbol_range),
1449 }))
1450 });
1451 cx.background_executor
1452 .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100));
1453 requests.next().await;
1454
1455 cx.editor(|editor, _, cx| {
1456 assert!(editor.hover_state.visible());
1457 assert_eq!(
1458 editor.hover_state.info_popovers.len(),
1459 1,
1460 "Expected exactly one hover but got: {:?}",
1461 editor.hover_state.info_popovers.len()
1462 );
1463 let rendered_text = editor
1464 .hover_state
1465 .info_popovers
1466 .first()
1467 .unwrap()
1468 .get_rendered_text(cx);
1469
1470 assert_eq!(rendered_text, "some basic docs".to_string())
1471 });
1472
1473 // Mouse moved with no hover response dismisses
1474 let hover_point = cx.display_point(indoc! {"
1475 fn teˇst() { println!(); }
1476 "});
1477 let mut request = cx
1478 .lsp
1479 .set_request_handler::<lsp::request::HoverRequest, _, _>(
1480 |_, _| async move { Ok(None) },
1481 );
1482 cx.update_editor(|editor, window, cx| {
1483 let snapshot = editor.snapshot(window, cx);
1484 let anchor = snapshot
1485 .buffer_snapshot()
1486 .anchor_before(hover_point.to_offset(&snapshot, Bias::Left));
1487 hover_at(editor, Some(anchor), None, window, cx)
1488 });
1489 cx.background_executor
1490 .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100));
1491 request.next().await;
1492 cx.editor(|editor, _, _| {
1493 assert!(!editor.hover_state.visible());
1494 });
1495 }
1496
1497 #[gpui::test]
1498 async fn test_keyboard_hover_info_popover(cx: &mut gpui::TestAppContext) {
1499 init_test(cx, |_| {});
1500
1501 let mut cx = EditorLspTestContext::new_rust(
1502 lsp::ServerCapabilities {
1503 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
1504 ..Default::default()
1505 },
1506 cx,
1507 )
1508 .await;
1509
1510 // Hover with keyboard has no delay
1511 cx.set_state(indoc! {"
1512 fˇn test() { println!(); }
1513 "});
1514 cx.update_editor(|editor, window, cx| hover(editor, &Hover, window, cx));
1515 let symbol_range = cx.lsp_range(indoc! {"
1516 «fn» test() { println!(); }
1517 "});
1518
1519 cx.editor(|editor, _window, _cx| {
1520 assert!(!editor.hover_state.visible());
1521
1522 assert_eq!(
1523 editor.hover_state.info_popovers.len(),
1524 0,
1525 "Expected no hovers but got but got: {:?}",
1526 editor.hover_state.info_popovers.len()
1527 );
1528 });
1529
1530 let mut requests =
1531 cx.set_request_handler::<lsp::request::HoverRequest, _, _>(move |_, _, _| async move {
1532 Ok(Some(lsp::Hover {
1533 contents: lsp::HoverContents::Markup(lsp::MarkupContent {
1534 kind: lsp::MarkupKind::Markdown,
1535 value: "some other basic docs".to_string(),
1536 }),
1537 range: Some(symbol_range),
1538 }))
1539 });
1540
1541 requests.next().await;
1542 cx.dispatch_action(Hover);
1543
1544 cx.condition(|editor, _| editor.hover_state.visible()).await;
1545 cx.editor(|editor, _, cx| {
1546 assert_eq!(
1547 editor.hover_state.info_popovers.len(),
1548 1,
1549 "Expected exactly one hover but got: {:?}",
1550 editor.hover_state.info_popovers.len()
1551 );
1552
1553 let rendered_text = editor
1554 .hover_state
1555 .info_popovers
1556 .first()
1557 .unwrap()
1558 .get_rendered_text(cx);
1559
1560 assert_eq!(rendered_text, "some other basic docs".to_string())
1561 });
1562 }
1563
1564 #[gpui::test]
1565 async fn test_empty_hovers_filtered(cx: &mut gpui::TestAppContext) {
1566 init_test(cx, |_| {});
1567
1568 let mut cx = EditorLspTestContext::new_rust(
1569 lsp::ServerCapabilities {
1570 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
1571 ..Default::default()
1572 },
1573 cx,
1574 )
1575 .await;
1576
1577 // Hover with keyboard has no delay
1578 cx.set_state(indoc! {"
1579 fˇn test() { println!(); }
1580 "});
1581 cx.update_editor(|editor, window, cx| hover(editor, &Hover, window, cx));
1582 let symbol_range = cx.lsp_range(indoc! {"
1583 «fn» test() { println!(); }
1584 "});
1585 cx.set_request_handler::<lsp::request::HoverRequest, _, _>(move |_, _, _| async move {
1586 Ok(Some(lsp::Hover {
1587 contents: lsp::HoverContents::Array(vec![
1588 lsp::MarkedString::String("regular text for hover to show".to_string()),
1589 lsp::MarkedString::String("".to_string()),
1590 lsp::MarkedString::LanguageString(lsp::LanguageString {
1591 language: "Rust".to_string(),
1592 value: "".to_string(),
1593 }),
1594 ]),
1595 range: Some(symbol_range),
1596 }))
1597 })
1598 .next()
1599 .await;
1600 cx.dispatch_action(Hover);
1601
1602 cx.condition(|editor, _| editor.hover_state.visible()).await;
1603 cx.editor(|editor, _, cx| {
1604 assert_eq!(
1605 editor.hover_state.info_popovers.len(),
1606 1,
1607 "Expected exactly one hover but got: {:?}",
1608 editor.hover_state.info_popovers.len()
1609 );
1610 let rendered_text = editor
1611 .hover_state
1612 .info_popovers
1613 .first()
1614 .unwrap()
1615 .get_rendered_text(cx);
1616
1617 assert_eq!(
1618 rendered_text,
1619 "regular text for hover to show".to_string(),
1620 "No empty string hovers should be shown"
1621 );
1622 });
1623 }
1624
1625 #[gpui::test]
1626 async fn test_line_ends_trimmed(cx: &mut gpui::TestAppContext) {
1627 init_test(cx, |_| {});
1628
1629 let mut cx = EditorLspTestContext::new_rust(
1630 lsp::ServerCapabilities {
1631 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
1632 ..Default::default()
1633 },
1634 cx,
1635 )
1636 .await;
1637
1638 // Hover with keyboard has no delay
1639 cx.set_state(indoc! {"
1640 fˇn test() { println!(); }
1641 "});
1642 cx.update_editor(|editor, window, cx| hover(editor, &Hover, window, cx));
1643 let symbol_range = cx.lsp_range(indoc! {"
1644 «fn» test() { println!(); }
1645 "});
1646
1647 let code_str = "\nlet hovered_point: Vector2F // size = 8, align = 0x4\n";
1648 let markdown_string = format!("\n```rust\n{code_str}```");
1649
1650 let closure_markdown_string = markdown_string.clone();
1651 cx.set_request_handler::<lsp::request::HoverRequest, _, _>(move |_, _, _| {
1652 let future_markdown_string = closure_markdown_string.clone();
1653 async move {
1654 Ok(Some(lsp::Hover {
1655 contents: lsp::HoverContents::Markup(lsp::MarkupContent {
1656 kind: lsp::MarkupKind::Markdown,
1657 value: future_markdown_string,
1658 }),
1659 range: Some(symbol_range),
1660 }))
1661 }
1662 })
1663 .next()
1664 .await;
1665
1666 cx.dispatch_action(Hover);
1667
1668 cx.condition(|editor, _| editor.hover_state.visible()).await;
1669 cx.editor(|editor, _, cx| {
1670 assert_eq!(
1671 editor.hover_state.info_popovers.len(),
1672 1,
1673 "Expected exactly one hover but got: {:?}",
1674 editor.hover_state.info_popovers.len()
1675 );
1676 let rendered_text = editor
1677 .hover_state
1678 .info_popovers
1679 .first()
1680 .unwrap()
1681 .get_rendered_text(cx);
1682
1683 assert_eq!(
1684 rendered_text, code_str,
1685 "Should not have extra line breaks at end of rendered hover"
1686 );
1687 });
1688 }
1689
1690 #[gpui::test]
1691 // https://github.com/zed-industries/zed/issues/15498
1692 async fn test_info_hover_with_hrs(cx: &mut gpui::TestAppContext) {
1693 init_test(cx, |_| {});
1694
1695 let mut cx = EditorLspTestContext::new_rust(
1696 lsp::ServerCapabilities {
1697 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
1698 ..Default::default()
1699 },
1700 cx,
1701 )
1702 .await;
1703
1704 cx.set_state(indoc! {"
1705 fn fuˇnc(abc def: i32) -> u32 {
1706 }
1707 "});
1708
1709 cx.lsp
1710 .set_request_handler::<lsp::request::HoverRequest, _, _>({
1711 |_, _| async move {
1712 Ok(Some(lsp::Hover {
1713 contents: lsp::HoverContents::Markup(lsp::MarkupContent {
1714 kind: lsp::MarkupKind::Markdown,
1715 value: indoc!(
1716 r#"
1717 ### function `errands_data_read`
1718
1719 ---
1720 → `char *`
1721 Function to read a file into a string
1722
1723 ---
1724 ```cpp
1725 static char *errands_data_read()
1726 ```
1727 "#
1728 )
1729 .to_string(),
1730 }),
1731 range: None,
1732 }))
1733 }
1734 });
1735 cx.update_editor(|editor, window, cx| hover(editor, &Default::default(), window, cx));
1736 cx.run_until_parked();
1737
1738 cx.update_editor(|editor, _, cx| {
1739 let popover = editor.hover_state.info_popovers.first().unwrap();
1740 let content = popover.get_rendered_text(cx);
1741
1742 assert!(content.contains("Function to read a file"));
1743 });
1744 }
1745
1746 #[gpui::test]
1747 async fn test_hover_inlay_label_parts(cx: &mut gpui::TestAppContext) {
1748 init_test(cx, |settings| {
1749 settings.defaults.inlay_hints = Some(InlayHintSettingsContent {
1750 show_value_hints: Some(true),
1751 enabled: Some(true),
1752 edit_debounce_ms: Some(0),
1753 scroll_debounce_ms: Some(0),
1754 show_type_hints: Some(true),
1755 show_parameter_hints: Some(true),
1756 show_other_hints: Some(true),
1757 show_background: Some(false),
1758 toggle_on_modifiers_press: None,
1759 })
1760 });
1761
1762 let mut cx = EditorLspTestContext::new_rust(
1763 lsp::ServerCapabilities {
1764 inlay_hint_provider: Some(lsp::OneOf::Right(
1765 lsp::InlayHintServerCapabilities::Options(lsp::InlayHintOptions {
1766 resolve_provider: Some(true),
1767 ..Default::default()
1768 }),
1769 )),
1770 ..Default::default()
1771 },
1772 cx,
1773 )
1774 .await;
1775
1776 cx.set_state(indoc! {"
1777 struct TestStruct;
1778
1779 // ==================
1780
1781 struct TestNewType<T>(T);
1782
1783 fn main() {
1784 let variableˇ = TestNewType(TestStruct);
1785 }
1786 "});
1787
1788 let hint_start_offset = cx.ranges(indoc! {"
1789 struct TestStruct;
1790
1791 // ==================
1792
1793 struct TestNewType<T>(T);
1794
1795 fn main() {
1796 let variableˇ = TestNewType(TestStruct);
1797 }
1798 "})[0]
1799 .start;
1800 let hint_position = cx.to_lsp(MultiBufferOffset(hint_start_offset));
1801 let new_type_target_range = cx.lsp_range(indoc! {"
1802 struct TestStruct;
1803
1804 // ==================
1805
1806 struct «TestNewType»<T>(T);
1807
1808 fn main() {
1809 let variable = TestNewType(TestStruct);
1810 }
1811 "});
1812 let struct_target_range = cx.lsp_range(indoc! {"
1813 struct «TestStruct»;
1814
1815 // ==================
1816
1817 struct TestNewType<T>(T);
1818
1819 fn main() {
1820 let variable = TestNewType(TestStruct);
1821 }
1822 "});
1823
1824 let uri = cx.buffer_lsp_url.clone();
1825 let new_type_label = "TestNewType";
1826 let struct_label = "TestStruct";
1827 let entire_hint_label = ": TestNewType<TestStruct>";
1828 let closure_uri = uri.clone();
1829 cx.lsp
1830 .set_request_handler::<lsp::request::InlayHintRequest, _, _>(move |params, _| {
1831 let task_uri = closure_uri.clone();
1832 async move {
1833 assert_eq!(params.text_document.uri, task_uri);
1834 Ok(Some(vec![lsp::InlayHint {
1835 position: hint_position,
1836 label: lsp::InlayHintLabel::LabelParts(vec![lsp::InlayHintLabelPart {
1837 value: entire_hint_label.to_string(),
1838 ..Default::default()
1839 }]),
1840 kind: Some(lsp::InlayHintKind::TYPE),
1841 text_edits: None,
1842 tooltip: None,
1843 padding_left: Some(false),
1844 padding_right: Some(false),
1845 data: None,
1846 }]))
1847 }
1848 })
1849 .next()
1850 .await;
1851 cx.background_executor.run_until_parked();
1852 cx.update_editor(|editor, _, cx| {
1853 let expected_layers = vec![entire_hint_label.to_string()];
1854 assert_eq!(expected_layers, cached_hint_labels(editor, cx));
1855 assert_eq!(expected_layers, visible_hint_labels(editor, cx));
1856 });
1857
1858 let inlay_range = cx
1859 .ranges(indoc! {"
1860 struct TestStruct;
1861
1862 // ==================
1863
1864 struct TestNewType<T>(T);
1865
1866 fn main() {
1867 let variable« »= TestNewType(TestStruct);
1868 }
1869 "})
1870 .first()
1871 .cloned()
1872 .unwrap();
1873 let new_type_hint_part_hover_position = cx.update_editor(|editor, window, cx| {
1874 let snapshot = editor.snapshot(window, cx);
1875 let previous_valid = MultiBufferOffset(inlay_range.start).to_display_point(&snapshot);
1876 let next_valid = MultiBufferOffset(inlay_range.end).to_display_point(&snapshot);
1877 assert_eq!(previous_valid.row(), next_valid.row());
1878 assert!(previous_valid.column() < next_valid.column());
1879 let exact_unclipped = DisplayPoint::new(
1880 previous_valid.row(),
1881 previous_valid.column()
1882 + (entire_hint_label.find(new_type_label).unwrap() + new_type_label.len() / 2)
1883 as u32,
1884 );
1885 PointForPosition {
1886 previous_valid,
1887 next_valid,
1888 exact_unclipped,
1889 column_overshoot_after_line_end: 0,
1890 }
1891 });
1892 cx.update_editor(|editor, window, cx| {
1893 editor.update_inlay_link_and_hover_points(
1894 &editor.snapshot(window, cx),
1895 new_type_hint_part_hover_position,
1896 None,
1897 true,
1898 false,
1899 window,
1900 cx,
1901 );
1902 });
1903
1904 let resolve_closure_uri = uri.clone();
1905 cx.lsp
1906 .set_request_handler::<lsp::request::InlayHintResolveRequest, _, _>(
1907 move |mut hint_to_resolve, _| {
1908 let mut resolved_hint_positions = BTreeSet::new();
1909 let task_uri = resolve_closure_uri.clone();
1910 async move {
1911 let inserted = resolved_hint_positions.insert(hint_to_resolve.position);
1912 assert!(inserted, "Hint {hint_to_resolve:?} was resolved twice");
1913
1914 // `: TestNewType<TestStruct>`
1915 hint_to_resolve.label = lsp::InlayHintLabel::LabelParts(vec![
1916 lsp::InlayHintLabelPart {
1917 value: ": ".to_string(),
1918 ..Default::default()
1919 },
1920 lsp::InlayHintLabelPart {
1921 value: new_type_label.to_string(),
1922 location: Some(lsp::Location {
1923 uri: task_uri.clone(),
1924 range: new_type_target_range,
1925 }),
1926 tooltip: Some(lsp::InlayHintLabelPartTooltip::String(format!(
1927 "A tooltip for `{new_type_label}`"
1928 ))),
1929 ..Default::default()
1930 },
1931 lsp::InlayHintLabelPart {
1932 value: "<".to_string(),
1933 ..Default::default()
1934 },
1935 lsp::InlayHintLabelPart {
1936 value: struct_label.to_string(),
1937 location: Some(lsp::Location {
1938 uri: task_uri,
1939 range: struct_target_range,
1940 }),
1941 tooltip: Some(lsp::InlayHintLabelPartTooltip::MarkupContent(
1942 lsp::MarkupContent {
1943 kind: lsp::MarkupKind::Markdown,
1944 value: format!("A tooltip for `{struct_label}`"),
1945 },
1946 )),
1947 ..Default::default()
1948 },
1949 lsp::InlayHintLabelPart {
1950 value: ">".to_string(),
1951 ..Default::default()
1952 },
1953 ]);
1954
1955 Ok(hint_to_resolve)
1956 }
1957 },
1958 )
1959 .next()
1960 .await;
1961 cx.background_executor.run_until_parked();
1962
1963 cx.update_editor(|editor, window, cx| {
1964 editor.update_inlay_link_and_hover_points(
1965 &editor.snapshot(window, cx),
1966 new_type_hint_part_hover_position,
1967 None,
1968 true,
1969 false,
1970 window,
1971 cx,
1972 );
1973 });
1974 cx.background_executor
1975 .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100));
1976 cx.background_executor.run_until_parked();
1977 cx.update_editor(|editor, _, cx| {
1978 let hover_state = &editor.hover_state;
1979 assert!(
1980 hover_state.diagnostic_popover.is_none() && hover_state.info_popovers.len() == 1
1981 );
1982 let popover = hover_state.info_popovers.first().unwrap();
1983 let buffer_snapshot = editor.buffer().update(cx, |buffer, cx| buffer.snapshot(cx));
1984 assert_eq!(
1985 popover.symbol_range,
1986 RangeInEditor::Inlay(InlayHighlight {
1987 inlay: InlayId::Hint(0),
1988 inlay_position: buffer_snapshot
1989 .anchor_after(MultiBufferOffset(inlay_range.start)),
1990 range: ": ".len()..": ".len() + new_type_label.len(),
1991 }),
1992 "Popover range should match the new type label part"
1993 );
1994 assert_eq!(
1995 popover.get_rendered_text(cx),
1996 format!("A tooltip for {new_type_label}"),
1997 );
1998 });
1999
2000 let struct_hint_part_hover_position = cx.update_editor(|editor, window, cx| {
2001 let snapshot = editor.snapshot(window, cx);
2002 let previous_valid = MultiBufferOffset(inlay_range.start).to_display_point(&snapshot);
2003 let next_valid = MultiBufferOffset(inlay_range.end).to_display_point(&snapshot);
2004 assert_eq!(previous_valid.row(), next_valid.row());
2005 assert!(previous_valid.column() < next_valid.column());
2006 let exact_unclipped = DisplayPoint::new(
2007 previous_valid.row(),
2008 previous_valid.column()
2009 + (entire_hint_label.find(struct_label).unwrap() + struct_label.len() / 2)
2010 as u32,
2011 );
2012 PointForPosition {
2013 previous_valid,
2014 next_valid,
2015 exact_unclipped,
2016 column_overshoot_after_line_end: 0,
2017 }
2018 });
2019 cx.update_editor(|editor, window, cx| {
2020 editor.update_inlay_link_and_hover_points(
2021 &editor.snapshot(window, cx),
2022 struct_hint_part_hover_position,
2023 None,
2024 true,
2025 false,
2026 window,
2027 cx,
2028 );
2029 });
2030 cx.background_executor
2031 .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100));
2032 cx.background_executor.run_until_parked();
2033 cx.update_editor(|editor, _, cx| {
2034 let hover_state = &editor.hover_state;
2035 assert!(
2036 hover_state.diagnostic_popover.is_none() && hover_state.info_popovers.len() == 1
2037 );
2038 let popover = hover_state.info_popovers.first().unwrap();
2039 let buffer_snapshot = editor.buffer().update(cx, |buffer, cx| buffer.snapshot(cx));
2040 assert_eq!(
2041 popover.symbol_range,
2042 RangeInEditor::Inlay(InlayHighlight {
2043 inlay: InlayId::Hint(0),
2044 inlay_position: buffer_snapshot
2045 .anchor_after(MultiBufferOffset(inlay_range.start)),
2046 range: ": ".len() + new_type_label.len() + "<".len()
2047 ..": ".len() + new_type_label.len() + "<".len() + struct_label.len(),
2048 }),
2049 "Popover range should match the struct label part"
2050 );
2051 assert_eq!(
2052 popover.get_rendered_text(cx),
2053 format!("A tooltip for {struct_label}"),
2054 "Rendered markdown element should remove backticks from text"
2055 );
2056 });
2057 }
2058
2059 #[test]
2060 fn test_find_hovered_hint_part_with_multibyte_characters() {
2061 use crate::display_map::InlayOffset;
2062 use multi_buffer::MultiBufferOffset;
2063 use project::InlayHintLabelPart;
2064
2065 // Test with multi-byte UTF-8 character "→" (3 bytes, 1 character)
2066 let label = "→ app/Livewire/UserProfile.php";
2067 let label_parts = vec![InlayHintLabelPart {
2068 value: label.to_string(),
2069 tooltip: None,
2070 location: None,
2071 }];
2072
2073 let hint_start = InlayOffset(MultiBufferOffset(100));
2074
2075 // Verify the label has more bytes than characters (due to "→")
2076 assert_eq!(label.len(), 32); // bytes
2077 assert_eq!(label.chars().count(), 30); // characters
2078
2079 // Test hovering at the last byte (should find the part)
2080 let last_byte_offset = InlayOffset(MultiBufferOffset(100 + label.len() - 1));
2081 let result = find_hovered_hint_part(label_parts.clone(), hint_start, last_byte_offset);
2082 assert!(
2083 result.is_some(),
2084 "Should find part when hovering at last byte"
2085 );
2086 let (part, range) = result.unwrap();
2087 assert_eq!(part.value, label);
2088 assert_eq!(range.start, hint_start);
2089 assert_eq!(range.end, InlayOffset(MultiBufferOffset(100 + label.len())));
2090
2091 // Test hovering at the first byte of "→" (byte 0)
2092 let first_byte_offset = InlayOffset(MultiBufferOffset(100));
2093 let result = find_hovered_hint_part(label_parts.clone(), hint_start, first_byte_offset);
2094 assert!(
2095 result.is_some(),
2096 "Should find part when hovering at first byte"
2097 );
2098
2099 // Test hovering in the middle of "→" (byte 1, still part of the arrow character)
2100 let mid_arrow_offset = InlayOffset(MultiBufferOffset(101));
2101 let result = find_hovered_hint_part(label_parts, hint_start, mid_arrow_offset);
2102 assert!(
2103 result.is_some(),
2104 "Should find part when hovering in middle of multi-byte char"
2105 );
2106
2107 // Test with multiple parts containing multi-byte characters
2108 // Part ranges are [start, end) - start inclusive, end exclusive
2109 // "→ " occupies bytes [0, 4), "path" occupies bytes [4, 8)
2110 let parts = vec![
2111 InlayHintLabelPart {
2112 value: "→ ".to_string(), // 4 bytes (3 + 1)
2113 tooltip: None,
2114 location: None,
2115 },
2116 InlayHintLabelPart {
2117 value: "path".to_string(), // 4 bytes
2118 tooltip: None,
2119 location: None,
2120 },
2121 ];
2122
2123 // Hover at byte 3 (last byte of "→ ", the space character)
2124 let arrow_last_byte = InlayOffset(MultiBufferOffset(100 + 3));
2125 let result = find_hovered_hint_part(parts.clone(), hint_start, arrow_last_byte);
2126 assert!(result.is_some(), "Should find first part at its last byte");
2127 let (part, range) = result.unwrap();
2128 assert_eq!(part.value, "→ ");
2129 assert_eq!(
2130 range,
2131 InlayOffset(MultiBufferOffset(100))..InlayOffset(MultiBufferOffset(104))
2132 );
2133
2134 // Hover at byte 4 (first byte of "path", at the boundary)
2135 let path_start_offset = InlayOffset(MultiBufferOffset(100 + 4));
2136 let result = find_hovered_hint_part(parts.clone(), hint_start, path_start_offset);
2137 assert!(result.is_some(), "Should find second part at boundary");
2138 let (part, _) = result.unwrap();
2139 assert_eq!(part.value, "path");
2140
2141 // Hover at byte 7 (last byte of "path")
2142 let path_end_offset = InlayOffset(MultiBufferOffset(100 + 7));
2143 let result = find_hovered_hint_part(parts, hint_start, path_end_offset);
2144 assert!(result.is_some(), "Should find second part at last byte");
2145 let (part, range) = result.unwrap();
2146 assert_eq!(part.value, "path");
2147 assert_eq!(
2148 range,
2149 InlayOffset(MultiBufferOffset(104))..InlayOffset(MultiBufferOffset(108))
2150 );
2151 }
2152}