Debounce refresh of inlay hints on buffer edits (#8282)

Thorsten Ball and Kirill created

I think this makes it less chaotic to edit text when the inlay hints are
on.

It's for cases where you're editing to the right side of an inlay hint.
Example:

```rust
for name in names.iter().map(|item| item.len()) {
    println!("{:?}", name);
}
```

We display a `usize` inlay hint right next to `name`.

But as soon as you remove that `.` in `names.iter` your cursor jumps
around because the inlay hint has been removed.

With this change we now have a 700ms debounce before we update the inlay
hints.

VS Code seems to have an even longer debounce, I think somewhere around
~1s.

Release Notes:

- Added debouncing to make it easier to edit text when inlay hints are
enabled and to save rendering of inlay hints when scrolling. Both
debounce durations can be configured with `{"inlay_hints":
{"edit_debounce_ms": 700}}` (default) and `{"inlay_hints":
{"scroll_debounce_ms": 50}}`. Set a value to `0` to turn off the
debouncing.


### Before


https://github.com/zed-industries/zed/assets/1185253/3afbe548-dcfb-45a3-ab9f-cce14c04a148



### After



https://github.com/zed-industries/zed/assets/1185253/7ea90e42-bca6-4f6c-995e-83324669ab43

---------

Co-authored-by: Kirill <kirill@zed.dev>

Change summary

assets/settings/default.json             |  8 +
crates/collab/src/tests/editor_tests.rs  |  8 ++
crates/editor/src/editor.rs              |  8 ++
crates/editor/src/hover_links.rs         |  2 
crates/editor/src/hover_popover.rs       |  2 
crates/editor/src/inlay_hint_cache.rs    | 99 +++++++++++++++++++++----
crates/language/src/language_settings.rs | 22 +++++
docs/src/configuring_zed.md              |  7 +
8 files changed, 137 insertions(+), 19 deletions(-)

Detailed changes

assets/settings/default.json 🔗

@@ -169,7 +169,13 @@
     "show_type_hints": true,
     "show_parameter_hints": true,
     // Corresponds to null/None LSP hint type value.
-    "show_other_hints": true
+    "show_other_hints": true,
+    // Time to wait after editing the buffer, before requesting the hints,
+    // set to 0 to disable debouncing.
+    "edit_debounce_ms": 700,
+    // Time to wait after scrolling the buffer, before requesting the hints,
+    // set to 0 to disable debouncing.
+    "scroll_debounce_ms": 50
   },
   "project_panel": {
     // Default width of the project panel.

crates/collab/src/tests/editor_tests.rs 🔗

@@ -1426,6 +1426,8 @@ async fn test_mutual_editor_inlay_hint_cache_update(
             store.update_user_settings::<AllLanguageSettings>(cx, |settings| {
                 settings.defaults.inlay_hints = Some(InlayHintSettings {
                     enabled: true,
+                    edit_debounce_ms: 0,
+                    scroll_debounce_ms: 0,
                     show_type_hints: true,
                     show_parameter_hints: false,
                     show_other_hints: true,
@@ -1438,6 +1440,8 @@ async fn test_mutual_editor_inlay_hint_cache_update(
             store.update_user_settings::<AllLanguageSettings>(cx, |settings| {
                 settings.defaults.inlay_hints = Some(InlayHintSettings {
                     enabled: true,
+                    edit_debounce_ms: 0,
+                    scroll_debounce_ms: 0,
                     show_type_hints: true,
                     show_parameter_hints: false,
                     show_other_hints: true,
@@ -1695,6 +1699,8 @@ async fn test_inlay_hint_refresh_is_forwarded(
             store.update_user_settings::<AllLanguageSettings>(cx, |settings| {
                 settings.defaults.inlay_hints = Some(InlayHintSettings {
                     enabled: false,
+                    edit_debounce_ms: 0,
+                    scroll_debounce_ms: 0,
                     show_type_hints: false,
                     show_parameter_hints: false,
                     show_other_hints: false,
@@ -1707,6 +1713,8 @@ async fn test_inlay_hint_refresh_is_forwarded(
             store.update_user_settings::<AllLanguageSettings>(cx, |settings| {
                 settings.defaults.inlay_hints = Some(InlayHintSettings {
                     enabled: true,
+                    edit_debounce_ms: 0,
+                    scroll_debounce_ms: 0,
                     show_type_hints: true,
                     show_parameter_hints: true,
                     show_other_hints: true,

crates/editor/src/editor.rs 🔗

@@ -1360,6 +1360,7 @@ enum InlayHintRefreshReason {
     RefreshRequested,
     ExcerptsRemoved(Vec<ExcerptId>),
 }
+
 impl InlayHintRefreshReason {
     fn description(&self) -> &'static str {
         match self {
@@ -3029,6 +3030,12 @@ impl Editor {
         }
 
         let reason_description = reason.description();
+        let ignore_debounce = matches!(
+            reason,
+            InlayHintRefreshReason::SettingsChange(_)
+                | InlayHintRefreshReason::Toggle(_)
+                | InlayHintRefreshReason::ExcerptsRemoved(_)
+        );
         let (invalidate_cache, required_languages) = match reason {
             InlayHintRefreshReason::Toggle(enabled) => {
                 self.inlay_hint_cache.enabled = enabled;
@@ -3091,6 +3098,7 @@ impl Editor {
             reason_description,
             self.excerpts_for_inlay_hints_query(required_languages.as_ref(), cx),
             invalidate_cache,
+            ignore_debounce,
             cx,
         ) {
             self.splice_inlay_hints(to_remove, to_insert, cx);

crates/editor/src/hover_links.rs 🔗

@@ -984,6 +984,8 @@ mod tests {
         init_test(cx, |settings| {
             settings.defaults.inlay_hints = Some(InlayHintSettings {
                 enabled: true,
+                edit_debounce_ms: 0,
+                scroll_debounce_ms: 0,
                 show_type_hints: true,
                 show_parameter_hints: true,
                 show_other_hints: true,

crates/editor/src/hover_popover.rs 🔗

@@ -1066,6 +1066,8 @@ mod tests {
         init_test(cx, |settings| {
             settings.defaults.inlay_hints = Some(InlayHintSettings {
                 enabled: true,
+                edit_debounce_ms: 0,
+                scroll_debounce_ms: 0,
                 show_type_hints: true,
                 show_parameter_hints: true,
                 show_other_hints: true,

crates/editor/src/inlay_hint_cache.rs 🔗

@@ -37,6 +37,9 @@ pub struct InlayHintCache {
     version: usize,
     pub(super) enabled: bool,
     update_tasks: HashMap<ExcerptId, TasksForRanges>,
+    refresh_task: Option<Task<()>>,
+    invalidate_debounce: Option<Duration>,
+    append_debounce: Option<Duration>,
     lsp_request_limiter: Arc<Semaphore>,
 }
 
@@ -267,6 +270,9 @@ impl InlayHintCache {
             enabled: inlay_hint_settings.enabled,
             hints: HashMap::default(),
             update_tasks: HashMap::default(),
+            refresh_task: None,
+            invalidate_debounce: debounce_value(inlay_hint_settings.edit_debounce_ms),
+            append_debounce: debounce_value(inlay_hint_settings.scroll_debounce_ms),
             version: 0,
             lsp_request_limiter: Arc::new(Semaphore::new(MAX_CONCURRENT_LSP_REQUESTS)),
         }
@@ -282,6 +288,8 @@ impl InlayHintCache {
         visible_hints: Vec<Inlay>,
         cx: &mut ViewContext<Editor>,
     ) -> ControlFlow<Option<InlaySplice>> {
+        self.invalidate_debounce = debounce_value(new_hint_settings.edit_debounce_ms);
+        self.append_debounce = debounce_value(new_hint_settings.scroll_debounce_ms);
         let new_allowed_hint_kinds = new_hint_settings.enabled_inlay_hint_kinds();
         match (self.enabled, new_hint_settings.enabled) {
             (false, false) => {
@@ -332,15 +340,15 @@ impl InlayHintCache {
     /// This way, concequent refresh invocations are less likely to trigger LSP queries for the invisible ranges.
     pub(super) fn spawn_hint_refresh(
         &mut self,
-        reason: &'static str,
+        reason_description: &'static str,
         excerpts_to_query: HashMap<ExcerptId, (Model<Buffer>, Global, Range<usize>)>,
         invalidate: InvalidationStrategy,
+        ignore_debounce: bool,
         cx: &mut ViewContext<Editor>,
     ) -> Option<InlaySplice> {
         if !self.enabled {
             return None;
         }
-
         let mut invalidated_hints = Vec::new();
         if invalidate.should_invalidate() {
             self.update_tasks
@@ -358,12 +366,23 @@ impl InlayHintCache {
         }
 
         let cache_version = self.version + 1;
-        cx.spawn(|editor, mut cx| async move {
+        let debounce_duration = if ignore_debounce {
+            None
+        } else if invalidate.should_invalidate() {
+            self.invalidate_debounce
+        } else {
+            self.append_debounce
+        };
+        self.refresh_task = Some(cx.spawn(|editor, mut cx| async move {
+            if let Some(debounce_duration) = debounce_duration {
+                cx.background_executor().timer(debounce_duration).await;
+            }
+
             editor
                 .update(&mut cx, |editor, cx| {
                     spawn_new_update_tasks(
                         editor,
-                        reason,
+                        reason_description,
                         excerpts_to_query,
                         invalidate,
                         cache_version,
@@ -371,8 +390,7 @@ impl InlayHintCache {
                     )
                 })
                 .ok();
-        })
-        .detach();
+        }));
 
         if invalidated_hints.is_empty() {
             None
@@ -612,6 +630,14 @@ impl InlayHintCache {
     }
 }
 
+fn debounce_value(debounce_ms: u64) -> Option<Duration> {
+    if debounce_ms > 0 {
+        Some(Duration::from_millis(debounce_ms))
+    } else {
+        None
+    }
+}
+
 fn spawn_new_update_tasks(
     editor: &mut Editor,
     reason: &'static str,
@@ -1259,6 +1285,8 @@ pub mod tests {
         init_test(cx, |settings| {
             settings.defaults.inlay_hints = Some(InlayHintSettings {
                 enabled: true,
+                edit_debounce_ms: 0,
+                scroll_debounce_ms: 0,
                 show_type_hints: allowed_hint_kinds.contains(&Some(InlayHintKind::Type)),
                 show_parameter_hints: allowed_hint_kinds.contains(&Some(InlayHintKind::Parameter)),
                 show_other_hints: allowed_hint_kinds.contains(&None),
@@ -1389,6 +1417,8 @@ pub mod tests {
         init_test(cx, |settings| {
             settings.defaults.inlay_hints = Some(InlayHintSettings {
                 enabled: true,
+                edit_debounce_ms: 0,
+                scroll_debounce_ms: 0,
                 show_type_hints: true,
                 show_parameter_hints: true,
                 show_other_hints: true,
@@ -1506,6 +1536,8 @@ pub mod tests {
         init_test(cx, |settings| {
             settings.defaults.inlay_hints = Some(InlayHintSettings {
                 enabled: true,
+                edit_debounce_ms: 0,
+                scroll_debounce_ms: 0,
                 show_type_hints: true,
                 show_parameter_hints: true,
                 show_other_hints: true,
@@ -1734,6 +1766,8 @@ pub mod tests {
         init_test(cx, |settings| {
             settings.defaults.inlay_hints = Some(InlayHintSettings {
                 enabled: true,
+                edit_debounce_ms: 0,
+                scroll_debounce_ms: 0,
                 show_type_hints: allowed_hint_kinds.contains(&Some(InlayHintKind::Type)),
                 show_parameter_hints: allowed_hint_kinds.contains(&Some(InlayHintKind::Parameter)),
                 show_other_hints: allowed_hint_kinds.contains(&None),
@@ -1895,6 +1929,8 @@ pub mod tests {
             update_test_language_settings(cx, |settings| {
                 settings.defaults.inlay_hints = Some(InlayHintSettings {
                     enabled: true,
+                    edit_debounce_ms: 0,
+                    scroll_debounce_ms: 0,
                     show_type_hints: new_allowed_hint_kinds.contains(&Some(InlayHintKind::Type)),
                     show_parameter_hints: new_allowed_hint_kinds
                         .contains(&Some(InlayHintKind::Parameter)),
@@ -1939,6 +1975,8 @@ pub mod tests {
         update_test_language_settings(cx, |settings| {
             settings.defaults.inlay_hints = Some(InlayHintSettings {
                 enabled: false,
+                edit_debounce_ms: 0,
+                scroll_debounce_ms: 0,
                 show_type_hints: another_allowed_hint_kinds.contains(&Some(InlayHintKind::Type)),
                 show_parameter_hints: another_allowed_hint_kinds
                     .contains(&Some(InlayHintKind::Parameter)),
@@ -1997,6 +2035,8 @@ pub mod tests {
         update_test_language_settings(cx, |settings| {
             settings.defaults.inlay_hints = Some(InlayHintSettings {
                 enabled: true,
+                edit_debounce_ms: 0,
+                scroll_debounce_ms: 0,
                 show_type_hints: final_allowed_hint_kinds.contains(&Some(InlayHintKind::Type)),
                 show_parameter_hints: final_allowed_hint_kinds
                     .contains(&Some(InlayHintKind::Parameter)),
@@ -2071,6 +2111,8 @@ pub mod tests {
         init_test(cx, |settings| {
             settings.defaults.inlay_hints = Some(InlayHintSettings {
                 enabled: true,
+                edit_debounce_ms: 0,
+                scroll_debounce_ms: 0,
                 show_type_hints: true,
                 show_parameter_hints: true,
                 show_other_hints: true,
@@ -2203,6 +2245,8 @@ pub mod tests {
         init_test(cx, |settings| {
             settings.defaults.inlay_hints = Some(InlayHintSettings {
                 enabled: true,
+                edit_debounce_ms: 0,
+                scroll_debounce_ms: 0,
                 show_type_hints: true,
                 show_parameter_hints: true,
                 show_other_hints: true,
@@ -2361,6 +2405,11 @@ pub mod tests {
         editor
             .update(cx, |editor, cx| {
                 editor.scroll_screen(&ScrollAmount::Page(1.0), cx);
+            })
+            .unwrap();
+        cx.executor().run_until_parked();
+        editor
+            .update(cx, |editor, cx| {
                 editor.scroll_screen(&ScrollAmount::Page(1.0), cx);
             })
             .unwrap();
@@ -2497,6 +2546,8 @@ pub mod tests {
         init_test(cx, |settings| {
             settings.defaults.inlay_hints = Some(InlayHintSettings {
                 enabled: true,
+                edit_debounce_ms: 0,
+                scroll_debounce_ms: 0,
                 show_type_hints: true,
                 show_parameter_hints: true,
                 show_other_hints: true,
@@ -2782,6 +2833,9 @@ pub mod tests {
                 });
             })
             .unwrap();
+        cx.executor().advance_clock(Duration::from_millis(
+            INVISIBLE_RANGES_HINTS_REQUEST_DELAY_MILLIS + 100,
+        ));
         cx.executor().run_until_parked();
         editor.update(cx, |editor, cx| {
                 let expected_hints = vec![
@@ -2816,12 +2870,12 @@ pub mod tests {
         cx.executor().run_until_parked();
         editor.update(cx, |editor, cx| {
             let expected_hints = vec![
-                "main hint(edited) #0".to_string(),
-                "main hint(edited) #1".to_string(),
-                "main hint(edited) #2".to_string(),
-                "main hint(edited) #3".to_string(),
-                "main hint(edited) #4".to_string(),
-                "main hint(edited) #5".to_string(),
+                "main hint #0".to_string(),
+                "main hint #1".to_string(),
+                "main hint #2".to_string(),
+                "main hint #3".to_string(),
+                "main hint #4".to_string(),
+                "main hint #5".to_string(),
                 "other hint(edited) #0".to_string(),
                 "other hint(edited) #1".to_string(),
             ];
@@ -2834,11 +2888,12 @@ pub mod tests {
             assert_eq!(expected_hints, visible_hint_labels(editor, cx));
 
             let current_cache_version = editor.inlay_hint_cache().version;
-            let expected_version = last_scroll_update_version + expected_hints.len();
-            assert!(
-                current_cache_version == expected_version || current_cache_version == expected_version + 1 ,
-                // TODO we sometimes get an extra cache version bump, why?
-                "We should have updated cache N times == N of new hints arrived (separately from each excerpt), or hit a bug and do that one extra time"
+            // We expect two new hints for the excerpts from `other.rs`:
+            let expected_version = last_scroll_update_version + 2;
+            assert_eq!(
+                current_cache_version,
+                expected_version,
+                "We should have updated cache N times == N of new hints arrived (separately from each edited excerpt)"
             );
         }).unwrap();
     }
@@ -2848,6 +2903,8 @@ pub mod tests {
         init_test(cx, |settings| {
             settings.defaults.inlay_hints = Some(InlayHintSettings {
                 enabled: true,
+                edit_debounce_ms: 0,
+                scroll_debounce_ms: 0,
                 show_type_hints: false,
                 show_parameter_hints: false,
                 show_other_hints: false,
@@ -3049,6 +3106,8 @@ pub mod tests {
         update_test_language_settings(cx, |settings| {
             settings.defaults.inlay_hints = Some(InlayHintSettings {
                 enabled: true,
+                edit_debounce_ms: 0,
+                scroll_debounce_ms: 0,
                 show_type_hints: true,
                 show_parameter_hints: true,
                 show_other_hints: true,
@@ -3082,6 +3141,8 @@ pub mod tests {
         init_test(cx, |settings| {
             settings.defaults.inlay_hints = Some(InlayHintSettings {
                 enabled: true,
+                edit_debounce_ms: 0,
+                scroll_debounce_ms: 0,
                 show_type_hints: true,
                 show_parameter_hints: true,
                 show_other_hints: true,
@@ -3180,6 +3241,8 @@ pub mod tests {
         init_test(cx, |settings| {
             settings.defaults.inlay_hints = Some(InlayHintSettings {
                 enabled: false,
+                edit_debounce_ms: 0,
+                scroll_debounce_ms: 0,
                 show_type_hints: true,
                 show_parameter_hints: true,
                 show_other_hints: true,
@@ -3258,6 +3321,8 @@ pub mod tests {
         update_test_language_settings(cx, |settings| {
             settings.defaults.inlay_hints = Some(InlayHintSettings {
                 enabled: true,
+                edit_debounce_ms: 0,
+                scroll_debounce_ms: 0,
                 show_type_hints: true,
                 show_parameter_hints: true,
                 show_other_hints: true,

crates/language/src/language_settings.rs 🔗

@@ -327,12 +327,34 @@ pub struct InlayHintSettings {
     /// Default: true
     #[serde(default = "default_true")]
     pub show_other_hints: bool,
+    /// Whether or not to debounce inlay hints updates after buffer edits.
+    ///
+    /// Set to 0 to disable debouncing.
+    ///
+    /// Default: 700
+    #[serde(default = "edit_debounce_ms")]
+    pub edit_debounce_ms: u64,
+    /// Whether or not to debounce inlay hints updates after buffer scrolls.
+    ///
+    /// Set to 0 to disable debouncing.
+    ///
+    /// Default: 50
+    #[serde(default = "scroll_debounce_ms")]
+    pub scroll_debounce_ms: u64,
 }
 
 fn default_true() -> bool {
     true
 }
 
+fn edit_debounce_ms() -> u64 {
+    700
+}
+
+fn scroll_debounce_ms() -> u64 {
+    50
+}
+
 impl InlayHintSettings {
     /// Returns the kinds of inlay hints that are enabled based on the settings.
     pub fn enabled_inlay_hint_kinds(&self) -> HashSet<Option<InlayHintKind>> {

docs/src/configuring_zed.md 🔗

@@ -383,7 +383,9 @@ To override settings for a language, add an entry for that language server's nam
   "enabled": false,
   "show_type_hints": true,
   "show_parameter_hints": true,
-  "show_other_hints": true
+  "show_other_hints": true,
+  "edit_debounce_ms": 700,
+  "scroll_debounce_ms": 50
 }
 ```
 
@@ -402,6 +404,9 @@ The following languages have inlay hints preconfigured by Zed:
 
 Use the `lsp` section for the server configuration. Examples are provided in the corresponding language documentation.
 
+Hints are not instantly queried in Zed, two kinds of debounces are used, either may be set to 0 to be disabled.
+Settings-related hint updates are not debounced.
+
 ## Journal
 
 - Description: Configuration for the journal.