blob: 9092fef115fd11b881ff24172b7aa7a7eea95089 [file] [log] [blame]
// Copyright (c) 2014 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* @implements {UI.SuggestBoxDelegate}
* @unrestricted
*/
export class TextEditorAutocompleteController {
/**
* @param {!TextEditor.CodeMirrorTextEditor} textEditor
* @param {!CodeMirror} codeMirror
* @param {!UI.AutocompleteConfig} config
*/
constructor(textEditor, codeMirror, config) {
this._textEditor = textEditor;
this._codeMirror = codeMirror;
this._config = config;
this._initialized = false;
this._onScroll = this._onScroll.bind(this);
this._onCursorActivity = this._onCursorActivity.bind(this);
this._changes = this._changes.bind(this);
this._blur = this._blur.bind(this);
this._beforeChange = this._beforeChange.bind(this);
this._mouseDown = () => {
this.clearAutocomplete();
this._tooltipGlassPane.hide();
};
this._codeMirror.on('changes', this._changes);
this._lastHintText = '';
/** @type {?UI.SuggestBox} */
this._suggestBox = null;
/** @type {?UI.SuggestBox.Suggestion} */
this._currentSuggestion = null;
this._hintElement = createElementWithClass('span', 'auto-complete-text');
this._tooltipGlassPane = new UI.GlassPane();
this._tooltipGlassPane.setSizeBehavior(UI.GlassPane.SizeBehavior.MeasureContent);
this._tooltipGlassPane.setOutsideClickCallback(this._tooltipGlassPane.hide.bind(this._tooltipGlassPane));
this._tooltipElement = createElementWithClass('div', 'autocomplete-tooltip');
const shadowRoot =
UI.createShadowRootWithCoreStyles(this._tooltipGlassPane.contentElement, 'text_editor/autocompleteTooltip.css');
shadowRoot.appendChild(this._tooltipElement);
}
_initializeIfNeeded() {
if (this._initialized) {
return;
}
this._initialized = true;
this._codeMirror.on('scroll', this._onScroll);
this._codeMirror.on('cursorActivity', this._onCursorActivity);
this._codeMirror.on('mousedown', this._mouseDown);
this._codeMirror.on('blur', this._blur);
if (this._config.isWordChar) {
this._codeMirror.on('beforeChange', this._beforeChange);
this._dictionary = new Common.TextDictionary();
this._addWordsFromText(this._codeMirror.getValue());
}
}
dispose() {
this._codeMirror.off('changes', this._changes);
if (this._initialized) {
this._codeMirror.off('scroll', this._onScroll);
this._codeMirror.off('cursorActivity', this._onCursorActivity);
this._codeMirror.off('mousedown', this._mouseDown);
this._codeMirror.off('blur', this._blur);
}
if (this._dictionary) {
this._codeMirror.off('beforeChange', this._beforeChange);
this._dictionary.reset();
}
}
/**
* @param {!CodeMirror} codeMirror
* @param {!CodeMirror.BeforeChangeObject} changeObject
*/
_beforeChange(codeMirror, changeObject) {
this._updatedLines = this._updatedLines || {};
for (let i = changeObject.from.line; i <= changeObject.to.line; ++i) {
if (this._updatedLines[i] === undefined) {
this._updatedLines[i] = this._codeMirror.getLine(i);
}
}
}
/**
* @param {string} text
*/
_addWordsFromText(text) {
TextUtils.TextUtils.textToWords(
text, /** @type {function(string):boolean} */ (this._config.isWordChar), addWord.bind(this));
/**
* @param {string} word
* @this {TextEditorAutocompleteController}
*/
function addWord(word) {
if (word.length && (word[0] < '0' || word[0] > '9')) {
this._dictionary.addWord(word);
}
}
}
/**
* @param {string} text
*/
_removeWordsFromText(text) {
TextUtils.TextUtils.textToWords(
text, /** @type {function(string):boolean} */ (this._config.isWordChar),
word => this._dictionary.removeWord(word));
}
/**
* @param {number} lineNumber
* @param {number} columnNumber
* @return {?TextUtils.TextRange}
*/
_substituteRange(lineNumber, columnNumber) {
let range =
this._config.substituteRangeCallback ? this._config.substituteRangeCallback(lineNumber, columnNumber) : null;
if (!range && this._config.isWordChar) {
range = this._textEditor.wordRangeForCursorPosition(lineNumber, columnNumber, this._config.isWordChar);
}
return range;
}
/**
* @param {!TextUtils.TextRange} queryRange
* @param {!TextUtils.TextRange} substituteRange
* @param {boolean=} force
* @return {!Promise.<!UI.SuggestBox.Suggestions>}
*/
_wordsWithQuery(queryRange, substituteRange, force) {
const external =
this._config.suggestionsCallback ? this._config.suggestionsCallback(queryRange, substituteRange, force) : null;
if (external) {
return external;
}
if (!this._dictionary || (!force && queryRange.isEmpty())) {
return Promise.resolve([]);
}
let completions = this._dictionary.wordsWithPrefix(this._textEditor.text(queryRange));
const substituteWord = this._textEditor.text(substituteRange);
if (this._dictionary.wordCount(substituteWord) === 1) {
completions = completions.filter(word => word !== substituteWord);
}
completions.sort((a, b) => this._dictionary.wordCount(b) - this._dictionary.wordCount(a) || a.length - b.length);
return Promise.resolve(completions.map(item => ({text: item})));
}
/**
* @param {!CodeMirror} codeMirror
* @param {!Array.<!CodeMirror.ChangeObject>} changes
*/
_changes(codeMirror, changes) {
if (!changes.length) {
return;
}
if (this._dictionary && this._updatedLines) {
for (const lineNumber in this._updatedLines) {
this._removeWordsFromText(this._updatedLines[lineNumber]);
}
delete this._updatedLines;
const linesToUpdate = {};
for (let changeIndex = 0; changeIndex < changes.length; ++changeIndex) {
const changeObject = changes[changeIndex];
const editInfo = TextEditor.CodeMirrorUtils.changeObjectToEditOperation(changeObject);
for (let i = editInfo.newRange.startLine; i <= editInfo.newRange.endLine; ++i) {
linesToUpdate[i] = this._codeMirror.getLine(i);
}
}
for (const lineNumber in linesToUpdate) {
this._addWordsFromText(linesToUpdate[lineNumber]);
}
}
let singleCharInput = false;
let singleCharDelete = false;
const cursor = this._codeMirror.getCursor('head');
for (let changeIndex = 0; changeIndex < changes.length; ++changeIndex) {
const changeObject = changes[changeIndex];
if (changeObject.origin === '+input' && changeObject.text.length === 1 && changeObject.text[0].length === 1 &&
changeObject.to.line === cursor.line && changeObject.to.ch + 1 === cursor.ch) {
singleCharInput = true;
break;
}
if (changeObject.origin === '+delete' && changeObject.removed.length === 1 &&
changeObject.removed[0].length === 1 && changeObject.to.line === cursor.line &&
changeObject.to.ch - 1 === cursor.ch) {
singleCharDelete = true;
break;
}
}
if (this._queryRange) {
if (singleCharInput) {
this._queryRange.endColumn++;
} else if (singleCharDelete) {
this._queryRange.endColumn--;
}
if (singleCharDelete || singleCharInput) {
this._setHint(this._lastHintText);
}
}
if (singleCharInput || singleCharDelete) {
setImmediate(this.autocomplete.bind(this));
} else {
this.clearAutocomplete();
}
}
_blur() {
this.clearAutocomplete();
}
/**
* @param {!TextUtils.TextRange} mainSelection
* @return {boolean}
*/
_validateSelectionsContexts(mainSelection) {
const selections = this._codeMirror.listSelections();
if (selections.length <= 1) {
return true;
}
const mainSelectionContext = this._textEditor.text(mainSelection);
for (let i = 0; i < selections.length; ++i) {
const wordRange = this._substituteRange(selections[i].head.line, selections[i].head.ch);
if (!wordRange) {
return false;
}
const context = this._textEditor.text(wordRange);
if (context !== mainSelectionContext) {
return false;
}
}
return true;
}
/**
* @param {boolean=} force
*/
autocomplete(force) {
this._initializeIfNeeded();
if (this._codeMirror.somethingSelected()) {
this._hideSuggestBox();
return;
}
const cursor = this._codeMirror.getCursor('head');
const substituteRange = this._substituteRange(cursor.line, cursor.ch);
if (!substituteRange || !this._validateSelectionsContexts(substituteRange)) {
this._hideSuggestBox();
return;
}
const queryRange = substituteRange.clone();
queryRange.endColumn = cursor.ch;
const query = this._textEditor.text(queryRange);
let hadSuggestBox = false;
if (this._suggestBox) {
hadSuggestBox = true;
}
this._wordsWithQuery(queryRange, substituteRange, force).then(wordsAcquired.bind(this));
/**
* @param {!UI.SuggestBox.Suggestions} wordsWithQuery
* @this {TextEditorAutocompleteController}
*/
function wordsAcquired(wordsWithQuery) {
if (!wordsWithQuery.length || (wordsWithQuery.length === 1 && query === wordsWithQuery[0].text) ||
(!this._suggestBox && hadSuggestBox)) {
this._hideSuggestBox();
this._onSuggestionsShownForTest([]);
return;
}
if (!this._suggestBox) {
this._suggestBox = new UI.SuggestBox(this, 20);
if (this._config.anchorBehavior) {
this._suggestBox.setAnchorBehavior(this._config.anchorBehavior);
}
}
const oldQueryRange = this._queryRange;
this._queryRange = queryRange;
if (!oldQueryRange || queryRange.startLine !== oldQueryRange.startLine ||
queryRange.startColumn !== oldQueryRange.startColumn) {
this._updateAnchorBox();
}
this._suggestBox.updateSuggestions(this._anchorBox, wordsWithQuery, true, !this._isCursorAtEndOfLine(), query);
if (this._suggestBox.visible) {
this._tooltipGlassPane.hide();
}
this._onSuggestionsShownForTest(wordsWithQuery);
}
}
/**
* @param {string} hint
*/
_setHint(hint) {
const query = this._textEditor.text(this._queryRange);
if (!hint || !this._isCursorAtEndOfLine() || !hint.startsWith(query)) {
this._clearHint();
return;
}
const suffix = hint.substring(query.length).split('\n')[0];
this._hintElement.textContent = suffix.trimEndWithMaxLength(10000);
const cursor = this._codeMirror.getCursor('to');
if (this._hintMarker) {
const position = this._hintMarker.position();
if (!position || !position.equal(TextUtils.TextRange.createFromLocation(cursor.line, cursor.ch))) {
this._hintMarker.clear();
this._hintMarker = null;
}
}
if (!this._hintMarker) {
this._hintMarker = this._textEditor.addBookmark(
cursor.line, cursor.ch, this._hintElement, TextEditorAutocompleteController.HintBookmark, true);
} else if (this._lastHintText !== hint) {
this._hintMarker.refresh();
}
this._lastHintText = hint;
}
_clearHint() {
if (!this._hintElement.textContent) {
return;
}
this._lastHintText = '';
this._hintElement.textContent = '';
if (this._hintMarker) {
this._hintMarker.refresh();
}
}
/**
* @param {!UI.SuggestBox.Suggestions} suggestions
*/
_onSuggestionsShownForTest(suggestions) {
}
_onSuggestionsHiddenForTest() {
}
clearAutocomplete() {
this._tooltipGlassPane.hide();
this._hideSuggestBox();
}
_hideSuggestBox() {
if (!this._suggestBox) {
return;
}
this._suggestBox.hide();
this._suggestBox = null;
this._queryRange = null;
this._anchorBox = null;
this._currentSuggestion = null;
this._textEditor.dispatchEventToListeners(UI.TextEditor.Events.SuggestionChanged);
this._clearHint();
this._onSuggestionsHiddenForTest();
}
/**
* @param {!KeyboardEvent} event
* @return {boolean}
*/
keyDown(event) {
if (this._tooltipGlassPane.isShowing() && event.keyCode === UI.KeyboardShortcut.Keys.Esc.code) {
this._tooltipGlassPane.hide();
return true;
}
if (!this._suggestBox) {
return false;
}
switch (event.keyCode) {
case UI.KeyboardShortcut.Keys.Tab.code:
this._suggestBox.acceptSuggestion();
this.clearAutocomplete();
return true;
case UI.KeyboardShortcut.Keys.End.code:
case UI.KeyboardShortcut.Keys.Right.code:
if (this._isCursorAtEndOfLine()) {
this._suggestBox.acceptSuggestion();
this.clearAutocomplete();
return true;
} else {
this.clearAutocomplete();
return false;
}
case UI.KeyboardShortcut.Keys.Left.code:
case UI.KeyboardShortcut.Keys.Home.code:
this.clearAutocomplete();
return false;
case UI.KeyboardShortcut.Keys.Esc.code:
this.clearAutocomplete();
return true;
}
return this._suggestBox.keyPressed(event);
}
/**
* @return {boolean}
*/
_isCursorAtEndOfLine() {
const cursor = this._codeMirror.getCursor('to');
return cursor.ch === this._codeMirror.getLine(cursor.line).length;
}
/**
* @override
* @param {?UI.SuggestBox.Suggestion} suggestion
* @param {boolean=} isIntermediateSuggestion
*/
applySuggestion(suggestion, isIntermediateSuggestion) {
const oldSuggestion = this._currentSuggestion;
this._currentSuggestion = suggestion;
this._setHint(suggestion ? suggestion.text : '');
if ((oldSuggestion ? oldSuggestion.text : '') !== (suggestion ? suggestion.text : '')) {
this._textEditor.dispatchEventToListeners(UI.TextEditor.Events.SuggestionChanged);
}
}
/**
* @override
*/
acceptSuggestion() {
const selections = this._codeMirror.listSelections().slice();
const queryLength = this._queryRange.endColumn - this._queryRange.startColumn;
const suggestion = this._currentSuggestion.text;
this._codeMirror.operation(() => {
for (let i = selections.length - 1; i >= 0; --i) {
const start = selections[i].head;
const end = new CodeMirror.Pos(start.line, start.ch - queryLength);
this._codeMirror.replaceRange(suggestion, start, end, '+autocomplete');
}
});
}
/**
* @return {string}
*/
textWithCurrentSuggestion() {
if (!this._queryRange || this._currentSuggestion === null) {
return this._codeMirror.getValue();
}
const selections = this._codeMirror.listSelections().slice();
let last = {line: 0, column: 0};
let text = '';
const queryLength = this._queryRange.endColumn - this._queryRange.startColumn;
for (const selection of selections) {
const range =
new TextUtils.TextRange(last.line, last.column, selection.head.line, selection.head.ch - queryLength);
text += this._textEditor.text(range);
text += this._currentSuggestion.text;
last = {line: selection.head.line, column: selection.head.ch};
}
const range = new TextUtils.TextRange(last.line, last.column, Infinity, Infinity);
text += this._textEditor.text(range);
return text;
}
_onScroll() {
this._tooltipGlassPane.hide();
if (!this._suggestBox) {
return;
}
const cursor = this._codeMirror.getCursor();
const scrollInfo = this._codeMirror.getScrollInfo();
const topmostLineNumber = this._codeMirror.lineAtHeight(scrollInfo.top, 'local');
const bottomLine = this._codeMirror.lineAtHeight(scrollInfo.top + scrollInfo.clientHeight, 'local');
if (cursor.line < topmostLineNumber || cursor.line > bottomLine) {
this.clearAutocomplete();
} else {
this._updateAnchorBox();
this._suggestBox.setPosition(this._anchorBox);
}
}
async _updateTooltip() {
const cursor = this._codeMirror.getCursor();
const tooltip = this._config.tooltipCallback ? await this._config.tooltipCallback(cursor.line, cursor.ch) : null;
const newCursor = this._codeMirror.getCursor();
if (newCursor.line !== cursor.line && newCursor.ch !== cursor.ch) {
return;
}
if (this._suggestBox && this._suggestBox.visible) {
return;
}
if (!tooltip) {
this._tooltipGlassPane.hide();
return;
}
const metrics = this._textEditor.cursorPositionToCoordinates(cursor.line, cursor.ch);
if (!metrics) {
this._tooltipGlassPane.hide();
return;
}
this._tooltipGlassPane.setContentAnchorBox(new AnchorBox(metrics.x, metrics.y, 0, metrics.height));
this._tooltipElement.removeChildren();
this._tooltipElement.appendChild(tooltip);
this._tooltipGlassPane.show(/** @type {!Document} */ (this._textEditor.element.ownerDocument));
}
_onCursorActivity() {
this._updateTooltip();
if (!this._suggestBox) {
return;
}
const cursor = this._codeMirror.getCursor();
let shouldCloseAutocomplete =
!(cursor.line === this._queryRange.startLine && this._queryRange.startColumn <= cursor.ch &&
cursor.ch <= this._queryRange.endColumn);
// Try not to hide autocomplete when user types in.
if (cursor.line === this._queryRange.startLine && cursor.ch === this._queryRange.endColumn + 1) {
const line = this._codeMirror.getLine(cursor.line);
shouldCloseAutocomplete = this._config.isWordChar ? !this._config.isWordChar(line.charAt(cursor.ch - 1)) : false;
}
if (shouldCloseAutocomplete) {
this.clearAutocomplete();
}
this._onCursorActivityHandledForTest();
}
_onCursorActivityHandledForTest() {
}
_updateAnchorBox() {
const line = this._queryRange.startLine;
const column = this._queryRange.startColumn;
const metrics = this._textEditor.cursorPositionToCoordinates(line, column);
this._anchorBox = metrics ? new AnchorBox(metrics.x, metrics.y, 0, metrics.height) : null;
}
}
TextEditorAutocompleteController.HintBookmark = Symbol('hint');
/* Legacy exported object */
self.TextEditor = self.TextEditor || {};
/* Legacy exported object */
TextEditor = TextEditor || {};
/** @constructor */
TextEditor.TextEditorAutocompleteController = TextEditorAutocompleteController;