From bec263be33be0f504c1942b359c51fa4aaf604a5 Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Sun, 27 Jun 2021 22:02:56 +0200 Subject: [PATCH] Implements multi line ghost text. --- .../contrib/inlineCompletions/ghostText.css | 4 + .../contrib/inlineCompletions/ghostText.ts | 10 +- .../inlineCompletions/ghostTextController.ts | 4 +- .../inlineCompletions/ghostTextWidget.ts | 215 ++++++++++++------ .../inlineCompletionsModel.ts | 32 +-- .../suggestWidgetAdapterModel.ts | 4 +- .../test/inlineCompletionsProvider.test.ts | 12 +- .../contrib/inlineCompletions/test/utils.ts | 6 +- 8 files changed, 184 insertions(+), 103 deletions(-) diff --git a/src/vs/editor/contrib/inlineCompletions/ghostText.css b/src/vs/editor/contrib/inlineCompletions/ghostText.css index 009f27cef75..b5756de0d99 100644 --- a/src/vs/editor/contrib/inlineCompletions/ghostText.css +++ b/src/vs/editor/contrib/inlineCompletions/ghostText.css @@ -18,3 +18,7 @@ text-decoration: underline; text-underline-position: under; } + +.monaco-editor .ghost-text-hidden { + opacity: 0; +} diff --git a/src/vs/editor/contrib/inlineCompletions/ghostText.ts b/src/vs/editor/contrib/inlineCompletions/ghostText.ts index 5e0b13626e8..2d27fc8324e 100644 --- a/src/vs/editor/contrib/inlineCompletions/ghostText.ts +++ b/src/vs/editor/contrib/inlineCompletions/ghostText.ts @@ -12,21 +12,13 @@ export class GhostText { constructor( public readonly lineNumber: number, public readonly parts: GhostTextPart[], - public readonly additionalLines: string[], public readonly additionalReservedLineCount: number = 0 ) { } - - public get isMultiLine(): boolean { - return this.additionalLines.length > 0; - } } export interface GhostTextPart { - /** - * Single line text. - */ - readonly text: string; + readonly lines: string[]; readonly column: number; } diff --git a/src/vs/editor/contrib/inlineCompletions/ghostTextController.ts b/src/vs/editor/contrib/inlineCompletions/ghostTextController.ts index db1240e922b..8e76f4a8a25 100644 --- a/src/vs/editor/contrib/inlineCompletions/ghostTextController.ts +++ b/src/vs/editor/contrib/inlineCompletions/ghostTextController.ts @@ -148,8 +148,8 @@ export class ActiveGhostTextController extends Disposable { const ghostText = this.model.inlineCompletionsModel.ghostText; if (ghostText && ghostText.parts.length > 0) { - const { column, text } = ghostText.parts[0]; - const suggestionStartsWithWs = text.startsWith(' ') || text.startsWith('\t'); + const { column, lines } = ghostText.parts[0]; + const suggestionStartsWithWs = lines[0].startsWith(' ') || lines[0].startsWith('\t'); const indentationEndColumn = this.editor.getModel().getLineIndentColumn(ghostText.lineNumber); const inIndentation = column <= indentationEndColumn; diff --git a/src/vs/editor/contrib/inlineCompletions/ghostTextWidget.ts b/src/vs/editor/contrib/inlineCompletions/ghostTextWidget.ts index 0f2ade5643a..fbc08982840 100644 --- a/src/vs/editor/contrib/inlineCompletions/ghostTextWidget.ts +++ b/src/vs/editor/contrib/inlineCompletions/ghostTextWidget.ts @@ -21,13 +21,16 @@ import { ghostTextBorder, ghostTextForeground } from 'vs/editor/common/view/edit import { RGBA, Color } from 'vs/base/common/color'; import { CursorColumns } from 'vs/editor/common/controller/cursorCommon'; import { IDecorationRenderOptions } from 'vs/editor/common/editorCommon'; -import { GhostTextWidgetModel, GhostTextPart } from 'vs/editor/contrib/inlineCompletions/ghostText'; +import { GhostTextWidgetModel } from 'vs/editor/contrib/inlineCompletions/ghostText'; +import { IModelDeltaDecoration } from 'vs/editor/common/model'; +import { LineDecoration } from 'vs/editor/common/viewLayout/lineDecorations'; +import { InlineDecorationType } from 'vs/editor/common/viewModel/viewModel'; const ttPolicy = window.trustedTypes?.createPolicy('editorGhostText', { createHTML: value => value }); export class GhostTextWidget extends Disposable { private disposed = false; - private readonly partsWidget = this._register(new PartsWidget(this.editor, this.codeEditorService, this.themeService)); + private readonly partsWidget = this._register(new DecorationsWidget(this.editor, this.codeEditorService, this.themeService)); private readonly additionalLinesWidget = this._register(new AdditionalLinesWidget(this.editor)); private viewMoreContentWidget: ViewMoreLinesContentWidget | undefined = undefined; @@ -79,14 +82,67 @@ export class GhostTextWidget extends Disposable { } const ghostText = this.model.ghostText; - this.partsWidget.setParts(ghostText.lineNumber, ghostText.parts); - this.additionalLinesWidget.updateLines(ghostText.lineNumber, ghostText.additionalLines, ghostText.additionalReservedLineCount); - if (ghostText.additionalLines.length < 0) { - // not supported at the moment + const inlineTexts = new Array(); + const additionalLines = new Array(); + + function addToAdditionalLines(lines: string[], className: string | undefined) { + if (additionalLines.length > 0) { + const lastLine = additionalLines[additionalLines.length - 1]; + if (className) { + lastLine.decorations.push(new LineDecoration(lastLine.content.length + 1, lastLine.content.length + 1 + lines[0].length, className, InlineDecorationType.Regular)); + } + lastLine.content += lines[0]; + + lines.splice(0, 1); + } + for (const line of lines) { + additionalLines.push({ + content: line, + decorations: className ? [new LineDecoration(1, line.length + 1, className, InlineDecorationType.Regular)] : [] + }); + } + } + + const textBufferLine = this.editor.getModel().getLineContent(ghostText.lineNumber); + this.editor.getModel().getLineTokens(ghostText.lineNumber); + + let hiddenTextStartColumn: number | undefined = undefined; + let lastIdx = 0; + for (const part of ghostText.parts) { + let lines = part.lines; + if (hiddenTextStartColumn === undefined) { + inlineTexts.push({ + column: part.column, + text: lines[0], + }); + lines = lines.slice(1); + } else { + addToAdditionalLines([textBufferLine.substring(lastIdx, part.column - 1)], undefined); + } + + if (lines.length > 0) { + addToAdditionalLines(lines, 'ghost-text'); + if (hiddenTextStartColumn === undefined && part.column <= textBufferLine.length) { + hiddenTextStartColumn = part.column; + } + } + + lastIdx = part.column - 1; + } + if (hiddenTextStartColumn !== undefined) { + addToAdditionalLines([textBufferLine.substring(lastIdx)], undefined); + } + + this.partsWidget.setParts(ghostText.lineNumber, inlineTexts, + hiddenTextStartColumn !== undefined ? { column: hiddenTextStartColumn, length: textBufferLine.length + 1 - hiddenTextStartColumn } : undefined); + this.additionalLinesWidget.updateLines(ghostText.lineNumber, additionalLines, ghostText.additionalReservedLineCount); + + if (ghostText.parts.some(p => p.lines.length < 0)) { + // Not supported at the moment, condition is always false. this.viewMoreContentWidget = this.renderViewMoreLines( new Position(ghostText.lineNumber, this.editor.getModel()!.getLineMaxColumn(ghostText.lineNumber)), - '', ghostText.additionalLines.length + '', 0 ); } else { this.viewMoreContentWidget?.dispose(); @@ -127,7 +183,17 @@ export class GhostTextWidget extends Disposable { } } -class PartsWidget implements IDisposable { +interface HiddenText { + column: number; + length: number; +} + +interface InsertedInlineText { + column: number; + text: string; +} + +class DecorationsWidget implements IDisposable { private decorationIds: string[] = []; private disposableStore: DisposableStore = new DisposableStore(); @@ -148,7 +214,7 @@ class PartsWidget implements IDisposable { this.disposableStore.clear(); } - public setParts(lineNumber: number, parts: GhostTextPart[]): void { + public setParts(lineNumber: number, parts: InsertedInlineText[], hiddenText?: HiddenText): void { this.disposableStore.clear(); const colorTheme = this.themeService.getColorTheme(); @@ -177,15 +243,25 @@ class PartsWidget implements IDisposable { let lastIndex = 0; let currentLinePrefix = ''; - this.decorationIds = this.editor.deltaDecorations(this.decorationIds, parts.map(p => { + const hiddenTextDecorations = new Array(); + if (hiddenText) { + hiddenTextDecorations.push({ + range: Range.fromPositions(new Position(lineNumber, hiddenText.column), new Position(lineNumber, hiddenText.column + hiddenText.length)), + options: { + inlineClassName: 'ghost-text-hidden', + description: 'ghost-text-hidden' + } + }); + } + this.decorationIds = this.editor.deltaDecorations(this.decorationIds, parts.map(p => { currentLinePrefix += line.substring(lastIndex, p.column - 1); lastIndex = p.column - 1; // To avoid visual confusion, we don't want to render visible whitespace const contentText = this.renderSingleLineText(p.text, currentLinePrefix, tabSize, false); - const decorationType = registerDecorationType(this.codeEditorService, 'ghost-text', '0-ghost-text-', { + const decorationType = this.disposableStore.add(registerDecorationType(this.codeEditorService, 'ghost-text', '0-ghost-text-', { after: { // TODO: escape? contentText, @@ -193,22 +269,21 @@ class PartsWidget implements IDisposable { color, border, }, - }); - this.disposableStore.add(decorationType); + })); + return ({ range: Range.fromPositions(new Position(lineNumber, p.column)), options: { ...decorationType.resolve() } }); - })); + }).concat(hiddenTextDecorations)); } private 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) { @@ -264,7 +339,7 @@ class AdditionalLinesWidget implements IDisposable { }); } - public updateLines(lineNumber: number, additionalLines: string[], minReservedLineCount: number): void { + public updateLines(lineNumber: number, additionalLines: LineData[], minReservedLineCount: number): void { const textModel = this.editor.getModel(); if (!textModel) { return; @@ -281,7 +356,7 @@ class AdditionalLinesWidget implements IDisposable { const heightInLines = Math.max(additionalLines.length, minReservedLineCount); if (heightInLines > 0) { const domNode = document.createElement('div'); - this.renderLines(domNode, tabSize, additionalLines, this.editor.getOptions()); + renderLines(domNode, tabSize, additionalLines, this.editor.getOptions()); this._viewZoneId = changeAccessor.addZone({ afterLineNumber: lineNumber, @@ -291,62 +366,68 @@ class AdditionalLinesWidget implements IDisposable { } }); } +} - private renderLines(domNode: HTMLElement, tabSize: number, lines: string[], opts: IComputedEditorOptions): void { - const disableMonospaceOptimizations = opts.get(EditorOption.disableMonospaceOptimizations); - const stopRenderingLineAfter = opts.get(EditorOption.stopRenderingLineAfter); - // To avoid visual confusion, we don't want to render visible whitespace - const renderWhitespace = 'none'; - const renderControlCharacters = opts.get(EditorOption.renderControlCharacters); - const fontLigatures = opts.get(EditorOption.fontLigatures); - const fontInfo = opts.get(EditorOption.fontInfo); - const lineHeight = opts.get(EditorOption.lineHeight); +interface LineData { + content: string; + decorations: LineDecoration[]; +} - const sb = createStringBuilder(10000); - sb.appendASCIIString('
'); +function renderLines(domNode: HTMLElement, tabSize: number, lines: LineData[], opts: IComputedEditorOptions): void { + const disableMonospaceOptimizations = opts.get(EditorOption.disableMonospaceOptimizations); + const stopRenderingLineAfter = opts.get(EditorOption.stopRenderingLineAfter); + // To avoid visual confusion, we don't want to render visible whitespace + const renderWhitespace = 'none'; + const renderControlCharacters = opts.get(EditorOption.renderControlCharacters); + const fontLigatures = opts.get(EditorOption.fontLigatures); + const fontInfo = opts.get(EditorOption.fontInfo); + const lineHeight = opts.get(EditorOption.lineHeight); - for (let i = 0, len = lines.length; i < len; i++) { - const line = lines[i]; - sb.appendASCIIString('
'); + const sb = createStringBuilder(10000); + sb.appendASCIIString('
'); - const isBasicASCII = strings.isBasicASCII(line); - const containsRTL = strings.containsRTL(line); - const lineTokens = LineTokens.createEmpty(line); + for (let i = 0, len = lines.length; i < len; i++) { + const lineData = lines[i]; + const line = lineData.content; + sb.appendASCIIString('
'); - renderViewLine(new RenderLineInput( - (fontInfo.isMonospace && !disableMonospaceOptimizations), - fontInfo.canUseHalfwidthRightwardsArrow, - line, - false, - isBasicASCII, - containsRTL, - 0, - lineTokens, - [], - tabSize, - 0, - fontInfo.spaceWidth, - fontInfo.middotWidth, - fontInfo.wsmiddotWidth, - stopRenderingLineAfter, - renderWhitespace, - renderControlCharacters, - fontLigatures !== EditorFontLigatures.OFF, - null - ), sb); + const isBasicASCII = strings.isBasicASCII(line); + const containsRTL = strings.containsRTL(line); + const lineTokens = LineTokens.createEmpty(line); + + renderViewLine(new RenderLineInput( + (fontInfo.isMonospace && !disableMonospaceOptimizations), + fontInfo.canUseHalfwidthRightwardsArrow, + line, + false, + isBasicASCII, + containsRTL, + 0, + lineTokens, + lineData.decorations, + tabSize, + 0, + fontInfo.spaceWidth, + fontInfo.middotWidth, + fontInfo.wsmiddotWidth, + stopRenderingLineAfter, + renderWhitespace, + renderControlCharacters, + fontLigatures !== EditorFontLigatures.OFF, + null + ), sb); - sb.appendASCIIString('
'); - } sb.appendASCIIString('
'); - - Configuration.applyFontInfoSlow(domNode, fontInfo); - const html = sb.build(); - const trustedhtml = ttPolicy ? ttPolicy.createHTML(html) : html; - domNode.innerHTML = trustedhtml as string; } + sb.appendASCIIString('
'); + + Configuration.applyFontInfoSlow(domNode, fontInfo); + const html = sb.build(); + const trustedhtml = ttPolicy ? ttPolicy.createHTML(html) : html; + domNode.innerHTML = trustedhtml as string; } let keyCounter = 0; @@ -406,11 +487,11 @@ registerThemingParticipant((theme, collector) => { const color = Color.Format.CSS.format(opaque(foreground))!; // We need to override the only used token type .mtk1 - collector.addRule(`.monaco-editor .suggest-preview-text .mtk1 { opacity: ${opacity}; color: ${color}; }`); + collector.addRule(`.monaco-editor .suggest-preview-text .ghost-text { opacity: ${opacity}; color: ${color}; }`); } const border = theme.getColor(ghostTextBorder); if (border) { - collector.addRule(`.monaco-editor .suggest-preview-text .mtk1 { border: 2px dashed ${border}; }`); + collector.addRule(`.monaco-editor .suggest-preview-text .ghost-text { border: 2px dashed ${border}; }`); } }); diff --git a/src/vs/editor/contrib/inlineCompletions/inlineCompletionsModel.ts b/src/vs/editor/contrib/inlineCompletions/inlineCompletionsModel.ts index da8566f8325..8e461548b7c 100644 --- a/src/vs/editor/contrib/inlineCompletions/inlineCompletionsModel.ts +++ b/src/vs/editor/contrib/inlineCompletions/inlineCompletionsModel.ts @@ -178,6 +178,12 @@ export class InlineCompletionsSession extends BaseGhostTextWidgetModel { } })); + this._register(this.editor.onDidChangeCursorPosition((e) => { + if (this.cache.value) { + this.onDidChangeEmitter.fire(); + } + })); + this._register(this.editor.onDidChangeModelContent((e) => { if (this.cache.value) { let hasChanged = false; @@ -287,7 +293,7 @@ export class InlineCompletionsSession extends BaseGhostTextWidgetModel { public get ghostText(): GhostText | undefined { const currentCompletion = this.currentCompletion; const mode = this.editor.getOptions().get(EditorOption.inlineSuggest).mode; - return currentCompletion ? inlineCompletionToGhostText(currentCompletion, this.editor.getModel(), mode) : undefined; + return currentCompletion ? inlineCompletionToGhostText(currentCompletion, this.editor.getModel(), mode, this.editor.getSelection().getEndPosition()) : undefined; } get currentCompletion(): LiveInlineCompletion | undefined { @@ -484,7 +490,7 @@ export interface NormalizedInlineCompletion extends InlineCompletion { range: Range; } -export function inlineCompletionToGhostText(inlineCompletion: NormalizedInlineCompletion, textModel: ITextModel, mode: 'prefix' | 'subwordDiff'): GhostText | undefined { +export function inlineCompletionToGhostText(inlineCompletion: NormalizedInlineCompletion, textModel: ITextModel, mode: 'prefix' | 'subwordDiff', cursorPosition?: Position): GhostText | undefined { if (inlineCompletion.range.startLineNumber !== inlineCompletion.range.endLineNumber) { // Only single line replacements are supported. return undefined; @@ -498,7 +504,6 @@ export function inlineCompletionToGhostText(inlineCompletion: NormalizedInlineCo const lineNumber = inlineCompletion.range.startLineNumber; const parts = new Array(); - let additionalLines = new Array(); if (mode === 'prefix') { const filteredChanges = changes.filter(c => c.originalLength === 0); @@ -510,6 +515,11 @@ export function inlineCompletionToGhostText(inlineCompletion: NormalizedInlineCo for (const c of changes) { const insertColumn = inlineCompletion.range.startColumn + c.originalStart + c.originalLength; + if (cursorPosition && cursorPosition.lineNumber === inlineCompletion.range.startLineNumber && insertColumn < cursorPosition.column) { + // No ghost text before cursor + return undefined; + } + if (c.originalLength > 0) { const originalText = valueToBeReplaced.substr(c.originalStart, c.originalLength); const firstNonWsCol = textModel.getLineFirstNonWhitespaceColumn(lineNumber); @@ -523,21 +533,11 @@ export function inlineCompletionToGhostText(inlineCompletion: NormalizedInlineCo } const text = inlineCompletion.text.substr(c.modifiedStart, c.modifiedLength); - const isEndOfLine = insertColumn === textModel.getLineMaxColumn(lineNumber); - if (!isEndOfLine) { - if (text.indexOf('\n') !== -1) { - // no line breaks inside the text - return undefined; - } - parts.push({ column: insertColumn, text }); - } else { - const lines = strings.splitLines(text); - additionalLines = lines.slice(1); - parts.push({ column: insertColumn, text: lines[0] }); - } + const lines = strings.splitLines(text); + parts.push({ column: insertColumn, lines }); } - return new GhostText(lineNumber, parts, additionalLines, 0); + return new GhostText(lineNumber, parts, 0); } export interface LiveInlineCompletion extends NormalizedInlineCompletion { diff --git a/src/vs/editor/contrib/inlineCompletions/suggestWidgetAdapterModel.ts b/src/vs/editor/contrib/inlineCompletions/suggestWidgetAdapterModel.ts index 2054d04bdeb..28b5adcda57 100644 --- a/src/vs/editor/contrib/inlineCompletions/suggestWidgetAdapterModel.ts +++ b/src/vs/editor/contrib/inlineCompletions/suggestWidgetAdapterModel.ts @@ -142,11 +142,11 @@ export class SuggestWidgetAdapterModel extends BaseGhostTextWidgetModel { ? ( inlineCompletionToGhostText(completion, this.editor.getModel(), mode) || // Show an invisible ghost text to reserve space - new GhostText(completion.range.endLineNumber, [], [], this.minReservedLineCount) + new GhostText(completion.range.endLineNumber, [], this.minReservedLineCount) ) : undefined; if (this.currentGhostText && this.expanded) { - this.minReservedLineCount = Math.max(this.minReservedLineCount, this.currentGhostText.additionalLines.length); + this.minReservedLineCount = Math.max(this.minReservedLineCount, ...this.currentGhostText.parts.map(p => p.lines.length - 1)); } const suggestController = SuggestController.get(this.editor); diff --git a/src/vs/editor/contrib/inlineCompletions/test/inlineCompletionsProvider.test.ts b/src/vs/editor/contrib/inlineCompletions/test/inlineCompletionsProvider.test.ts index c4b485617dd..341a391aaf1 100644 --- a/src/vs/editor/contrib/inlineCompletions/test/inlineCompletionsProvider.test.ts +++ b/src/vs/editor/contrib/inlineCompletions/test/inlineCompletionsProvider.test.ts @@ -42,7 +42,7 @@ suite('inlineCompletionToGhostText', () => { assert.deepStrictEqual(getOutput('[aaa]aaa', 'aaaaaa'), 'aaa[aaa]aaa'); assert.deepStrictEqual(getOutput('[foo]baz', 'boobar'), undefined); assert.deepStrictEqual(getOutput('[foo]foo', 'foofoo'), 'foo[foo]foo'); - assert.deepStrictEqual(getOutput('foo[]', 'bar\nhello'), 'foo[bar]{\nhello}'); + assert.deepStrictEqual(getOutput('foo[]', 'bar\nhello'), 'foo[bar\nhello]'); }); test('Empty ghost text', () => { @@ -437,7 +437,15 @@ test('Support backward instability', async function () { assert.deepStrictEqual(provider.getAndClearCallHistory(), [ { position: '(1,5)', text: 'foob', triggerKind: 0, } ]); - assert.deepStrictEqual(context.getAndClearViewStates(), ['foob[ar]', 'foob[az]']); + assert.deepStrictEqual(context.getAndClearViewStates(), [ + /* + TODO: Remove this flickering. Fortunately, it is not visible. + It is caused by the text model updating before the cursor position. + */ + 'foo', + 'foob[ar]', + 'foob[az]' + ]); } ); }); diff --git a/src/vs/editor/contrib/inlineCompletions/test/utils.ts b/src/vs/editor/contrib/inlineCompletions/test/utils.ts index e9963014f61..72226659481 100644 --- a/src/vs/editor/contrib/inlineCompletions/test/utils.ts +++ b/src/vs/editor/contrib/inlineCompletions/test/utils.ts @@ -24,11 +24,7 @@ export function renderGhostTextToText(ghostText: GhostText | undefined, text: st const tempModel = createTextModel(text); tempModel.applyEdits( [ - ...ghostText.parts.map(p => ({ range: { startLineNumber: l, endLineNumber: l, startColumn: p.column, endColumn: p.column }, text: `[${p.text}]` })), - ...(ghostText.additionalLines.length > 0 ? [{ - range: { startLineNumber: l, endLineNumber: l, startColumn: tempModel.getLineMaxColumn(l), endColumn: tempModel.getLineMaxColumn(l) }, - text: `{${ghostText.additionalLines.map(s => '\n' + s).join('')}}` - }] : []) + ...ghostText.parts.map(p => ({ range: { startLineNumber: l, endLineNumber: l, startColumn: p.column, endColumn: p.column }, text: `[${p.lines.join('\n')}]` })), ] ); const value = tempModel.getValue();