Skip to content

Commit 0983134

Browse files
authoredSep 22, 2023
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.
1 parent f401013 commit 0983134

File tree

3 files changed

+144
-2
lines changed

3 files changed

+144
-2
lines changed
 

‎ace.d.ts

+2
Original file line numberDiff line numberDiff line change
@@ -1065,6 +1065,8 @@ export namespace Ace {
10651065
exactMatch?: boolean;
10661066
inlineEnabled?: boolean;
10671067
parentNode?: HTMLElement;
1068+
setSelectOnHover?: Boolean;
1069+
stickySelectionDelay?: Number;
10681070
emptyMessage?(prefix: String): String;
10691071
getPopup(): AcePopup;
10701072
showPopup(editor: Editor, options: CompletionOptions): void;

‎src/autocomplete.js

+30-2
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,13 @@ class Autocomplete {
6363
this.parentNode = null;
6464
this.setSelectOnHover = false;
6565

66+
/**
67+
* @property {number} stickySelectionDelay - a numerical value that determines after how many ms the popup selection will become 'sticky'.
68+
* 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
69+
* 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.
70+
*/
71+
this.stickySelectionDelay = 500;
72+
6673
this.blurListener = this.blurListener.bind(this);
6774
this.changeListener = this.changeListener.bind(this);
6875
this.mousedownListener = this.mousedownListener.bind(this);
@@ -74,6 +81,10 @@ class Autocomplete {
7481
}.bind(this));
7582

7683
this.tooltipTimer = lang.delayedCall(this.updateDocTooltip.bind(this), 50);
84+
85+
this.stickySelectionTimer = lang.delayedCall(function() {
86+
this.stickySelection = true;
87+
}.bind(this), this.stickySelectionDelay);
7788
}
7889

7990
$init() {
@@ -83,7 +94,7 @@ class Autocomplete {
8394
e.stop();
8495
}.bind(this));
8596
this.popup.focus = this.editor.focus.bind(this.editor);
86-
this.popup.on("show", this.$onPopupChange.bind(this));
97+
this.popup.on("show", this.$onPopupShow.bind(this));
8798
this.popup.on("hide", this.$onHidePopup.bind(this));
8899
this.popup.on("select", this.$onPopupChange.bind(this));
89100
this.popup.on("changeHoverMarker", this.tooltipTimer.bind(null, null));
@@ -106,6 +117,8 @@ class Autocomplete {
106117
this.inlineRenderer.hide();
107118
}
108119
this.hideDocTooltip();
120+
this.stickySelectionTimer.cancel();
121+
this.stickySelection = false;
109122
}
110123

111124
$onPopupChange(hide) {
@@ -120,6 +133,13 @@ class Autocomplete {
120133
this.tooltipTimer.call(null, null);
121134
}
122135

136+
$onPopupShow(hide) {
137+
this.$onPopupChange(hide);
138+
this.stickySelection = false;
139+
if (this.stickySelectionDelay >= 0)
140+
this.stickySelectionTimer.schedule(this.stickySelectionDelay);
141+
}
142+
123143
observeLayoutChanges() {
124144
if (this.$elements || !this.editor) return;
125145
window.addEventListener("resize", this.onLayoutChange, {passive: true});
@@ -194,6 +214,8 @@ class Autocomplete {
194214
this.popup.autoSelect = this.autoSelect;
195215
this.popup.setSelectOnHover(this.setSelectOnHover);
196216

217+
var previousSelectedItem = this.popup.data[this.popup.getRow()];
218+
197219
this.popup.setData(this.completions.filtered, this.completions.filterText);
198220
if (this.editor.textInput.setAriaOptions) {
199221
this.editor.textInput.setAriaOptions({
@@ -204,7 +226,13 @@ class Autocomplete {
204226

205227
editor.keyBinding.addKeyboardHandler(this.keyboardHandler);
206228

207-
this.popup.setRow(this.autoSelect ? 0 : -1);
229+
var newRow = this.popup.data.indexOf(previousSelectedItem);
230+
231+
if (newRow && this.stickySelection)
232+
this.popup.setRow(this.autoSelect ? newRow : -1);
233+
else
234+
this.popup.setRow(this.autoSelect ? 0 : -1);
235+
208236
if (!keepPopupPosition) {
209237
this.popup.setTheme(editor.getTheme());
210238
this.popup.setFontSize(editor.getFontSize());

‎src/autocomplete_test.js

+112
Original file line numberDiff line numberDiff line change
@@ -614,6 +614,118 @@ module.exports = {
614614

615615

616616
done();
617+
},
618+
"test: should maintain selection on fast completer item when slow completer results come in": function(done) {
619+
var editor = initEditor("hello world\n");
620+
621+
var slowCompleter = {
622+
getCompletions: function (editor, session, pos, prefix, callback) {
623+
var completions = [
624+
{
625+
caption: "slow option 1",
626+
value: "s1",
627+
score: 3
628+
}, {
629+
caption: "slow option 2",
630+
value: "s2",
631+
score: 0
632+
}
633+
];
634+
setTimeout(() => {
635+
callback(null, completions);
636+
}, 200);
637+
}
638+
};
639+
640+
var fastCompleter = {
641+
getCompletions: function (editor, session, pos, prefix, callback) {
642+
var completions = [
643+
{
644+
caption: "fast option 1",
645+
value: "f1",
646+
score: 2
647+
}, {
648+
caption: "fast option 2",
649+
value: "f2",
650+
score: 1
651+
}
652+
];
653+
callback(null, completions);
654+
}
655+
};
656+
657+
editor.completers = [fastCompleter, slowCompleter];
658+
659+
var completer = Autocomplete.for(editor);
660+
completer.stickySelectionDelay = 100;
661+
user.type("Ctrl-Space");
662+
assert.equal(completer.popup.isOpen, true);
663+
assert.equal(completer.popup.data.length, 2);
664+
assert.equal(completer.popup.getRow(), 0);
665+
666+
setTimeout(() => {
667+
completer.popup.renderer.$loop._flush();
668+
assert.equal(completer.popup.data.length, 4);
669+
assert.equal(completer.popup.getRow(), 1);
670+
671+
done();
672+
}, 500);
673+
},
674+
"test: should not maintain selection on fast completer item when slow completer results come in when stickySelectionDelay negative": function(done) {
675+
var editor = initEditor("hello world\n");
676+
677+
var slowCompleter = {
678+
getCompletions: function (editor, session, pos, prefix, callback) {
679+
var completions = [
680+
{
681+
caption: "slow option 1",
682+
value: "s1",
683+
score: 3
684+
}, {
685+
caption: "slow option 2",
686+
value: "s2",
687+
score: 0
688+
}
689+
];
690+
setTimeout(() => {
691+
callback(null, completions);
692+
}, 200);
693+
}
694+
};
695+
696+
var fastCompleter = {
697+
getCompletions: function (editor, session, pos, prefix, callback) {
698+
var completions = [
699+
{
700+
caption: "fast option 1",
701+
value: "f1",
702+
score: 2
703+
}, {
704+
caption: "fast option 2",
705+
value: "f2",
706+
score: 1
707+
}
708+
];
709+
callback(null, completions);
710+
}
711+
};
712+
713+
editor.completers = [fastCompleter, slowCompleter];
714+
715+
var completer = Autocomplete.for(editor);
716+
completer.stickySelectionDelay = -1;
717+
user.type("Ctrl-Space");
718+
assert.equal(completer.popup.isOpen, true);
719+
assert.equal(completer.popup.data.length, 2);
720+
assert.equal(completer.popup.getRow(), 0);
721+
722+
setTimeout(() => {
723+
completer.popup.renderer.$loop._flush();
724+
assert.equal(completer.popup.data.length, 4);
725+
assert.equal(completer.popup.getRow(), 0);
726+
727+
done();
728+
}, 500);
617729
}
618730
};
619731

0 commit comments

Comments
 (0)
Please sign in to comment.