Merge pull request #127295 from microsoft/hediet/multi-line-ghost-text

Implements support for multiline suggestions in the middle of an existing line
This commit is contained in:
Henning Dieterichs 2021-06-28 15:05:06 +02:00 committed by GitHub
commit e1a8566a29
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 184 additions and 103 deletions

View file

@ -18,3 +18,7 @@
text-decoration: underline;
text-underline-position: under;
}
.monaco-editor .ghost-text-hidden {
opacity: 0;
}

View file

@ -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;
}

View file

@ -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;

View file

@ -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<InsertedInlineText>();
const additionalLines = new Array<LineData>();
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<IModelDeltaDecoration>();
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<IModelDeltaDecoration>(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('<div class="suggest-preview-text">');
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('<div class="view-line');
sb.appendASCIIString('" style="top:');
sb.appendASCIIString(String(i * lineHeight));
sb.appendASCIIString('px;width:1000000px;">');
const sb = createStringBuilder(10000);
sb.appendASCIIString('<div class="suggest-preview-text">');
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('<div class="view-line');
sb.appendASCIIString('" style="top:');
sb.appendASCIIString(String(i * lineHeight));
sb.appendASCIIString('px;width:1000000px;">');
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('</div>');
}
sb.appendASCIIString('</div>');
Configuration.applyFontInfoSlow(domNode, fontInfo);
const html = sb.build();
const trustedhtml = ttPolicy ? ttPolicy.createHTML(html) : html;
domNode.innerHTML = trustedhtml as string;
}
sb.appendASCIIString('</div>');
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}; }`);
}
});

View file

@ -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<GhostTextPart>();
let additionalLines = new Array<string>();
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 {

View file

@ -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);

View file

@ -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.
*/
'foob',
'foob[ar]',
'foob[az]'
]);
}
);
});

View file

@ -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();