From c8118a49c5ecae7f92c54483f4e96b5bb0bde111 Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Wed, 7 Jul 2021 22:36:08 +0200 Subject: [PATCH 1/6] Prevents cursor to move into injected text. Fixes bracket decoration bug. --- .../editor/browser/controller/mouseTarget.ts | 7 ++ src/vs/editor/common/controller/cursor.ts | 4 +- .../editor/common/controller/cursorCommon.ts | 9 +- .../common/controller/cursorMoveOperations.ts | 6 +- src/vs/editor/common/controller/oneCursor.ts | 29 ++++- src/vs/editor/common/model.ts | 17 ++- src/vs/editor/common/model/textModel.ts | 2 +- .../common/viewModel/splitLinesCollection.ts | 115 ++++++++++++------ src/vs/editor/common/viewModel/viewModel.ts | 75 +++++++++++- .../editor/common/viewModel/viewModelImpl.ts | 4 +- .../common/viewModel/lineBreakData.test.ts | 17 ++- 11 files changed, 227 insertions(+), 58 deletions(-) diff --git a/src/vs/editor/browser/controller/mouseTarget.ts b/src/vs/editor/browser/controller/mouseTarget.ts index 3d766b78764da..41ad643ecc39c 100644 --- a/src/vs/editor/browser/controller/mouseTarget.ts +++ b/src/vs/editor/browser/controller/mouseTarget.ts @@ -18,6 +18,7 @@ import { IViewModel } from 'vs/editor/common/viewModel/viewModel'; import { CursorColumns } from 'vs/editor/common/controller/cursorCommon'; import * as dom from 'vs/base/browser/dom'; import { AtomicTabMoveOperations, Direction } from 'vs/editor/common/controller/cursorAtomicMoveOperations'; +import { PositionAffinity } from 'vs/editor/common/model'; export interface IViewZoneData { viewZoneId: string; @@ -955,6 +956,12 @@ export class MouseTargetFactory { } else if ((document).caretPositionFromPoint) { result = this._doHitTestWithCaretPositionFromPoint(ctx, request.pos.toClientCoordinates()); } + if (result.type === HitTestResultType.Content) { + const normalizedPosition = ctx.model.normalizePosition(result.position, PositionAffinity.None); + if (!normalizedPosition.equals(result.position)) { + result = new ContentHitTestResult(normalizedPosition, result.spanNode); + } + } // Snap to the nearest soft tab boundary if atomic soft tabs are enabled. if (result.type === HitTestResultType.Content && ctx.stickyTabStops) { result = new ContentHitTestResult(this._snapToSoftTabBoundary(result.position, ctx.model), result.spanNode); diff --git a/src/vs/editor/common/controller/cursor.ts b/src/vs/editor/common/controller/cursor.ts index 2ec7d252ac207..5236d2bd23048 100644 --- a/src/vs/editor/common/controller/cursor.ts +++ b/src/vs/editor/common/controller/cursor.ts @@ -144,7 +144,7 @@ export class CursorsController extends Disposable { this._knownModelVersionId = this._model.getVersionId(); this._viewModel = viewModel; this._coordinatesConverter = coordinatesConverter; - this.context = new CursorContext(this._model, this._coordinatesConverter, cursorConfig); + this.context = new CursorContext(this._model, this._viewModel, this._coordinatesConverter, cursorConfig); this._cursors = new CursorCollection(this.context); this._hasFocus = false; @@ -163,7 +163,7 @@ export class CursorsController extends Disposable { } public updateConfiguration(cursorConfig: CursorConfiguration): void { - this.context = new CursorContext(this._model, this._coordinatesConverter, cursorConfig); + this.context = new CursorContext(this._model, this._viewModel, this._coordinatesConverter, cursorConfig); this._cursors.updateContext(this.context); } diff --git a/src/vs/editor/common/controller/cursorCommon.ts b/src/vs/editor/common/controller/cursorCommon.ts index a387696118c5a..ae17e57f6f230 100644 --- a/src/vs/editor/common/controller/cursorCommon.ts +++ b/src/vs/editor/common/controller/cursorCommon.ts @@ -11,7 +11,7 @@ import { Position } from 'vs/editor/common/core/position'; import { Range } from 'vs/editor/common/core/range'; import { ISelection, Selection } from 'vs/editor/common/core/selection'; import { ICommand, IConfiguration } from 'vs/editor/common/editorCommon'; -import { ITextModel, PositionNormalizationAffinity, TextModelResolvedOptions } from 'vs/editor/common/model'; +import { ITextModel, PositionAffinity, TextModelResolvedOptions } from 'vs/editor/common/model'; import { TextModel } from 'vs/editor/common/model/textModel'; import { LanguageIdentifier } from 'vs/editor/common/modes'; import { AutoClosingPairs, IAutoClosingPair } from 'vs/editor/common/modes/languageConfiguration'; @@ -221,7 +221,8 @@ export interface ICursorSimpleModel { getLineMaxColumn(lineNumber: number): number; getLineFirstNonWhitespaceColumn(lineNumber: number): number; getLineLastNonWhitespaceColumn(lineNumber: number): number; - normalizePosition(position: Position, affinity: PositionNormalizationAffinity): Position; + normalizePosition(position: Position, affinity: PositionAffinity): Position; + /** * Gets the column at which indentation stops at a given line. * @internal @@ -321,11 +322,13 @@ export class CursorContext { _cursorContextBrand: void = undefined; public readonly model: ITextModel; + public readonly viewModel: ICursorSimpleModel; public readonly coordinatesConverter: ICoordinatesConverter; public readonly cursorConfig: CursorConfiguration; - constructor(model: ITextModel, coordinatesConverter: ICoordinatesConverter, cursorConfig: CursorConfiguration) { + constructor(model: ITextModel, viewModel: ICursorSimpleModel, coordinatesConverter: ICoordinatesConverter, cursorConfig: CursorConfiguration) { this.model = model; + this.viewModel = viewModel; this.coordinatesConverter = coordinatesConverter; this.cursorConfig = cursorConfig; } diff --git a/src/vs/editor/common/controller/cursorMoveOperations.ts b/src/vs/editor/common/controller/cursorMoveOperations.ts index 2190fea68ebe4..938780996372c 100644 --- a/src/vs/editor/common/controller/cursorMoveOperations.ts +++ b/src/vs/editor/common/controller/cursorMoveOperations.ts @@ -9,7 +9,7 @@ import { Range } from 'vs/editor/common/core/range'; import * as strings from 'vs/base/common/strings'; import { Constants } from 'vs/base/common/uint'; import { AtomicTabMoveOperations, Direction } from 'vs/editor/common/controller/cursorAtomicMoveOperations'; -import { PositionNormalizationAffinity } from 'vs/editor/common/model'; +import { PositionAffinity } from 'vs/editor/common/model'; export class CursorPosition { _cursorPositionBrand: void = undefined; @@ -75,7 +75,7 @@ export class MoveOperations { const pos = cursor.position.delta(undefined, -(noOfColumns - 1)); // We clip the position before normalization, as normalization is not defined // for possibly negative columns. - const normalizedPos = model.normalizePosition(MoveOperations.clipPositionColumn(pos, model), PositionNormalizationAffinity.Left); + const normalizedPos = model.normalizePosition(MoveOperations.clipPositionColumn(pos, model), PositionAffinity.Left); const p = MoveOperations.left(config, model, normalizedPos); lineNumber = p.lineNumber; @@ -144,7 +144,7 @@ export class MoveOperations { column = cursor.selection.endColumn; } else { const pos = cursor.position.delta(undefined, noOfColumns - 1); - const normalizedPos = model.normalizePosition(MoveOperations.clipPositionColumn(pos, model), PositionNormalizationAffinity.Right); + const normalizedPos = model.normalizePosition(MoveOperations.clipPositionColumn(pos, model), PositionAffinity.Right); const r = MoveOperations.right(config, model, normalizedPos); lineNumber = r.lineNumber; column = r.column; diff --git a/src/vs/editor/common/controller/oneCursor.ts b/src/vs/editor/common/controller/oneCursor.ts index f32a1b43c6479..4b2758d50b3fd 100644 --- a/src/vs/editor/common/controller/oneCursor.ts +++ b/src/vs/editor/common/controller/oneCursor.ts @@ -7,7 +7,7 @@ import { CursorContext, CursorState, SingleCursorState } from 'vs/editor/common/ import { Position } from 'vs/editor/common/core/position'; import { Range } from 'vs/editor/common/core/range'; import { Selection, SelectionDirection } from 'vs/editor/common/core/selection'; -import { TrackedRangeStickiness } from 'vs/editor/common/model'; +import { PositionAffinity, TrackedRangeStickiness } from 'vs/editor/common/model'; /** * Represents a single cursor. @@ -78,6 +78,33 @@ export class Cursor { } private _setState(context: CursorContext, modelState: SingleCursorState | null, viewState: SingleCursorState | null): void { + if (viewState) { + const cache = new Map(); + function normalize(pos: Position): Position { + const existing = cache.get(pos.toString()); + if (existing) { + return existing; + } + const result = context.viewModel.normalizePosition(pos, PositionAffinity.None); + cache.set(pos.toString(), result); + return result; + } + const normalizedPosition = normalize(viewState.position); + const columnDelta = viewState.position.column - normalizedPosition.column; + + const updatedState = new SingleCursorState( + Selection.fromPositions( + normalize(viewState.selectionStart.getStartPosition()), + normalize(viewState.selectionStart.getEndPosition()) + ), + viewState.selectionStartLeftoverVisibleColumns + columnDelta, + normalizedPosition, + viewState.leftoverVisibleColumns + columnDelta, + ); + + viewState = updatedState; + } + if (!modelState) { if (!viewState) { return; diff --git a/src/vs/editor/common/model.ts b/src/vs/editor/common/model.ts index 88e3f01a75a85..e33a53efea312 100644 --- a/src/vs/editor/common/model.ts +++ b/src/vs/editor/common/model.ts @@ -1293,9 +1293,16 @@ export interface ITextModel { /** * Among all positions that are projected to the same position in the underlying text model as * the given position, select a unique position as indicated by the affinity. + * + * PositionAffinity.Left: + * The normalized position must be equal or left to the requested position. + * + * PositionAffinity.Right: + * The normalized position must be equal or right to the requested position. + * * @internal */ - normalizePosition(position: Position, affinity: PositionNormalizationAffinity): Position; + normalizePosition(position: Position, affinity: PositionAffinity): Position; /** * Gets the column at which indentation stops at a given line. @@ -1307,15 +1314,21 @@ export interface ITextModel { /** * @internal */ -export const enum PositionNormalizationAffinity { +export const enum PositionAffinity { /** * Prefers the left most position. */ Left = 0, + /** * Prefers the right most position. */ Right = 1, + + /** + * No preference. + */ + None = 2, } /** diff --git a/src/vs/editor/common/model/textModel.ts b/src/vs/editor/common/model/textModel.ts index 967a60375d00d..747b4becd2bd5 100644 --- a/src/vs/editor/common/model/textModel.ts +++ b/src/vs/editor/common/model/textModel.ts @@ -3122,7 +3122,7 @@ export class TextModel extends Disposable implements model.ITextModel, IDecorati } //#endregion - normalizePosition(position: Position, affinity: model.PositionNormalizationAffinity): Position { + normalizePosition(position: Position, affinity: model.PositionAffinity): Position { return position; } diff --git a/src/vs/editor/common/viewModel/splitLinesCollection.ts b/src/vs/editor/common/viewModel/splitLinesCollection.ts index 005c364358ffc..c6ce5e8d53d80 100644 --- a/src/vs/editor/common/viewModel/splitLinesCollection.ts +++ b/src/vs/editor/common/viewModel/splitLinesCollection.ts @@ -8,7 +8,7 @@ import { WrappingIndent } from 'vs/editor/common/config/editorOptions'; import { IViewLineTokens, LineTokens } from 'vs/editor/common/core/lineTokens'; import { Position } from 'vs/editor/common/core/position'; import { IRange, Range } from 'vs/editor/common/core/range'; -import { EndOfLinePreference, IActiveIndentGuideInfo, IModelDecoration, IModelDeltaDecoration, ITextModel, PositionNormalizationAffinity } from 'vs/editor/common/model'; +import { EndOfLinePreference, IActiveIndentGuideInfo, IModelDecoration, IModelDeltaDecoration, ITextModel, PositionAffinity } from 'vs/editor/common/model'; import { ModelDecorationOptions, ModelDecorationOverviewRulerOptions } from 'vs/editor/common/model/textModel'; import * as viewEvents from 'vs/editor/common/view/viewEvents'; import { PrefixSumIndexOfResult } from 'vs/editor/common/viewModel/prefixSumComputer'; @@ -45,9 +45,9 @@ export interface ISplitLine { getViewLinesData(model: ISimpleModel, modelLineNumber: number, fromOuputLineIndex: number, toOutputLineIndex: number, globalStartIndex: number, needed: boolean[], result: Array): void; getModelColumnOfViewPosition(outputLineIndex: number, outputColumn: number): number; - getViewPositionOfModelPosition(deltaLineNumber: number, inputColumn: number): Position; + getViewPositionOfModelPosition(deltaLineNumber: number, inputColumn: number, affinity?: PositionAffinity): Position; getViewLineNumberOfModelPosition(deltaLineNumber: number, inputColumn: number): number; - normalizePosition(model: ISimpleModel, modelLineNumber: number, outputLineIndex: number, outputPosition: Position, affinity: PositionNormalizationAffinity): Position; + normalizePosition(model: ISimpleModel, modelLineNumber: number, outputLineIndex: number, outputPosition: Position, affinity: PositionAffinity): Position; } export interface IViewModelLinesCollection extends IDisposable { @@ -78,7 +78,7 @@ export interface IViewModelLinesCollection extends IDisposable { getAllOverviewRulerDecorations(ownerId: number, filterOutValidation: boolean, theme: EditorTheme): IOverviewRulerDecorations; getDecorationsInRange(range: Range, ownerId: number, filterOutValidation: boolean): IModelDecoration[]; - normalizePosition(position: Position, affinity: PositionNormalizationAffinity): Position; + normalizePosition(position: Position, affinity: PositionAffinity): Position; /** * Gets the column at which indentation stops at a given line. * @internal @@ -853,7 +853,7 @@ export class SplitLinesCollection implements IViewModelLinesCollection { return new Range(start.lineNumber, start.column, end.lineNumber, end.column); } - public convertModelPositionToViewPosition(_modelLineNumber: number, _modelColumn: number): Position { + public convertModelPositionToViewPosition(_modelLineNumber: number, _modelColumn: number, affinity: PositionAffinity = PositionAffinity.None): Position { const validPosition = this.model.validatePosition(new Position(_modelLineNumber, _modelColumn)); const inputLineNumber = validPosition.lineNumber; @@ -873,9 +873,9 @@ export class SplitLinesCollection implements IViewModelLinesCollection { let r: Position; if (lineIndexChanged) { - r = this.lines[lineIndex].getViewPositionOfModelPosition(deltaLineNumber, this.model.getLineMaxColumn(lineIndex + 1)); + r = this.lines[lineIndex].getViewPositionOfModelPosition(deltaLineNumber, this.model.getLineMaxColumn(lineIndex + 1), affinity); } else { - r = this.lines[inputLineNumber - 1].getViewPositionOfModelPosition(deltaLineNumber, inputColumn); + r = this.lines[inputLineNumber - 1].getViewPositionOfModelPosition(deltaLineNumber, inputColumn, affinity); } // console.log('in -> out ' + inputLineNumber + ',' + inputColumn + ' ===> ' + r.lineNumber + ',' + r); @@ -883,8 +883,12 @@ export class SplitLinesCollection implements IViewModelLinesCollection { } public convertModelRangeToViewRange(modelRange: Range): Range { - let start = this.convertModelPositionToViewPosition(modelRange.startLineNumber, modelRange.startColumn); - let end = this.convertModelPositionToViewPosition(modelRange.endLineNumber, modelRange.endColumn); + // If the range is empty, we don't want the range to get expanded or moved when + let start = this.convertModelPositionToViewPosition(modelRange.startLineNumber, modelRange.startColumn, PositionAffinity.Right); + let end = this.convertModelPositionToViewPosition(modelRange.endLineNumber, modelRange.endColumn, PositionAffinity.Left); + if (end.isBefore(start)) { + end = start; + } if (modelRange.startLineNumber === modelRange.endLineNumber && start.lineNumber !== end.lineNumber) { // This is a single line range that ends up taking more lines due to wrapping if (end.column === this.getViewLineMinColumn(end.lineNumber)) { @@ -1000,7 +1004,7 @@ export class SplitLinesCollection implements IViewModelLinesCollection { return finalResult; } - normalizePosition(position: Position, affinity: PositionNormalizationAffinity): Position { + normalizePosition(position: Position, affinity: PositionAffinity): Position { const viewLineNumber = this._toValidViewLineNumber(position.lineNumber); const r = this.prefixSumComputer.getIndexOf(viewLineNumber - 1); const lineIndex = r.index; @@ -1101,7 +1105,7 @@ class VisibleIdentitySplitLine implements ISplitLine { return deltaLineNumber; } - public normalizePosition(model: ISimpleModel, modelLineNumber: number, outputLineIndex: number, outputPosition: Position, affinity: PositionNormalizationAffinity): Position { + public normalizePosition(model: ISimpleModel, modelLineNumber: number, outputLineIndex: number, outputPosition: Position, affinity: PositionAffinity): Position { return outputPosition; } } @@ -1167,7 +1171,7 @@ class InvisibleIdentitySplitLine implements ISplitLine { throw new Error('Not supported'); } - public normalizePosition(model: ISimpleModel, modelLineNumber: number, outputLineIndex: number, outputPosition: Position, affinity: PositionNormalizationAffinity): Position { + public normalizePosition(model: ISimpleModel, modelLineNumber: number, outputLineIndex: number, outputPosition: Position, affinity: PositionAffinity): Position { throw new Error('Not supported'); } } @@ -1217,14 +1221,28 @@ export class SplitLine implements ISplitLine { if (!this._isVisible) { throw new Error('Not supported'); } - let startOffset = this.getInputStartOffsetOfOutputLineIndex(outputLineIndex); - let endOffset = this.getInputEndOffsetOfOutputLineIndex(model, modelLineNumber, outputLineIndex); - let r = model.getValueInRange({ - startLineNumber: modelLineNumber, - startColumn: startOffset + 1, - endLineNumber: modelLineNumber, - endColumn: endOffset + 1 - }); + + // These offsets refer to model text with injected text. + const startOffset = outputLineIndex > 0 ? this._lineBreakData.breakOffsets[outputLineIndex - 1] : 0; + const endOffset = outputLineIndex < this._lineBreakData.breakOffsets.length + ? this._lineBreakData.breakOffsets[outputLineIndex] + // This case might not be possible anyway, but we clamp the value to be on the safe side. + : this._lineBreakData.breakOffsets[this._lineBreakData.breakOffsets.length - 1]; + + let r: string; + if (this._lineBreakData.injectionOffsets !== null) { + // This could be done more performant if required. + r = LineInjectedText.applyInjectedText(model.getLineContent(modelLineNumber), this._lineBreakData.injectionOffsets.map((offset, idx) => + new LineInjectedText(0, 0, offset, this._lineBreakData.injectionOptions![idx], 0) + )).substring(startOffset, endOffset); + } else { + r = model.getValueInRange({ + startLineNumber: modelLineNumber, + startColumn: startOffset + 1, + endLineNumber: modelLineNumber, + endColumn: endOffset + 1 + }); + } if (outputLineIndex > 0) { r = spaces(this._lineBreakData.wrappedTextIndentLength) + r; @@ -1280,7 +1298,6 @@ export class SplitLine implements ISplitLine { if (!this._isVisible) { throw new Error('Not supported'); } - const lineBreakData = this._lineBreakData; const deltaStartIndex = (outputLineIndex > 0 ? lineBreakData.wrappedTextIndentLength : 0); @@ -1306,8 +1323,9 @@ export class SplitLine implements ISplitLine { let totalInjectedTextLengthBefore = 0; for (let i = 0; i < injectionOffsets.length; i++) { + const length = injectionOptions![i].content.length; const injectedTextStartOffsetInUnwrappedLine = injectionOffsets[i] + totalInjectedTextLengthBefore; - const injectedTextEndOffsetInUnwrappedLine = injectionOffsets[i] + totalInjectedTextLengthBefore + injectionOptions![i].content.length; + const injectedTextEndOffsetInUnwrappedLine = injectionOffsets[i] + totalInjectedTextLengthBefore + length; if (injectedTextStartOffsetInUnwrappedLine > lineEndOffsetInUnwrappedLine) { // Injected text only starts in later wrapped lines. @@ -1316,17 +1334,25 @@ export class SplitLine implements ISplitLine { if (lineStartOffsetInUnwrappedLine < injectedTextEndOffsetInUnwrappedLine) { // Injected text ends after or in this line (but also starts in or before this line). - const length = injectionOptions![i].content.length; const inlineClassName = injectionOptions![i].inlineClassName; if (inlineClassName) { - inlineDecorations.push(new SingleLineInlineDecoration( - Math.max(injectedTextStartOffsetInUnwrappedLine - lineStartOffsetInUnwrappedLine, 0), - Math.min(injectedTextEndOffsetInUnwrappedLine - lineStartOffsetInUnwrappedLine, lineEndOffsetInUnwrappedLine), - inlineClassName - )); + let offset = 0; + if (outputLineIndex > 0) { + offset = lineBreakData.wrappedTextIndentLength; + } + const start = Math.max(injectedTextStartOffsetInUnwrappedLine - lineStartOffsetInUnwrappedLine, 0) + offset; + const end = Math.min(injectedTextEndOffsetInUnwrappedLine - lineStartOffsetInUnwrappedLine, lineEndOffsetInUnwrappedLine) + offset; + if (start !== end) { + inlineDecorations.push(new SingleLineInlineDecoration( + start, + end, + inlineClassName + )); + } } - totalInjectedTextLengthBefore += length; } + + totalInjectedTextLengthBefore += length; } } else { const startOffset = this.getInputStartOffsetOfOutputLineIndex(outputLineIndex); @@ -1392,11 +1418,11 @@ export class SplitLine implements ISplitLine { return this._lineBreakData.getInputOffsetOfOutputPosition(outputLineIndex, adjustedColumn) + 1; } - public getViewPositionOfModelPosition(deltaLineNumber: number, inputColumn: number): Position { + public getViewPositionOfModelPosition(deltaLineNumber: number, inputColumn: number, affinity: PositionAffinity = PositionAffinity.None): Position { if (!this._isVisible) { throw new Error('Not supported'); } - let r = this._lineBreakData.getOutputPositionOfInputOffset(inputColumn - 1); + let r = this._lineBreakData.getOutputPositionOfInputOffset(inputColumn - 1, affinity); let outputLineIndex = r.outputLineIndex; let outputColumn = r.outputOffset + 1; @@ -1416,18 +1442,37 @@ export class SplitLine implements ISplitLine { return (deltaLineNumber + r.outputLineIndex); } - public normalizePosition(model: ISimpleModel, modelLineNumber: number, outputLineIndex: number, outputPosition: Position, affinity: PositionNormalizationAffinity): Position { - if (affinity === PositionNormalizationAffinity.Left) { + public normalizePosition(model: ISimpleModel, modelLineNumber: number, outputLineIndex: number, outputPosition: Position, affinity: PositionAffinity): Position { + if (this._lineBreakData.injectionOffsets !== null) { + const baseViewLineNumber = outputPosition.lineNumber - outputLineIndex; + + if (affinity === PositionAffinity.None) { + const offsetInUnwrappedLine = this._lineBreakData.outputPositionToOffsetInUnwrappedLine(outputLineIndex, outputPosition.column - 1); + const injectedText = this._lineBreakData.getInjectedTextAt(offsetInUnwrappedLine); + if (injectedText) { + if (offsetInUnwrappedLine === injectedText.offsetInUnwrappedLine + injectedText.length) { + return this._lineBreakData.getOutputPositionOfOffsetInUnwrappedLine(injectedText.offsetInUnwrappedLine + injectedText.length).toPosition(baseViewLineNumber, this._lineBreakData.wrappedTextIndentLength); + } + return this._lineBreakData.getOutputPositionOfOffsetInUnwrappedLine(injectedText.offsetInUnwrappedLine).toPosition(baseViewLineNumber, this._lineBreakData.wrappedTextIndentLength); + } + } else { + const modelColumn = this.getModelColumnOfViewPosition(outputLineIndex, outputPosition.column); + return this.getViewPositionOfModelPosition(baseViewLineNumber, modelColumn, affinity); + } + } + + if (affinity === PositionAffinity.Left) { if (outputLineIndex > 0 && outputPosition.column === this._getViewLineMinColumn(outputLineIndex)) { return new Position(outputPosition.lineNumber - 1, this.getViewLineMaxColumn(model, modelLineNumber, outputLineIndex - 1)); } } - else if (affinity === PositionNormalizationAffinity.Right) { + else if (affinity === PositionAffinity.Right) { const maxOutputLineIndex = this.getViewLineCount() - 1; if (outputLineIndex < maxOutputLineIndex && outputPosition.column === this.getViewLineMaxColumn(model, modelLineNumber, outputLineIndex)) { return new Position(outputPosition.lineNumber + 1, this._getViewLineMinColumn(outputLineIndex + 1)); } } + return outputPosition; } } @@ -1666,7 +1711,7 @@ export class IdentityLinesCollection implements IViewModelLinesCollection { return this.model.getDecorationsInRange(range, ownerId, filterOutValidation); } - normalizePosition(position: Position, affinity: PositionNormalizationAffinity): Position { + normalizePosition(position: Position, affinity: PositionAffinity): Position { return this.model.normalizePosition(position, affinity); } diff --git a/src/vs/editor/common/viewModel/viewModel.ts b/src/vs/editor/common/viewModel/viewModel.ts index f201f605fde56..3663bd67d25fe 100644 --- a/src/vs/editor/common/viewModel/viewModel.ts +++ b/src/vs/editor/common/viewModel/viewModel.ts @@ -9,7 +9,7 @@ import { IViewLineTokens } from 'vs/editor/common/core/lineTokens'; import { IPosition, Position } from 'vs/editor/common/core/position'; import { IRange, Range } from 'vs/editor/common/core/range'; import { INewScrollPosition, ScrollType } from 'vs/editor/common/editorCommon'; -import { EndOfLinePreference, IActiveIndentGuideInfo, IModelDecorationOptions, TextModelResolvedOptions, ITextModel, InjectedTextOptions } from 'vs/editor/common/model'; +import { EndOfLinePreference, IActiveIndentGuideInfo, IModelDecorationOptions, TextModelResolvedOptions, ITextModel, InjectedTextOptions, PositionAffinity } from 'vs/editor/common/model'; import { VerticalRevealType } from 'vs/editor/common/view/viewEvents'; import { IPartialViewLinesViewportData } from 'vs/editor/common/viewLayout/viewLinesViewportData'; import { IEditorWhitespace, IWhitespaceChangeAccessor } from 'vs/editor/common/viewLayout/linesLayout'; @@ -100,6 +100,14 @@ export class OutputPosition { toString(): string { return `${this.outputLineIndex}:${this.outputOffset}`; } + + toPosition(baseLineNumber: number, wrappedTextIndentLength: number): Position { + let delta = 0; + if (this.outputLineIndex > 0) { + delta += wrappedTextIndentLength; + } + return new Position(baseLineNumber + this.outputLineIndex, this.outputOffset + 1 + delta); + } } export class LineBreakData { @@ -137,18 +145,27 @@ export class LineBreakData { return inputOffset; } - public getOutputPositionOfInputOffset(inputOffset: number): OutputPosition { + public getOutputPositionOfInputOffset(inputOffset: number, affinity: PositionAffinity = PositionAffinity.None): OutputPosition { let delta = 0; if (this.injectionOffsets !== null) { for (let i = 0; i < this.injectionOffsets.length; i++) { - if (inputOffset <= this.injectionOffsets[i]) { + if (inputOffset < this.injectionOffsets[i]) { + break; + } + + if (affinity !== PositionAffinity.Right && inputOffset === this.injectionOffsets[i]) { break; } + delta += this.injectionOptions![i].content.length; } } inputOffset += delta; + return this.getOutputPositionOfOffsetInUnwrappedLine(inputOffset, affinity); + } + + public getOutputPositionOfOffsetInUnwrappedLine(offset: number, affinity: PositionAffinity = PositionAffinity.None): OutputPosition { let low = 0; let high = this.breakOffsets.length - 1; let mid = 0; @@ -160,16 +177,62 @@ export class LineBreakData { const midStop = this.breakOffsets[mid]; midStart = mid > 0 ? this.breakOffsets[mid - 1] : 0; - if (inputOffset < midStart) { + if (affinity === PositionAffinity.Left && offset === midStart) { + console.log('foo'); + } + if (affinity !== PositionAffinity.Left && offset === midStop) { + console.log('bar'); + } + + if (offset < midStart || (affinity === PositionAffinity.Left && offset === midStart)) { high = mid - 1; - } else if (inputOffset >= midStop) { + } else if (offset > midStop || (affinity !== PositionAffinity.Left && offset === midStop)) { low = mid + 1; } else { break; } } - return new OutputPosition(mid, inputOffset - midStart); + return new OutputPosition(mid, offset - midStart); + } + + public outputPositionToOffsetInUnwrappedLine(outputLineIndex: number, outputOffset: number): number { + let result = (outputLineIndex > 0 ? this.breakOffsets[outputLineIndex - 1] : 0) + outputOffset; + if (outputLineIndex > 0) { + result -= this.wrappedTextIndentLength; + } + return result; + } + + public getInjectedTextAt(offsetInUnwrappedLine: number): { offsetInUnwrappedLine: number, length: number } | undefined { + const injectionOffsets = this.injectionOffsets; + const injectionOptions = this.injectionOptions; + + if (injectionOffsets !== null) { + let totalInjectedTextLengthBefore = 0; + for (let i = 0; i < injectionOffsets.length; i++) { + const length = injectionOptions![i].content.length; + const injectedTextStartOffsetInUnwrappedLine = injectionOffsets[i] + totalInjectedTextLengthBefore; + const injectedTextEndOffsetInUnwrappedLine = injectionOffsets[i] + totalInjectedTextLengthBefore + length; + + if (injectedTextStartOffsetInUnwrappedLine > offsetInUnwrappedLine) { + // Injected text starts later. + break; // All later injected texts have an even larger offset. + } + + if (offsetInUnwrappedLine < injectedTextEndOffsetInUnwrappedLine) { + // Injected text ends after or with the given position (but also starts with or before it). + return { + offsetInUnwrappedLine: injectedTextStartOffsetInUnwrappedLine, + length + }; + } + + totalInjectedTextLengthBefore += length; + } + } + + return undefined; } } diff --git a/src/vs/editor/common/viewModel/viewModelImpl.ts b/src/vs/editor/common/viewModel/viewModelImpl.ts index 9e3e4f0b8f1d7..dc410ae2ca944 100644 --- a/src/vs/editor/common/viewModel/viewModelImpl.ts +++ b/src/vs/editor/common/viewModel/viewModelImpl.ts @@ -12,7 +12,7 @@ import { IPosition, Position } from 'vs/editor/common/core/position'; import { ISelection, Selection } from 'vs/editor/common/core/selection'; import { IRange, Range } from 'vs/editor/common/core/range'; import { IConfiguration, IViewState, ScrollType, ICursorState, ICommand, INewScrollPosition } from 'vs/editor/common/editorCommon'; -import { EndOfLinePreference, IActiveIndentGuideInfo, ITextModel, TrackedRangeStickiness, TextModelResolvedOptions, IIdentifiedSingleEditOperation, ICursorStateComputer, PositionNormalizationAffinity } from 'vs/editor/common/model'; +import { EndOfLinePreference, IActiveIndentGuideInfo, ITextModel, TrackedRangeStickiness, TextModelResolvedOptions, IIdentifiedSingleEditOperation, ICursorStateComputer, PositionAffinity } from 'vs/editor/common/model'; import { ModelDecorationOverviewRulerOptions, ModelDecorationMinimapOptions } from 'vs/editor/common/model/textModel'; import * as textModelEvents from 'vs/editor/common/model/textModelEvents'; import { ColorId, LanguageId, TokenizationRegistry } from 'vs/editor/common/modes'; @@ -1062,7 +1062,7 @@ export class ViewModel extends Disposable implements IViewModel { } } - normalizePosition(position: Position, affinity: PositionNormalizationAffinity): Position { + normalizePosition(position: Position, affinity: PositionAffinity): Position { return this._lines.normalizePosition(position, affinity); } diff --git a/src/vs/editor/test/common/viewModel/lineBreakData.test.ts b/src/vs/editor/test/common/viewModel/lineBreakData.test.ts index ccc275b94569d..dd451072be27f 100644 --- a/src/vs/editor/test/common/viewModel/lineBreakData.test.ts +++ b/src/vs/editor/test/common/viewModel/lineBreakData.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert = require('assert'); +import { PositionAffinity } from 'vs/editor/common/model'; import { ModelDecorationInjectedTextOptions } from 'vs/editor/common/model/textModel'; import { LineBreakData } from 'vs/editor/common/viewModel/viewModel'; @@ -33,8 +34,8 @@ suite('Editor ViewModel - LineBreakData', () => { return sequence(11).map(i => data.getInputOffsetOfOutputPosition(outputLineIdx, i)); } - function getOutputOffsets(data: LineBreakData): string[] { - return sequence(25).map(i => data.getOutputPositionOfInputOffset(i).toString()); + function getOutputOffsets(data: LineBreakData, affinity: PositionAffinity): string[] { + return sequence(25).map(i => data.getOutputPositionOfInputOffset(i, affinity).toString()); } function mapTextToInjectedTextOptions(arr: string[]): ModelDecorationInjectedTextOptions[] { @@ -52,10 +53,20 @@ suite('Editor ViewModel - LineBreakData', () => { test('getOutputPositionOfInputOffset', () => { data.getOutputPositionOfInputOffset(20); - assert.deepStrictEqual(getOutputOffsets(data), [ + assert.deepStrictEqual(getOutputOffsets(data, PositionAffinity.None), [ '0:0', '0:1', '0:2', '0:4', '0:7', '0:8', '0:9', '1:0', '1:1', '1:2', '1:3', '1:7', '1:8', '1:9', '1:10', '1:11', '1:12', '1:13', '1:14', '1:15', '1:16', '1:17', '1:18', '1:19', '1:20', ]); + + assert.deepStrictEqual(getOutputOffsets(data, PositionAffinity.Left), [ + '0:0', '0:1', '0:2', '0:4', '0:7', '0:8', '0:9', '0:10', + '1:1', '1:2', '1:3', '1:7', '1:8', '1:9', '1:10', '1:11', '1:12', '1:13', '1:14', '1:15', '1:16', '1:17', '1:18', '1:19', '1:20', + ]); + + assert.deepStrictEqual(getOutputOffsets(data, PositionAffinity.Right), [ + '0:0', '0:1', '0:3', '0:6', '0:7', '0:8', '0:9', + '1:0', '1:1', '1:2', '1:6', '1:7', '1:8', '1:9', '1:10', '1:11', '1:12', '1:13', '1:14', '1:15', '1:16', '1:17', '1:18', '1:19', '1:20', + ]); }); test('getInputOffsetOfOutputPosition is inverse of getOutputPositionOfInputOffset', () => { From a0bc22b88785d09e607eab2910523f3d5a7ec8a6 Mon Sep 17 00:00:00 2001 From: Alex Dima Date: Thu, 8 Jul 2021 12:31:27 +0200 Subject: [PATCH 2/6] Validate the view state quickly, with few allocations --- src/vs/editor/common/controller/oneCursor.ts | 56 +++++++++++--------- 1 file changed, 31 insertions(+), 25 deletions(-) diff --git a/src/vs/editor/common/controller/oneCursor.ts b/src/vs/editor/common/controller/oneCursor.ts index 4b2758d50b3fd..bb07a4da7172c 100644 --- a/src/vs/editor/common/controller/oneCursor.ts +++ b/src/vs/editor/common/controller/oneCursor.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { CursorContext, CursorState, SingleCursorState } from 'vs/editor/common/controller/cursorCommon'; +import { CursorContext, CursorState, ICursorSimpleModel, SingleCursorState } from 'vs/editor/common/controller/cursorCommon'; import { Position } from 'vs/editor/common/core/position'; import { Range } from 'vs/editor/common/core/range'; import { Selection, SelectionDirection } from 'vs/editor/common/core/selection'; @@ -77,32 +77,38 @@ export class Cursor { this._setState(context, modelState, viewState); } + private static _validatePositionWithCache(viewModel: ICursorSimpleModel, position: Position, cacheInput: Position, cacheOutput: Position): Position { + if (position.equals(cacheInput)) { + return cacheOutput; + } + return viewModel.normalizePosition(position, PositionAffinity.None); + } + + private static _validateViewState(viewModel: ICursorSimpleModel, viewState: SingleCursorState): SingleCursorState { + const position = viewState.position; + const sStartPosition = viewState.selectionStart.getStartPosition(); + const sEndPosition = viewState.selectionStart.getEndPosition(); + + const validPosition = viewModel.normalizePosition(position, PositionAffinity.None); + const validSStartPosition = this._validatePositionWithCache(viewModel, sStartPosition, position, validPosition); + const validSEndPosition = this._validatePositionWithCache(viewModel, sEndPosition, sStartPosition, validSStartPosition); + + if (position.equals(validPosition) && sStartPosition.equals(validSStartPosition) && sEndPosition.equals(validSEndPosition)) { + // fast path: the state is valid + return viewState; + } + + return new SingleCursorState( + Range.fromPositions(validSStartPosition, validSEndPosition), + viewState.selectionStartLeftoverVisibleColumns + sStartPosition.column - validSStartPosition.column, + validPosition, + viewState.leftoverVisibleColumns + position.column - validPosition.column, + ); + } + private _setState(context: CursorContext, modelState: SingleCursorState | null, viewState: SingleCursorState | null): void { if (viewState) { - const cache = new Map(); - function normalize(pos: Position): Position { - const existing = cache.get(pos.toString()); - if (existing) { - return existing; - } - const result = context.viewModel.normalizePosition(pos, PositionAffinity.None); - cache.set(pos.toString(), result); - return result; - } - const normalizedPosition = normalize(viewState.position); - const columnDelta = viewState.position.column - normalizedPosition.column; - - const updatedState = new SingleCursorState( - Selection.fromPositions( - normalize(viewState.selectionStart.getStartPosition()), - normalize(viewState.selectionStart.getEndPosition()) - ), - viewState.selectionStartLeftoverVisibleColumns + columnDelta, - normalizedPosition, - viewState.leftoverVisibleColumns + columnDelta, - ); - - viewState = updatedState; + viewState = Cursor._validateViewState(context.viewModel, viewState); } if (!modelState) { From fafd3b08f93d3025997756cff4f79ce9cccbb6f3 Mon Sep 17 00:00:00 2001 From: Alex Dima Date: Thu, 8 Jul 2021 13:51:40 +0200 Subject: [PATCH 3/6] Remove no longer necessary check (due to using now affinity) --- .../editor/common/viewModel/splitLinesCollection.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/vs/editor/common/viewModel/splitLinesCollection.ts b/src/vs/editor/common/viewModel/splitLinesCollection.ts index c6ce5e8d53d80..cb1ff3dda1dd7 100644 --- a/src/vs/editor/common/viewModel/splitLinesCollection.ts +++ b/src/vs/editor/common/viewModel/splitLinesCollection.ts @@ -883,19 +883,12 @@ export class SplitLinesCollection implements IViewModelLinesCollection { } public convertModelRangeToViewRange(modelRange: Range): Range { - // If the range is empty, we don't want the range to get expanded or moved when - let start = this.convertModelPositionToViewPosition(modelRange.startLineNumber, modelRange.startColumn, PositionAffinity.Right); + const start = this.convertModelPositionToViewPosition(modelRange.startLineNumber, modelRange.startColumn, PositionAffinity.Right); let end = this.convertModelPositionToViewPosition(modelRange.endLineNumber, modelRange.endColumn, PositionAffinity.Left); if (end.isBefore(start)) { + // If the range is empty, we don't want the range to get expanded just by converting to a view range end = start; } - if (modelRange.startLineNumber === modelRange.endLineNumber && start.lineNumber !== end.lineNumber) { - // This is a single line range that ends up taking more lines due to wrapping - if (end.column === this.getViewLineMinColumn(end.lineNumber)) { - // the end column lands on the first column of the next line - return new Range(start.lineNumber, start.column, end.lineNumber - 1, this.getViewLineMaxColumn(end.lineNumber - 1)); - } - } return new Range(start.lineNumber, start.column, end.lineNumber, end.column); } From a32a2920a46975f7dfc3ffd9a69c4d5a474f0c60 Mon Sep 17 00:00:00 2001 From: Alex Dima Date: Thu, 8 Jul 2021 14:45:57 +0200 Subject: [PATCH 4/6] :lipstick: --- .../common/viewModel/splitLinesCollection.ts | 21 +++------- src/vs/editor/common/viewModel/viewModel.ts | 38 +++++++++---------- 2 files changed, 25 insertions(+), 34 deletions(-) diff --git a/src/vs/editor/common/viewModel/splitLinesCollection.ts b/src/vs/editor/common/viewModel/splitLinesCollection.ts index cb1ff3dda1dd7..2b53edf7ff65e 100644 --- a/src/vs/editor/common/viewModel/splitLinesCollection.ts +++ b/src/vs/editor/common/viewModel/splitLinesCollection.ts @@ -1224,10 +1224,8 @@ export class SplitLine implements ISplitLine { let r: string; if (this._lineBreakData.injectionOffsets !== null) { - // This could be done more performant if required. - r = LineInjectedText.applyInjectedText(model.getLineContent(modelLineNumber), this._lineBreakData.injectionOffsets.map((offset, idx) => - new LineInjectedText(0, 0, offset, this._lineBreakData.injectionOptions![idx], 0) - )).substring(startOffset, endOffset); + const injectedTexts = this._lineBreakData.injectionOffsets.map((offset, idx) => new LineInjectedText(0, 0, offset, this._lineBreakData.injectionOptions![idx], 0)); + r = LineInjectedText.applyInjectedText(model.getLineContent(modelLineNumber), injectedTexts).substring(startOffset, endOffset); } else { r = model.getValueInRange({ startLineNumber: modelLineNumber, @@ -1329,18 +1327,11 @@ export class SplitLine implements ISplitLine { // Injected text ends after or in this line (but also starts in or before this line). const inlineClassName = injectionOptions![i].inlineClassName; if (inlineClassName) { - let offset = 0; - if (outputLineIndex > 0) { - offset = lineBreakData.wrappedTextIndentLength; - } - const start = Math.max(injectedTextStartOffsetInUnwrappedLine - lineStartOffsetInUnwrappedLine, 0) + offset; - const end = Math.min(injectedTextEndOffsetInUnwrappedLine - lineStartOffsetInUnwrappedLine, lineEndOffsetInUnwrappedLine) + offset; + const offset = (outputLineIndex > 0 ? lineBreakData.wrappedTextIndentLength : 0); + const start = offset + Math.max(injectedTextStartOffsetInUnwrappedLine - lineStartOffsetInUnwrappedLine, 0); + const end = offset + Math.min(injectedTextEndOffsetInUnwrappedLine - lineStartOffsetInUnwrappedLine, lineEndOffsetInUnwrappedLine); if (start !== end) { - inlineDecorations.push(new SingleLineInlineDecoration( - start, - end, - inlineClassName - )); + inlineDecorations.push(new SingleLineInlineDecoration(start, end, inlineClassName)); } } } diff --git a/src/vs/editor/common/viewModel/viewModel.ts b/src/vs/editor/common/viewModel/viewModel.ts index 3663bd67d25fe..13dc707696bf7 100644 --- a/src/vs/editor/common/viewModel/viewModel.ts +++ b/src/vs/editor/common/viewModel/viewModel.ts @@ -102,11 +102,8 @@ export class OutputPosition { } toPosition(baseLineNumber: number, wrappedTextIndentLength: number): Position { - let delta = 0; - if (this.outputLineIndex > 0) { - delta += wrappedTextIndentLength; - } - return new Position(baseLineNumber + this.outputLineIndex, this.outputOffset + 1 + delta); + const delta = (this.outputLineIndex > 0 ? wrappedTextIndentLength : 0); + return new Position(baseLineNumber + this.outputLineIndex, delta + this.outputOffset + 1); } } @@ -165,7 +162,7 @@ export class LineBreakData { return this.getOutputPositionOfOffsetInUnwrappedLine(inputOffset, affinity); } - public getOutputPositionOfOffsetInUnwrappedLine(offset: number, affinity: PositionAffinity = PositionAffinity.None): OutputPosition { + public getOutputPositionOfOffsetInUnwrappedLine(inputOffset: number, affinity: PositionAffinity = PositionAffinity.None): OutputPosition { let low = 0; let high = this.breakOffsets.length - 1; let mid = 0; @@ -177,23 +174,26 @@ export class LineBreakData { const midStop = this.breakOffsets[mid]; midStart = mid > 0 ? this.breakOffsets[mid - 1] : 0; - if (affinity === PositionAffinity.Left && offset === midStart) { - console.log('foo'); - } - if (affinity !== PositionAffinity.Left && offset === midStop) { - console.log('bar'); - } - - if (offset < midStart || (affinity === PositionAffinity.Left && offset === midStart)) { - high = mid - 1; - } else if (offset > midStop || (affinity !== PositionAffinity.Left && offset === midStop)) { - low = mid + 1; + if (affinity === PositionAffinity.Left) { + if (inputOffset <= midStart) { + high = mid - 1; + } else if (inputOffset > midStop) { + low = mid + 1; + } else { + break; + } } else { - break; + if (inputOffset < midStart) { + high = mid - 1; + } else if (inputOffset >= midStop) { + low = mid + 1; + } else { + break; + } } } - return new OutputPosition(mid, offset - midStart); + return new OutputPosition(mid, inputOffset - midStart); } public outputPositionToOffsetInUnwrappedLine(outputLineIndex: number, outputOffset: number): number { From 05beeb67ea938e4036deb6cdc43b2b4e8bc599fd Mon Sep 17 00:00:00 2001 From: Alex Dima Date: Thu, 8 Jul 2021 14:49:17 +0200 Subject: [PATCH 5/6] Avoid converting to model position and back when hitting injected text --- .../common/viewModel/splitLinesCollection.ts | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/src/vs/editor/common/viewModel/splitLinesCollection.ts b/src/vs/editor/common/viewModel/splitLinesCollection.ts index 2b53edf7ff65e..dcc9a30709daf 100644 --- a/src/vs/editor/common/viewModel/splitLinesCollection.ts +++ b/src/vs/editor/common/viewModel/splitLinesCollection.ts @@ -1429,19 +1429,20 @@ export class SplitLine implements ISplitLine { public normalizePosition(model: ISimpleModel, modelLineNumber: number, outputLineIndex: number, outputPosition: Position, affinity: PositionAffinity): Position { if (this._lineBreakData.injectionOffsets !== null) { const baseViewLineNumber = outputPosition.lineNumber - outputLineIndex; - - if (affinity === PositionAffinity.None) { - const offsetInUnwrappedLine = this._lineBreakData.outputPositionToOffsetInUnwrappedLine(outputLineIndex, outputPosition.column - 1); - const injectedText = this._lineBreakData.getInjectedTextAt(offsetInUnwrappedLine); - if (injectedText) { - if (offsetInUnwrappedLine === injectedText.offsetInUnwrappedLine + injectedText.length) { - return this._lineBreakData.getOutputPositionOfOffsetInUnwrappedLine(injectedText.offsetInUnwrappedLine + injectedText.length).toPosition(baseViewLineNumber, this._lineBreakData.wrappedTextIndentLength); - } - return this._lineBreakData.getOutputPositionOfOffsetInUnwrappedLine(injectedText.offsetInUnwrappedLine).toPosition(baseViewLineNumber, this._lineBreakData.wrappedTextIndentLength); + const offsetInUnwrappedLine = this._lineBreakData.outputPositionToOffsetInUnwrappedLine(outputLineIndex, outputPosition.column - 1); + const injectedText = this._lineBreakData.getInjectedTextAt(offsetInUnwrappedLine); + if (injectedText) { + // we've hit injected text + let newOffsetInUnwrappedLine: number; + if (affinity === PositionAffinity.Right || (affinity === PositionAffinity.None && offsetInUnwrappedLine === injectedText.offsetInUnwrappedLine + injectedText.length)) { + // going right + newOffsetInUnwrappedLine = injectedText.offsetInUnwrappedLine + injectedText.length; + } else { + // going left + newOffsetInUnwrappedLine = injectedText.offsetInUnwrappedLine; } - } else { - const modelColumn = this.getModelColumnOfViewPosition(outputLineIndex, outputPosition.column); - return this.getViewPositionOfModelPosition(baseViewLineNumber, modelColumn, affinity); + + return this._lineBreakData.getOutputPositionOfOffsetInUnwrappedLine(newOffsetInUnwrappedLine).toPosition(baseViewLineNumber, this._lineBreakData.wrappedTextIndentLength); } } From e03e480ddb3638888510501153e0ba6808252e28 Mon Sep 17 00:00:00 2001 From: Alex Dima Date: Thu, 8 Jul 2021 16:11:51 +0200 Subject: [PATCH 6/6] Add `LineBreakData.normalizeOffsetAroundInjections` which can deal with multiple touching injected texts and add a test with this case --- .../common/viewModel/splitLinesCollection.ts | 17 ++---- src/vs/editor/common/viewModel/viewModel.ts | 43 ++++++++++++++- .../common/viewModel/viewModelImpl.test.ts | 54 ++++++++++++++++++- 3 files changed, 98 insertions(+), 16 deletions(-) diff --git a/src/vs/editor/common/viewModel/splitLinesCollection.ts b/src/vs/editor/common/viewModel/splitLinesCollection.ts index dcc9a30709daf..1010899cc6197 100644 --- a/src/vs/editor/common/viewModel/splitLinesCollection.ts +++ b/src/vs/editor/common/viewModel/splitLinesCollection.ts @@ -1430,19 +1430,10 @@ export class SplitLine implements ISplitLine { if (this._lineBreakData.injectionOffsets !== null) { const baseViewLineNumber = outputPosition.lineNumber - outputLineIndex; const offsetInUnwrappedLine = this._lineBreakData.outputPositionToOffsetInUnwrappedLine(outputLineIndex, outputPosition.column - 1); - const injectedText = this._lineBreakData.getInjectedTextAt(offsetInUnwrappedLine); - if (injectedText) { - // we've hit injected text - let newOffsetInUnwrappedLine: number; - if (affinity === PositionAffinity.Right || (affinity === PositionAffinity.None && offsetInUnwrappedLine === injectedText.offsetInUnwrappedLine + injectedText.length)) { - // going right - newOffsetInUnwrappedLine = injectedText.offsetInUnwrappedLine + injectedText.length; - } else { - // going left - newOffsetInUnwrappedLine = injectedText.offsetInUnwrappedLine; - } - - return this._lineBreakData.getOutputPositionOfOffsetInUnwrappedLine(newOffsetInUnwrappedLine).toPosition(baseViewLineNumber, this._lineBreakData.wrappedTextIndentLength); + const normalizedOffsetInUnwrappedLine = this._lineBreakData.normalizeOffsetAroundInjections(offsetInUnwrappedLine, affinity); + if (normalizedOffsetInUnwrappedLine !== offsetInUnwrappedLine) { + // injected text caused a change + return this._lineBreakData.getOutputPositionOfOffsetInUnwrappedLine(normalizedOffsetInUnwrappedLine, affinity).toPosition(baseViewLineNumber, this._lineBreakData.wrappedTextIndentLength); } } diff --git a/src/vs/editor/common/viewModel/viewModel.ts b/src/vs/editor/common/viewModel/viewModel.ts index 13dc707696bf7..3b4825d1c1dfc 100644 --- a/src/vs/editor/common/viewModel/viewModel.ts +++ b/src/vs/editor/common/viewModel/viewModel.ts @@ -204,7 +204,45 @@ export class LineBreakData { return result; } - public getInjectedTextAt(offsetInUnwrappedLine: number): { offsetInUnwrappedLine: number, length: number } | undefined { + public normalizeOffsetAroundInjections(offsetInUnwrappedLine: number, affinity: PositionAffinity): number { + const injectedText = this.getInjectedTextAt(offsetInUnwrappedLine); + if (!injectedText) { + return offsetInUnwrappedLine; + } + + if (affinity === PositionAffinity.None) { + if (offsetInUnwrappedLine === injectedText.offsetInUnwrappedLine + injectedText.length) { + // go to the end of this injected text + return injectedText.offsetInUnwrappedLine + injectedText.length; + } else { + // go to the start of this injected text + return injectedText.offsetInUnwrappedLine; + } + } + + if (affinity === PositionAffinity.Right) { + let result = injectedText.offsetInUnwrappedLine + injectedText.length; + let index = injectedText.injectedTextIndex; + // traverse all injected text that touch eachother + while (index + 1 < this.injectionOffsets!.length && this.injectionOffsets![index + 1] === this.injectionOffsets![index]) { + result += this.injectionOptions![index + 1].content.length; + index++; + } + return result; + } + + // affinity is left + let result = injectedText.offsetInUnwrappedLine; + let index = injectedText.injectedTextIndex; + // traverse all injected text that touch eachother + while (index - 1 >= 0 && this.injectionOffsets![index - 1] === this.injectionOffsets![index]) { + result -= this.injectionOptions![index - 1].content.length; + index++; + } + return result; + } + + private getInjectedTextAt(offsetInUnwrappedLine: number): { injectedTextIndex: number, offsetInUnwrappedLine: number, length: number } | undefined { const injectionOffsets = this.injectionOffsets; const injectionOptions = this.injectionOptions; @@ -220,9 +258,10 @@ export class LineBreakData { break; // All later injected texts have an even larger offset. } - if (offsetInUnwrappedLine < injectedTextEndOffsetInUnwrappedLine) { + if (offsetInUnwrappedLine <= injectedTextEndOffsetInUnwrappedLine) { // Injected text ends after or with the given position (but also starts with or before it). return { + injectedTextIndex: i, offsetInUnwrappedLine: injectedTextStartOffsetInUnwrappedLine, length }; diff --git a/src/vs/editor/test/common/viewModel/viewModelImpl.test.ts b/src/vs/editor/test/common/viewModel/viewModelImpl.test.ts index 0e9f99aba50cd..ef9866ae107fb 100644 --- a/src/vs/editor/test/common/viewModel/viewModelImpl.test.ts +++ b/src/vs/editor/test/common/viewModel/viewModelImpl.test.ts @@ -5,10 +5,11 @@ import * as assert from 'assert'; import { Range } from 'vs/editor/common/core/range'; -import { EndOfLineSequence } from 'vs/editor/common/model'; +import { EndOfLineSequence, PositionAffinity } from 'vs/editor/common/model'; import { testViewModel } from 'vs/editor/test/common/viewModel/testViewModel'; import { ViewEventHandler } from 'vs/editor/common/viewModel/viewEventHandler'; import { ViewEvent } from 'vs/editor/common/view/viewEvents'; +import { Position } from 'vs/editor/common/core/position'; suite('ViewModel', () => { @@ -294,4 +295,55 @@ suite('ViewModel', () => { } ); }); + + test('normalizePosition with multiple touching injected text', () => { + testViewModel( + [ + 'just some text' + ], + {}, + (viewModel, model) => { + model.deltaDecorations([], [ + { + range: new Range(1, 8, 1, 8), + options: { + description: 'test', + before: { + content: 'bar' + } + } + }, + { + range: new Range(1, 8, 1, 8), + options: { + description: 'test', + before: { + content: 'bz' + } + } + }, + ]); + + // just sobarbzme text + + assert.deepStrictEqual(viewModel.normalizePosition(new Position(1, 8), PositionAffinity.None), new Position(1, 8)); + assert.deepStrictEqual(viewModel.normalizePosition(new Position(1, 9), PositionAffinity.None), new Position(1, 8)); + assert.deepStrictEqual(viewModel.normalizePosition(new Position(1, 11), PositionAffinity.None), new Position(1, 11)); + assert.deepStrictEqual(viewModel.normalizePosition(new Position(1, 12), PositionAffinity.None), new Position(1, 11)); + assert.deepStrictEqual(viewModel.normalizePosition(new Position(1, 13), PositionAffinity.None), new Position(1, 13)); + + assert.deepStrictEqual(viewModel.normalizePosition(new Position(1, 8), PositionAffinity.Left), new Position(1, 8)); + assert.deepStrictEqual(viewModel.normalizePosition(new Position(1, 9), PositionAffinity.Left), new Position(1, 8)); + assert.deepStrictEqual(viewModel.normalizePosition(new Position(1, 11), PositionAffinity.Left), new Position(1, 8)); + assert.deepStrictEqual(viewModel.normalizePosition(new Position(1, 12), PositionAffinity.Left), new Position(1, 8)); + assert.deepStrictEqual(viewModel.normalizePosition(new Position(1, 13), PositionAffinity.Left), new Position(1, 8)); + + assert.deepStrictEqual(viewModel.normalizePosition(new Position(1, 8), PositionAffinity.Right), new Position(1, 13)); + assert.deepStrictEqual(viewModel.normalizePosition(new Position(1, 9), PositionAffinity.Right), new Position(1, 13)); + assert.deepStrictEqual(viewModel.normalizePosition(new Position(1, 11), PositionAffinity.Right), new Position(1, 13)); + assert.deepStrictEqual(viewModel.normalizePosition(new Position(1, 12), PositionAffinity.Right), new Position(1, 13)); + assert.deepStrictEqual(viewModel.normalizePosition(new Position(1, 13), PositionAffinity.Right), new Position(1, 13)); + } + ); + }); });