From 3ef14dd4dc755d330a4547267791e34d21746774 Mon Sep 17 00:00:00 2001 From: Mark Kercso Date: Mon, 13 Mar 2023 06:34:12 +0000 Subject: [PATCH 1/6] Inline ghost-text autocompletion Added optional ghost-text preview to popup-based autocompletion (disabled by default) New external inline-autocompletion widget which supports ghost-text only autocompletion --- ace.d.ts | 105 ++++ demo/inline_autocompletion.html | 48 ++ src/autocomplete.js | 404 ++++++++----- src/autocomplete/inline.js | 72 +++ src/autocomplete/inline_test.js | 153 +++++ src/autocomplete/popup.js | 14 +- src/css/editor.css.js | 1 + src/ext/inline_autocomplete.js | 611 ++++++++++++++++++++ src/ext/inline_autocomplete_test.js | 286 +++++++++ src/ext/inline_autocomplete_tooltip_test.js | 174 ++++++ src/keyboard/textinput.js | 2 +- src/snippets.js | 22 +- src/test/all_browser.js | 2 + src/virtual_renderer.js | 1 + src/virtual_renderer_test.js | 12 + 15 files changed, 1760 insertions(+), 147 deletions(-) create mode 100644 demo/inline_autocompletion.html create mode 100644 src/autocomplete/inline.js create mode 100644 src/autocomplete/inline_test.js create mode 100644 src/ext/inline_autocomplete.js create mode 100644 src/ext/inline_autocomplete_test.js create mode 100644 src/ext/inline_autocomplete_tooltip_test.js diff --git a/ace.d.ts b/ace.d.ts index ca8d6e1e83a..99408411cb1 100644 --- a/ace.d.ts +++ b/ace.d.ts @@ -922,6 +922,71 @@ export namespace Ace { prefix: string, callback: CompleterCallback): void; } + + export class AceInline { + show(editor: Editor, completion: Completion, prefix: string): void; + isOpen(): void; + hide(): void; + destroy(): void; + } + + interface CompletionOptions { + matches?: Completion[]; + } + + type CompletionProviderOptions = { + exactMatch?: boolean; + ignoreCaption?: boolean; + } + + type CompletionRecord = { + all: Completion[]; + filtered: Completion[]; + filterText: string; + } | CompletionProviderOptions + + type GatherCompletionRecord = { + prefix: string; + matches: Completion[]; + finished: boolean; + } + + type CompletionCallbackFunction = (err: Error | undefined, data: GatherCompletionRecord) => void; + type CompletionProviderCallback = (err: Error | undefined, completions: CompletionRecord, finished: boolean) => void; + + export class CompletionProvider { + insertByIndex(editor: Editor, index: number, options: CompletionProviderOptions): boolean; + insertMatch(editor: Editor, data: Completion, options: CompletionProviderOptions): boolean; + completions: CompletionRecord; + gatherCompletions(editor: Editor, callback: CompletionCallbackFunction): boolean; + provideCompletions(editor: Editor, options: CompletionProviderOptions, callback: CompletionProviderCallback): void; + detach(): void; + } + + export class Autocomplete { + constructor(); + autoInsert?: boolean; + autoSelect?: boolean; + exactMatch?: boolean; + inlineEnabled?: boolean; + getPopup(): AcePopup; + showPopup(editor: Editor, options: CompletionOptions): void; + detach(): void; + destroy(): void; + } + + type AcePopupNavigation = "up" | "down" | "start" | "end"; + + export class AcePopup { + constructor(parentNode: HTMLElement); + setData(list: Completion[], filterText: string): void; + getData(row: number): Completion; + getRow(): number; + getRow(line: number): void; + hide(): void; + show(pos: Point, lineHeight: number, topdownOnly: boolean): void; + goTo(where: AcePopupNavigation): void; + } } @@ -944,3 +1009,43 @@ export const Range: { fromPoints(start: Ace.Point, end: Ace.Point): Ace.Range; comparePoints(p1: Ace.Point, p2: Ace.Point): number; }; + + +type InlineAutocompleteAction = "prev" | "next" | "first" | "last"; + +type TooltipCommandEnabledFunction = (editor: Ace.Editor) => boolean; + +interface TooltipCommand extends Ace.Command { + enabled: TooltipCommandEnabledFunction | boolean, + position?: number; +} + +export class InlineAutocomplete { + constructor(); + getInlineRenderer(): Ace.AceInline; + getInlineTooltip(): InlineTooltip; + getCompletionProvider(): Ace.CompletionProvider; + show(editor: Ace.Editor): void; + isOpen(): boolean; + detach(): void; + destroy(): void; + goTo(action: InlineAutocompleteAction): void; + tooltipEnabled: boolean; + commands: Record + getIndex(): number; + setIndex(value: number): void; + getLength(): number; + getData(index?: number): Ace.Completion | undefined; + updateCompletions(options: Ace.CompletionOptions): void; +} + +export class InlineTooltip { + constructor(parentElement: HTMLElement); + setCommands(commands: Record): void; + show(editor: Ace.Editor): void; + updatePosition(): void; + updateButtons(force?: boolean): void; + isShown(): boolean; + detach(): void; + destroy(): void; +} diff --git a/demo/inline_autocompletion.html b/demo/inline_autocompletion.html new file mode 100644 index 00000000000..b514f670e5f --- /dev/null +++ b/demo/inline_autocompletion.html @@ -0,0 +1,48 @@ + + + + + ACE Inline Autocompletion demo + + + + +

+
+
+
+
+
+
+
diff --git a/src/autocomplete.js b/src/autocomplete.js
index 079c80c2300..9442e53897c 100644
--- a/src/autocomplete.js
+++ b/src/autocomplete.js
@@ -2,6 +2,7 @@
 
 var HashHandler = require("./keyboard/hash_handler").HashHandler;
 var AcePopup = require("./autocomplete/popup").AcePopup;
+var AceInline = require("./autocomplete/inline").AceInline;
 var getAriaId = require("./autocomplete/popup").getAriaId;
 var util = require("./autocomplete/util");
 var lang = require("./lib/lang");
@@ -9,11 +10,22 @@ var dom = require("./lib/dom");
 var snippetManager = require("./snippets").snippetManager;
 var config = require("./config");
 
+var destroyCompleter = function(e, editor) {
+    editor.completer && editor.completer.destroy();
+};
+
+
+/**
+ * This object controls the autocompletion components and their lifecycle.
+ * There is an autocompletion popup, an optional inline ghost text renderer and a docuent tooltip popup inside.
+ * @class
+ */
+
 var Autocomplete = function() {
     this.autoInsert = false;
     this.autoSelect = true;
     this.exactMatch = false;
-    this.gatherCompletionsId = 0;
+    this.inlineEnabled = false;
     this.keyboardHandler = new HashHandler();
     this.keyboardHandler.bindKeys(this.commands);
 
@@ -30,16 +42,34 @@ var Autocomplete = function() {
 };
 
 (function() {
+    this.$onPopupChange = function(hide) {
+        if (hide) {
+            if (this.inlineEnabled) {
+                this.inlineRenderer.show(this.editor, null, prefix);    
+            }
+            this.hideDocTooltip();
+        } else {
+            this.tooltipTimer.call(null, null);
+            if (this.inlineEnabled) {
+                var completion = hide ? null : this.popup.getData(this.popup.getRow());
+                var prefix = util.getCompletionPrefix(this.editor);
+                this.inlineRenderer.show(this.editor, completion, prefix);
+                this.$updatePopupPosition();
+            }
+        }
+    };
 
     this.$init = function() {
         this.popup = new AcePopup(document.body || document.documentElement);
+        this.inlineRenderer = new AceInline();
         this.popup.on("click", function(e) {
             this.insertMatch();
             e.stop();
         }.bind(this));
         this.popup.focus = this.editor.focus.bind(this.editor);
-        this.popup.on("show", this.tooltipTimer.bind(null, null));
-        this.popup.on("select", this.tooltipTimer.bind(null, null));
+        this.popup.on("show", this.$onPopupChange.bind(this));
+        this.popup.on("hide", this.$onPopupChange.bind(this, true));
+        this.popup.on("select", this.$onPopupChange.bind(this));
         this.popup.on("changeHoverMarker", this.tooltipTimer.bind(null, null));
         return this.popup;
     };
@@ -48,6 +78,28 @@ var Autocomplete = function() {
         return this.popup || this.$init();
     };
 
+    this.$updatePopupPosition = function() {
+        var editor = this.editor;
+        var renderer = editor.renderer;
+
+        var lineHeight = renderer.layerConfig.lineHeight;
+        var pos = renderer.$cursorLayer.getPixelPosition(this.base, true);
+        pos.left -= this.popup.getTextLeftOffset();
+
+        var rect = editor.container.getBoundingClientRect();
+        pos.top += rect.top - renderer.layerConfig.offset;
+        pos.left += rect.left - editor.renderer.scrollLeft;
+        pos.left += renderer.gutterWidth;
+
+        if (renderer.$ghostText && renderer.$ghostTextWidget) {
+            if (this.base.row === renderer.$ghostText.position.row) {
+                pos.top += renderer.$ghostTextWidget.el.offsetHeight;
+            }
+        }
+
+        this.popup.show(pos, lineHeight);
+    };
+
     this.openPopup = function(editor, prefix, keepPopupPosition) {
         if (!this.popup)
             this.$init();
@@ -55,34 +107,33 @@ var Autocomplete = function() {
         this.popup.autoSelect = this.autoSelect;
 
         this.popup.setData(this.completions.filtered, this.completions.filterText);
-        if (this.editor.textInput.setAriaOptions)
-            this.editor.textInput.setAriaOptions({activeDescendant: getAriaId(this.popup.getRow())});
+        if (this.editor.textInput.setAriaOptions) {
+            this.editor.textInput.setAriaOptions({
+                activeDescendant: getAriaId(this.popup.getRow()),
+                inline: this.inlineEnabled
+            });
+        }
 
         editor.keyBinding.addKeyboardHandler(this.keyboardHandler);
         
-        var renderer = editor.renderer;
         this.popup.setRow(this.autoSelect ? 0 : -1);
         if (!keepPopupPosition) {
             this.popup.setTheme(editor.getTheme());
             this.popup.setFontSize(editor.getFontSize());
 
-            var lineHeight = renderer.layerConfig.lineHeight;
-
-            var pos = renderer.$cursorLayer.getPixelPosition(this.base, true);
-            pos.left -= this.popup.getTextLeftOffset();
-
-            var rect = editor.container.getBoundingClientRect();
-            pos.top += rect.top - renderer.layerConfig.offset;
-            pos.left += rect.left - editor.renderer.scrollLeft;
-            pos.left += renderer.gutterWidth;
-
-            this.popup.show(pos, lineHeight);
+            this.$updatePopupPosition();
+            if (this.tooltipNode) {
+                this.updateDocTooltip();
+            }
         } else if (keepPopupPosition && !prefix) {
             this.detach();
         }
         this.changeTimer.cancel();
     };
 
+    /**
+     * Detaches all elements from the editor, and cleans up the data for the session
+     */
     this.detach = function() {
         this.editor.keyBinding.removeKeyboardHandler(this.keyboardHandler);
         this.editor.off("changeSelection", this.changeListener);
@@ -92,14 +143,17 @@ var Autocomplete = function() {
         this.changeTimer.cancel();
         this.hideDocTooltip();
 
-        this.gatherCompletionsId += 1;
+        if (this.completionProvider) {
+            this.completionProvider.detach();
+        }
+
         if (this.popup && this.popup.isOpen)
             this.popup.hide();
 
         if (this.base)
             this.base.detach();
         this.activated = false;
-        this.completions = this.base = null;
+        this.completionProvider = this.completions = this.base = null;
     };
 
     this.changeListener = function(e) {
@@ -144,34 +198,14 @@ var Autocomplete = function() {
             data = this.popup.getData(this.popup.getRow());
         if (!data)
             return false;
-
         var completions = this.completions;
-        this.editor.startOperation({command: {name: "insertMatch"}});
-        if (data.completer && data.completer.insertMatch) {
-            data.completer.insertMatch(this.editor, data);
-        } else {
-            // TODO add support for options.deleteSuffix
-            if (!completions)
-                return false;
-            if (completions.filterText) {
-                var ranges = this.editor.selection.getAllRanges();
-                for (var i = 0, range; range = ranges[i]; i++) {
-                    range.start.column -= completions.filterText.length;
-                    this.editor.session.remove(range);
-                }
-            }
-            if (data.snippet)
-                snippetManager.insertSnippet(this.editor, data.snippet);
-            else
-                this.editor.execCommand("insertstring", data.value || data);
-        }
+        var result = this.getCompletionProvider().insertMatch(this.editor, data, completions.filterText, options);
         // detach only if new popup was not opened while inserting match
         if (this.completions == completions)
             this.detach();
-        this.editor.endOperation();
+        return result;
     };
 
-
     this.commands = {
         "Up": function(editor) { editor.completer.goTo("up"); },
         "Down": function(editor) { editor.completer.goTo("down"); },
@@ -193,32 +227,11 @@ var Autocomplete = function() {
         "PageDown": function(editor) { editor.completer.popup.gotoPageDown(); }
     };
 
-    this.gatherCompletions = function(editor, callback) {
-        var session = editor.getSession();
-        var pos = editor.getCursorPosition();
-
-        var prefix = util.getCompletionPrefix(editor);
-
-        this.base = session.doc.createAnchor(pos.row, pos.column - prefix.length);
-        this.base.$insertRight = true;
-
-        var matches = [];
-        var total = editor.completers.length;
-        editor.completers.forEach(function(completer, i) {
-            completer.getCompletions(editor, session, pos, prefix, function(err, results) {
-                if (!err && results)
-                    matches = matches.concat(results);
-                // Fetch prefix again, because they may have changed by now
-                callback(null, {
-                    prefix: util.getCompletionPrefix(editor),
-                    matches: matches,
-                    finished: (--total === 0)
-                });
-            });
-        });
-        return true;
-    };
-
+    /**
+     * This is the entry point for the autocompletion class, triggers the actions which collect and display suggestions
+     * @param {Editor} editor
+     * @param {CompletionOptions} options
+     */
     this.showPopup = function(editor, options) {
         if (this.editor)
             this.detach();
@@ -240,6 +253,21 @@ var Autocomplete = function() {
         this.updateCompletions(false, options);
     };
 
+    this.getCompletionProvider = function() {
+        if (!this.completionProvider)
+            this.completionProvider = new CompletionProvider();
+        return this.completionProvider;
+    };
+
+    /**
+     * This method is deprecated, it is only kept for backwards compatibility.
+     * Use the same method include CompletionProvider instead for the same functionality.
+     * @deprecated
+     */
+    this.gatherCompletions = function(editor, callback) {
+        return this.getCompletionProvider().gatherCompletions(editor, callback);
+    };
+
     this.updateCompletions = function(keepPopupPosition, options) {
         if (keepPopupPosition && this.base && this.completions) {
             var pos = this.editor.getCursorPosition();
@@ -265,70 +293,32 @@ var Autocomplete = function() {
             return this.openPopup(this.editor, "", keepPopupPosition);
         }
 
-        // Save current gatherCompletions session, session is close when a match is insert
-        var _id = this.gatherCompletionsId;
-
-        // Only detach if result gathering is finished
-        var detachIfFinished = function(results) {
-            if (!results.finished) return;
-            return this.detach();
-        }.bind(this);
-
-        var processResults = function(results) {
-            var prefix = results.prefix;
-            var matches = results.matches;
-
-            this.completions = new FilteredList(matches);
-
-            if (this.exactMatch)
-                this.completions.exactMatch = true;
-
-            this.completions.setFilter(prefix);
-            var filtered = this.completions.filtered;
-
-            // No results
-            if (!filtered.length)
-                return detachIfFinished(results);
-
-            // One result equals to the prefix
-            if (filtered.length == 1 && filtered[0].value == prefix && !filtered[0].snippet)
-                return detachIfFinished(results);
-
-            // Autoinsert if one result
-            if (this.autoInsert && filtered.length == 1 && results.finished)
-                return this.insertMatch(filtered[0]);
-
-            this.openPopup(this.editor, prefix, keepPopupPosition);
-        }.bind(this);
-
-        var isImmediate = true;
-        var immediateResults = null;
-        this.gatherCompletions(this.editor, function(err, results) {
-            var prefix = results.prefix;
-            var matches = results && results.matches;
-
-            if (!matches || !matches.length)
-                return detachIfFinished(results);
-
-            // Wrong prefix or wrong session -> ignore
-            if (prefix.indexOf(results.prefix) !== 0 || _id != this.gatherCompletionsId)
-                return;
-
-            // If multiple completers return their results immediately, we want to process them together
-            if (isImmediate) {
-                immediateResults = results;
-                return;
+        var session = this.editor.getSession();
+        var pos = this.editor.getCursorPosition();
+        var prefix = util.getCompletionPrefix(this.editor);
+        this.base = session.doc.createAnchor(pos.row, pos.column - prefix.length);
+        this.base.$insertRight = true;
+        var completionOptions = { exactMatch: this.exactMatch };
+        this.getCompletionProvider().provideCompletions(this.editor, completionOptions, function(err, completions, finished) {
+            var filtered = completions.filtered;
+            var prefix = util.getCompletionPrefix(this.editor);
+
+            if (finished) {
+                // No results
+                if (!filtered.length)
+                    return this.detach();
+
+                // One result equals to the prefix
+                if (filtered.length == 1 && filtered[0].value == prefix && !filtered[0].snippet)
+                    return this.detach();
+
+                // Autoinsert if one result
+                if (this.autoInsert && filtered.length == 1)
+                    return this.insertMatch(filtered[0]);
             }
-
-            processResults(results);
+            this.completions = completions;
+            this.openPopup(this.editor, prefix, keepPopupPosition);
         }.bind(this));
-        
-        isImmediate = false;
-        if (immediateResults) {
-            var results = immediateResults;
-            immediateResults = null;
-            processResults(results);
-        }
     };
 
     this.cancelContextMenu = function() {
@@ -437,27 +427,31 @@ var Autocomplete = function() {
             if (el && el.parentNode)
                 el.parentNode.removeChild(el);
         }
-        if (this.editor && this.editor.completer == this)
-            this.editor.completer == null;
-        this.popup = null;
+        if (this.editor && this.editor.completer == this) {
+            this.editor.off("destroy", destroyCompleter);
+            this.editor.completer = null;
+        }
+        this.inlineRenderer = this.popup = this.editor = null;
     };
 
 }).call(Autocomplete.prototype);
 
 
 Autocomplete.for = function(editor) {
-    if (editor.completer) {
+    if (editor.completer instanceof Autocomplete) {
         return editor.completer;
     }
+    if (editor.completer) {
+        editor.completer.destroy();
+        editor.completer = null;
+    }
     if (config.get("sharedPopups")) {
-        if (!Autocomplete.$shared)
+        if (!Autocomplete.$sharedInstance)
             Autocomplete.$sharedInstance = new Autocomplete();
         editor.completer = Autocomplete.$sharedInstance;
     } else {
         editor.completer = new Autocomplete();
-        editor.once("destroy", function(e, editor) {
-            editor.completer.destroy();
-        });
+        editor.once("destroy", destroyCompleter);
     }
     return editor.completer;
 };
@@ -475,11 +469,143 @@ Autocomplete.startCommand = {
     bindKey: "Ctrl-Space|Ctrl-Shift-Space|Alt-Space"
 };
 
+/**
+ * This class is responsible for providing completions and inserting them to the editor
+ * @class
+ */
+
+var CompletionProvider = function() {
+    this.active = true;
+};
+
+(function() {
+    this.insertByIndex = function(editor, index, options) {
+        if (!this.completions || !this.completions.filtered) {
+            return false;
+        }
+        return this.insertMatch(editor, this.completions.filtered[index], options);
+    };
+
+    this.insertMatch = function(editor, data, options) {
+        if (!data)
+            return false;
+
+        editor.startOperation({command: {name: "insertMatch"}});
+        if (data.completer && data.completer.insertMatch) {
+            data.completer.insertMatch(editor, data);
+        } else {
+            // TODO add support for options.deleteSuffix
+            if (!this.completions) {
+                return false;
+            }
+            if (this.completions.filterText) {
+                var ranges = editor.selection.getAllRanges();
+                for (var i = 0, range; range = ranges[i]; i++) {
+                    range.start.column -= this.completions.filterText.length;
+                    editor.session.remove(range);
+                }
+            }
+            if (data.snippet)
+                snippetManager.insertSnippet(editor, data.snippet);
+            else
+                editor.execCommand("insertstring", data.value || data);
+        }
+        editor.endOperation();
+        return true;
+    };
+
+    this.gatherCompletions = function(editor, callback) {
+        var session = editor.getSession();
+        var pos = editor.getCursorPosition();
+    
+        var prefix = util.getCompletionPrefix(editor);
+    
+        var matches = [];
+        var total = editor.completers.length;
+        editor.completers.forEach(function(completer, i) {
+            completer.getCompletions(editor, session, pos, prefix, function(err, results) {
+                if (!err && results)
+                    matches = matches.concat(results);
+                // Fetch prefix again, because they may have changed by now
+                callback(null, {
+                    prefix: util.getCompletionPrefix(editor),
+                    matches: matches,
+                    finished: (--total === 0)
+                });
+            });
+        });
+        return true;
+    };
+
+    /**
+     * This is the entry point to the class, it gathers, then provides the completions asynchronously via callback.
+     * The callback function may be called multiple times, the last invokation is marked with a `finished` flag
+     * @param {Editor} editor
+     * @param {CompletionProviderOptions} options
+     * @param {CompletionProviderCallback} callback
+     */
+    this.provideCompletions = function(editor, options, callback) {
+        var processResults = function(results) {
+            var prefix = results.prefix;
+            var matches = results.matches;
+
+            this.completions = new FilteredList(matches);
+
+            if (options.exactMatch)
+                this.completions.exactMatch = true;
+
+            if (options.ignoreCaption)
+                this.completions.ignoreCaption = true;
+
+            this.completions.setFilter(prefix);
+
+            callback(null, this.completions, results.finished);
+        }.bind(this);
+
+        var isImmediate = true;
+        var immediateResults = null;
+        this.gatherCompletions(editor, function(err, results) {
+            if (!this.active) {
+                return;
+            }
+            if (err) {
+                callback(err, [], true);
+                this.detach();
+            }
+            var prefix = results.prefix;
+
+            // Wrong prefix or wrong session -> ignore
+            if (prefix.indexOf(results.prefix) !== 0)
+                return;
+
+            // If multiple completers return their results immediately, we want to process them together
+            if (isImmediate) {
+                immediateResults = results;
+                return;
+            }
+
+            processResults(results);
+        }.bind(this));
+        
+        isImmediate = false;
+        if (immediateResults) {
+            var results = immediateResults;
+            immediateResults = null;
+            processResults(results);
+        }
+    };
+
+    this.detach = function() {
+        this.active = false;
+    };
+}).call(CompletionProvider.prototype);
+
 var FilteredList = function(array, filterText) {
     this.all = array;
     this.filtered = array;
     this.filterText = filterText || "";
     this.exactMatch = false;
+    this.ignoreCaption = false;
 };
 (function(){
     this.setFilter = function(str) {
@@ -506,12 +632,13 @@ var FilteredList = function(array, filterText) {
 
         this.filtered = matches;
     };
+
     this.filterCompletions = function(items, needle) {
         var results = [];
         var upper = needle.toUpperCase();
         var lower = needle.toLowerCase();
         loop: for (var i = 0, item; item = items[i]; i++) {
-            var caption = item.caption || item.value || item.snippet;
+            var caption = (!this.ignoreCaption && item.caption) || item.value || item.snippet;
             if (!caption) continue;
             var lastIndex = -1;
             var matchMask = 0;
@@ -560,4 +687,5 @@ var FilteredList = function(array, filterText) {
 }).call(FilteredList.prototype);
 
 exports.Autocomplete = Autocomplete;
+exports.CompletionProvider = CompletionProvider;
 exports.FilteredList = FilteredList;
diff --git a/src/autocomplete/inline.js b/src/autocomplete/inline.js
new file mode 100644
index 00000000000..f5b741ffe3c
--- /dev/null
+++ b/src/autocomplete/inline.js
@@ -0,0 +1,72 @@
+"use strict";
+
+var snippetManager = require("../snippets").snippetManager;
+
+/**
+ * This object is used to manage inline code completions rendered into an editor with ghost text.
+ * @class
+ */
+
+/**
+ * Creates the inline completion renderer which renders the inline code completions directly in the target editor.
+ * @constructor
+ */
+
+var AceInline = function() {
+    this.editor = null;
+};
+
+(function() {
+
+    /**
+     * Renders the completion as ghost text to the current cursor position
+     * @param {Editor} editor
+     * @param {Completion} completion
+     * @param {string} prefix
+     */
+    this.show = function(editor, completion, prefix) {
+        if (this.editor && this.editor !== editor) {
+            this.hide();
+            this.editor = null;
+        }
+        if (!editor) {
+            return false;
+        }
+        this.editor = editor;
+        if (!completion) {
+            editor.removeGhostText();
+            return false;
+        }
+        var displayText = completion.snippet ? snippetManager.getDisplayTextForSnippet(editor, completion.snippet) : completion.value;
+        if (!displayText || !displayText.startsWith(prefix)) {
+            editor.removeGhostText();
+            return false;
+        }
+        displayText = displayText.slice(prefix.length);
+        editor.setGhostText(displayText);
+        return true;
+    };
+
+    this.isOpen = function() {
+        if (!this.editor) {
+            return false;
+        }
+        return !!this.editor.renderer.$ghostText;
+    };
+
+    this.hide = function() {
+        if (!this.editor) {
+            return false;
+        }
+        this.editor.removeGhostText();
+        return true;
+    };
+
+    this.destroy = function() {
+        this.hide();
+        this.editor = null;
+    };
+}).call(AceInline.prototype);
+
+
+exports.AceInline = AceInline;
diff --git a/src/autocomplete/inline_test.js b/src/autocomplete/inline_test.js
new file mode 100644
index 00000000000..6b12519624e
--- /dev/null
+++ b/src/autocomplete/inline_test.js
@@ -0,0 +1,153 @@
+if (typeof process !== "undefined") {
+    require("amd-loader");
+    require("../test/mockdom");
+}
+
+"use strict";
+
+var assert = require("../test/assertions");
+var AceInline = require("./inline").AceInline;
+var Editor = require("../ace").Editor;
+var EditSession = require("../ace").EditSession;
+var VirtualRenderer = require("../ace").VirtualRenderer;
+
+var editor;
+var inline;
+
+var textBase = "abc123\n\n    ";
+
+var completions = [
+    {
+        value: "foo",
+        score: 4
+    },
+    {
+        value: "function",
+        score: 3
+    },
+    {
+        value: "foobar",
+        score: 2
+    },
+    {
+        snippet: "function foo() {\n    console.log('test');\n}",
+        score: 1
+    },
+    {
+        snippet: "foobar2",
+        score: 0
+    }
+];
+
+var getAllLines = function() {
+    return editor.renderer.$textLayer.element.childNodes.map(function (node) {
+        return node.textContent;
+    }).join("\n");
+};
+
+module.exports = {
+    setUp: function(done) {
+        var el = document.createElement("div");
+        el.style.left = "20px";
+        el.style.top = "30px";
+        el.style.width = "500px";
+        el.style.height = "500px";
+        document.body.appendChild(el);
+        var renderer = new VirtualRenderer(el);
+        var session = new EditSession("");
+        editor = new Editor(renderer, session);
+        editor.execCommand("insertstring", textBase + "f");
+        inline = new AceInline();
+        editor.getSelection().moveCursorFileEnd();
+        editor.renderer.$loop._flush();
+        done();
+    },
+    "test: displays the ghost text in the editor on show": function(done) {
+        inline.show(editor, completions[0], "f");
+        editor.renderer.$loop._flush();
+        assert.equal(getAllLines(), textBase + "foo");
+        done();
+    },
+    "test: replaces the ghost text in the editor with the latest show": function(done) {
+        inline.show(editor, completions[0], "f");
+        editor.renderer.$loop._flush();
+        assert.equal(getAllLines(), textBase + "foo");
+        inline.show(editor, completions[1], "f");
+        editor.renderer.$loop._flush();
+        assert.equal(getAllLines(), textBase + "function");
+        done();
+    },
+    "test: renders multi-line ghost text indentation": function(done) {
+        assert.equal(editor.renderer.$ghostTextWidget, null);
+        inline.show(editor, completions[3], "f");
+        editor.renderer.$loop._flush();
+        assert.strictEqual(getAllLines(), textBase + "function foo() {");
+        assert.strictEqual(editor.renderer.$ghostTextWidget.text, "        console.log('test');\n    }");
+        assert.strictEqual(editor.renderer.$ghostTextWidget.el.textContent, "        console.log('test');\n    }");
+        done();
+    },
+    "test: boundary conditions": function(done) {
+        inline.show(null, null, null);
+        inline.show(editor, null, null);
+        inline.show(editor, completions[1], null);
+        inline.show(editor, null, "");
+        inline.show(editor, completions[1], "");
+        inline.show(null, completions[3], "");
+        done();
+    },
+    "test: only renders the ghost text without the prefix": function(done) {
+        inline.show(editor, completions[1], "fun");
+        editor.renderer.$loop._flush();
+        assert.equal(getAllLines(), textBase + "fction");
+        done();
+    },
+    "test: verify explicit and implicit hide": function(done) {
+        inline.show(editor, completions[1], "f");
+        editor.renderer.$loop._flush();
+        assert.equal(getAllLines(), textBase + "function");
+        assert.strictEqual(inline.isOpen(), true);
+        inline.hide();
+        editor.renderer.$loop._flush();
+        assert.strictEqual(getAllLines(), textBase + "f");
+        assert.strictEqual(inline.isOpen(), false);
+
+        inline.show(editor, completions[1], "f");
+        editor.renderer.$loop._flush();
+        assert.equal(getAllLines(), textBase + "function");
+        assert.strictEqual(inline.isOpen(), true);
+        inline.show(editor, null, "f");
+        editor.renderer.$loop._flush();
+        assert.strictEqual(getAllLines(), textBase + "f");
+        assert.strictEqual(inline.isOpen(), false);
+        done();
+    },
+    "test: verify destroy": function(done) {
+        inline.show(editor, completions[0], "f");
+        editor.renderer.$loop._flush();
+        assert.strictEqual(getAllLines(), textBase + "foo");
+
+        inline.destroy();
+        editor.renderer.$loop._flush();
+        assert.strictEqual(inline.isOpen(), false);
+        assert.strictEqual(getAllLines(), textBase + "f");
+
+        inline.destroy();
+        editor.renderer.$loop._flush();
+        assert.strictEqual(inline.isOpen(), false);
+        assert.strictEqual(getAllLines(), textBase + "f");
+
+        inline.hide();
+        editor.renderer.$loop._flush();
+        assert.strictEqual(inline.isOpen(), false);
+        assert.strictEqual(getAllLines(), textBase + "f");
+        done();
+    },
+    tearDown: function() {
+        inline.destroy();
+        editor.destroy();
+    }
+};
+
+if (typeof module !== "undefined" && module === require.main) {
+    require("asyncjs").test.testcase(module.exports).exec();
+}
diff --git a/src/autocomplete/popup.js b/src/autocomplete/popup.js
index 920f4178de0..b7b11aea668 100644
--- a/src/autocomplete/popup.js
+++ b/src/autocomplete/popup.js
@@ -256,8 +256,10 @@ var AcePopup = function(parentNode) {
 
     popup.hide = function() {
         this.container.style.display = "none";
-        this._signal("hide");
-        popup.isOpen = false;
+        if (popup.isOpen) {
+            popup.isOpen = false;
+            this._signal("hide");
+        }
     };
     popup.show = function(pos, lineHeight, topdownOnly) {
         var el = this.container;
@@ -289,9 +291,11 @@ var AcePopup = function(parentNode) {
 
         el.style.left = left + "px";
 
-        this._signal("show");
-        lastMouseEvent = null;
-        popup.isOpen = true;
+        if (!popup.isOpen) {
+            popup.isOpen = true;
+            this._signal("show");
+            lastMouseEvent = null;
+        }
     };
 
     popup.goTo = function(where) {
diff --git a/src/css/editor.css.js b/src/css/editor.css.js
index eaad5d28cb7..3d5cc42d31e 100644
--- a/src/css/editor.css.js
+++ b/src/css/editor.css.js
@@ -589,4 +589,5 @@ module.exports = `
 .ace_ghost_text {
     opacity: 0.5;
     font-style: italic;
+    white-space: pre;
 }`;
diff --git a/src/ext/inline_autocomplete.js b/src/ext/inline_autocomplete.js
new file mode 100644
index 00000000000..cfa14565d0b
--- /dev/null
+++ b/src/ext/inline_autocomplete.js
@@ -0,0 +1,611 @@
+"use strict";
+
+var HashHandler = require("../keyboard/hash_handler").HashHandler;
+var AceInline = require("../autocomplete/inline").AceInline;
+var FilteredList = require("../autocomplete").FilteredList;
+var CompletionProvider = require("../autocomplete").CompletionProvider;
+var Editor = require("../editor").Editor;
+var util = require("../autocomplete/util");
+var lang = require("../lib/lang");
+var dom = require("../lib/dom");
+var useragent = require("../lib/useragent");
+var snippetCompleter = require("./language_tools").snippetCompleter;
+var textCompleter = require("./language_tools").textCompleter;
+var keyWordCompleter = require("./language_tools").keyWordCompleter;
+
+var destroyCompleter = function(e, editor) {
+    editor.completer && editor.completer.destroy();
+};
+
+var minPosition = function (posA, posB) {
+    if (posB.row > posA.row) {
+        return posA;
+    } else if (posB.row === posA.row && posB.column > posA.column) {
+        return posA;
+    }
+    return posB;
+};
+
+
+/**
+ * This class controls the inline-only autocompletion components and their lifecycle.
+ * This is more lightweight than the popup-based autocompletion, as it can only work with exact prefix matches.
+ * There is an inline ghost text renderer and an optional command bar tooltip inside.
+ * @class
+ */
+
+var InlineAutocomplete = function() {
+    this.tooltipEnabled = true;
+    this.keyboardHandler = new HashHandler(this.commands);
+    this.$index = -1;
+
+    this.blurListener = this.blurListener.bind(this);
+    this.changeListener = this.changeListener.bind(this);
+    this.mousewheelListener = this.mousewheelListener.bind(this);
+
+    this.changeTimer = lang.delayedCall(function() {
+        this.updateCompletions();
+    }.bind(this));
+};
+
+(function() {
+    this.getInlineRenderer = function() {
+        if (!this.inlineRenderer)
+            this.inlineRenderer = new AceInline();
+        return this.inlineRenderer;
+    };
+
+    this.getInlineTooltip = function() {
+        if (!this.inlineTooltip) {
+            this.inlineTooltip = new InlineTooltip(document.body || document.documentElement);
+            this.inlineTooltip.setCommands(this.commands);
+        }
+        return this.inlineTooltip;
+    };
+
+
+    /**
+     * This function is the entry point to the class. This triggers the gathering of the autocompletion and displaying the results;
+     * @param {Editor} editor
+     * @param {CompletionOptions} options
+     */
+    this.show = function(editor, options) {
+        if (!editor)
+            return;
+        if (this.editor && this.editor !== editor)
+            this.detach();
+
+        this.activated = true;
+
+        this.editor = editor;
+        if (editor.completer !== this) {
+            if (editor.completer)
+                editor.completer.detach();
+            editor.completer = this;
+        }
+
+        editor.on("changeSelection", this.changeListener);
+        editor.on("blur", this.blurListener);
+        editor.on("mousewheel", this.mousewheelListener);
+
+        this.updateCompletions(options);
+    };
+
+    this.$open = function() {
+        if (this.editor.textInput.setAriaOptions) {
+            this.editor.textInput.setAriaOptions({});
+        }
+
+        if (this.tooltipEnabled) {
+            this.getInlineTooltip().show(this.editor);
+        } else if (this.tooltipEnabled === "hover") {
+        }
+
+        this.editor.keyBinding.addKeyboardHandler(this.keyboardHandler);
+
+        if (this.$index === -1) {
+            this.setIndex(0);
+        } else {
+            this.$showCompletion();
+        }
+        
+        this.changeTimer.cancel();
+    };
+    
+    this.insertMatch = function() {
+        var result = this.getCompletionProvider().insertByIndex(this.editor, this.$index);
+        this.detach();
+        return result;
+    };
+
+    this.commands = {
+        "Previous": {
+            bindKey: "Alt-[",
+            name: "Previous",
+            exec: function(editor) {
+                editor.completer.goTo("prev");
+            },
+            enabled: function(editor) {
+                return editor.completer.getIndex() > 0;
+            },
+            position: 10
+        },
+        "Next": {
+            bindKey: "Alt-]",
+            name: "Next",
+            exec: function(editor) {
+                editor.completer.goTo("next");
+            },
+            enabled: function(editor) {
+                return editor.completer.getIndex() < editor.completer.getLength() - 1;
+            },
+            position: 20
+        },
+        "Accept": {
+            bindKey: { win: "Tab|Ctrl-Right", mac: "Tab|Cmd-Right" },
+            name: "Accept",
+            exec: function(editor) {
+                return editor.completer.insertMatch();
+            },
+            enabled: function(editor) {
+                return editor.completer.getIndex() >= 0;
+            },
+            position: 30
+        },
+        "Close": {
+            bindKey: "Esc",
+            name: "Close",
+            exec: function(editor) {
+                editor.completer.detach();
+            },
+            enabled: true,
+            position: 40
+        }
+    };
+
+    this.changeListener = function(e) {
+        var cursor = this.editor.selection.lead;
+        if (cursor.row != this.base.row || cursor.column < this.base.column) {
+            this.detach();
+        }
+        if (this.activated)
+            this.changeTimer.schedule();
+        else
+            this.detach();
+    };
+
+    this.blurListener = function(e) {
+        this.detach();
+    };
+
+    this.mousewheelListener = function(e) {
+        if (this.inlineTooltip && this.inlineTooltip.isShown()) {
+            this.inlineTooltip.updatePosition();
+        }
+    };
+
+    this.goTo = function(where) {
+        if (!this.completions || !this.completions.filtered) {
+            return;
+        }
+        switch(where.toLowerCase()) {
+            case "prev":
+                this.setIndex(Math.max(0, this.$index - 1));
+                break;
+            case "next":
+                this.setIndex(this.$index + 1);
+                break;
+            case "first":
+                this.setIndex(0);
+                break;
+            case "last":
+                this.setIndex(this.completions.filtered.length - 1);
+                break;
+        }
+    };
+
+    this.getLength = function() {
+        if (!this.completions || !this.completions.filtered) {
+            return 0;
+        }
+        return this.completions.filtered.length;
+    };
+
+    this.getData = function(index) {
+        if (index == undefined || index === null) {
+            return this.completions.filtered[this.$index];
+        } else {
+            return this.completions.filtered[index];
+        }
+    };
+
+    this.getIndex = function() {
+        return this.$index;
+    };
+
+    this.isOpen = function() {
+        return this.$index >= 0;
+    };
+
+    this.setIndex = function(value) {
+        if (!this.completions || !this.completions.filtered) {
+            return;
+        }
+        var newIndex = Math.max(-1, Math.min(this.completions.filtered.length - 1, value));
+        if (newIndex !== this.$index) {
+            this.$index = newIndex;
+            this.$showCompletion();
+        }
+    };
+
+    this.getCompletionProvider = function() {
+        if (!this.completionProvider)
+            this.completionProvider = new CompletionProvider();
+        return this.completionProvider;
+    };
+
+    this.$showCompletion = function() {
+        this.getInlineRenderer().show(this.editor, this.completions.filtered[this.$index], this.completions.filterText);
+        if (this.inlineTooltip && this.inlineTooltip.isShown()) {
+            this.inlineTooltip.updateButtons();
+        }
+    };
+
+    this.$updatePrefix = function() {
+        var pos = this.editor.getCursorPosition();
+        var prefix = this.editor.session.getTextRange({start: this.base, end: pos});
+        this.completions.setFilter(prefix);
+        if (!this.completions.filtered.length)
+            return this.detach();
+        if (this.completions.filtered.length == 1
+        && this.completions.filtered[0].value == prefix
+        && !this.completions.filtered[0].snippet)
+            return this.detach();
+        this.$open(this.editor, prefix);
+        return prefix;
+    };
+
+    this.updateCompletions = function(options) {
+        var prefix = "";
+        
+        if (options && options.matches) {
+            var pos = this.editor.getSelectionRange().start;
+            this.base = this.editor.session.doc.createAnchor(pos.row, pos.column);
+            this.base.$insertRight = true;
+            this.completions = new FilteredList(options.matches);
+            return this.$open(this.editor, "");
+        }
+
+        if (this.base && this.completions) {
+            prefix = this.$updatePrefix();
+        }
+
+        var session = this.editor.getSession();
+        var pos = this.editor.getCursorPosition();
+        var prefix = util.getCompletionPrefix(this.editor);
+        this.base = session.doc.createAnchor(pos.row, pos.column - prefix.length);
+        this.base.$insertRight = true;
+        var options = {
+            exactMatch: true,
+            ignoreCaption: true
+        };
+        this.getCompletionProvider().provideCompletions(this.editor, options, function(err, completions, finished) {
+            var filtered = completions.filtered;
+            var prefix = util.getCompletionPrefix(this.editor);
+
+            if (finished) {
+                // No results
+                if (!filtered.length)
+                    return this.detach();
+
+                // One result equals to the prefix
+                if (filtered.length == 1 && filtered[0].value == prefix && !filtered[0].snippet)
+                    return this.detach();
+            }
+            this.completions = completions;
+            this.$open(this.editor, prefix);
+        }.bind(this));
+    };
+
+    this.detach = function() {
+        if (this.editor) {
+            this.editor.keyBinding.removeKeyboardHandler(this.keyboardHandler);
+            this.editor.off("changeSelection", this.changeListener);
+            this.editor.off("blur", this.blurListener);
+            this.editor.off("mousewheel", this.mousewheelListener);
+        }
+        this.changeTimer.cancel();
+        if (this.inlineTooltip) {
+            this.inlineTooltip.detach();
+        }
+        
+        this.setIndex(-1);
+
+        if (this.completionProvider) {
+            this.completionProvider.detach();
+        }
+
+        if (this.inlineRenderer && this.inlineRenderer.isOpen()) {
+            this.inlineRenderer.hide();
+        }
+
+        if (this.base)
+            this.base.detach();
+        this.activated = false;
+        this.completionProvider = this.completions = this.base = null;
+    };
+
+    this.destroy = function() {
+        this.detach();
+        if (this.inlineRenderer)
+            this.inlineRenderer.destroy();
+        if (this.inlineTooltip)
+            this.inlineTooltip.destroy();
+        if (this.editor && this.editor.completer == this) {
+            this.editor.off("destroy", destroyCompleter);
+            this.editor.completer = null;
+        }
+        this.inlineTooltip = this.editor = this.inlineRenderer = null;
+    };
+
+}).call(InlineAutocomplete.prototype);
+
+InlineAutocomplete.for = function(editor) {
+    if (editor.completer instanceof InlineAutocomplete) {
+        return editor.completer;
+    }
+    if (editor.completer) {
+        editor.completer.destroy();
+        editor.completer = null;
+    }
+
+    editor.completer = new InlineAutocomplete();
+    editor.once("destroy", destroyCompleter);
+    return editor.completer;
+};
+
+InlineAutocomplete.startCommand = {
+    name: "startInlineAutocomplete",
+    exec: function(editor, options) {
+        var completer = InlineAutocomplete.for(editor);
+        completer.show(editor, options);
+    },
+    bindKey: { win: "Alt-C", mac: "Option-C" }
+};
+
+
+var completers = [snippetCompleter, textCompleter, keyWordCompleter];
+
+require("../config").defineOptions(Editor.prototype, "editor", {
+    enableInlineAutocompletion: {
+        set: function(val) {
+            if (val) {
+                if (!this.completers)
+                    this.completers = Array.isArray(val)? val : completers;
+                this.commands.addCommand(InlineAutocomplete.startCommand);
+            } else {
+                this.commands.removeCommand(InlineAutocomplete.startCommand);
+            }
+        },
+        value: false
+    }
+});
+
+/**
+ * Displays a command tooltip above the selection, with clickable elements.
+ * @class
+ */
+
+/**
+ * Creates the inline command tooltip helper which displays the available keyboard commands for the user.
+ * @param {HTMLElement} parentElement
+ * @constructor
+ */
+
+var ENTRY_CLASS_NAME = 'inline_autocomplete_tooltip_entry';
+var BUTTON_CLASS_NAME = 'inline_autocomplete_tooltip_button';
+var TOOLTIP_CLASS_NAME = 'ace_tooltip ace_inline_autocomplete_tooltip';
+var TOOLTIP_ID = 'inline_autocomplete_tooltip';
+
+function InlineTooltip(parentElement) {
+    this.htmlElement = document.createElement('div');
+    var el = this.htmlElement;
+    el.style.display = 'none';
+    if (parentElement) {
+        parentElement.appendChild(el);
+    }
+    el.id = TOOLTIP_ID;
+    el.style['pointer-events'] = 'auto';
+    el.className = TOOLTIP_CLASS_NAME;
+    this.commands = {};
+    this.buttons = {};
+    this.eventListeners = {};
+}
+
+(function() {
+
+    var captureMousedown = function(e) {
+        e.preventDefault();
+    };
+
+    /**
+     * This function sets the commands. Note that it is advised to call this before calling show, otherwise there are no buttons to render
+     * @param {Record} commands
+     */
+    this.setCommands = function(commands) {
+        if (!commands || !this.htmlElement) {
+            return;
+        }
+        this.detach();
+        var el = this.htmlElement;
+        while (el.hasChildNodes()) {
+            el.removeChild(el.firstChild);
+        }
+
+        this.commands = commands;
+        this.buttons = {};
+        this.eventListeners = {};
+    
+        Object.keys(commands)
+            .map(function(key) { return [key, commands[key]]; })
+            .filter(function (entry) { return entry[1].position > 0; })
+            .sort(function (a, b) { return a[1].position - b[1].position; })
+            .forEach(function (entry) {
+                var key = entry[0];
+                var command = entry[1];
+                dom.buildDom(["div", { class: ENTRY_CLASS_NAME }, [['div', { class: BUTTON_CLASS_NAME, ref: key }, this.buttons]]], el, this.buttons);
+                var bindKey = command.bindKey;
+                if (typeof bindKey === 'object') {
+                    bindKey = useragent.isMac ? bindKey.mac : bindKey.win;
+                }
+                bindKey = bindKey.replace("|", " / ");
+                this.buttons[key].textContent = command.name + ' (' + bindKey + ')';
+            }.bind(this));
+    };
+
+    /**
+     * Displays the clickable command bar tooltip
+     * @param {Record} commands
+     */
+    this.show = function(editor) {
+        this.detach();
+
+        this.htmlElement.style.display = '';
+        this.htmlElement.addEventListener('mousedown', captureMousedown.bind(this));
+        
+        this.editor = editor;
+
+        this.updatePosition();
+        this.updateButtons(true);
+    };
+
+    this.isShown = function() {
+        return !!this.htmlElement && this.htmlElement.style.display !== "none";
+    };
+
+    /**
+     * Updates the position of the command bar tooltip. It aligns itself above the topmost selection in the editor.
+     */
+    this.updatePosition = function() {
+        if (!this.editor) {
+            return;
+        }
+        var renderer = this.editor.renderer;
+
+        var ranges = this.editor.selection.getAllRanges();
+        if (!ranges.length) {
+            return;
+        }
+        var minPos = minPosition(ranges[0].start, ranges[0].end);
+        for (var i = 0, range; range = ranges[i]; i++) {
+            minPos = minPosition(minPos, minPosition(range.start, range.end));
+        }
+
+        var pos = renderer.$cursorLayer.getPixelPosition(minPos, true);
+
+        var el = this.htmlElement;
+        var screenWidth = window.innerWidth;
+        var rect = this.editor.container.getBoundingClientRect();
+
+        pos.top += rect.top - renderer.layerConfig.offset;
+        pos.left += rect.left - this.editor.renderer.scrollLeft;
+        pos.left += renderer.gutterWidth;
+
+        var top = pos.top - el.offsetHeight;
+
+        if (pos.top < 0) {
+            this.hide();
+            return;
+        }
+
+        el.style.top = top + "px";
+        el.style.bottom = "";
+        el.style.left = Math.min(screenWidth - el.offsetWidth, pos.left) + "px";
+    };
+
+    /**
+     * Updates the buttons in the command bar tooltip. Should be called every time when any of the buttons can become disabled or enabled.
+     */
+    this.updateButtons = function(force) {
+        Object.keys(this.buttons).forEach(function(key) {
+            var commandEnabled = this.commands[key].enabled;
+            if (typeof commandEnabled === 'function') {
+                commandEnabled = commandEnabled(this.editor);
+            }
+
+            if (commandEnabled && (force || !this.eventListeners[key])) {
+                this.buttons[key].className = BUTTON_CLASS_NAME;
+                this.buttons[key].ariaDisabled = this.buttons[key].disabled = false;
+                this.buttons[key].removeAttribute("disabled");
+                var eventListener = function(e) {
+                    this.commands[key].exec(this.editor);
+                    e.preventDefault();
+                }.bind(this);
+                this.eventListeners[key] = eventListener;
+                this.buttons[key].addEventListener('mousedown', eventListener);
+            }
+            if (!commandEnabled && (force || this.eventListeners[key])) {
+                this.buttons[key].className = BUTTON_CLASS_NAME + "_disabled";
+                this.buttons[key].ariaDisabled = this.buttons[key].disabled = true;
+                this.buttons[key].setAttribute("disabled", "");
+                this.buttons[key].removeEventListener('mousedown', this.eventListeners[key]);
+                delete this.eventListeners[key];
+            }
+        }.bind(this));
+    };
+    
+    this.detach = function() {
+        if (this.eventListeners && Object.keys(this.eventListeners).length) {
+            Object.keys(this.eventListeners).forEach(function(key) {
+                this.buttons[key].removeEventListener('mousedown', this.eventListeners[key]);
+                delete this.eventListeners[key];
+            }.bind(this));
+        }
+        if (this.htmlElement) {
+            this.htmlElement.removeEventListener('mousedown', captureMousedown.bind(this));
+            this.htmlElement.style.display = 'none';
+        }
+    };
+
+    this.destroy = function() {
+        this.detach();
+        if (this.htmlElement) {
+            this.htmlElement.parentNode.removeChild(this.htmlElement);
+        }
+        this.editor = null;
+        this.buttons = null;
+        this.htmlElement = null;
+        this.controls = null;
+    };
+}).call(InlineTooltip.prototype);
+
+dom.importCssString(`
+.ace_inline_autocomplete_tooltip {
+    display: inline-block;
+}
+.${ENTRY_CLASS_NAME} {
+    display: inline-block;
+    padding: 0 5px;
+}
+
+.${BUTTON_CLASS_NAME} {
+    display: inline-block;
+    cursor: pointer;
+    padding: 5px;
+}
+
+.${BUTTON_CLASS_NAME}:hover {
+    background-color: rgba(0, 0, 0, 0.1);
+}
+
+div.${BUTTON_CLASS_NAME}_disabled {
+    display: inline-block;
+    padding: 5px;
+    cursor: default;
+    color: #777;
+}`, "inlinetooltip.css", false);
+
+exports.InlineAutocomplete = InlineAutocomplete;
+exports.InlineTooltip = InlineTooltip;
+exports.TOOLTIP_ID = TOOLTIP_ID;
+exports.BUTTON_CLASS_NAME = BUTTON_CLASS_NAME;
diff --git a/src/ext/inline_autocomplete_test.js b/src/ext/inline_autocomplete_test.js
new file mode 100644
index 00000000000..c3d8a4670f0
--- /dev/null
+++ b/src/ext/inline_autocomplete_test.js
@@ -0,0 +1,286 @@
+
+if (typeof process !== "undefined") {
+    require("amd-loader");
+    require("../test/mockdom");
+}
+
+"use strict";
+
+var ace = require("../ace");
+var Editor = require("../editor").Editor;
+var EditSession = require("../edit_session").EditSession;
+var InlineAutocomplete = require("./inline_autocomplete").InlineAutocomplete;
+var assert = require("../test/assertions");
+var type = require("../test/user").type;
+var VirtualRenderer = require("../virtual_renderer").VirtualRenderer;
+
+var editor;
+var autocomplete;
+
+var getAllLines = function() {
+    var text = Array.from(editor.renderer.$textLayer.element.childNodes).map(function (node) {
+        return node.textContent;
+    }).join("\n");
+    if (editor.renderer.$ghostTextWidget) {
+        return text + "\n" + editor.renderer.$ghostTextWidget.text;
+    }
+    return text;
+};
+
+var typeAndChange = function(...args) {
+    assert.equal(autocomplete.changeTimer.isPending(), null);
+    type(args);
+    assert.ok(autocomplete.changeTimer.isPending);
+    autocomplete.changeTimer.call();
+};
+
+var completions = [
+    {
+        value: "foo"
+    },
+    {
+        value: "foobar"
+    },
+    {
+        value: "function"
+    },
+    {
+        value: "fundraiser"
+    },
+    {
+        snippet: "function foo() {\n    console.log('test');\n}",
+        caption: "func"
+    },
+    {
+        value: "foobar2"
+    }
+];
+
+var mockCompleter = {
+    getCompletions: function(_1, _2, _3, _4, callback) {
+        callback(null, completions);
+    }
+};
+
+module.exports = {
+    setUp: function(done) {
+        var el = document.createElement("div");
+        el.style.left = "20px";
+        el.style.top = "30px";
+        el.style.width = "500px";
+        el.style.height = "500px";
+        document.body.appendChild(el);
+        var renderer = new VirtualRenderer(el);
+        var session = new EditSession("");
+        editor = new Editor(renderer, session);
+        editor.execCommand("insertstring", "f");
+        editor.getSelection().moveCursorFileEnd();
+        editor.renderer.$loop._flush();
+        editor.completers = [mockCompleter];
+        autocomplete = InlineAutocomplete.for(editor);
+        editor.focus();
+        done();
+    },
+    "test: autocomplete completion shows up": function(done) {
+        autocomplete.show(editor);
+        assert.strictEqual(autocomplete.getIndex(), 0);
+        assert.strictEqual(autocomplete.getData().value, "foo");
+        editor.renderer.$loop._flush();
+        assert.strictEqual(getAllLines(), "foo");
+        done();
+    },
+    "test: autocomplete tooltip is shown according to the selected option": function(done) {
+        assert.equal(autocomplete.inlineTooltip, null);
+
+        autocomplete.show(editor);
+        assert.strictEqual(autocomplete.inlineTooltip.isShown(), true);
+
+        autocomplete.detach();
+        assert.strictEqual(autocomplete.inlineTooltip.isShown(), false);
+        done();
+    },
+    "test: autocomplete navigation works": function(done) {
+        autocomplete.show(editor);
+        editor.renderer.$loop._flush();
+        assert.strictEqual(autocomplete.getIndex(), 0);
+        assert.strictEqual(autocomplete.getData().value, "foo");
+        assert.equal(getAllLines(), "foo");
+
+        type("Alt-]");
+        editor.renderer.$loop._flush();
+        assert.strictEqual(autocomplete.getIndex(), 1);
+        assert.strictEqual(autocomplete.getData().value, "foobar");
+        assert.equal(getAllLines(), "foobar");
+
+        type("Alt-[");
+        editor.renderer.$loop._flush();
+        assert.strictEqual(autocomplete.getIndex(), 0);
+        assert.strictEqual(autocomplete.getData().value, "foo");
+        assert.equal(getAllLines(), "foo");
+        done();
+    },
+    "test: verify goTo commands": function(done) {
+        autocomplete.show(editor);
+        autocomplete.setIndex(1);
+        editor.renderer.$loop._flush();
+        assert.strictEqual(autocomplete.getData().value, "foobar");
+        assert.equal(getAllLines(), "foobar");
+
+        autocomplete.goTo("next");
+        editor.renderer.$loop._flush();
+        assert.strictEqual(autocomplete.getIndex(), 2);
+        assert.strictEqual(autocomplete.getData().value, "foobar2");
+        assert.strictEqual(getAllLines(), "foobar2");
+
+        autocomplete.goTo("prev");
+        editor.renderer.$loop._flush();
+        assert.strictEqual(autocomplete.getIndex(), 1);
+        assert.strictEqual(autocomplete.getData().value, "foobar");
+        assert.strictEqual(getAllLines(), "foobar");
+
+        autocomplete.goTo("last");
+        editor.renderer.$loop._flush();
+        assert.strictEqual(autocomplete.getIndex(), 5);
+        assert.strictEqual(autocomplete.getData().value, "fundraiser");
+        assert.strictEqual(getAllLines(), "fundraiser");
+
+        autocomplete.goTo("next");
+        editor.renderer.$loop._flush();
+        assert.strictEqual(autocomplete.getIndex(), 5);
+        assert.strictEqual(autocomplete.getData().value, "fundraiser");
+        assert.strictEqual(getAllLines(), "fundraiser");
+
+        autocomplete.goTo("first");
+        editor.renderer.$loop._flush();
+        assert.strictEqual(autocomplete.getIndex(), 0);
+        assert.strictEqual(autocomplete.getData().value, "foo");
+        assert.strictEqual(getAllLines(), "foo");
+
+        autocomplete.goTo("prev");
+        editor.renderer.$loop._flush();
+        assert.strictEqual(autocomplete.getIndex(), 0);
+        assert.strictEqual(autocomplete.getData().value, "foo");
+        assert.strictEqual(getAllLines(), "foo");
+        done();
+    },
+    "test: set index to negative value hides suggestions": function(done) {
+        autocomplete.show(editor);
+        editor.renderer.$loop._flush();
+        assert.strictEqual(autocomplete.getIndex(), 0);
+        assert.strictEqual(getAllLines(), "foo");
+
+        autocomplete.setIndex(-1);
+        editor.renderer.$loop._flush();
+        assert.strictEqual(autocomplete.isOpen(), false);
+        assert.strictEqual(getAllLines(), "f");
+        done();
+    },
+    "test: autocomplete can be closed": function(done) {
+        autocomplete.show(editor);
+        editor.renderer.$loop._flush();
+        assert.strictEqual(autocomplete.isOpen(), true);
+        assert.equal(getAllLines(), "foo");
+
+        type("Escape");
+        editor.renderer.$loop._flush();
+        assert.strictEqual(autocomplete.isOpen(), false);
+        assert.equal(getAllLines(), "f");
+        done();
+    },
+    "test: autocomplete can be accepted": function(done) {
+        autocomplete.show(editor);
+        editor.renderer.$loop._flush();
+        assert.strictEqual(autocomplete.isOpen(), true);
+        assert.ok(document.querySelectorAll(".ace_ghost_text").length > 0);
+        assert.strictEqual(getAllLines(), "foo");
+
+        type("Tab");
+        editor.renderer.$loop._flush();
+        assert.equal(autocomplete.inlineCompleter, null);
+        assert.equal(autocomplete.inlineTooltip.isShown(), false);
+        assert.strictEqual(autocomplete.isOpen(), false);
+        assert.equal(editor.renderer.$ghostText, null);
+        assert.equal(editor.renderer.$ghostTextWidget, null);
+        assert.strictEqual(document.querySelectorAll(".ace_ghost_text").length, 0);
+        assert.strictEqual(getAllLines(), "foo");
+        done();
+    },
+    "test: incremental typing filters results": function(done) {
+        autocomplete.show(editor);
+        editor.renderer.$loop._flush();
+        assert.strictEqual(autocomplete.isOpen(), true);
+        assert.equal(getAllLines(), "foo");
+
+        typeAndChange("u", "n");
+        editor.renderer.$loop._flush();
+        assert.strictEqual(autocomplete.isOpen(), true);
+        assert.equal(getAllLines(), "function foo() {\n    console.log('test');\n}");
+
+        typeAndChange("d");
+        editor.renderer.$loop._flush();
+        assert.strictEqual(autocomplete.isOpen(), true);
+        assert.equal(getAllLines(), "fundraiser");
+
+        typeAndChange("Backspace", "Backspace", "Backspace");
+        editor.renderer.$loop._flush();
+        assert.strictEqual(autocomplete.isOpen(), true);
+        assert.equal(getAllLines(), "foo");
+
+        typeAndChange("r");
+        editor.renderer.$loop._flush();
+        assert.strictEqual(autocomplete.isOpen(), false);
+        assert.equal(getAllLines(), "fr");
+
+        done();
+    },
+    "test: verify detach": function(done) {
+        autocomplete.show(editor);
+        editor.renderer.$loop._flush();
+        assert.strictEqual(autocomplete.getIndex(), 0);
+        assert.strictEqual(getAllLines(), "foo");
+
+        autocomplete.detach();
+        editor.renderer.$loop._flush();
+        assert.strictEqual(autocomplete.getIndex(), -1);
+        assert.strictEqual(autocomplete.getLength(), 0);
+        assert.strictEqual(autocomplete.isOpen(), false);
+        assert.equal(autocomplete.base, null);
+        assert.equal(autocomplete.completions, null);
+        assert.equal(autocomplete.completionProvider, null);
+        assert.strictEqual(getAllLines(), "f");
+        done();
+    },
+    "test: verify destroy": function(done) {
+        autocomplete.show(editor);
+        editor.renderer.$loop._flush();
+        assert.strictEqual(autocomplete.getIndex(), 0);
+        assert.strictEqual(getAllLines(), "foo");
+        assert.strictEqual(editor.completer, autocomplete);
+
+        autocomplete.destroy();
+        editor.renderer.$loop._flush();
+        assert.strictEqual(autocomplete.getIndex(), -1);
+        assert.strictEqual(autocomplete.getLength(), 0);
+        assert.strictEqual(autocomplete.isOpen(), false);
+        assert.equal(autocomplete.base, null);
+        assert.equal(autocomplete.completions, null);
+        assert.equal(autocomplete.completionProvider, null);
+        assert.equal(autocomplete.editor, null);
+        assert.equal(autocomplete.inlineTooltip, null);
+        assert.equal(autocomplete.inlineRenderer, null);
+        assert.strictEqual(editor.completer, null);
+        assert.strictEqual(getAllLines(), "f");
+
+        autocomplete.destroy();
+        editor.renderer.$loop._flush();
+        done();
+    },
+    tearDown: function() {
+        autocomplete.destroy();
+        editor.destroy();
+    }
+};
+
+if (typeof module !== "undefined" && module === require.main) {
+    require("asyncjs").test.testcase(module.exports).exec();
+}
diff --git a/src/ext/inline_autocomplete_tooltip_test.js b/src/ext/inline_autocomplete_tooltip_test.js
new file mode 100644
index 00000000000..5f24ed10ff2
--- /dev/null
+++ b/src/ext/inline_autocomplete_tooltip_test.js
@@ -0,0 +1,174 @@
+if (typeof process !== "undefined") {
+    require("amd-loader");
+    require("../test/mockdom");
+}
+
+"use strict";
+
+var TOOLTIP_ID = require("./inline_autocomplete").TOOLTIP_ID;
+var BUTTON_CLASS_NAME = require("./inline_autocomplete").BUTTON_CLASS_NAME;
+var InlineTooltip = require("./inline_autocomplete").InlineTooltip;
+var Editor = require("../ace").Editor;
+var EditSession = require("../ace").EditSession;
+var VirtualRenderer = require("../ace").VirtualRenderer;
+var assert = require("../test/assertions");
+
+function mousedown(node) {
+    node.dispatchEvent(new window.CustomEvent("mousedown", { bubbles: true }));
+}
+
+var editor;
+var counters = {};
+var inlineTooltip;
+var testCommand2Enabled = true;
+var commands = {
+    "testCommand1": {
+        name: "testCommand1",
+        bindKey: "Alt-K",
+        exec: function(editor) {
+            if (!editor) {
+                return;
+            }
+            if (!counters["testCommand1"]) {
+            counters["testCommand1"] = 0;
+            }
+            counters["testCommand1"]++;
+        },
+        enabled: function(editor) {
+            if (!editor) {
+                return;
+            }
+            if (!counters["testEnabled1"]) {
+                counters["testEnabled1"] = 0;
+            }
+            counters["testEnabled1"]++;
+            return true;
+        },
+        position: 10
+    },
+    "testCommand2": {
+        name: "testCommand2",
+        bindKey: "Alt-L",
+        exec: function(editor) {
+            if (!editor) {
+                return;
+            }
+            if (!counters["testCommand2"]) {
+            counters["testCommand2"] = 0;
+            }
+            counters["testCommand2"]++;
+        },
+        enabled: function(editor) {
+            if (!editor) {
+                return;
+            }
+            if (!counters["testEnabled2"]) {
+                counters["testEnabled2"] = 0;
+            }
+            counters["testEnabled2"]++;
+            return testCommand2Enabled;
+        },
+        position: 20
+    }
+};
+
+module.exports = {
+    setUp: function() {
+        var el = document.createElement("div");
+        el.style.left = "20px";
+        el.style.top = "30px";
+        el.style.width = "500px";
+        el.style.height = "500px";
+        document.body.appendChild(el);
+        var renderer = new VirtualRenderer(el);
+        var session = new EditSession("abc123\n\nfunc");
+        editor = new Editor(renderer, session);
+        counters = {};
+        inlineTooltip = new InlineTooltip(document.body);
+        inlineTooltip.setCommands(commands);
+        testCommand2Enabled = true;
+        editor.getSelection().moveCursorFileEnd();
+        editor.renderer.$loop._flush();
+    },
+    "test: displays inline tooltip above cursor with commands": function(done) {
+        var tooltipDomElement = document.getElementById(TOOLTIP_ID);
+        assert.strictEqual(inlineTooltip.isShown(), false);
+        assert.strictEqual(tooltipDomElement.style.display, "none");
+
+        inlineTooltip.show(editor);
+        tooltipDomElement = document.getElementById(TOOLTIP_ID);
+        assert.strictEqual(tooltipDomElement.style.display, "");
+        assert.strictEqual(inlineTooltip.isShown(), true);
+        done();
+    },
+    "test: commands are clickable": function(done) {
+        inlineTooltip.show(editor);
+        assert.strictEqual(inlineTooltip.isShown(), true);
+        assert.strictEqual(counters["testCommand1"], undefined);
+        var buttonElements = Array.from(document.querySelectorAll("." + BUTTON_CLASS_NAME));
+        assert.strictEqual(buttonElements.length, 2);
+        mousedown(buttonElements[0]);
+        mousedown(buttonElements[1]);
+        assert.strictEqual(counters["testCommand1"], 1);
+        done();
+    },
+    "test: commands are disabled when enable check is falsy": function(done) {
+        inlineTooltip.show(editor);
+        var buttonElements = Array.from(document.querySelectorAll("." + BUTTON_CLASS_NAME));
+        var disabledButtonElements = Array.from(document.querySelectorAll("." + BUTTON_CLASS_NAME + "_disabled"));
+        assert.strictEqual(buttonElements.length, 2);
+        assert.strictEqual(disabledButtonElements.length, 0);
+        assert.strictEqual(buttonElements.filter(function (button) { return !button.disabled; }).length, 2);
+        assert.strictEqual(counters["testEnabled1"], 1);
+        assert.strictEqual(counters["testEnabled2"], 1);
+
+        testCommand2Enabled = false;
+        inlineTooltip.updateButtons();
+        buttonElements = Array.from(document.querySelectorAll("." + BUTTON_CLASS_NAME));
+        disabledButtonElements = Array.from(document.querySelectorAll("." + BUTTON_CLASS_NAME + "_disabled"));
+        assert.strictEqual(buttonElements.length, 1);
+        assert.strictEqual(disabledButtonElements.length, 1);
+        assert.strictEqual(disabledButtonElements.filter(function (button) { return button.disabled; }).length, 1);
+        assert.strictEqual(buttonElements.filter(function (button) { return !button.disabled; }).length, 1);
+        assert.strictEqual(counters["testEnabled1"], 2);
+        assert.strictEqual(counters["testEnabled2"], 2);
+        done();
+    },
+    "test: verify detach": function(done) {
+        inlineTooltip.show(editor);
+        var tooltipDomElement = document.getElementById(TOOLTIP_ID);
+        assert.strictEqual(inlineTooltip.isShown(), true);
+
+        inlineTooltip.detach();
+        assert.strictEqual(inlineTooltip.isShown(), false);
+        var tooltipDomElement = document.getElementById(TOOLTIP_ID);
+        assert.strictEqual(tooltipDomElement.style.display, "none");
+        done();
+    },
+    "test: verify destroy": function(done) {
+        inlineTooltip.show(editor);
+        var tooltipDomElement = document.getElementById(TOOLTIP_ID);
+        assert.strictEqual(inlineTooltip.isShown(), true);
+        assert.ok(tooltipDomElement);
+
+        inlineTooltip.destroy();
+        assert.strictEqual(inlineTooltip.isShown(), false);
+        tooltipDomElement = document.getElementById(TOOLTIP_ID);
+        assert.equal(tooltipDomElement, null);
+
+        // Intentionally called twice
+        inlineTooltip.destroy();
+        assert.strictEqual(inlineTooltip.isShown(), false);
+        tooltipDomElement = document.getElementById(TOOLTIP_ID);
+        assert.equal(tooltipDomElement, null);
+        done();
+    },
+    tearDown: function() {
+        inlineTooltip.destroy();
+        editor.destroy();
+    }
+};
+
+if (typeof module !== "undefined" && module === require.main) {
+    require("asyncjs").test.testcase(module.exports).exec();
+}
diff --git a/src/keyboard/textinput.js b/src/keyboard/textinput.js
index 1ee56031e2c..8953353d78c 100644
--- a/src/keyboard/textinput.js
+++ b/src/keyboard/textinput.js
@@ -52,7 +52,7 @@ var TextInput = function(parentNode, host) {
     this.setAriaOptions = function(options) {
         if (options.activeDescendant) {
             text.setAttribute("aria-haspopup", "true");
-            text.setAttribute("aria-autocomplete", "list");
+            text.setAttribute("aria-autocomplete", options.inline ? "both" : "list");
             text.setAttribute("aria-activedescendant", options.activeDescendant);
         } else {
             text.setAttribute("aria-haspopup", "false");
diff --git a/src/snippets.js b/src/snippets.js
index d54e3ead65c..7947a49d877 100644
--- a/src/snippets.js
+++ b/src/snippets.js
@@ -349,7 +349,7 @@ var SnippetManager = function() {
         return result;
     };
 
-    this.insertSnippetForSelection = function(editor, snippetText) {
+    var processSnippetText = function(editor, snippetText) {
         var cursor = editor.getCursorPosition();
         var line = editor.session.getLine(cursor.row);
         var tabString = editor.session.getTabString();
@@ -466,12 +466,28 @@ var SnippetManager = function() {
                     t.end = {row: row, column: column};
             }
         });
+
+        return {
+            text,
+            tabstops,
+            tokens
+        };
+    };
+
+    this.getDisplayTextForSnippet = function(editor, snippetText) {
+        var processedSnippet = processSnippetText.call(this, editor, snippetText);
+        return processedSnippet.text;
+    };
+
+    this.insertSnippetForSelection = function(editor, snippetText) {
+        var processedSnippet = processSnippetText.call(this, editor, snippetText);
+        
         var range = editor.getSelectionRange();
-        var end = editor.session.replace(range, text);
+        var end = editor.session.replace(range, processedSnippet.text);
 
         var tabstopManager = new TabstopManager(editor);
         var selectionId = editor.inVirtualSelectionMode && editor.selection.index;
-        tabstopManager.addTabstops(tabstops, range.start, end, selectionId);
+        tabstopManager.addTabstops(processedSnippet.tabstops, range.start, end, selectionId);
     };
     
     this.insertSnippet = function(editor, snippetText) {
diff --git a/src/test/all_browser.js b/src/test/all_browser.js
index 76f32465fa7..3455eaf7987 100644
--- a/src/test/all_browser.js
+++ b/src/test/all_browser.js
@@ -25,6 +25,8 @@ var testNames = [
     "ace/editor_text_edit_test",
     "ace/editor_commands_test",
     "ace/ext/hardwrap_test",
+    "ace/ext/inline_autocomplete_test",
+    "ace/ext/inline_autocomplete_tooltip_test",
     "ace/ext/static_highlight_test",
     "ace/ext/whitespace_test",
     "ace/ext/error_marker_test",
diff --git a/src/virtual_renderer.js b/src/virtual_renderer.js
index 76d99adf144..aaf58673bc3 100644
--- a/src/virtual_renderer.js
+++ b/src/virtual_renderer.js
@@ -1683,6 +1683,7 @@ var VirtualRenderer = function(container, theme) {
     };
 
     this.removeExtraToken = function(row, column) {
+        this.session.bgTokenizer.lines[row] = null;
         this.updateLines(row, row);
     };
 
diff --git a/src/virtual_renderer_test.js b/src/virtual_renderer_test.js
index e08e4f916dc..aaf35d49cd6 100644
--- a/src/virtual_renderer_test.js
+++ b/src/virtual_renderer_test.js
@@ -320,6 +320,11 @@ module.exports = {
 
         editor.renderer.$loop._flush();
         assert.equal(editor.renderer.content.textContent, "abcdefGhost");
+
+        editor.removeGhostText();
+
+        editor.renderer.$loop._flush();
+        assert.equal(editor.renderer.content.textContent, "abcdef");
     },
 
     "test multiline ghost text": function() {
@@ -332,6 +337,13 @@ module.exports = {
         assert.equal(editor.renderer.content.textContent, "abcdefGhost1");
         
         assert.equal(editor.session.lineWidgets[0].el.textContent, "Ghost2\nGhost3");
+
+        editor.removeGhostText();
+
+        editor.renderer.$loop._flush();
+        assert.equal(editor.renderer.content.textContent, "abcdef");
+        
+        assert.equal(editor.session.lineWidgets, null);
     },
     "test: brackets highlighting": function (done) {
         var renderer = editor.renderer;

From 774e7d7067770f33e9e9cd5438c9936fc3c24eed Mon Sep 17 00:00:00 2001
From: Mark Kercso 
Date: Thu, 16 Mar 2023 00:03:42 +0000
Subject: [PATCH 2/6] Autocomplete bugfixes

Added the inline autocompletion to the kitchen sink demo
Added kitchen sink demo option to enable inline preview for popup-based autocomplete
Inline completion and command bar tooltip are changed to be editor scoped
---
 demo/kitchen-sink/demo.js                   | 27 +++++++++
 src/autocomplete.js                         | 60 +++++++++++--------
 src/autocomplete/inline.js                  | 17 +++---
 src/autocomplete/inline_test.js             | 56 +++++++++++++++---
 src/ext/inline_autocomplete.js              | 65 ++++++++++-----------
 src/ext/inline_autocomplete_test.js         |  1 -
 src/ext/inline_autocomplete_tooltip_test.js |  8 +--
 src/virtual_renderer.js                     |  2 +-
 8 files changed, 158 insertions(+), 78 deletions(-)

diff --git a/demo/kitchen-sink/demo.js b/demo/kitchen-sink/demo.js
index 08c66c910bd..27272d3c4d4 100644
--- a/demo/kitchen-sink/demo.js
+++ b/demo/kitchen-sink/demo.js
@@ -312,6 +312,8 @@ doclist.pickDocument = function(name) {
 var OptionPanel = require("ace/ext/options").OptionPanel;
 var optionsPanel = new OptionPanel(env.editor);
 
+var originalAutocompleteCommand = null;
+
 optionsPanel.add({
     Main: {
         Document: {
@@ -368,6 +370,29 @@ optionsPanel.add({
             path: "showTokenInfo",
             position: 2000
         },
+        "Inline preview for autocomplete": {
+            path: "inlineEnabledForAutocomplete",
+            position: 2000,
+            onchange: function(value) {
+                var Autocomplete = require("ace/autocomplete").Autocomplete;
+                if (value && !originalAutocompleteCommand) {
+                    originalAutocompleteCommand = Autocomplete.startCommand.exec;
+                    Autocomplete.startCommand.exec = function(editor) {
+                        var autocomplete = Autocomplete.for(editor);
+                        autocomplete.inlineEnabled = true;
+                        originalAutocompleteCommand(...arguments);
+                    }
+                } else if (!value) {
+                    var autocomplete = Autocomplete.for(editor);
+                    autocomplete.destroy();
+                    Autocomplete.startCommand.exec = originalAutocompleteCommand;
+                    originalAutocompleteCommand = null;
+                }
+            },
+            getValue: function() {
+                return !!originalAutocompleteCommand;
+            }
+        },
         "Show Textarea Position": devUtil.textPositionDebugger,
         "Text Input Debugger": devUtil.textInputDebugger,
     }
@@ -468,8 +493,10 @@ optionsPanelContainer.insertBefore(
 );
 
 require("ace/ext/language_tools");
+require("ace/ext/inline_autocomplete");
 env.editor.setOptions({
     enableBasicAutocompletion: true,
+    enableInlineAutocompletion: true,
     enableSnippets: true
 });
 
diff --git a/src/autocomplete.js b/src/autocomplete.js
index 9442e53897c..c96826cb9b6 100644
--- a/src/autocomplete.js
+++ b/src/autocomplete.js
@@ -42,42 +42,51 @@ var Autocomplete = function() {
 };
 
 (function() {
-    this.$onPopupChange = function(hide) {
-        if (hide) {
-            if (this.inlineEnabled) {
-                this.inlineRenderer.show(this.editor, null, prefix);    
-            }
-            this.hideDocTooltip();
-        } else {
-            this.tooltipTimer.call(null, null);
-            if (this.inlineEnabled) {
-                var completion = hide ? null : this.popup.getData(this.popup.getRow());
-                var prefix = util.getCompletionPrefix(this.editor);
-                this.inlineRenderer.show(this.editor, completion, prefix);
-                this.$updatePopupPosition();
-            }
-        }
-    };
 
     this.$init = function() {
         this.popup = new AcePopup(document.body || document.documentElement);
-        this.inlineRenderer = new AceInline();
         this.popup.on("click", function(e) {
             this.insertMatch();
             e.stop();
         }.bind(this));
         this.popup.focus = this.editor.focus.bind(this.editor);
         this.popup.on("show", this.$onPopupChange.bind(this));
-        this.popup.on("hide", this.$onPopupChange.bind(this, true));
+        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));
         return this.popup;
     };
 
+    this.$initInline = function() {
+        if (!this.inlineEnabled || this.inlineRenderer)
+            return;
+        this.inlineRenderer = new AceInline();
+        return this.inlineRenderer;
+    }
+
     this.getPopup = function() {
         return this.popup || this.$init();
     };
 
+    this.$onHidePopup = function() {
+        if (this.inlineRenderer) {
+            this.inlineRenderer.hide();
+        }
+        this.hideDocTooltip();
+    }
+
+    this.$onPopupChange = function(hide) {
+        this.tooltipTimer.call(null, null);
+        if (this.inlineRenderer && this.inlineEnabled) {
+            var completion = hide ? null : this.popup.getData(this.popup.getRow());
+            var prefix = util.getCompletionPrefix(this.editor);
+            if (!this.inlineRenderer.show(this.editor, completion, prefix)) {
+                this.inlineRenderer.hide();
+            }
+            this.$updatePopupPosition();
+        }
+    };
+
     this.$updatePopupPosition = function() {
         var editor = this.editor;
         var renderer = editor.renderer;
@@ -104,6 +113,9 @@ var Autocomplete = function() {
         if (!this.popup)
             this.$init();
 
+        if (this.inlineEnabled && !this.inlineRenderer)
+            this.$initInline();
+
         this.popup.autoSelect = this.autoSelect;
 
         this.popup.setData(this.completions.filtered, this.completions.filterText);
@@ -135,11 +147,13 @@ var Autocomplete = function() {
      * Detaches all elements from the editor, and cleans up the data for the session
      */
     this.detach = function() {
-        this.editor.keyBinding.removeKeyboardHandler(this.keyboardHandler);
-        this.editor.off("changeSelection", this.changeListener);
-        this.editor.off("blur", this.blurListener);
-        this.editor.off("mousedown", this.mousedownListener);
-        this.editor.off("mousewheel", this.mousewheelListener);
+        if (this.editor) {
+            this.editor.keyBinding.removeKeyboardHandler(this.keyboardHandler);
+            this.editor.off("changeSelection", this.changeListener);
+            this.editor.off("blur", this.blurListener);
+            this.editor.off("mousedown", this.mousedownListener);
+            this.editor.off("mousewheel", this.mousewheelListener);
+        }
         this.changeTimer.cancel();
         this.hideDocTooltip();
 
diff --git a/src/autocomplete/inline.js b/src/autocomplete/inline.js
index f5b741ffe3c..af1637d135b 100644
--- a/src/autocomplete/inline.js
+++ b/src/autocomplete/inline.js
@@ -23,27 +23,28 @@ var AceInline = function() {
      * @param {Editor} editor
      * @param {Completion} completion
      * @param {string} prefix
+     * @returns {boolean} True if the completion could be rendered to the editor, false otherwise
      */
     this.show = function(editor, completion, prefix) {
+        prefix = prefix || "";
         if (this.editor && this.editor !== editor) {
             this.hide();
             this.editor = null;
         }
-        if (!editor) {
-            return false;
-        }
-        this.editor = editor;
-        if (!completion) {
-            editor.removeGhostText();
+        if (!editor || !completion) {
             return false;
         }
         var displayText = completion.snippet ? snippetManager.getDisplayTextForSnippet(editor, completion.snippet) : completion.value;
         if (!displayText || !displayText.startsWith(prefix)) {
-            editor.removeGhostText();
             return false;
         }
+        this.editor = editor;
         displayText = displayText.slice(prefix.length);
-        editor.setGhostText(displayText);
+        if (displayText === "") {
+            editor.removeGhostText();
+        } else {
+            editor.setGhostText(displayText);
+        }
         return true;
     };
 
diff --git a/src/autocomplete/inline_test.js b/src/autocomplete/inline_test.js
index 6b12519624e..80ba475d5cf 100644
--- a/src/autocomplete/inline_test.js
+++ b/src/autocomplete/inline_test.js
@@ -86,13 +86,42 @@ module.exports = {
         assert.strictEqual(editor.renderer.$ghostTextWidget.el.textContent, "        console.log('test');\n    }");
         done();
     },
-    "test: boundary conditions": function(done) {
-        inline.show(null, null, null);
-        inline.show(editor, null, null);
-        inline.show(editor, completions[1], null);
-        inline.show(editor, null, "");
-        inline.show(editor, completions[1], "");
-        inline.show(null, completions[3], "");
+    "test: boundary tests": function(done) {
+        var noRenderTestCases = [
+            [null, null, null],
+            [editor, null, null],
+            [editor, null, ""],
+            [null, completions[3], ""]
+        ];
+        var result;
+        noRenderTestCases.forEach(function(params) {
+            result = inline.show(params[0], params[1], params[2]);
+            editor.renderer.$loop._flush();
+            assert.notOk(result);
+            assert.equal(editor.renderer.$ghostText, null);
+            assert.equal(editor.renderer.$ghostTextWidget, null);
+        });
+
+        var renderTestCases = [
+            [editor, completions[1], undefined],
+            [editor, completions[1], null],
+            [editor, completions[1], ""],
+        ];
+        renderTestCases.forEach(function(params) {
+            result = inline.show(params[0], params[1], params[2]);
+            editor.renderer.$loop._flush();
+            assert.ok(result);
+            assert.strictEqual(editor.renderer.$ghostText.text, "function");
+            assert.strictEqual(getAllLines(), textBase + "ffunction");
+            assert.equal(editor.renderer.$ghostTextWidget, null);
+        });
+
+        result = inline.show(editor, completions[0], "foo");
+        editor.renderer.$loop._flush();
+        assert.ok(result);
+        assert.equal(editor.renderer.$ghostText, null);
+        assert.equal(editor.renderer.$ghostTextWidget, null);
+        
         done();
     },
     "test: only renders the ghost text without the prefix": function(done) {
@@ -111,11 +140,22 @@ module.exports = {
         assert.strictEqual(getAllLines(), textBase + "f");
         assert.strictEqual(inline.isOpen(), false);
 
+        inline.show(editor, completions[1], "function");
+        editor.renderer.$loop._flush();
+        assert.strictEqual(getAllLines(), textBase + "f");
+        assert.strictEqual(inline.isOpen(), false);
+        done();
+    },
+    "test: does not hide previous ghost text if cannot show current one": function(done) {
         inline.show(editor, completions[1], "f");
         editor.renderer.$loop._flush();
         assert.equal(getAllLines(), textBase + "function");
         assert.strictEqual(inline.isOpen(), true);
-        inline.show(editor, null, "f");
+        inline.show(editor, null, "");
+        editor.renderer.$loop._flush();
+        assert.equal(getAllLines(), textBase + "function");
+        assert.strictEqual(inline.isOpen(), true);
+        inline.hide();
         editor.renderer.$loop._flush();
         assert.strictEqual(getAllLines(), textBase + "f");
         assert.strictEqual(inline.isOpen(), false);
diff --git a/src/ext/inline_autocomplete.js b/src/ext/inline_autocomplete.js
index cfa14565d0b..ca0df707bb4 100644
--- a/src/ext/inline_autocomplete.js
+++ b/src/ext/inline_autocomplete.js
@@ -34,7 +34,8 @@ var minPosition = function (posA, posB) {
  * @class
  */
 
-var InlineAutocomplete = function() {
+var InlineAutocomplete = function(editor) {
+    this.editor = editor;
     this.tooltipEnabled = true;
     this.keyboardHandler = new HashHandler(this.commands);
     this.$index = -1;
@@ -57,7 +58,7 @@ var InlineAutocomplete = function() {
 
     this.getInlineTooltip = function() {
         if (!this.inlineTooltip) {
-            this.inlineTooltip = new InlineTooltip(document.body || document.documentElement);
+            this.inlineTooltip = new InlineTooltip(this.editor, document.body || document.documentElement);
             this.inlineTooltip.setCommands(this.commands);
         }
         return this.inlineTooltip;
@@ -69,24 +70,18 @@ var InlineAutocomplete = function() {
      * @param {Editor} editor
      * @param {CompletionOptions} options
      */
-    this.show = function(editor, options) {
-        if (!editor)
-            return;
-        if (this.editor && this.editor !== editor)
-            this.detach();
-
+    this.show = function(options) {
         this.activated = true;
 
-        this.editor = editor;
-        if (editor.completer !== this) {
-            if (editor.completer)
-                editor.completer.detach();
-            editor.completer = this;
+        if (this.editor.completer !== this) {
+            if (this.editor.completer)
+                this.editor.completer.detach();
+            this.editor.completer = this;
         }
 
-        editor.on("changeSelection", this.changeListener);
-        editor.on("blur", this.blurListener);
-        editor.on("mousewheel", this.mousewheelListener);
+        this.editor.on("changeSelection", this.changeListener);
+        this.editor.on("blur", this.blurListener);
+        this.editor.on("mousewheel", this.mousewheelListener);
 
         this.updateCompletions(options);
     };
@@ -245,7 +240,10 @@ var InlineAutocomplete = function() {
     };
 
     this.$showCompletion = function() {
-        this.getInlineRenderer().show(this.editor, this.completions.filtered[this.$index], this.completions.filterText);
+        if (!this.getInlineRenderer().show(this.editor, this.completions.filtered[this.$index], this.completions.filterText)) {
+            // Not able to show the completion, hide the previous one
+            this.getInlineRenderer().hide();
+        }
         if (this.inlineTooltip && this.inlineTooltip.isShown()) {
             this.inlineTooltip.updateButtons();
         }
@@ -359,7 +357,7 @@ InlineAutocomplete.for = function(editor) {
         editor.completer = null;
     }
 
-    editor.completer = new InlineAutocomplete();
+    editor.completer = new InlineAutocomplete(editor);
     editor.once("destroy", destroyCompleter);
     return editor.completer;
 };
@@ -368,7 +366,7 @@ InlineAutocomplete.startCommand = {
     name: "startInlineAutocomplete",
     exec: function(editor, options) {
         var completer = InlineAutocomplete.for(editor);
-        completer.show(editor, options);
+        completer.show(options);
     },
     bindKey: { win: "Alt-C", mac: "Option-C" }
 };
@@ -407,7 +405,8 @@ var BUTTON_CLASS_NAME = 'inline_autocomplete_tooltip_button';
 var TOOLTIP_CLASS_NAME = 'ace_tooltip ace_inline_autocomplete_tooltip';
 var TOOLTIP_ID = 'inline_autocomplete_tooltip';
 
-function InlineTooltip(parentElement) {
+function InlineTooltip(editor, parentElement) {
+    this.editor = editor;
     this.htmlElement = document.createElement('div');
     var el = this.htmlElement;
     el.style.display = 'none';
@@ -459,7 +458,8 @@ function InlineTooltip(parentElement) {
                     bindKey = useragent.isMac ? bindKey.mac : bindKey.win;
                 }
                 bindKey = bindKey.replace("|", " / ");
-                this.buttons[key].textContent = command.name + ' (' + bindKey + ')';
+                var buttonText = dom.createTextNode([command.name, "(", bindKey, ")"].join(" "));
+                this.buttons[key].appendChild(buttonText);
             }.bind(this));
     };
 
@@ -467,20 +467,18 @@ function InlineTooltip(parentElement) {
      * Displays the clickable command bar tooltip
      * @param {Record} commands
      */
-    this.show = function(editor) {
+    this.show = function() {
         this.detach();
 
         this.htmlElement.style.display = '';
         this.htmlElement.addEventListener('mousedown', captureMousedown.bind(this));
-        
-        this.editor = editor;
 
         this.updatePosition();
         this.updateButtons(true);
     };
 
     this.isShown = function() {
-        return !!this.htmlElement && this.htmlElement.style.display !== "none";
+        return !!this.htmlElement && window.getComputedStyle(this.htmlElement).display !== "none";
     };
 
     /**
@@ -492,7 +490,12 @@ function InlineTooltip(parentElement) {
         }
         var renderer = this.editor.renderer;
 
-        var ranges = this.editor.selection.getAllRanges();
+        var ranges;
+        if (this.editor.selection.getAllRanges) {
+            ranges = this.editor.selection.getAllRanges();
+        } else {
+            ranges = [this.editor.getSelection()];
+        }
         if (!ranges.length) {
             return;
         }
@@ -513,11 +516,6 @@ function InlineTooltip(parentElement) {
 
         var top = pos.top - el.offsetHeight;
 
-        if (pos.top < 0) {
-            this.hide();
-            return;
-        }
-
         el.style.top = top + "px";
         el.style.bottom = "";
         el.style.left = Math.min(screenWidth - el.offsetWidth, pos.left) + "px";
@@ -555,8 +553,9 @@ function InlineTooltip(parentElement) {
     };
     
     this.detach = function() {
-        if (this.eventListeners && Object.keys(this.eventListeners).length) {
-            Object.keys(this.eventListeners).forEach(function(key) {
+        var listenerKeys = Object.keys(this.eventListeners)
+        if (this.eventListeners && listenerKeys.length) {
+            listenerKeys.forEach(function(key) {
                 this.buttons[key].removeEventListener('mousedown', this.eventListeners[key]);
                 delete this.eventListeners[key];
             }.bind(this));
diff --git a/src/ext/inline_autocomplete_test.js b/src/ext/inline_autocomplete_test.js
index c3d8a4670f0..3bdcea986e1 100644
--- a/src/ext/inline_autocomplete_test.js
+++ b/src/ext/inline_autocomplete_test.js
@@ -6,7 +6,6 @@ if (typeof process !== "undefined") {
 
 "use strict";
 
-var ace = require("../ace");
 var Editor = require("../editor").Editor;
 var EditSession = require("../edit_session").EditSession;
 var InlineAutocomplete = require("./inline_autocomplete").InlineAutocomplete;
diff --git a/src/ext/inline_autocomplete_tooltip_test.js b/src/ext/inline_autocomplete_tooltip_test.js
index 5f24ed10ff2..bee40330e0c 100644
--- a/src/ext/inline_autocomplete_tooltip_test.js
+++ b/src/ext/inline_autocomplete_tooltip_test.js
@@ -84,7 +84,7 @@ module.exports = {
         var session = new EditSession("abc123\n\nfunc");
         editor = new Editor(renderer, session);
         counters = {};
-        inlineTooltip = new InlineTooltip(document.body);
+        inlineTooltip = new InlineTooltip(editor, document.body);
         inlineTooltip.setCommands(commands);
         testCommand2Enabled = true;
         editor.getSelection().moveCursorFileEnd();
@@ -93,11 +93,11 @@ module.exports = {
     "test: displays inline tooltip above cursor with commands": function(done) {
         var tooltipDomElement = document.getElementById(TOOLTIP_ID);
         assert.strictEqual(inlineTooltip.isShown(), false);
-        assert.strictEqual(tooltipDomElement.style.display, "none");
+        assert.strictEqual(window.getComputedStyle(tooltipDomElement).display, "none");
 
         inlineTooltip.show(editor);
         tooltipDomElement = document.getElementById(TOOLTIP_ID);
-        assert.strictEqual(tooltipDomElement.style.display, "");
+        assert.strictEqual(window.getComputedStyle(tooltipDomElement).display, "");
         assert.strictEqual(inlineTooltip.isShown(), true);
         done();
     },
@@ -142,7 +142,7 @@ module.exports = {
         inlineTooltip.detach();
         assert.strictEqual(inlineTooltip.isShown(), false);
         var tooltipDomElement = document.getElementById(TOOLTIP_ID);
-        assert.strictEqual(tooltipDomElement.style.display, "none");
+        assert.strictEqual(window.getComputedStyle(tooltipDomElement).display, "none");
         done();
     },
     "test: verify destroy": function(done) {
diff --git a/src/virtual_renderer.js b/src/virtual_renderer.js
index aaf58673bc3..f73f4271273 100644
--- a/src/virtual_renderer.js
+++ b/src/virtual_renderer.js
@@ -1662,7 +1662,7 @@ var VirtualRenderer = function(container, theme) {
         session.bgTokenizer.lines[row] = null;
         var newToken = {type: type, value: text};
         var tokens = session.getTokens(row);
-        if (column == null) {
+        if (column == null || !tokens.length) {
             tokens.push(newToken);
         } else {
             var l = 0;

From 8c0f160bbc3150fe90a8b93580de2161f6a94dcd Mon Sep 17 00:00:00 2001
From: azmkercso <126491695+azmkercso@users.noreply.github.com>
Date: Thu, 16 Mar 2023 10:18:57 +0100
Subject: [PATCH 3/6] Update src/ext/inline_autocomplete.js
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Co-authored-by: André Oliveira 
---
 src/ext/inline_autocomplete.js | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/ext/inline_autocomplete.js b/src/ext/inline_autocomplete.js
index ca0df707bb4..d37acaac4d8 100644
--- a/src/ext/inline_autocomplete.js
+++ b/src/ext/inline_autocomplete.js
@@ -465,7 +465,7 @@ function InlineTooltip(editor, parentElement) {
 
     /**
      * Displays the clickable command bar tooltip
-     * @param {Record} commands
+     * @param {Editor} editor
      */
     this.show = function() {
         this.detach();

From 3595d756a3420547340442f0af526de9b59baedc Mon Sep 17 00:00:00 2001
From: Mark Kercso 
Date: Thu, 16 Mar 2023 11:10:48 +0000
Subject: [PATCH 4/6] Fix styling and add cross-editor tests

---
 src/autocomplete.js             |  4 +--
 src/autocomplete/inline.js      |  2 +-
 src/autocomplete/inline_test.js | 55 +++++++++++++++++++++++++++++----
 src/ext/inline_autocomplete.js  |  2 +-
 4 files changed, 53 insertions(+), 10 deletions(-)

diff --git a/src/autocomplete.js b/src/autocomplete.js
index c96826cb9b6..73cec67940b 100644
--- a/src/autocomplete.js
+++ b/src/autocomplete.js
@@ -62,7 +62,7 @@ var Autocomplete = function() {
             return;
         this.inlineRenderer = new AceInline();
         return this.inlineRenderer;
-    }
+    };
 
     this.getPopup = function() {
         return this.popup || this.$init();
@@ -73,7 +73,7 @@ var Autocomplete = function() {
             this.inlineRenderer.hide();
         }
         this.hideDocTooltip();
-    }
+    };
 
     this.$onPopupChange = function(hide) {
         this.tooltipTimer.call(null, null);
diff --git a/src/autocomplete/inline.js b/src/autocomplete/inline.js
index af1637d135b..e161b4df5e1 100644
--- a/src/autocomplete/inline.js
+++ b/src/autocomplete/inline.js
@@ -27,7 +27,7 @@ var AceInline = function() {
      */
     this.show = function(editor, completion, prefix) {
         prefix = prefix || "";
-        if (this.editor && this.editor !== editor) {
+        if (editor && this.editor && this.editor !== editor) {
             this.hide();
             this.editor = null;
         }
diff --git a/src/autocomplete/inline_test.js b/src/autocomplete/inline_test.js
index 80ba475d5cf..18b9e6d7e00 100644
--- a/src/autocomplete/inline_test.js
+++ b/src/autocomplete/inline_test.js
@@ -12,6 +12,7 @@ var EditSession = require("../ace").EditSession;
 var VirtualRenderer = require("../ace").VirtualRenderer;
 
 var editor;
+var editor2;
 var inline;
 
 var textBase = "abc123\n\n    ";
@@ -39,12 +40,19 @@ var completions = [
     }
 ];
 
-var getAllLines = function() {
-    return editor.renderer.$textLayer.element.childNodes.map(function (node) {
+var getAllLines = function(editorOverride) {
+    editorOverride = editorOverride || editor;
+    return editorOverride.renderer.$textLayer.element.childNodes.map(function (node) {
         return node.textContent;
     }).join("\n");
 };
 
+var createEditor = function(element) {
+    var renderer = new VirtualRenderer(element);
+    var session = new EditSession("");
+    return new Editor(renderer, session);
+};
+
 module.exports = {
     setUp: function(done) {
         var el = document.createElement("div");
@@ -53,9 +61,7 @@ module.exports = {
         el.style.width = "500px";
         el.style.height = "500px";
         document.body.appendChild(el);
-        var renderer = new VirtualRenderer(el);
-        var session = new EditSession("");
-        editor = new Editor(renderer, session);
+        editor = createEditor(el);
         editor.execCommand("insertstring", textBase + "f");
         inline = new AceInline();
         editor.getSelection().moveCursorFileEnd();
@@ -105,7 +111,7 @@ module.exports = {
         var renderTestCases = [
             [editor, completions[1], undefined],
             [editor, completions[1], null],
-            [editor, completions[1], ""],
+            [editor, completions[1], ""]
         ];
         renderTestCases.forEach(function(params) {
             result = inline.show(params[0], params[1], params[2]);
@@ -161,6 +167,40 @@ module.exports = {
         assert.strictEqual(inline.isOpen(), false);
         done();
     },
+    "test: removes ghost text from previous editor if new valid editor is passed to show function": function(done) {
+        var el = document.createElement("div");
+        el.style.left = "520px";
+        el.style.top = "530px";
+        el.style.width = "500px";
+        el.style.height = "500px";
+        document.body.appendChild(el);
+        editor2 = createEditor(el);
+        var editor2Text = "different text\n\n    f";
+        editor2.execCommand("insertstring", editor2Text);
+
+        inline.show(editor, completions[1], "f");
+        editor.renderer.$loop._flush();
+        editor2.renderer.$loop._flush();
+        assert.strictEqual(getAllLines(), textBase + "function");
+        assert.strictEqual(getAllLines(editor2), editor2Text);
+        assert.strictEqual(inline.isOpen(), true);
+
+        inline.show(editor2, completions[2], "f");
+        editor.renderer.$loop._flush();
+        editor2.renderer.$loop._flush();
+        assert.strictEqual(getAllLines(), textBase + "f");
+        assert.strictEqual(getAllLines(editor2), editor2Text + "oobar");
+        assert.strictEqual(inline.isOpen(), true);
+
+        inline.show(null, completions[2], "f");
+        editor.renderer.$loop._flush();
+        editor2.renderer.$loop._flush();
+        assert.strictEqual(getAllLines(), textBase + "f");
+        assert.strictEqual(getAllLines(editor2), editor2Text + "oobar");
+        assert.strictEqual(inline.isOpen(), true);
+
+        done();
+    },
     "test: verify destroy": function(done) {
         inline.show(editor, completions[0], "f");
         editor.renderer.$loop._flush();
@@ -185,6 +225,9 @@ module.exports = {
     tearDown: function() {
         inline.destroy();
         editor.destroy();
+        if (editor2) {
+            editor2.destroy();
+        }
     }
 };
 
diff --git a/src/ext/inline_autocomplete.js b/src/ext/inline_autocomplete.js
index d37acaac4d8..5c855da7cb3 100644
--- a/src/ext/inline_autocomplete.js
+++ b/src/ext/inline_autocomplete.js
@@ -553,7 +553,7 @@ function InlineTooltip(editor, parentElement) {
     };
     
     this.detach = function() {
-        var listenerKeys = Object.keys(this.eventListeners)
+        var listenerKeys = Object.keys(this.eventListeners);
         if (this.eventListeners && listenerKeys.length) {
             listenerKeys.forEach(function(key) {
                 this.buttons[key].removeEventListener('mousedown', this.eventListeners[key]);

From 1842287a40fdeaf79d2251292bd774eec570d40d Mon Sep 17 00:00:00 2001
From: Mark Kercso 
Date: Thu, 16 Mar 2023 17:25:24 +0000
Subject: [PATCH 5/6] Fix for popup display positioning for autocompletion

---
 ace.d.ts                  |  1 +
 src/autocomplete.js       | 18 ++++++++--
 src/autocomplete/popup.js | 74 ++++++++++++++++++++++++++++++++++-----
 3 files changed, 82 insertions(+), 11 deletions(-)

diff --git a/ace.d.ts b/ace.d.ts
index 99408411cb1..33e4cfa8e8d 100644
--- a/ace.d.ts
+++ b/ace.d.ts
@@ -985,6 +985,7 @@ export namespace Ace {
     getRow(line: number): void;
     hide(): void;
     show(pos: Point, lineHeight: number, topdownOnly: boolean): void;
+    tryShow(pos: Point, lineHeight: number, anchor: "top" | "bottom" | undefined, forceShow?: boolean): boolean;
     goTo(where: AcePopupNavigation): void;
   }
 }
diff --git a/src/autocomplete.js b/src/autocomplete.js
index 73cec67940b..91a35f159b4 100644
--- a/src/autocomplete.js
+++ b/src/autocomplete.js
@@ -76,7 +76,6 @@ var Autocomplete = function() {
     };
 
     this.$onPopupChange = function(hide) {
-        this.tooltipTimer.call(null, null);
         if (this.inlineRenderer && this.inlineEnabled) {
             var completion = hide ? null : this.popup.getData(this.popup.getRow());
             var prefix = util.getCompletionPrefix(this.editor);
@@ -85,6 +84,7 @@ var Autocomplete = function() {
             }
             this.$updatePopupPosition();
         }
+        this.tooltipTimer.call(null, null);
     };
 
     this.$updatePopupPosition = function() {
@@ -100,12 +100,26 @@ var Autocomplete = function() {
         pos.left += rect.left - editor.renderer.scrollLeft;
         pos.left += renderer.gutterWidth;
 
+        var posGhostText = {
+            top: pos.top,
+            left: pos.left
+        };
+
         if (renderer.$ghostText && renderer.$ghostTextWidget) {
             if (this.base.row === renderer.$ghostText.position.row) {
-                pos.top += renderer.$ghostTextWidget.el.offsetHeight;
+                posGhostText.top += renderer.$ghostTextWidget.el.offsetHeight;
             }
         }
 
+        // Try to render below ghost text, then above ghost text, then over ghost text
+        if (this.popup.tryShow(posGhostText, lineHeight, "bottom")) {
+            return;
+        }
+
+        if (this.popup.tryShow(pos, lineHeight, "top")) {
+            return;
+        }
+        
         this.popup.show(pos, lineHeight);
     };
 
diff --git a/src/autocomplete/popup.js b/src/autocomplete/popup.js
index b7b11aea668..a2064629027 100644
--- a/src/autocomplete/popup.js
+++ b/src/autocomplete/popup.js
@@ -256,29 +256,75 @@ var AcePopup = function(parentNode) {
 
     popup.hide = function() {
         this.container.style.display = "none";
+        popup.anchorPos = null;
+        popup.anchor = null;
         if (popup.isOpen) {
             popup.isOpen = false;
             this._signal("hide");
         }
     };
-    popup.show = function(pos, lineHeight, topdownOnly) {
+
+    /**
+     * Tries to show the popup anchored to the given position and anchors.
+     * If the anchor is not specified it tries to align to bottom and right as much as possible.
+     * If the popup does not have enough space to be rendered with the given anchors, it returns false without rendering the popup.
+     * The forceShow flag can be used to render the popup in these cases, which slides the popup so it entirely fits on the screen.
+     * @param {Point} pos
+     * @param {number} lineHeight
+     * @param {"top" | "bottom" | undefined} anchor
+     * @param {boolean} forceShow
+     * @returns {boolean}
+     */
+    popup.tryShow = function(pos, lineHeight, anchor, forceShow) {
+        if (!forceShow && popup.isOpen && popup.anchorPos && popup.anchor &&
+            popup.anchorPos.top === pos.top && popup.anchorPos.left === pos.left &&
+            popup.anchor === anchor
+        ) {
+            return true;
+        }
+
         var el = this.container;
         var screenHeight = window.innerHeight;
         var screenWidth = window.innerWidth;
         var renderer = this.renderer;
         // var maxLines = Math.min(renderer.$maxLines, this.session.getLength());
         var maxH = renderer.$maxLines * lineHeight * 1.4;
-        var top = pos.top + this.$borderSize;
-        var allowTopdown = top > screenHeight / 2 && !topdownOnly;
-        if (allowTopdown && top + lineHeight + maxH > screenHeight) {
-            renderer.$maxPixelHeight = top - 2 * this.$borderSize;
+        var dims = { top: 0, bottom: 0, left: 0 };
+
+        if (anchor === "top") {
+            dims.bottom = pos.top - this.$borderSize;
+            dims.top = dims.bottom - maxH;
+        } else {
+            dims.top = pos.top + lineHeight + this.$borderSize;
+            dims.bottom = dims.top + maxH;
+        }
+
+        var fitsX = dims.top >= 0 && dims.bottom <= screenHeight;
+
+        if (!forceShow && !fitsX) {
+            return false;
+        }
+
+        if (!fitsX) {
+            var spaceBelow = screenHeight - pos.top - this.$borderSize - 0.2 * lineHeight;
+            var spaceAbove = pos.top - this.$borderSize;
+            if (!anchor) {
+                anchor = spaceAbove > spaceBelow ? "top" : "bottom";
+            }
+            if (anchor === "top") {
+                renderer.$maxPixelHeight = spaceAbove;
+            } else {
+                renderer.$maxPixelHeight = spaceBelow;
+            }
+        }
+
+
+        if (anchor === "top") {
             el.style.top = "";
-            el.style.bottom = screenHeight - top + "px";
+            el.style.bottom = (screenHeight - dims.bottom) + "px";
             popup.isTopdown = false;
         } else {
-            top += lineHeight;
-            renderer.$maxPixelHeight = screenHeight - top - 0.2 * lineHeight;
-            el.style.top = top + "px";
+            el.style.top = dims.top + "px";
             el.style.bottom = "";
             popup.isTopdown = true;
         }
@@ -290,12 +336,22 @@ var AcePopup = function(parentNode) {
             left = screenWidth - el.offsetWidth;
 
         el.style.left = left + "px";
+        el.style.right = "";
 
         if (!popup.isOpen) {
             popup.isOpen = true;
             this._signal("show");
             lastMouseEvent = null;
         }
+
+        popup.anchorPos = pos;
+        popup.anchor = anchor;
+
+        return true;
+    };
+
+    popup.show = function(pos, lineHeight, topdownOnly) {
+        this.tryShow(pos, lineHeight, topdownOnly ? "bottom" : undefined, true);
     };
 
     popup.goTo = function(where) {

From 70859cb34acfd3fddb11ef610e819af51aa61470 Mon Sep 17 00:00:00 2001
From: Mark Kercso 
Date: Thu, 16 Mar 2023 23:35:49 +0000
Subject: [PATCH 6/6] Add popup display unit tests

---
 src/autocomplete/popup.js      |  19 ++-
 src/autocomplete/popup_test.js | 217 +++++++++++++++++++++++++++++++++
 src/test/all_browser.js        |   1 +
 src/test/mockdom.js            |   2 +-
 4 files changed, 232 insertions(+), 7 deletions(-)
 create mode 100644 src/autocomplete/popup_test.js

diff --git a/src/autocomplete/popup.js b/src/autocomplete/popup.js
index a2064629027..0f163aa874a 100644
--- a/src/autocomplete/popup.js
+++ b/src/autocomplete/popup.js
@@ -291,10 +291,20 @@ var AcePopup = function(parentNode) {
         var maxH = renderer.$maxLines * lineHeight * 1.4;
         var dims = { top: 0, bottom: 0, left: 0 };
 
+        var spaceBelow = screenHeight - pos.top - 3 * this.$borderSize - lineHeight;
+        var spaceAbove = pos.top - 3 * this.$borderSize;
+        if (!anchor) {
+            if (spaceAbove <= spaceBelow || spaceBelow >= maxH) {
+                anchor = "bottom";
+            } else {
+                anchor = "top";
+            }
+        }
+
         if (anchor === "top") {
             dims.bottom = pos.top - this.$borderSize;
             dims.top = dims.bottom - maxH;
-        } else {
+        } else if (anchor === "bottom") {
             dims.top = pos.top + lineHeight + this.$borderSize;
             dims.bottom = dims.top + maxH;
         }
@@ -306,16 +316,13 @@ var AcePopup = function(parentNode) {
         }
 
         if (!fitsX) {
-            var spaceBelow = screenHeight - pos.top - this.$borderSize - 0.2 * lineHeight;
-            var spaceAbove = pos.top - this.$borderSize;
-            if (!anchor) {
-                anchor = spaceAbove > spaceBelow ? "top" : "bottom";
-            }
             if (anchor === "top") {
                 renderer.$maxPixelHeight = spaceAbove;
             } else {
                 renderer.$maxPixelHeight = spaceBelow;
             }
+        } else {
+            renderer.$maxPixelHeight = null;
         }
 
 
diff --git a/src/autocomplete/popup_test.js b/src/autocomplete/popup_test.js
new file mode 100644
index 00000000000..8c4ee962ee4
--- /dev/null
+++ b/src/autocomplete/popup_test.js
@@ -0,0 +1,217 @@
+if (typeof process !== "undefined") {
+    require("amd-loader");
+    require("../test/mockdom");
+}
+
+"use strict";
+
+var assert = require("../test/assertions");
+var AcePopup = require("./popup").AcePopup;
+
+var popup;
+var lineHeight = 14;
+var renderHeight = 8 * lineHeight;
+var renderWidth = 300;
+var iframe;
+
+var notEnoughSpaceOnRight = window.innerWidth - 50;
+var notEnoughSpaceOnBottom = window.innerHeight - 50;
+
+var originalDocument;
+
+var completions = [];
+
+for (var i = 0; i < 8; i++) {
+    completions.push({ value: "foo" + i, caption: "foo" + i, name: "foo" + i, score: 4 });
+}
+
+var tryShowAndRender = function(pos, lineHeight, anchor, forceShow) {
+    var result = popup.tryShow(pos, lineHeight, anchor, forceShow);
+    popup.renderer.updateFull(true);
+    return result;
+};
+
+
+var setupPopup = function() {
+    popup = new AcePopup(document.body);
+
+    // Mock the CSS behaviour
+    popup.container.style.width = "300px";
+    popup.setData(completions, "");
+};
+
+var tearDown = function(done) {
+    if (popup) {
+        var el = popup.container;
+        if (el && el.parentNode)
+            el.parentNode.removeChild(el);
+    }
+    if (iframe)
+        document.body.removeChild(iframe);
+    if (originalDocument) {
+        window.document = originalDocument;
+        originalDocument = null;
+    }
+    done && done();
+};
+
+module.exports = {
+    "test: verify width and height": function(done) {
+        setupPopup();
+        tryShowAndRender({ top: 0, left: 0 }, lineHeight, "bottom");
+        renderHeight = popup.container.offsetHeight;
+        assert.strictEqual(popup.isOpen, true);
+        assert.strictEqual(renderHeight > 0, true);
+        popup.hide();
+        assert.strictEqual(popup.isOpen, false);
+        done();
+    },
+    "test: tryShow does not display popup if there is not enough space on the anchor side": function(done) {
+        setupPopup();
+        var result = tryShowAndRender({ top: notEnoughSpaceOnBottom, left: 0}, lineHeight, "bottom");
+        assert.strictEqual(result, false);
+        assert.strictEqual(popup.isOpen, false);
+        result = tryShowAndRender({ top: 50, left: 0}, lineHeight, "top");
+        assert.strictEqual(result, false);
+        assert.strictEqual(popup.isOpen, false);
+        done();
+    },
+    "test: tryShow slides popup on the X axis if there are not enough space on the right": function(done) {
+        setupPopup();
+        
+        var result = tryShowAndRender({ top: 0, left: notEnoughSpaceOnRight }, lineHeight, "bottom");
+        assert.strictEqual(result, true);
+        assert.strictEqual(popup.isOpen, true);
+        assert.strictEqual(popup.container.getBoundingClientRect().right, window.innerWidth);
+        assert.strictEqual(Math.abs(popup.container.getBoundingClientRect().width - renderWidth) < 5, true);
+        popup.hide();
+        assert.strictEqual(popup.isOpen, false);
+
+        result = tryShowAndRender({ top: notEnoughSpaceOnBottom, left: notEnoughSpaceOnRight }, lineHeight, "top");
+        assert.strictEqual(result, true);
+        assert.strictEqual(popup.isOpen, true);
+        assert.strictEqual(popup.container.getBoundingClientRect().right, window.innerWidth);
+        assert.strictEqual(Math.abs(popup.container.getBoundingClientRect().width - renderWidth) < 5, true);
+        popup.hide();
+        assert.strictEqual(popup.isOpen, false);
+        done();
+    },
+    "test: tryShow called with forceShow resizes popup height to fit popup": function(done) {
+        setupPopup();
+        
+        var result = tryShowAndRender({ top: notEnoughSpaceOnBottom, left: 0 }, lineHeight, "bottom", true);
+        assert.strictEqual(result, true);
+        assert.strictEqual(popup.isOpen, true);
+        assert.notEqual(popup.container.style.display, "none");
+        assert.strictEqual(popup.container.getBoundingClientRect().height <= 50, true);
+        assert.strictEqual(popup.container.getBoundingClientRect().top > notEnoughSpaceOnBottom + lineHeight, true);
+        assert.strictEqual(Math.abs(popup.container.getBoundingClientRect().width - renderWidth) < 5, true);
+        popup.hide();
+        assert.strictEqual(popup.isOpen, false);
+
+        result = tryShowAndRender({ top: 50, left: 0 }, lineHeight, "top", true);
+        assert.strictEqual(result, true);
+        assert.strictEqual(popup.isOpen, true);
+        assert.notEqual(popup.container.style.display, "none");
+        assert.strictEqual(popup.container.getBoundingClientRect().height <= 50, true);
+        assert.strictEqual(popup.container.getBoundingClientRect().bottom <= 50, true);
+        assert.strictEqual(Math.abs(popup.container.getBoundingClientRect().width - renderWidth) < 5, true);
+        popup.hide();
+        assert.strictEqual(popup.isOpen, false);
+        done();
+    },
+    "test: show displays popup in all 4 corners correctly without topdownOnly specified": function(done) {
+        setupPopup();
+        popup.show({ top: 50, left: 0 }, lineHeight);
+        popup.renderer.updateFull(true);
+        assert.notEqual(popup.container.style.display, "none");
+        assert.strictEqual(popup.container.getBoundingClientRect().height, renderHeight);
+        assert.ok(popup.container.getBoundingClientRect().top >= 50 + lineHeight);
+        popup.hide();
+        assert.strictEqual(popup.container.style.display, "none");
+
+        popup.show({ top: notEnoughSpaceOnBottom, left: 0 }, lineHeight);
+        popup.renderer.updateFull(true);
+        assert.notEqual(popup.container.style.display, "none");
+        assert.strictEqual(popup.container.getBoundingClientRect().height, renderHeight);
+        assert.ok(popup.container.getBoundingClientRect().bottom <= notEnoughSpaceOnBottom);
+        popup.hide();
+        assert.strictEqual(popup.container.style.display, "none");
+
+        popup.show({ top: 50, left: notEnoughSpaceOnRight }, lineHeight);
+        popup.renderer.updateFull(true);
+        assert.notEqual(popup.container.style.display, "none");
+        assert.strictEqual(popup.container.getBoundingClientRect().height, renderHeight);
+        assert.ok(popup.container.getBoundingClientRect().top >= 50 + lineHeight);
+        popup.hide();
+        assert.strictEqual(popup.container.style.display, "none");
+
+        popup.show({ top: notEnoughSpaceOnBottom, left: notEnoughSpaceOnRight }, lineHeight);
+        popup.renderer.updateFull(true);
+        assert.notEqual(popup.container.style.display, "none");
+        assert.strictEqual(popup.container.getBoundingClientRect().height, renderHeight);
+        assert.ok(popup.container.getBoundingClientRect().bottom <= notEnoughSpaceOnBottom);
+        popup.hide();
+        assert.strictEqual(popup.container.style.display, "none");
+        done();
+    },
+    "test: show displays popup in all 4 corners correctly with topdownOnly specified": function(done) {
+        setupPopup();
+        popup.show({ top: 50, left: 0 }, lineHeight, true);
+        popup.renderer.updateFull(true);
+        assert.notEqual(popup.container.style.display, "none");
+        assert.strictEqual(popup.container.getBoundingClientRect().height, renderHeight);
+        assert.ok(popup.container.getBoundingClientRect().top >= 50 + lineHeight);
+        popup.hide();
+        assert.strictEqual(popup.container.style.display, "none");
+
+        popup.show({ top: 50, left: notEnoughSpaceOnRight }, lineHeight);
+        popup.renderer.updateFull(true);
+        assert.notEqual(popup.container.style.display, "none");
+        assert.strictEqual(popup.container.getBoundingClientRect().height, renderHeight);
+        assert.ok(popup.container.getBoundingClientRect().top >= 50 + lineHeight);
+        popup.hide();
+        assert.strictEqual(popup.container.style.display, "none");
+
+        popup.show({ top: notEnoughSpaceOnBottom, left: 0 }, lineHeight, true);
+        popup.renderer.updateFull(true);
+        assert.notEqual(popup.container.style.display, "none");
+        assert.ok(popup.container.getBoundingClientRect().height <= 50);
+        assert.ok(popup.container.getBoundingClientRect().top >= notEnoughSpaceOnBottom + lineHeight);
+        popup.hide();
+        assert.strictEqual(popup.container.style.display, "none");
+
+        popup.show({ top: notEnoughSpaceOnBottom, left: notEnoughSpaceOnRight }, lineHeight, true);
+        popup.renderer.updateFull(true);
+        assert.notEqual(popup.container.style.display, "none");
+        assert.ok(popup.container.getBoundingClientRect().height <= 50);
+        assert.ok(popup.container.getBoundingClientRect().top >= notEnoughSpaceOnBottom + lineHeight);
+        popup.hide();
+        assert.strictEqual(popup.container.style.display, "none");
+        done();
+    },
+    "test: resets popup size if space is available again": function(done) {
+        setupPopup();
+        popup.show({ top: notEnoughSpaceOnBottom, left: notEnoughSpaceOnRight }, lineHeight, true);
+        popup.renderer.updateFull(true);
+        assert.notEqual(popup.container.style.display, "none");
+        assert.ok(popup.container.getBoundingClientRect().height <= 50);
+        assert.ok(popup.container.getBoundingClientRect().top >= notEnoughSpaceOnBottom + lineHeight);
+        popup.hide();
+        assert.strictEqual(popup.container.style.display, "none");
+
+        popup.show({ top: 50, left: notEnoughSpaceOnRight }, lineHeight);
+        popup.renderer.updateFull(true);
+        assert.notEqual(popup.container.style.display, "none");
+        assert.ok(popup.container.getBoundingClientRect().height, renderHeight);
+        assert.ok(popup.container.getBoundingClientRect().top >= 50 + lineHeight);
+        popup.hide();
+        assert.strictEqual(popup.container.style.display, "none");
+        done();
+    },
+    tearDown: tearDown
+};
+
+if (typeof module !== "undefined" && module === require.main) {
+    require("asyncjs").test.testcase(module.exports).exec();
+}
diff --git a/src/test/all_browser.js b/src/test/all_browser.js
index 3455eaf7987..944889212b2 100644
--- a/src/test/all_browser.js
+++ b/src/test/all_browser.js
@@ -13,6 +13,7 @@ var el = document.createElement.bind(document);
 var testNames = [
     "ace/ace_test",
     "ace/anchor_test",
+    "ace/autocomplete/popup_test",
     "ace/autocomplete_test",
     "ace/background_tokenizer_test",
     "ace/commands/command_manager_test",
diff --git a/src/test/mockdom.js b/src/test/mockdom.js
index e0c65ddddae..fa81e9d692c 100644
--- a/src/test/mockdom.js
+++ b/src/test/mockdom.js
@@ -375,7 +375,7 @@ function Node(name) {
             else
                 width = rect.width - right - left;
             
-            if (this.style.width)
+            if (this.style.height)
                 height = parseCssLength(this.style.height || "100%", rect.height);
             else
                 height = rect.height - top - bottom;