Repository: microsoft/vscode (TypeScript)
Date: 2021-05-14 09:21:58+00:00
Task type: Bug Fix

Issue:
Leading whitespace in wrapped line confuses sticky tab stops
* use the following settings:
```json
	"editor.renderWhitespace": "all",
	"editor.stickyTabStops": true,
	"editor.wordWrap": "wordWrapColumn",
	"editor.wordWrapColumn": 20
```
* and open the following file:
```
    x
        aaaaaaaaaaaa    bbb
aaaaaaaaaaaa    bbb
```
* add two cursors before `bbb`
* press shift+left
* observe that the selections have diverged


https://user-images.githubusercontent.com/5047891/117347785-bee02a00-aea9-11eb-9d66-7b5ef2ab2283.mp4



Leading whitespace in wrapped line confuses sticky tab stops
* use the following settings:
```json
	"editor.renderWhitespace": "all",
	"editor.stickyTabStops": true,
	"editor.wordWrap": "wordWrapColumn",
	"editor.wordWrapColumn": 20
```
* and open the following file:
```
    x
        aaaaaaaaaaaa    bbb
aaaaaaaaaaaa    bbb
```
* add two cursors before `bbb`
* press shift+left
* observe that the selections have diverged


https://user-images.githubusercontent.com/5047891/117347785-bee02a00-aea9-11eb-9d66-7b5ef2ab2283.mp4





Issue discussion:

Solution patch:
diff --git a/src/vs/editor/common/controller/cursorCommon.ts b/src/vs/editor/common/controller/cursorCommon.ts
--- a/src/vs/editor/common/controller/cursorCommon.ts
+++ b/src/vs/editor/common/controller/cursorCommon.ts
@@ -222,6 +222,11 @@ export interface ICursorSimpleModel {
 	getLineFirstNonWhitespaceColumn(lineNumber: number): number;
 	getLineLastNonWhitespaceColumn(lineNumber: number): number;
 	normalizePosition(position: Position, affinity: PositionNormalizationAffinity): Position;
+	/**
+	 * Gets the column at which indentation stops at a given line.
+	 * @internal
+	 */
+	getLineIndentColumn(lineNumber: number): number;
 }
 
 /**
diff --git a/src/vs/editor/common/controller/cursorMoveOperations.ts b/src/vs/editor/common/controller/cursorMoveOperations.ts
--- a/src/vs/editor/common/controller/cursorMoveOperations.ts
+++ b/src/vs/editor/common/controller/cursorMoveOperations.ts
@@ -38,13 +38,15 @@ export class MoveOperations {
 	}
 
 	private static leftPositionAtomicSoftTabs(model: ICursorSimpleModel, position: Position, tabSize: number): Position {
-		const minColumn = model.getLineMinColumn(position.lineNumber);
-		const lineContent = model.getLineContent(position.lineNumber);
-		const newPosition = AtomicTabMoveOperations.atomicPosition(lineContent, position.column - 1, tabSize, Direction.Left);
-		if (newPosition === -1 || newPosition + 1 < minColumn) {
-			return this.leftPosition(model, position);
+		if (position.column <= model.getLineIndentColumn(position.lineNumber)) {
+			const minColumn = model.getLineMinColumn(position.lineNumber);
+			const lineContent = model.getLineContent(position.lineNumber);
+			const newPosition = AtomicTabMoveOperations.atomicPosition(lineContent, position.column - 1, tabSize, Direction.Left);
+			if (newPosition !== -1 && newPosition + 1 >= minColumn) {
+				return new Position(position.lineNumber, newPosition + 1);
+			}
 		}
-		return new Position(position.lineNumber, newPosition + 1);
+		return this.leftPosition(model, position);
 	}
 
 	private static left(config: CursorConfiguration, model: ICursorSimpleModel, position: Position): CursorPosition {
@@ -115,12 +117,14 @@ export class MoveOperations {
 	}
 
 	public static rightPositionAtomicSoftTabs(model: ICursorSimpleModel, lineNumber: number, column: number, tabSize: number, indentSize: number): Position {
-		const lineContent = model.getLineContent(lineNumber);
-		const newPosition = AtomicTabMoveOperations.atomicPosition(lineContent, column - 1, tabSize, Direction.Right);
-		if (newPosition === -1) {
-			return this.rightPosition(model, lineNumber, column);
+		if (column < model.getLineIndentColumn(lineNumber)) {
+			const lineContent = model.getLineContent(lineNumber);
+			const newPosition = AtomicTabMoveOperations.atomicPosition(lineContent, column - 1, tabSize, Direction.Right);
+			if (newPosition !== -1) {
+				return new Position(lineNumber, newPosition + 1);
+			}
 		}
-		return new Position(lineNumber, newPosition + 1);
+		return this.rightPosition(model, lineNumber, column);
 	}
 
 	public static right(config: CursorConfiguration, model: ICursorSimpleModel, position: Position): CursorPosition {
diff --git a/src/vs/editor/common/model.ts b/src/vs/editor/common/model.ts
--- a/src/vs/editor/common/model.ts
+++ b/src/vs/editor/common/model.ts
@@ -1261,6 +1261,12 @@ export interface ITextModel {
 	 * @internal
 	 */
 	normalizePosition(position: Position, affinity: PositionNormalizationAffinity): Position;
+
+	/**
+	 * Gets the column at which indentation stops at a given line.
+	 * @internal
+	*/
+	getLineIndentColumn(lineNumber: number): number;
 }
 
 /**
diff --git a/src/vs/editor/common/model/textModel.ts b/src/vs/editor/common/model/textModel.ts
--- a/src/vs/editor/common/model/textModel.ts
+++ b/src/vs/editor/common/model/textModel.ts
@@ -3031,6 +3031,27 @@ export class TextModel extends Disposable implements model.ITextModel {
 	normalizePosition(position: Position, affinity: model.PositionNormalizationAffinity): Position {
 		return position;
 	}
+
+	/**
+	 * Gets the column at which indentation stops at a given line.
+	 * @internal
+	*/
+	public getLineIndentColumn(lineNumber: number): number {
+		// Columns start with 1.
+		return indentOfLine(this.getLineContent(lineNumber)) + 1;
+	}
+}
+
+function indentOfLine(line: string): number {
+	let indent = 0;
+	for (const c of line) {
+		if (c === ' ' || c === '\t') {
+			indent++;
+		} else {
+			break;
+		}
+	}
+	return indent;
 }
 
 //#region Decorations
diff --git a/src/vs/editor/common/viewModel/splitLinesCollection.ts b/src/vs/editor/common/viewModel/splitLinesCollection.ts
--- a/src/vs/editor/common/viewModel/splitLinesCollection.ts
+++ b/src/vs/editor/common/viewModel/splitLinesCollection.ts
@@ -78,6 +78,11 @@ export interface IViewModelLinesCollection extends IDisposable {
 	getDecorationsInRange(range: Range, ownerId: number, filterOutValidation: boolean): IModelDecoration[];
 
 	normalizePosition(position: Position, affinity: PositionNormalizationAffinity): Position;
+	/**
+	 * Gets the column at which indentation stops at a given line.
+	 * @internal
+	*/
+	getLineIndentColumn(lineNumber: number): number;
 }
 
 export class CoordinatesConverter implements ICoordinatesConverter {
@@ -983,6 +988,22 @@ export class SplitLinesCollection implements IViewModelLinesCollection {
 
 		return this.lines[lineIndex].normalizePosition(this.model, lineIndex + 1, remainder, position, affinity);
 	}
+
+	public getLineIndentColumn(lineNumber: number): number {
+		const viewLineNumber = this._toValidViewLineNumber(lineNumber);
+		const r = this.prefixSumComputer.getIndexOf(viewLineNumber - 1);
+		const lineIndex = r.index;
+		const remainder = r.remainder;
+
+		if (remainder === 0) {
+			return this.model.getLineIndentColumn(lineIndex + 1);
+		}
+
+		// wrapped lines have no indentation.
+		// We deliberately don't handle the case that indentation is wrapped
+		// to avoid two view lines reporting indentation for the very same model line.
+		return 0;
+	}
 }
 
 class VisibleIdentitySplitLine implements ISplitLine {
@@ -1575,6 +1596,10 @@ export class IdentityLinesCollection implements IViewModelLinesCollection {
 	normalizePosition(position: Position, affinity: PositionNormalizationAffinity): Position {
 		return this.model.normalizePosition(position, affinity);
 	}
+
+	public getLineIndentColumn(lineNumber: number): number {
+		return this.model.getLineIndentColumn(lineNumber);
+	}
 }
 
 class OverviewRulerDecorations {
diff --git a/src/vs/editor/common/viewModel/viewModelImpl.ts b/src/vs/editor/common/viewModel/viewModelImpl.ts
--- a/src/vs/editor/common/viewModel/viewModelImpl.ts
+++ b/src/vs/editor/common/viewModel/viewModelImpl.ts
@@ -1041,4 +1041,12 @@ export class ViewModel extends Disposable implements IViewModel {
 	normalizePosition(position: Position, affinity: PositionNormalizationAffinity): Position {
 		return this._lines.normalizePosition(position, affinity);
 	}
+
+	/**
+	 * Gets the column at which indentation stops at a given line.
+	 * @internal
+	*/
+	getLineIndentColumn(lineNumber: number): number {
+		return this._lines.getLineIndentColumn(lineNumber);
+	}
 }


Test patch:
diff --git a/src/vs/editor/test/browser/controller/cursor.test.ts b/src/vs/editor/test/browser/controller/cursor.test.ts
--- a/src/vs/editor/test/browser/controller/cursor.test.ts
+++ b/src/vs/editor/test/browser/controller/cursor.test.ts
@@ -2760,6 +2760,34 @@ suite('Editor Controller - Regression tests', () => {
 			}
 		);
 	});
+
+	test('issue #123178: sticky tab in consecutive wrapped lines', () => {
+		const model = createTextModel('    aaaa        aaaa', { tabSize: 4 });
+
+		withTestCodeEditor(
+			null,
+			{
+				model: model,
+				wordWrap: 'wordWrapColumn',
+				wordWrapColumn: 8,
+				stickyTabStops: true,
+			},
+			(editor, viewModel) => {
+				viewModel.setSelections('test', [
+					new Selection(1, 9, 1, 9)
+				]);
+				moveRight(editor, viewModel, false);
+				assertCursor(viewModel, [
+					new Selection(1, 10, 1, 10),
+				]);
+
+				moveLeft(editor, viewModel, false);
+				assertCursor(viewModel, [
+					new Selection(1, 9, 1, 9),
+				]);
+			}
+		);
+	});
 });
 
 suite('Editor Controller - Cursor Configuration', () => {