From 09c17437fb177ef8206d75fe8b7349844f611139 Mon Sep 17 00:00:00 2001 From: daydalek Date: Wed, 27 May 2026 21:01:10 +0800 Subject: [PATCH 1/3] Treat zero-width hovers as nearby offsets When a hover range is zero-width, compare the hovered offset within a small nearby window instead of a single point to reduce hover jitter. --- crates/editor/src/hover_popover.rs | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs index 21177ad27b5..2f874766929 100644 --- a/crates/editor/src/hover_popover.rs +++ b/crates/editor/src/hover_popover.rs @@ -37,6 +37,7 @@ pub const MIN_POPOVER_CHARACTER_WIDTH: f32 = 20.; pub const MIN_POPOVER_LINE_HEIGHT: f32 = 4.; pub const POPOVER_RIGHT_OFFSET: Pixels = px(8.0); pub const HOVER_POPOVER_GAP: Pixels = px(10.); +const ZERO_WIDTH_HOVER_EQUIVALENT_OFFSETS: usize = 4; /// Bindable action which uses the most recent selection head to trigger a hover pub fn hover(editor: &mut Editor, _: &Hover, window: &mut Window, cx: &mut Context) { @@ -599,7 +600,19 @@ fn same_info_hover(editor: &Editor, snapshot: &EditorSnapshot, anchor: Anchor) - let offset = anchor.to_offset(&snapshot.buffer_snapshot()); // LSP returns a hover result for the end index of ranges that should be hovered, so we need to // use an inclusive range here to check if we should dismiss the popover - (hover_range.start..=hover_range.end).contains(&offset) + let mut start = hover_range.start; + let mut end = hover_range.end; + if start == end { + // Some language servers report zero-width hover ranges. + // Treat nearby offsets as equivalent to avoid flicker around zero-width ranges. + start = MultiBufferOffset( + start.0.saturating_sub(ZERO_WIDTH_HOVER_EQUIVALENT_OFFSETS), + ); + end = MultiBufferOffset( + end.0.saturating_add(ZERO_WIDTH_HOVER_EQUIVALENT_OFFSETS), + ); + } + (start..=end).contains(&offset) }) .unwrap_or(false) }) @@ -618,7 +631,14 @@ fn same_diagnostic_hover(editor: &Editor, snapshot: &EditorSnapshot, anchor: Anc let offset = anchor.to_offset(&snapshot.buffer_snapshot()); // Here we do basically the same as in `same_info_hover`, see comment there for an explanation - (hover_range.start..=hover_range.end).contains(&offset) + let mut start = hover_range.start; + let mut end = hover_range.end; + if start == end { + start = + MultiBufferOffset(start.0.saturating_sub(ZERO_WIDTH_HOVER_EQUIVALENT_OFFSETS)); + end = MultiBufferOffset(end.0.saturating_add(ZERO_WIDTH_HOVER_EQUIVALENT_OFFSETS)); + } + (start..=end).contains(&offset) }) .unwrap_or(false) } From 5c44fba390ae8b7d5108c0b0782a5cb275b1450b Mon Sep 17 00:00:00 2001 From: daydalek Date: Thu, 28 May 2026 18:36:35 +0800 Subject: [PATCH 2/3] Clip zero-width hover padding to excerpts --- crates/editor/src/hover_popover.rs | 132 ++++++++++++++++++++--------- 1 file changed, 92 insertions(+), 40 deletions(-) diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs index e685ed62faf..93bb1cba70b 100644 --- a/crates/editor/src/hover_popover.rs +++ b/crates/editor/src/hover_popover.rs @@ -18,7 +18,7 @@ use itertools::Itertools; use language::{DiagnosticEntry, Language, LanguageRegistry}; use lsp::DiagnosticSeverity; use markdown::{CopyButtonVisibility, Markdown, MarkdownElement, MarkdownStyle}; -use multi_buffer::{MultiBufferOffset, ToOffset, ToPoint}; +use multi_buffer::{MultiBufferOffset, MultiBufferSnapshot, ToOffset, ToPoint}; use project::{HoverBlock, HoverBlockKind, InlayHintLabelPart}; use settings::Settings; use std::{ @@ -27,6 +27,7 @@ use std::{ }; use std::{ops::Range, sync::Arc, time::Duration}; use std::{path::PathBuf, rc::Rc}; +use text::Bias; use theme_settings::ThemeSettings; use ui::{CopyButton, Scrollbars, WithScrollbar, prelude::*, theme_is_transparent}; use url::Url; @@ -301,7 +302,6 @@ fn show_hover( if !ignore_timeout { if same_info_hover(editor, &snapshot, anchor) || same_diagnostic_hover(editor, &snapshot, anchor) - || editor.hover_state.diagnostic_popover.is_some() { // Hover triggered from same location as last time. Don't show again. return None; @@ -629,34 +629,25 @@ fn show_hover( } fn same_info_hover(editor: &Editor, snapshot: &EditorSnapshot, anchor: Anchor) -> bool { - editor - .hover_state - .info_popovers - .iter() - .any(|InfoPopover { symbol_range, .. }| { + editor.hover_state.info_popovers.iter().any( + |InfoPopover { + symbol_range, + anchor: hover_anchor, + .. + }| { symbol_range .as_text_range() .map(|range| { - let hover_range = range.to_offset(&snapshot.buffer_snapshot()); - let offset = anchor.to_offset(&snapshot.buffer_snapshot()); - // LSP returns a hover result for the end index of ranges that should be hovered, so we need to - // use an inclusive range here to check if we should dismiss the popover - let mut start = hover_range.start; - let mut end = hover_range.end; - if start == end { - // Some language servers report zero-width hover ranges. - // Treat nearby offsets as equivalent to avoid flicker around zero-width ranges. - start = MultiBufferOffset( - start.0.saturating_sub(ZERO_WIDTH_HOVER_EQUIVALENT_OFFSETS), - ); - end = MultiBufferOffset( - end.0.saturating_add(ZERO_WIDTH_HOVER_EQUIVALENT_OFFSETS), - ); - } - (start..=end).contains(&offset) + hover_range_contains_anchor( + snapshot, + &range, + hover_anchor.unwrap_or(range.start), + anchor, + ) }) .unwrap_or(false) - }) + }, + ) } fn same_diagnostic_hover(editor: &Editor, snapshot: &EditorSnapshot, anchor: Anchor) -> bool { @@ -665,25 +656,86 @@ fn same_diagnostic_hover(editor: &Editor, snapshot: &EditorSnapshot, anchor: Anc .diagnostic_popover .as_ref() .map(|diagnostic| { - let hover_range = diagnostic - .local_diagnostic - .range - .to_offset(&snapshot.buffer_snapshot()); - let offset = anchor.to_offset(&snapshot.buffer_snapshot()); - - // Here we do basically the same as in `same_info_hover`, see comment there for an explanation - let mut start = hover_range.start; - let mut end = hover_range.end; - if start == end { - start = - MultiBufferOffset(start.0.saturating_sub(ZERO_WIDTH_HOVER_EQUIVALENT_OFFSETS)); - end = MultiBufferOffset(end.0.saturating_add(ZERO_WIDTH_HOVER_EQUIVALENT_OFFSETS)); - } - (start..=end).contains(&offset) + hover_range_contains_anchor( + snapshot, + &diagnostic.local_diagnostic.range, + diagnostic.anchor, + anchor, + ) }) .unwrap_or(false) } +fn hover_range_contains_anchor( + snapshot: &EditorSnapshot, + hover_range: &Range, + hover_anchor: Anchor, + anchor: Anchor, +) -> bool { + let multibuffer = snapshot.buffer_snapshot(); + let hover_offsets = hover_range.to_offset(&multibuffer); + let anchor_offset = anchor.to_offset(&multibuffer); + if hover_offsets.start != hover_offsets.end { + // LSP returns a hover result for the end index of ranges that should be hovered, so we need to + // use an inclusive range here to check if we should dismiss the popover. + return (hover_offsets.start..=hover_offsets.end).contains(&anchor_offset); + } + + let Some((_, anchor_buffer)) = multibuffer.anchor_to_buffer_anchor(anchor) else { + return false; + }; + let Some((_, hover_buffer)) = multibuffer.anchor_to_buffer_anchor(hover_anchor) else { + return false; + }; + if anchor_buffer.remote_id() != hover_buffer.remote_id() { + return false; + } + + let Some(hover_excerpt_range) = + excerpt_multibuffer_range_containing_anchor(&multibuffer, hover_anchor) + else { + return false; + }; + + if !hover_excerpt_range.contains(&hover_offsets.start) { + return false; + } + + let expanded_start = hover_offsets + .start + .saturating_sub_usize(ZERO_WIDTH_HOVER_EQUIVALENT_OFFSETS) + .max(hover_excerpt_range.start); + let expanded_end = MultiBufferOffset( + hover_offsets + .end + .0 + .saturating_add(ZERO_WIDTH_HOVER_EQUIVALENT_OFFSETS), + ) + .min(hover_excerpt_range.end); + + (expanded_start..=expanded_end).contains(&anchor_offset) +} + +fn excerpt_multibuffer_range_containing_anchor( + multibuffer: &MultiBufferSnapshot, + anchor: Anchor, +) -> Option> { + let offset = anchor.to_offset(multibuffer); + let lookup_range = if anchor.bias() == Bias::Left && offset > MultiBufferOffset(0) { + offset.saturating_sub_usize(1)..offset + } else { + offset..offset + }; + + multibuffer + .map_excerpt_ranges(lookup_range, |_, excerpt_range, _| { + vec![(excerpt_range.context, ())] + })? + .into_iter() + .next() + .map(|(range, ())| range) +} + fn parse_blocks( blocks: &[HoverBlock], language_registry: Option<&Arc>, From 376f4fa12e353704d9724e9953dc881d1aa3af13 Mon Sep 17 00:00:00 2001 From: daydalek Date: Sat, 30 May 2026 17:27:17 +0800 Subject: [PATCH 3/3] Add test ensuring zero-width hover padding does not bleed across excerpts --- crates/editor/src/hover_popover.rs | 63 +++++++++++++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs index 93bb1cba70b..c0272f5d854 100644 --- a/crates/editor/src/hover_popover.rs +++ b/crates/editor/src/hover_popover.rs @@ -302,8 +302,8 @@ fn show_hover( if !ignore_timeout { if same_info_hover(editor, &snapshot, anchor) || same_diagnostic_hover(editor, &snapshot, anchor) + || editor.hover_state.diagnostic_popover.is_some() { - // Hover triggered from same location as last time. Don't show again. return None; } else { hide_hover(editor, cx); @@ -721,6 +721,8 @@ fn excerpt_multibuffer_range_containing_anchor( anchor: Anchor, ) -> Option> { let offset = anchor.to_offset(multibuffer); + // A left-biased anchor at an excerpt boundary belongs to the preceding excerpt, + // so probe one offset back to land in the correct one. let lookup_range = if anchor.bias() == Bias::Left && offset > MultiBufferOffset(0) { offset.saturating_sub_usize(1)..offset } else { @@ -1316,13 +1318,16 @@ mod tests { actions::ConfirmCompletion, editor_tests::{handle_completion_request, init_test}, inlays::inlay_hints::tests::{cached_hint_labels, visible_hint_labels}, + test::build_editor, test::editor_lsp_test_context::EditorLspTestContext, }; use collections::BTreeSet; use futures::stream::StreamExt; use gpui::App; use indoc::indoc; + use language::{Buffer, Capability::ReadWrite, Point}; use markdown::parser::MarkdownEvent; + use multi_buffer::{MultiBuffer, PathKey}; use project::InlayId; use settings::InlayHintSettingsContent; use settings::{DelayMs, SettingsStore}; @@ -2769,4 +2774,60 @@ mod tests { ); }); } + + #[gpui::test] + fn test_zero_width_hover_padding_clipped_to_excerpt(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + + // One buffer surfaced as two excerpts so they share a `remote_id`, forcing the + // excerpt-clipping path rather than the buffer-mismatch early-out. + let buffer = cx.new(|cx| Buffer::local("aaaa\nbbbb\ncccc\ndddd\n", cx)); + let multibuffer = cx.new(|cx| { + let mut multibuffer = MultiBuffer::new(ReadWrite); + multibuffer.set_excerpts_for_path( + PathKey::sorted(0), + buffer.clone(), + [ + Point::new(0, 0)..Point::new(0, 4), + Point::new(2, 0)..Point::new(2, 4), + ], + 0, + cx, + ); + multibuffer + }); + + cx.add_window(|window, cx| { + let editor = build_editor(multibuffer, window, cx); + let snapshot = editor.snapshot(window, cx); + let multibuffer = snapshot.buffer_snapshot(); + // First excerpt "aaaa" is offsets 0..4, second excerpt "cccc" starts at 5. + assert_eq!(multibuffer.text(), "aaaa\ncccc"); + + // Zero-width hover inside the first excerpt. + let hover_anchor = multibuffer.anchor_before(MultiBufferOffset(3)); + let hover_range = hover_anchor..hover_anchor; + + // Same excerpt and within the ±4 padding: treated as the same hover. + let same_excerpt = multibuffer.anchor_before(MultiBufferOffset(1)); + assert!(hover_range_contains_anchor( + &snapshot, + &hover_range, + hover_anchor, + same_excerpt, + )); + + // The next excerpt is only 2 offsets away and would fall inside the raw + // padding, but the clipped range must not bleed across the boundary. + let other_excerpt = multibuffer.anchor_before(MultiBufferOffset(5)); + assert!(!hover_range_contains_anchor( + &snapshot, + &hover_range, + hover_anchor, + other_excerpt, + )); + + editor + }); + } }