Merge pull request #124745 from microsoft/alex/ghost-text

More Ghost Text Improvements
This commit is contained in:
Henning Dieterichs 2021-05-28 09:21:14 +02:00 committed by GitHub
commit b2da15ea2d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 312 additions and 120 deletions

View file

@ -458,6 +458,55 @@ export class CursorColumns {
return result;
}
/**
* Returns an array that maps one based columns to one based visible columns. The entry at position 0 is -1.
*/
public static visibleColumnsByColumns(lineContent: string, tabSize: number): number[] {
const endOffset = lineContent.length;
let result = new Array<number>();
result.push(-1);
let pos = 0;
let i = 0;
while (i < endOffset) {
const codePoint = strings.getNextCodePoint(lineContent, endOffset, i);
i += (codePoint >= Constants.UNICODE_SUPPLEMENTARY_PLANE_BEGIN ? 2 : 1);
result.push(pos);
if (codePoint >= Constants.UNICODE_SUPPLEMENTARY_PLANE_BEGIN) {
result.push(pos);
}
if (codePoint === CharCode.Tab) {
pos = CursorColumns.nextRenderTabStop(pos, tabSize);
} else {
let graphemeBreakType = strings.getGraphemeBreakType(codePoint);
while (i < endOffset) {
const nextCodePoint = strings.getNextCodePoint(lineContent, endOffset, i);
const nextGraphemeBreakType = strings.getGraphemeBreakType(nextCodePoint);
if (strings.breakBetweenGraphemeBreakType(graphemeBreakType, nextGraphemeBreakType)) {
break;
}
i += (nextCodePoint >= Constants.UNICODE_SUPPLEMENTARY_PLANE_BEGIN ? 2 : 1);
result.push(pos);
if (codePoint >= Constants.UNICODE_SUPPLEMENTARY_PLANE_BEGIN) {
result.push(pos);
}
graphemeBreakType = nextGraphemeBreakType;
}
if (strings.isFullWidthCharacter(codePoint) || strings.isEmojiImprecise(codePoint)) {
pos = pos + 2;
} else {
pos = pos + 1;
}
}
}
result.push(pos);
return result;
}
public static toStatusbarColumn(lineContent: string, column: number, tabSize: number): number {
const lineContentLength = lineContent.length;
const endOffset = column - 1 < lineContentLength ? column - 1 : lineContentLength;

View file

@ -15,11 +15,13 @@ import { InlineCompletionsModel } from 'vs/editor/contrib/inlineCompletions/inli
import { SuggestWidgetAdapterModel } from 'vs/editor/contrib/inlineCompletions/suggestWidgetAdapterModel';
import * as nls from 'vs/nls';
import { ICommandService } from 'vs/platform/commands/common/commands';
import { IContextKeyService, RawContextKey } from 'vs/platform/contextkey/common/contextkey';
import { ContextKeyExpr, IContextKeyService, RawContextKey } from 'vs/platform/contextkey/common/contextkey';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
export class GhostTextController extends Disposable {
public static readonly inlineCompletionsVisible = new RawContextKey<boolean>('inlineCompletionsVisible ', false, nls.localize('inlineCompletionsVisible', "Whether inline suggestions are visible"));
public static readonly inlineCompletionSuggestsIndentation = new RawContextKey<boolean>('inlineCompletionSuggestsIndentation', false, nls.localize('inlineCompletionSuggestsIndentation', "Whether the inline suggestion suggests extending indentation"));
static ID = 'editor.contrib.ghostTextController';
public static get(editor: ICodeEditor): GhostTextController {
@ -88,6 +90,7 @@ export class GhostTextController extends Disposable {
class GhostTextContextKeys {
public readonly inlineCompletionVisible = GhostTextController.inlineCompletionsVisible.bindTo(this.contextKeyService);
public readonly inlineCompletionSuggestsIndentation = GhostTextController.inlineCompletionSuggestsIndentation.bindTo(this.contextKeyService);
constructor(private readonly contextKeyService: IContextKeyService) {
}
@ -130,6 +133,21 @@ export class ActiveGhostTextController extends Disposable {
this.widget.model === this.inlineCompletionsModel
&& this.inlineCompletionsModel.ghostText !== undefined
);
if (this.inlineCompletionsModel.ghostText) {
const firstLine = this.inlineCompletionsModel.ghostText.lines[0] || '';
const suggestionStartsWithWs = firstLine.startsWith(' ') || firstLine.startsWith('\t');
const p = this.inlineCompletionsModel.ghostText.position;
const indentationEndColumn = this.editor.getModel().getLineIndentColumn(p.lineNumber);
const inIndentation = p.column <= indentationEndColumn;
this.contextKeys.inlineCompletionSuggestsIndentation.set(
this.widget.model === this.inlineCompletionsModel
&& suggestionStartsWithWs && inIndentation
);
} else {
this.contextKeys.inlineCompletionSuggestsIndentation.set(false);
}
}
public shouldShowHoverAt(hoverRange: Range): boolean {
@ -182,7 +200,10 @@ const GhostTextCommand = EditorCommand.bindToContribution(GhostTextController.ge
registerEditorCommand(new GhostTextCommand({
id: 'commitInlineCompletion',
precondition: GhostTextController.inlineCompletionsVisible,
precondition: ContextKeyExpr.and(
GhostTextController.inlineCompletionsVisible,
GhostTextController.inlineCompletionSuggestsIndentation.toNegated()
),
kbOpts: {
weight: 100,
primary: KeyCode.Tab,

View file

@ -21,6 +21,8 @@ import { IModelDeltaDecoration } from 'vs/editor/common/model';
import { IThemeService, registerThemingParticipant } from 'vs/platform/theme/common/themeService';
import { editorSuggestPreviewBorder, editorSuggestPreviewOpacity } from 'vs/editor/common/view/editorColorRegistry';
import { RGBA, Color } from 'vs/base/common/color';
import { MarkdownString } from 'vs/base/common/htmlContent';
import { CursorColumns } from 'vs/editor/common/controller/cursorCommon';
const ttPolicy = window.trustedTypes?.createPolicy('editorGhostText', { createHTML: value => value });
@ -149,8 +151,8 @@ export class GhostTextWidget extends Disposable {
if (renderData) {
const suggestPreviewForeground = this._themeService.getColorTheme().getColor(editorSuggestPreviewOpacity);
let opacity = '0.467';
let color = 'white';
let opacity: string | undefined = undefined;
let color: string | undefined = undefined;
if (suggestPreviewForeground) {
function opaque(color: Color): Color {
const { r, b, g } = color.rgba;
@ -162,10 +164,18 @@ export class GhostTextWidget extends Disposable {
}
// We add 0 to bring it before any other decoration.
this.codeEditorDecorationTypeKey = `0-ghost-text-${++GhostTextWidget.decorationTypeCount}`;
const line = this.editor.getModel()?.getLineContent(renderData.position.lineNumber) || '';
const linePrefix = line.substr(0, renderData.position.column - 1);
const opts = this.editor.getOptions();
const renderWhitespace = opts.get(EditorOption.renderWhitespace);
const contentText = renderSingleLineText(renderData.lines[0] || '', linePrefix, renderData.tabSize, renderWhitespace === 'all');
this._codeEditorService.registerDecorationType('ghost-text', this.codeEditorDecorationTypeKey, {
after: {
// TODO: escape?
contentText: renderData.lines[0],
contentText,
opacity,
color,
},
@ -305,6 +315,41 @@ export class GhostTextWidget extends Disposable {
}
}
function renderSingleLineText(text: string, lineStart: string, tabSize: number, renderWhitespace: boolean): string {
const newLine = lineStart + text;
const visibleColumnsByColumns = CursorColumns.visibleColumnsByColumns(newLine, tabSize);
let contentText = '';
let curCol = lineStart.length + 1;
for (const c of text) {
if (c === '\t') {
const width = visibleColumnsByColumns[curCol + 1] - visibleColumnsByColumns[curCol];
if (renderWhitespace) {
contentText += '→';
for (let i = 1; i < width; i++) {
contentText += '\xa0';
}
} else {
for (let i = 0; i < width; i++) {
contentText += '\xa0';
}
}
} else if (c === ' ') {
if (renderWhitespace) {
contentText += '·';
} else {
contentText += '\xa0';
}
} else {
contentText += c;
}
curCol += 1;
}
return contentText;
}
class ViewMoreLinesContentWidget extends Disposable implements IContentWidget {
readonly allowEditorOverflow = false;
readonly suppressMouseDown = false;
@ -340,7 +385,6 @@ class ViewMoreLinesContentWidget extends Disposable implements IContentWidget {
}
registerThemingParticipant((theme, collector) => {
const suggestPreviewForeground = theme.getColor(editorSuggestPreviewOpacity);
if (suggestPreviewForeground) {

View file

@ -37,6 +37,7 @@ export class InlineCompletionsModel extends Disposable implements GhostTextWidge
this.hide();
}
setTimeout(() => {
// Wait for the cursor update that happens in the same iteration loop iteration
this.startSessionIfTriggered();
}, 0);
}));
@ -71,7 +72,7 @@ export class InlineCompletionsModel extends Disposable implements GhostTextWidge
public setActive(active: boolean) {
this.active = active;
if (active) {
this.session?.scheduleUpdate();
this.session?.scheduleAutomaticUpdate();
}
}
@ -101,44 +102,25 @@ export class InlineCompletionsModel extends Disposable implements GhostTextWidge
}
public commitCurrentSuggestion(): void {
if (this.session) {
this.session.commitCurrentCompletion();
}
this.session?.commitCurrentCompletion();
}
public showNextInlineCompletion(): void {
if (this.session) {
this.session.showNextInlineCompletion();
}
this.session?.showNextInlineCompletion();
}
public showPreviousInlineCompletion(): void {
if (this.session) {
this.session.showPreviousInlineCompletion();
}
}
}
class CachedInlineCompletion {
public readonly semanticId: string = JSON.stringify(this.inlineCompletion);
public lastRange: Range;
constructor(
public readonly inlineCompletion: LiveInlineCompletion,
public readonly decorationId: string,
) {
this.lastRange = inlineCompletion.range;
this.session?.showPreviousInlineCompletion();
}
}
class InlineCompletionsSession extends BaseGhostTextWidgetModel {
public readonly minReservedLineCount = 0;
private updatePromise: CancelablePromise<LiveInlineCompletions> | undefined = undefined;
private cachedCompletions: CachedInlineCompletion[] | undefined = undefined;
private cachedCompletionsSource: LiveInlineCompletions | undefined = undefined;
private readonly updateOperation = this._register(new MutableDisposable<UpdateOperation>());
private readonly cache = this._register(new MutableDisposable<SynchronizedInlineCompletionsCache>());
private updateSoon = this._register(new RunOnceScheduler(() => this.update(), 50));
private updateSoon = this._register(new RunOnceScheduler(() => this.update(InlineCompletionTriggerKind.Automatic), 50));
private readonly textModel = this.editor.getModel();
constructor(
@ -148,10 +130,6 @@ class InlineCompletionsSession extends BaseGhostTextWidgetModel {
private readonly commandService: ICommandService,
) {
super(editor);
this._register(toDisposable(() => {
this.clearGhostTextPromise();
this.clearCache();
}));
let lastCompletionItem: InlineCompletion | undefined = undefined;
this._register(this.onDidChange(() => {
@ -166,33 +144,29 @@ class InlineCompletionsSession extends BaseGhostTextWidgetModel {
}
}));
this._register(this.editor.onDidChangeModelDecorations(e => {
if (!this.cachedCompletions) {
return;
}
let hasChanged = false;
for (const c of this.cachedCompletions) {
const newRange = this.textModel.getDecorationRange(c.decorationId);
if (!newRange) {
onUnexpectedError(new Error('Decoration has no range'));
continue;
}
if (!c.lastRange.equalsRange(newRange)) {
hasChanged = true;
c.lastRange = newRange;
}
}
if (hasChanged) {
this.onDidChangeEmitter.fire();
}
}));
this._register(this.editor.onDidChangeModelContent((e) => {
this.updateSoon.schedule();
if (this.cache.value) {
let hasChanged = false;
for (const c of this.cache.value.completions) {
const newRange = this.textModel.getDecorationRange(c.decorationId);
if (!newRange) {
onUnexpectedError(new Error('Decoration has no range'));
continue;
}
if (!c.synchronizedRange.equalsRange(newRange)) {
hasChanged = true;
c.synchronizedRange = newRange;
}
}
if (hasChanged) {
this.onDidChangeEmitter.fire();
}
}
this.scheduleAutomaticUpdate();
}));
this.updateSoon.schedule();
this.scheduleAutomaticUpdate();
}
//#region Selection
@ -200,41 +174,71 @@ class InlineCompletionsSession extends BaseGhostTextWidgetModel {
// We use a semantic id to track the selection even if the cache changes.
private currentlySelectedCompletionId: string | undefined = undefined;
private getIndexOfCurrentSelection(): number {
if (!this.currentlySelectedCompletionId || !this.cachedCompletions) {
private fixAndGetIndexOfCurrentSelection(): number {
if (!this.currentlySelectedCompletionId || !this.cache.value) {
return 0;
}
if (this.cache.value.completions.length === 0) {
// don't reset the selection in this case
return 0;
}
return this.cachedCompletions.findIndex(v => v.semanticId === this.currentlySelectedCompletionId);
const idx = this.cache.value.completions.findIndex(v => v.semanticId === this.currentlySelectedCompletionId);
if (idx === -1) {
// Reset the selection so that the selection does not jump back when it appears again
this.currentlySelectedCompletionId = undefined;
return 0;
}
return idx;
}
private get currentCachedCompletion(): CachedInlineCompletion | undefined {
if (!this.cachedCompletions) {
if (!this.cache.value) {
return undefined;
}
return this.cachedCompletions[this.getIndexOfCurrentSelection()];
return this.cache.value.completions[this.fixAndGetIndexOfCurrentSelection()];
}
public showNextInlineCompletion(): void {
if (this.cachedCompletions && this.cachedCompletions.length > 0) {
const newIdx = (this.getIndexOfCurrentSelection() + 1) % this.cachedCompletions.length;
this.currentlySelectedCompletionId = this.cachedCompletions[newIdx].semanticId;
public async showNextInlineCompletion(): Promise<void> {
await this.ensureUpdateWithExplicitContext();
const completions = this.cache.value?.completions || [];
if (completions.length > 0) {
const newIdx = (this.fixAndGetIndexOfCurrentSelection() + 1) % completions.length;
this.currentlySelectedCompletionId = completions[newIdx].semanticId;
} else {
this.currentlySelectedCompletionId = undefined;
}
this.onDidChangeEmitter.fire();
}
public showPreviousInlineCompletion(): void {
if (this.cachedCompletions && this.cachedCompletions.length > 0) {
const newIdx = (this.getIndexOfCurrentSelection() + this.cachedCompletions.length - 1) % this.cachedCompletions.length;
this.currentlySelectedCompletionId = this.cachedCompletions[newIdx].semanticId;
public async showPreviousInlineCompletion(): Promise<void> {
await this.ensureUpdateWithExplicitContext();
const completions = this.cache.value?.completions || [];
if (completions.length > 0) {
const newIdx = (this.fixAndGetIndexOfCurrentSelection() + completions.length - 1) % completions.length;
this.currentlySelectedCompletionId = completions[newIdx].semanticId;
} else {
this.currentlySelectedCompletionId = undefined;
}
this.onDidChangeEmitter.fire();
}
private async ensureUpdateWithExplicitContext(): Promise<void> {
if (this.updateOperation.value) {
// Restart or wait for current update operation
if (this.updateOperation.value.triggerKind === InlineCompletionTriggerKind.Explicit) {
await this.updateOperation.value.promise;
} else {
await this.update(InlineCompletionTriggerKind.Explicit);
}
} else if (this.cache.value?.triggerKind !== InlineCompletionTriggerKind.Explicit) {
// Refresh cache
await this.update(InlineCompletionTriggerKind.Explicit);
}
}
//#endregion
public get ghostText(): GhostText | undefined {
@ -249,7 +253,7 @@ class InlineCompletionsSession extends BaseGhostTextWidgetModel {
}
return {
text: completion.inlineCompletion.text,
range: completion.lastRange,
range: completion.synchronizedRange,
command: completion.inlineCompletion.command,
sourceProvider: completion.inlineCompletion.sourceProvider,
sourceInlineCompletions: completion.inlineCompletion.sourceInlineCompletions,
@ -261,61 +265,47 @@ class InlineCompletionsSession extends BaseGhostTextWidgetModel {
return this.editor.getPosition().lineNumber === this.triggerPosition.lineNumber;
}
public scheduleUpdate(): void {
public scheduleAutomaticUpdate(): void {
this.updateSoon.schedule();
}
private update(): void {
private async update(triggerKind: InlineCompletionTriggerKind): Promise<void> {
if (!this.shouldUpdate()) {
return;
}
const position = this.editor.getPosition();
this.clearGhostTextPromise();
this.updatePromise = createCancelablePromise(token =>
provideInlineCompletions(position,
this.editor.getModel(),
{ triggerKind: InlineCompletionTriggerKind.Automatic },
token
)
);
this.updatePromise.then((result) => {
this.cachedCompletions = [];
const decorationIds = this.editor.deltaDecorations(
(this.cachedCompletions || []).map(c => c.decorationId),
(result.items).map(i => ({
range: i.range,
options: {
description: 'inline-completion-tracking-range'
},
}))
);
this.cachedCompletionsSource?.dispose();
this.cachedCompletionsSource = result;
this.cachedCompletions = result.items.map((item, idx) => new CachedInlineCompletion(item, decorationIds[idx]));
this.onDidChangeEmitter.fire();
}, onUnexpectedError);
}
private clearCache(): void {
const completions = this.cachedCompletions;
if (completions) {
this.cachedCompletions = undefined;
this.editor.deltaDecorations(completions.map(c => c.decorationId), []);
if (!this.cachedCompletionsSource) {
throw new Error('Unexpected state');
const promise = createCancelablePromise(async token => {
let result;
try {
result = await provideInlineCompletions(position,
this.editor.getModel(),
{ triggerKind },
token
);
} catch (e) {
onUnexpectedError(e);
return;
}
this.cachedCompletionsSource.dispose();
this.cachedCompletionsSource = undefined;
}
}
private clearGhostTextPromise(): void {
if (this.updatePromise) {
this.updatePromise.cancel();
this.updatePromise = undefined;
if (token.isCancellationRequested) {
return;
}
this.cache.value = new SynchronizedInlineCompletionsCache(
this.editor,
result,
() => this.onDidChangeEmitter.fire(),
triggerKind
);
this.onDidChangeEmitter.fire();
});
const operation = new UpdateOperation(promise, triggerKind);
this.updateOperation.value = operation;
await promise;
if (this.updateOperation.value === operation) {
this.updateOperation.clear();
}
}
@ -331,7 +321,7 @@ class InlineCompletionsSession extends BaseGhostTextWidgetModel {
}
public commit(completion: LiveInlineCompletion): void {
this.clearCache();
this.cache.clear();
this.editor.executeEdits(
'inlineCompletions.accept',
[
@ -346,6 +336,88 @@ class InlineCompletionsSession extends BaseGhostTextWidgetModel {
}
}
class UpdateOperation implements IDisposable {
constructor(public readonly promise: CancelablePromise<void>, public readonly triggerKind: InlineCompletionTriggerKind) {
}
dispose() {
this.promise.cancel();
}
}
/**
* The cache keeps itself in sync with the editor.
* It also owns the completions result and disposes it when the cache is diposed.
*/
class SynchronizedInlineCompletionsCache extends Disposable {
public readonly completions: readonly CachedInlineCompletion[];
constructor(
editor: IActiveCodeEditor,
completionsSource: LiveInlineCompletions,
onChange: () => void,
public readonly triggerKind: InlineCompletionTriggerKind,
) {
super();
const decorationIds = editor.deltaDecorations(
[],
completionsSource.items.map(i => ({
range: i.range,
options: {
description: 'inline-completion-tracking-range'
},
}))
);
this._register(toDisposable(() => {
editor.deltaDecorations(decorationIds, []);
}));
this.completions = completionsSource.items.map((c, idx) => new CachedInlineCompletion(c, decorationIds[idx]));
this._register(editor.onDidChangeModelContent(() => {
let hasChanged = false;
const model = editor.getModel();
for (const c of this.completions) {
const newRange = model.getDecorationRange(c.decorationId);
if (!newRange) {
onUnexpectedError(new Error('Decoration has no range'));
continue;
}
if (!c.synchronizedRange.equalsRange(newRange)) {
hasChanged = true;
c.synchronizedRange = newRange;
}
}
if (hasChanged) {
onChange();
}
}));
this._register(completionsSource);
}
}
class CachedInlineCompletion {
public readonly semanticId: string = JSON.stringify({
text: this.inlineCompletion.text,
startLine: this.inlineCompletion.range.startLineNumber,
startColumn: this.inlineCompletion.range.startColumn,
command: this.inlineCompletion.command
});
/**
* The range, synchronized with text model changes.
*/
public synchronizedRange: Range;
constructor(
public readonly inlineCompletion: LiveInlineCompletion,
public readonly decorationId: string,
) {
this.synchronizedRange = inlineCompletion.range;
}
}
export interface NormalizedInlineCompletion extends InlineCompletion {
range: Range;
}

View file

@ -83,7 +83,13 @@ export class SuggestWidgetAdapterModel extends BaseGhostTextWidgetModel {
}
// TODO: item.isResolved
this.setCurrentInlineCompletion(getInlineCompletion(suggestController, this.editor.getPosition(), focusedItem));
this.setCurrentInlineCompletion(
getInlineCompletion(
suggestController,
this.editor.getPosition(),
focusedItem
)
);
}
private setCurrentInlineCompletion(completion: NormalizedInlineCompletion | undefined): void {