From 09831343ccd450326cffb4b0f26ce3a8b830c7fb Mon Sep 17 00:00:00 2001 From: Alice Koreman Date: Fri, 22 Sep 2023 14:49:23 +0200 Subject: [PATCH] feat: Keep focus on same item in completion popup when slow completer delivers results. (#5322) Currently, when there are new async completion results delivered by a completer, the active row of the popup is set back to 0. This is jarring when there are completers which are sufficiently slow that the user has already started to interact with the other, faster, completion results. This adds a timer such that when completions are delivered a configurable number of milliseconds after opening the popup, the item in focus remains in focus after the new results are added to the popup. --- ace.d.ts | 2 + src/autocomplete.js | 32 ++++++++++- src/autocomplete_test.js | 112 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 144 insertions(+), 2 deletions(-) diff --git a/ace.d.ts b/ace.d.ts index f962515bd57..f867d4f319d 100644 --- a/ace.d.ts +++ b/ace.d.ts @@ -1065,6 +1065,8 @@ export namespace Ace { exactMatch?: boolean; inlineEnabled?: boolean; parentNode?: HTMLElement; + setSelectOnHover?: Boolean; + stickySelectionDelay?: Number; emptyMessage?(prefix: String): String; getPopup(): AcePopup; showPopup(editor: Editor, options: CompletionOptions): void; diff --git a/src/autocomplete.js b/src/autocomplete.js index cc308eefee0..9f241194030 100644 --- a/src/autocomplete.js +++ b/src/autocomplete.js @@ -63,6 +63,13 @@ class Autocomplete { this.parentNode = null; this.setSelectOnHover = false; + /** + * @property {number} stickySelectionDelay - a numerical value that determines after how many ms the popup selection will become 'sticky'. + * Normally, when new elements are added to an open popup, the selection is reset to the first row of the popup. If sticky, the focus will remain + * on the currently selected item when new items are added to the popup. Set to a negative value to disable this feature and never set selection to sticky. + */ + this.stickySelectionDelay = 500; + this.blurListener = this.blurListener.bind(this); this.changeListener = this.changeListener.bind(this); this.mousedownListener = this.mousedownListener.bind(this); @@ -74,6 +81,10 @@ class Autocomplete { }.bind(this)); this.tooltipTimer = lang.delayedCall(this.updateDocTooltip.bind(this), 50); + + this.stickySelectionTimer = lang.delayedCall(function() { + this.stickySelection = true; + }.bind(this), this.stickySelectionDelay); } $init() { @@ -83,7 +94,7 @@ class Autocomplete { e.stop(); }.bind(this)); this.popup.focus = this.editor.focus.bind(this.editor); - this.popup.on("show", this.$onPopupChange.bind(this)); + this.popup.on("show", this.$onPopupShow.bind(this)); this.popup.on("hide", this.$onHidePopup.bind(this)); this.popup.on("select", this.$onPopupChange.bind(this)); this.popup.on("changeHoverMarker", this.tooltipTimer.bind(null, null)); @@ -106,6 +117,8 @@ class Autocomplete { this.inlineRenderer.hide(); } this.hideDocTooltip(); + this.stickySelectionTimer.cancel(); + this.stickySelection = false; } $onPopupChange(hide) { @@ -120,6 +133,13 @@ class Autocomplete { this.tooltipTimer.call(null, null); } + $onPopupShow(hide) { + this.$onPopupChange(hide); + this.stickySelection = false; + if (this.stickySelectionDelay >= 0) + this.stickySelectionTimer.schedule(this.stickySelectionDelay); + } + observeLayoutChanges() { if (this.$elements || !this.editor) return; window.addEventListener("resize", this.onLayoutChange, {passive: true}); @@ -194,6 +214,8 @@ class Autocomplete { this.popup.autoSelect = this.autoSelect; this.popup.setSelectOnHover(this.setSelectOnHover); + var previousSelectedItem = this.popup.data[this.popup.getRow()]; + this.popup.setData(this.completions.filtered, this.completions.filterText); if (this.editor.textInput.setAriaOptions) { this.editor.textInput.setAriaOptions({ @@ -204,7 +226,13 @@ class Autocomplete { editor.keyBinding.addKeyboardHandler(this.keyboardHandler); - this.popup.setRow(this.autoSelect ? 0 : -1); + var newRow = this.popup.data.indexOf(previousSelectedItem); + + if (newRow && this.stickySelection) + this.popup.setRow(this.autoSelect ? newRow : -1); + else + this.popup.setRow(this.autoSelect ? 0 : -1); + if (!keepPopupPosition) { this.popup.setTheme(editor.getTheme()); this.popup.setFontSize(editor.getFontSize()); diff --git a/src/autocomplete_test.js b/src/autocomplete_test.js index 899ade64363..776ba08dc76 100644 --- a/src/autocomplete_test.js +++ b/src/autocomplete_test.js @@ -614,6 +614,118 @@ module.exports = { done(); + }, + "test: should maintain selection on fast completer item when slow completer results come in": function(done) { + var editor = initEditor("hello world\n"); + + var slowCompleter = { + getCompletions: function (editor, session, pos, prefix, callback) { + var completions = [ + { + caption: "slow option 1", + value: "s1", + score: 3 + }, { + caption: "slow option 2", + value: "s2", + score: 0 + } + ]; + setTimeout(() => { + callback(null, completions); + }, 200); + } + }; + + var fastCompleter = { + getCompletions: function (editor, session, pos, prefix, callback) { + var completions = [ + { + caption: "fast option 1", + value: "f1", + score: 2 + }, { + caption: "fast option 2", + value: "f2", + score: 1 + } + ]; + callback(null, completions); + } + }; + + editor.completers = [fastCompleter, slowCompleter]; + + var completer = Autocomplete.for(editor); + completer.stickySelectionDelay = 100; + user.type("Ctrl-Space"); + assert.equal(completer.popup.isOpen, true); + assert.equal(completer.popup.data.length, 2); + assert.equal(completer.popup.getRow(), 0); + + setTimeout(() => { + completer.popup.renderer.$loop._flush(); + assert.equal(completer.popup.data.length, 4); + assert.equal(completer.popup.getRow(), 1); + + done(); + }, 500); + }, + "test: should not maintain selection on fast completer item when slow completer results come in when stickySelectionDelay negative": function(done) { + var editor = initEditor("hello world\n"); + + var slowCompleter = { + getCompletions: function (editor, session, pos, prefix, callback) { + var completions = [ + { + caption: "slow option 1", + value: "s1", + score: 3 + }, { + caption: "slow option 2", + value: "s2", + score: 0 + } + ]; + setTimeout(() => { + callback(null, completions); + }, 200); + } + }; + + var fastCompleter = { + getCompletions: function (editor, session, pos, prefix, callback) { + var completions = [ + { + caption: "fast option 1", + value: "f1", + score: 2 + }, { + caption: "fast option 2", + value: "f2", + score: 1 + } + ]; + callback(null, completions); + } + }; + + editor.completers = [fastCompleter, slowCompleter]; + + var completer = Autocomplete.for(editor); + completer.stickySelectionDelay = -1; + user.type("Ctrl-Space"); + assert.equal(completer.popup.isOpen, true); + assert.equal(completer.popup.data.length, 2); + assert.equal(completer.popup.getRow(), 0); + + setTimeout(() => { + completer.popup.renderer.$loop._flush(); + assert.equal(completer.popup.data.length, 4); + assert.equal(completer.popup.getRow(), 0); + + done(); + }, 500); } };