Repository: microsoft/vscode (TypeScript)
Date: 2021-05-07 14:19:55+00:00
Task type: Bug Fix

Issue:
"Select all occurrences of find match" selects weirdly when word wrapping
Issue Type: <b>Bug</b>

With word wrap enabled, "select all occurrences of find match", if matching the end of a line, will select those weirdly, so that pressing Left will, instead of moving to the beginning of the line, move one beyond.

For instance, here, I'm searching for the string `,[space]`:

![image](https://user-images.githubusercontent.com/551184/91722406-0e1dd000-eb4f-11ea-9a77-3094a7e2f38f.png)

After I press Left, I expect the cursor to be at `,`, but instead:

![image](https://user-images.githubusercontent.com/551184/91722454-20980980-eb4f-11ea-9498-07857d293858.png)

Most cursors are correctly at `,`, but the cursors at the ends of lines are instead at `l`.

Here's a GIF showing the full process:

![fullprocess](https://user-images.githubusercontent.com/551184/91722893-c64b7880-eb4f-11ea-9cec-d25aa57fa7c4.gif)

This makes it very hard to process a list like `foo,bar,baz` into `'foo','bar','baz'`, because the misalignment will instead turn it into `'foo','ba'r',baz'`

VS Code version: Code 1.48.2 (a0479759d6e9ea56afa657e454193f72aef85bd0, 2020-08-25T10:09:08.021Z)
OS version: Darwin x64 19.3.0
Remote OS version: Linux x64 4.15.0-72-generic

<details>
<summary>System Info</summary>

|Item|Value|
|---|---|
|CPUs|Intel(R) Xeon(R) W-2140B CPU @ 3.20GHz (16 x 3200)|
|GPU Status|2d_canvas: enabled<br>flash_3d: enabled<br>flash_stage3d: enabled<br>flash_stage3d_baseline: enabled<br>gpu_compositing: enabled<br>metal: disabled_off<br>multiple_raster_threads: enabled_on<br>oop_rasterization: disabled_off<br>protected_video_decode: unavailable_off<br>rasterization: enabled<br>skia_renderer: disabled_off_ok<br>video_decode: enabled<br>viz_display_compositor: enabled_on<br>viz_hit_test_surface_layer: disabled_off_ok<br>webgl: enabled<br>webgl2: enabled|
|Load (avg)|1, 1, 1|
|Memory (System)|32.00GB (11.55GB free)|
|Process Argv|-psn_0_81940|
|Screen Reader|no|
|VM|0%|

|Item|Value|
|---|---|
|Remote|SSH: ps-sim|
|OS|Linux x64 4.15.0-72-generic|
|CPUs|Intel(R) Xeon(R) E-2136 CPU @ 3.30GHz (12 x 4298)|
|Memory (System)|62.69GB (4.80GB free)|
|VM|0%|
</details><details><summary>Extensions (7)</summary>

Extension|Author (truncated)|Version
---|---|---
gitlens|eam|10.2.2
remote-containers|ms-|0.134.1
remote-ssh|ms-|0.51.0
remote-ssh-edit|ms-|0.51.0
remote-wsl|ms-|0.44.4
vscode-remote-extensionpack|ms-|0.20.0
sublime-commands|Zar|0.1.0


</details>
<!-- generated by issue reporter -->
"Select all occurrences of find match" selects weirdly when word wrapping
Issue Type: <b>Bug</b>

With word wrap enabled, "select all occurrences of find match", if matching the end of a line, will select those weirdly, so that pressing Left will, instead of moving to the beginning of the line, move one beyond.

For instance, here, I'm searching for the string `,[space]`:

![image](https://user-images.githubusercontent.com/551184/91722406-0e1dd000-eb4f-11ea-9a77-3094a7e2f38f.png)

After I press Left, I expect the cursor to be at `,`, but instead:

![image](https://user-images.githubusercontent.com/551184/91722454-20980980-eb4f-11ea-9498-07857d293858.png)

Most cursors are correctly at `,`, but the cursors at the ends of lines are instead at `l`.

Here's a GIF showing the full process:

![fullprocess](https://user-images.githubusercontent.com/551184/91722893-c64b7880-eb4f-11ea-9cec-d25aa57fa7c4.gif)

This makes it very hard to process a list like `foo,bar,baz` into `'foo','bar','baz'`, because the misalignment will instead turn it into `'foo','ba'r',baz'`

VS Code version: Code 1.48.2 (a0479759d6e9ea56afa657e454193f72aef85bd0, 2020-08-25T10:09:08.021Z)
OS version: Darwin x64 19.3.0
Remote OS version: Linux x64 4.15.0-72-generic

<details>
<summary>System Info</summary>

|Item|Value|
|---|---|
|CPUs|Intel(R) Xeon(R) W-2140B CPU @ 3.20GHz (16 x 3200)|
|GPU Status|2d_canvas: enabled<br>flash_3d: enabled<br>flash_stage3d: enabled<br>flash_stage3d_baseline: enabled<br>gpu_compositing: enabled<br>metal: disabled_off<br>multiple_raster_threads: enabled_on<br>oop_rasterization: disabled_off<br>protected_video_decode: unavailable_off<br>rasterization: enabled<br>skia_renderer: disabled_off_ok<br>video_decode: enabled<br>viz_display_compositor: enabled_on<br>viz_hit_test_surface_layer: disabled_off_ok<br>webgl: enabled<br>webgl2: enabled|
|Load (avg)|1, 1, 1|
|Memory (System)|32.00GB (11.55GB free)|
|Process Argv|-psn_0_81940|
|Screen Reader|no|
|VM|0%|

|Item|Value|
|---|---|
|Remote|SSH: ps-sim|
|OS|Linux x64 4.15.0-72-generic|
|CPUs|Intel(R) Xeon(R) E-2136 CPU @ 3.30GHz (12 x 4298)|
|Memory (System)|62.69GB (4.80GB free)|
|VM|0%|
</details><details><summary>Extensions (7)</summary>

Extension|Author (truncated)|Version
---|---|---
gitlens|eam|10.2.2
remote-containers|ms-|0.134.1
remote-ssh|ms-|0.51.0
remote-ssh-edit|ms-|0.51.0
remote-wsl|ms-|0.44.4
vscode-remote-extensionpack|ms-|0.20.0
sublime-commands|Zar|0.1.0


</details>
<!-- generated by issue reporter -->


Issue discussion:
@hediet Here is the patch we sketched:

```diff
diff --git a/src/vs/editor/common/controller/cursorCommon.ts b/src/vs/editor/common/controller/cursorCommon.ts
index 0077027b265..5001e544f29 100644
--- a/src/vs/editor/common/controller/cursorCommon.ts
+++ b/src/vs/editor/common/controller/cursorCommon.ts
@@ -219,6 +219,8 @@ export interface ICursorSimpleModel {
 	getLineMaxColumn(lineNumber: number): number;
 	getLineFirstNonWhitespaceColumn(lineNumber: number): number;
 	getLineLastNonWhitespaceColumn(lineNumber: number): number;
+
+	getLeftPosition(lineNumber: number, column: number): Position;
 }
 
 /**
diff --git a/src/vs/editor/common/controller/cursorMoveCommands.ts b/src/vs/editor/common/controller/cursorMoveCommands.ts
index 2ae510c30a0..3302e57536a 100644
--- a/src/vs/editor/common/controller/cursorMoveCommands.ts
+++ b/src/vs/editor/common/controller/cursorMoveCommands.ts
@@ -419,26 +419,10 @@ export class CursorMoveCommands {
 	}
 
 	private static _moveLeft(viewModel: IViewModel, cursors: CursorState[], inSelectionMode: boolean, noOfColumns: number): PartialCursorState[] {
-		const hasMultipleCursors = (cursors.length > 1);
 		let result: PartialCursorState[] = [];
 		for (let i = 0, len = cursors.length; i < len; i++) {
 			const cursor = cursors[i];
-			const skipWrappingPointStop = hasMultipleCursors || !cursor.viewState.hasSelection();
 			let newViewState = MoveOperations.moveLeft(viewModel.cursorConfig, viewModel, cursor.viewState, inSelectionMode, noOfColumns);
-
-			if (skipWrappingPointStop
-				&& noOfColumns === 1
-				&& cursor.viewState.position.column === viewModel.getLineMinColumn(cursor.viewState.position.lineNumber)
-				&& newViewState.position.lineNumber !== cursor.viewState.position.lineNumber
-			) {
-				// moved over to the previous view line
-				const newViewModelPosition = viewModel.coordinatesConverter.convertViewPositionToModelPosition(newViewState.position);
-				if (newViewModelPosition.lineNumber === cursor.modelState.position.lineNumber) {
-					// stayed on the same model line => pass wrapping point where 2 view positions map to a single model position
-					newViewState = MoveOperations.moveLeft(viewModel.cursorConfig, viewModel, newViewState, inSelectionMode, 1);
-				}
-			}
-
 			result[i] = CursorState.fromViewState(newViewState);
 		}
 		return result;
diff --git a/src/vs/editor/common/controller/cursorMoveOperations.ts b/src/vs/editor/common/controller/cursorMoveOperations.ts
index 7d94c437434..772b0accfbe 100644
--- a/src/vs/editor/common/controller/cursorMoveOperations.ts
+++ b/src/vs/editor/common/controller/cursorMoveOperations.ts
@@ -27,13 +27,7 @@ export class CursorPosition {
 export class MoveOperations {
 
 	public static leftPosition(model: ICursorSimpleModel, lineNumber: number, column: number): Position {
-		if (column > model.getLineMinColumn(lineNumber)) {
-			column = column - strings.prevCharLength(model.getLineContent(lineNumber), column - 1);
-		} else if (lineNumber > 1) {
-			lineNumber = lineNumber - 1;
-			column = model.getLineMaxColumn(lineNumber);
-		}
-		return new Position(lineNumber, column);
+		return model.getLeftPosition(lineNumber, column);
 	}
 
 	public static leftPositionAtomicSoftTabs(model: ICursorSimpleModel, lineNumber: number, column: number, tabSize: number): Position {
diff --git a/src/vs/editor/common/model.ts b/src/vs/editor/common/model.ts
index 3f1a239dcbf..be75f5a10ad 100644
--- a/src/vs/editor/common/model.ts
+++ b/src/vs/editor/common/model.ts
@@ -716,6 +716,11 @@ export interface ITextModel {
 	 */
 	getLineLastNonWhitespaceColumn(lineNumber: number): number;
 
+	/**
+	 * @internal
+	 */
+	getLeftPosition(lineNumber: number, column: number): Position;
+
 	/**
 	 * Create a valid position,
 	 */
diff --git a/src/vs/editor/common/model/textModel.ts b/src/vs/editor/common/model/textModel.ts
index 61452e16c78..685792587f9 100644
--- a/src/vs/editor/common/model/textModel.ts
+++ b/src/vs/editor/common/model/textModel.ts
@@ -865,6 +865,16 @@ export class TextModel extends Disposable implements model.ITextModel {
 		return this._buffer.getLineLastNonWhitespaceColumn(lineNumber);
 	}
 
+	public getLeftPosition(lineNumber: number, column: number): Position {
+		if (column > this.getLineMinColumn(lineNumber)) {
+			column = column - strings.prevCharLength(this.getLineContent(lineNumber), column - 1);
+		} else if (lineNumber > 1) {
+			lineNumber = lineNumber - 1;
+			column = this.getLineMaxColumn(lineNumber);
+		}
+		return new Position(lineNumber, column);
+	}
+
 	/**
 	 * Validates `range` is within buffer bounds, but allows it to sit in between surrogate pairs, etc.
 	 * Will try to not allocate if possible.
diff --git a/src/vs/editor/common/viewModel/viewModel.ts b/src/vs/editor/common/viewModel/viewModel.ts
index 92966555364..11de82c685c 100644
--- a/src/vs/editor/common/viewModel/viewModel.ts
+++ b/src/vs/editor/common/viewModel/viewModel.ts
@@ -185,6 +185,8 @@ export interface IViewModel extends ICursorSimpleModel {
 	getLineMaxColumn(lineNumber: number): number;
 	getLineFirstNonWhitespaceColumn(lineNumber: number): number;
 	getLineLastNonWhitespaceColumn(lineNumber: number): number;
+	getLeftPosition(lineNumber: number, column: number): Position;
+
 	getAllOverviewRulerDecorations(theme: EditorTheme): IOverviewRulerDecorations;
 	invalidateOverviewRulerColorCache(): void;
 	invalidateMinimapColorCache(): void;
diff --git a/src/vs/editor/common/viewModel/viewModelImpl.ts b/src/vs/editor/common/viewModel/viewModelImpl.ts
index fccd9d50f9b..50afb58bef7 100644
--- a/src/vs/editor/common/viewModel/viewModelImpl.ts
+++ b/src/vs/editor/common/viewModel/viewModelImpl.ts
@@ -633,6 +633,11 @@ export class ViewModel extends Disposable implements IViewModel {
 		return result + 2;
 	}
 
+	public getLeftPosition(viewLineNumber: number, viewColumn: number): Position {
+		// TODO
+		throw new Error(`TODO: delegate to this._lines`);
+	}
+
 	public getDecorationsInViewport(visibleRange: Range): ViewModelDecoration[] {
 		return this._decorations.getDecorationsViewportData(visibleRange).decorations;
 	}
@@ -926,6 +931,7 @@ export class ViewModel extends Disposable implements IViewModel {
 	public setPrevEditOperationType(type: EditOperationType): void {
 		this._cursor.setPrevEditOperationType(type);
 	}
+
 	public getSelection(): Selection {
 		return this._cursor.getSelection();
 	}
```
@hediet Here is the patch we sketched:

```diff
diff --git a/src/vs/editor/common/controller/cursorCommon.ts b/src/vs/editor/common/controller/cursorCommon.ts
index 0077027b265..5001e544f29 100644
--- a/src/vs/editor/common/controller/cursorCommon.ts
+++ b/src/vs/editor/common/controller/cursorCommon.ts
@@ -219,6 +219,8 @@ export interface ICursorSimpleModel {
 	getLineMaxColumn(lineNumber: number): number;
 	getLineFirstNonWhitespaceColumn(lineNumber: number): number;
 	getLineLastNonWhitespaceColumn(lineNumber: number): number;
+
+	getLeftPosition(lineNumber: number, column: number): Position;
 }
 
 /**
diff --git a/src/vs/editor/common/controller/cursorMoveCommands.ts b/src/vs/editor/common/controller/cursorMoveCommands.ts
index 2ae510c30a0..3302e57536a 100644
--- a/src/vs/editor/common/controller/cursorMoveCommands.ts
+++ b/src/vs/editor/common/controller/cursorMoveCommands.ts
@@ -419,26 +419,10 @@ export class CursorMoveCommands {
 	}
 
 	private static _moveLeft(viewModel: IViewModel, cursors: CursorState[], inSelectionMode: boolean, noOfColumns: number): PartialCursorState[] {
-		const hasMultipleCursors = (cursors.length > 1);
 		let result: PartialCursorState[] = [];
 		for (let i = 0, len = cursors.length; i < len; i++) {
 			const cursor = cursors[i];
-			const skipWrappingPointStop = hasMultipleCursors || !cursor.viewState.hasSelection();
 			let newViewState = MoveOperations.moveLeft(viewModel.cursorConfig, viewModel, cursor.viewState, inSelectionMode, noOfColumns);
-
-			if (skipWrappingPointStop
-				&& noOfColumns === 1
-				&& cursor.viewState.position.column === viewModel.getLineMinColumn(cursor.viewState.position.lineNumber)
-				&& newViewState.position.lineNumber !== cursor.viewState.position.lineNumber
-			) {
-				// moved over to the previous view line
-				const newViewModelPosition = viewModel.coordinatesConverter.convertViewPositionToModelPosition(newViewState.position);
-				if (newViewModelPosition.lineNumber === cursor.modelState.position.lineNumber) {
-					// stayed on the same model line => pass wrapping point where 2 view positions map to a single model position
-					newViewState = MoveOperations.moveLeft(viewModel.cursorConfig, viewModel, newViewState, inSelectionMode, 1);
-				}
-			}
-
 			result[i] = CursorState.fromViewState(newViewState);
 		}
 		return result;
diff --git a/src/vs/editor/common/controller/cursorMoveOperations.ts b/src/vs/editor/common/controller/cursorMoveOperations.ts
index 7d94c437434..772b0accfbe 100644
--- a/src/vs/editor/common/controller/cursorMoveOperations.ts
+++ b/src/vs/editor/common/controller/cursorMoveOperations.ts
@@ -27,13 +27,7 @@ export class CursorPosition {
 export class MoveOperations {
 
 	public static leftPosition(model: ICursorSimpleModel, lineNumber: number, column: number): Position {
-		if (column > model.getLineMinColumn(lineNumber)) {
-			column = column - strings.prevCharLength(model.getLineContent(lineNumber), column - 1);
-		} else if (lineNumber > 1) {
-			lineNumber = lineNumber - 1;
-			column = model.getLineMaxColumn(lineNumber);
-		}
-		return new Position(lineNumber, column);
+		return model.getLeftPosition(lineNumber, column);
 	}
 
 	public static leftPositionAtomicSoftTabs(model: ICursorSimpleModel, lineNumber: number, column: number, tabSize: number): Position {
diff --git a/src/vs/editor/common/model.ts b/src/vs/editor/common/model.ts
index 3f1a239dcbf..be75f5a10ad 100644
--- a/src/vs/editor/common/model.ts
+++ b/src/vs/editor/common/model.ts
@@ -716,6 +716,11 @@ export interface ITextModel {
 	 */
 	getLineLastNonWhitespaceColumn(lineNumber: number): number;
 
+	/**
+	 * @internal
+	 */
+	getLeftPosition(lineNumber: number, column: number): Position;
+
 	/**
 	 * Create a valid position,
 	 */
diff --git a/src/vs/editor/common/model/textModel.ts b/src/vs/editor/common/model/textModel.ts
index 61452e16c78..685792587f9 100644
--- a/src/vs/editor/common/model/textModel.ts
+++ b/src/vs/editor/common/model/textModel.ts
@@ -865,6 +865,16 @@ export class TextModel extends Disposable implements model.ITextModel {
 		return this._buffer.getLineLastNonWhitespaceColumn(lineNumber);
 	}
 
+	public getLeftPosition(lineNumber: number, column: number): Position {
+		if (column > this.getLineMinColumn(lineNumber)) {
+			column = column - strings.prevCharLength(this.getLineContent(lineNumber), column - 1);
+		} else if (lineNumber > 1) {
+			lineNumber = lineNumber - 1;
+			column = this.getLineMaxColumn(lineNumber);
+		}
+		return new Position(lineNumber, column);
+	}
+
 	/**
 	 * Validates `range` is within buffer bounds, but allows it to sit in between surrogate pairs, etc.
 	 * Will try to not allocate if possible.
diff --git a/src/vs/editor/common/viewModel/viewModel.ts b/src/vs/editor/common/viewModel/viewModel.ts
index 92966555364..11de82c685c 100644
--- a/src/vs/editor/common/viewModel/viewModel.ts
+++ b/src/vs/editor/common/viewModel/viewModel.ts
@@ -185,6 +185,8 @@ export interface IViewModel extends ICursorSimpleModel {
 	getLineMaxColumn(lineNumber: number): number;
 	getLineFirstNonWhitespaceColumn(lineNumber: number): number;
 	getLineLastNonWhitespaceColumn(lineNumber: number): number;
+	getLeftPosition(lineNumber: number, column: number): Position;
+
 	getAllOverviewRulerDecorations(theme: EditorTheme): IOverviewRulerDecorations;
 	invalidateOverviewRulerColorCache(): void;
 	invalidateMinimapColorCache(): void;
diff --git a/src/vs/editor/common/viewModel/viewModelImpl.ts b/src/vs/editor/common/viewModel/viewModelImpl.ts
index fccd9d50f9b..50afb58bef7 100644
--- a/src/vs/editor/common/viewModel/viewModelImpl.ts
+++ b/src/vs/editor/common/viewModel/viewModelImpl.ts
@@ -633,6 +633,11 @@ export class ViewModel extends Disposable implements IViewModel {
 		return result + 2;
 	}
 
+	public getLeftPosition(viewLineNumber: number, viewColumn: number): Position {
+		// TODO
+		throw new Error(`TODO: delegate to this._lines`);
+	}
+
 	public getDecorationsInViewport(visibleRange: Range): ViewModelDecoration[] {
 		return this._decorations.getDecorationsViewportData(visibleRange).decorations;
 	}
@@ -926,6 +931,7 @@ export class ViewModel extends Disposable implements IViewModel {
 	public setPrevEditOperationType(type: EditOperationType): void {
 		this._cursor.setPrevEditOperationType(type);
 	}
+
 	public getSelection(): Selection {
 		return this._cursor.getSelection();
 	}
```

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
@@ -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, TextModelResolvedOptions } from 'vs/editor/common/model';
+import { ITextModel, PositionNormalizationAffinity, 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,6 +221,7 @@ export interface ICursorSimpleModel {
 	getLineMaxColumn(lineNumber: number): number;
 	getLineFirstNonWhitespaceColumn(lineNumber: number): number;
 	getLineLastNonWhitespaceColumn(lineNumber: number): number;
+	normalizePosition(position: Position, affinity: PositionNormalizationAffinity): Position;
 }
 
 /**
diff --git a/src/vs/editor/common/controller/cursorDeleteOperations.ts b/src/vs/editor/common/controller/cursorDeleteOperations.ts
--- a/src/vs/editor/common/controller/cursorDeleteOperations.ts
+++ b/src/vs/editor/common/controller/cursorDeleteOperations.ts
@@ -26,7 +26,7 @@ export class DeleteOperations {
 
 			if (deleteSelection.isEmpty()) {
 				let position = selection.getPosition();
-				let rightOfPosition = MoveOperations.right(config, model, position.lineNumber, position.column);
+				let rightOfPosition = MoveOperations.right(config, model, position);
 				deleteSelection = new Range(
 					rightOfPosition.lineNumber,
 					rightOfPosition.column,
diff --git a/src/vs/editor/common/controller/cursorMoveCommands.ts b/src/vs/editor/common/controller/cursorMoveCommands.ts
--- a/src/vs/editor/common/controller/cursorMoveCommands.ts
+++ b/src/vs/editor/common/controller/cursorMoveCommands.ts
@@ -419,29 +419,11 @@ export class CursorMoveCommands {
 	}
 
 	private static _moveLeft(viewModel: IViewModel, cursors: CursorState[], inSelectionMode: boolean, noOfColumns: number): PartialCursorState[] {
-		const hasMultipleCursors = (cursors.length > 1);
-		let result: PartialCursorState[] = [];
-		for (let i = 0, len = cursors.length; i < len; i++) {
-			const cursor = cursors[i];
-			const skipWrappingPointStop = hasMultipleCursors || !cursor.viewState.hasSelection();
-			let newViewState = MoveOperations.moveLeft(viewModel.cursorConfig, viewModel, cursor.viewState, inSelectionMode, noOfColumns);
-
-			if (skipWrappingPointStop
-				&& noOfColumns === 1
-				&& cursor.viewState.position.column === viewModel.getLineMinColumn(cursor.viewState.position.lineNumber)
-				&& newViewState.position.lineNumber !== cursor.viewState.position.lineNumber
-			) {
-				// moved over to the previous view line
-				const newViewModelPosition = viewModel.coordinatesConverter.convertViewPositionToModelPosition(newViewState.position);
-				if (newViewModelPosition.lineNumber === cursor.modelState.position.lineNumber) {
-					// stayed on the same model line => pass wrapping point where 2 view positions map to a single model position
-					newViewState = MoveOperations.moveLeft(viewModel.cursorConfig, viewModel, newViewState, inSelectionMode, 1);
-				}
-			}
-
-			result[i] = CursorState.fromViewState(newViewState);
-		}
-		return result;
+		return cursors.map(cursor =>
+			CursorState.fromViewState(
+				MoveOperations.moveLeft(viewModel.cursorConfig, viewModel, cursor.viewState, inSelectionMode, noOfColumns)
+			)
+		);
 	}
 
 	private static _moveHalfLineLeft(viewModel: IViewModel, cursors: CursorState[], inSelectionMode: boolean): PartialCursorState[] {
@@ -456,29 +438,11 @@ export class CursorMoveCommands {
 	}
 
 	private static _moveRight(viewModel: IViewModel, cursors: CursorState[], inSelectionMode: boolean, noOfColumns: number): PartialCursorState[] {
-		const hasMultipleCursors = (cursors.length > 1);
-		let result: PartialCursorState[] = [];
-		for (let i = 0, len = cursors.length; i < len; i++) {
-			const cursor = cursors[i];
-			const skipWrappingPointStop = hasMultipleCursors || !cursor.viewState.hasSelection();
-			let newViewState = MoveOperations.moveRight(viewModel.cursorConfig, viewModel, cursor.viewState, inSelectionMode, noOfColumns);
-
-			if (skipWrappingPointStop
-				&& noOfColumns === 1
-				&& cursor.viewState.position.column === viewModel.getLineMaxColumn(cursor.viewState.position.lineNumber)
-				&& newViewState.position.lineNumber !== cursor.viewState.position.lineNumber
-			) {
-				// moved over to the next view line
-				const newViewModelPosition = viewModel.coordinatesConverter.convertViewPositionToModelPosition(newViewState.position);
-				if (newViewModelPosition.lineNumber === cursor.modelState.position.lineNumber) {
-					// stayed on the same model line => pass wrapping point where 2 view positions map to a single model position
-					newViewState = MoveOperations.moveRight(viewModel.cursorConfig, viewModel, newViewState, inSelectionMode, 1);
-				}
-			}
-
-			result[i] = CursorState.fromViewState(newViewState);
-		}
-		return result;
+		return cursors.map(cursor =>
+			CursorState.fromViewState(
+				MoveOperations.moveRight(viewModel.cursorConfig, viewModel, cursor.viewState, inSelectionMode, noOfColumns)
+			)
+		);
 	}
 
 	private static _moveHalfLineRight(viewModel: IViewModel, cursors: CursorState[], inSelectionMode: boolean): PartialCursorState[] {
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
@@ -9,6 +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';
 
 export class CursorPosition {
 	_cursorPositionBrand: void;
@@ -25,51 +26,84 @@ export class CursorPosition {
 }
 
 export class MoveOperations {
-
-	public static leftPosition(model: ICursorSimpleModel, lineNumber: number, column: number): Position {
-		if (column > model.getLineMinColumn(lineNumber)) {
-			column = column - strings.prevCharLength(model.getLineContent(lineNumber), column - 1);
-		} else if (lineNumber > 1) {
-			lineNumber = lineNumber - 1;
-			column = model.getLineMaxColumn(lineNumber);
+	public static leftPosition(model: ICursorSimpleModel, position: Position): Position {
+		if (position.column > model.getLineMinColumn(position.lineNumber)) {
+			return position.delta(undefined, -strings.prevCharLength(model.getLineContent(position.lineNumber), position.column - 1));
+		} else if (position.lineNumber > 1) {
+			const newLineNumber = position.lineNumber - 1;
+			return new Position(newLineNumber, model.getLineMaxColumn(newLineNumber));
+		} else {
+			return position;
 		}
-		return new Position(lineNumber, column);
 	}
 
-	public static leftPositionAtomicSoftTabs(model: ICursorSimpleModel, lineNumber: number, column: number, tabSize: number): Position {
-		const minColumn = model.getLineMinColumn(lineNumber);
-		const lineContent = model.getLineContent(lineNumber);
-		const newPosition = AtomicTabMoveOperations.atomicPosition(lineContent, column - 1, tabSize, Direction.Left);
+	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, lineNumber, column);
+			return this.leftPosition(model, position);
 		}
-		return new Position(lineNumber, newPosition + 1);
+		return new Position(position.lineNumber, newPosition + 1);
 	}
 
-	public static left(config: CursorConfiguration, model: ICursorSimpleModel, lineNumber: number, column: number): CursorPosition {
+	private static left(config: CursorConfiguration, model: ICursorSimpleModel, position: Position): CursorPosition {
 		const pos = config.stickyTabStops
-			? MoveOperations.leftPositionAtomicSoftTabs(model, lineNumber, column, config.tabSize)
-			: MoveOperations.leftPosition(model, lineNumber, column);
+			? MoveOperations.leftPositionAtomicSoftTabs(model, position, config.tabSize)
+			: MoveOperations.leftPosition(model, position);
 		return new CursorPosition(pos.lineNumber, pos.column, 0);
 	}
 
+	/**
+	 * @param noOfColumns Must be either `1`
+	 * or `Math.round(viewModel.getLineContent(viewLineNumber).length / 2)` (for half lines).
+	*/
 	public static moveLeft(config: CursorConfiguration, model: ICursorSimpleModel, cursor: SingleCursorState, inSelectionMode: boolean, noOfColumns: number): SingleCursorState {
 		let lineNumber: number,
 			column: number;
 
 		if (cursor.hasSelection() && !inSelectionMode) {
-			// If we are in selection mode, move left without selection cancels selection and puts cursor at the beginning of the selection
+			// If the user has a selection and does not want to extend it,
+			// put the cursor at the beginning of the selection.
 			lineNumber = cursor.selection.startLineNumber;
 			column = cursor.selection.startColumn;
 		} else {
-			let r = MoveOperations.left(config, model, cursor.position.lineNumber, cursor.position.column - (noOfColumns - 1));
-			lineNumber = r.lineNumber;
-			column = r.column;
+			// This has no effect if noOfColumns === 1.
+			// It is ok to do so in the half-line scenario.
+			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 p = MoveOperations.left(config, model, normalizedPos);
+
+			lineNumber = p.lineNumber;
+			column = p.column;
 		}
 
 		return cursor.move(inSelectionMode, lineNumber, column, 0);
 	}
 
+	/**
+	 * Adjusts the column so that it is within min/max of the line.
+	*/
+	private static clipPositionColumn(position: Position, model: ICursorSimpleModel): Position {
+		return new Position(
+			position.lineNumber,
+			MoveOperations.clipRange(position.column, model.getLineMinColumn(position.lineNumber),
+				model.getLineMaxColumn(position.lineNumber))
+		);
+	}
+
+	private static clipRange(value: number, min: number, max: number): number {
+		if (value < min) {
+			return min;
+		}
+		if (value > max) {
+			return max;
+		}
+		return value;
+	}
+
 	public static rightPosition(model: ICursorSimpleModel, lineNumber: number, column: number): Position {
 		if (column < model.getLineMaxColumn(lineNumber)) {
 			column = column + strings.nextCharLength(model.getLineContent(lineNumber), column - 1);
@@ -89,10 +123,10 @@ export class MoveOperations {
 		return new Position(lineNumber, newPosition + 1);
 	}
 
-	public static right(config: CursorConfiguration, model: ICursorSimpleModel, lineNumber: number, column: number): CursorPosition {
+	public static right(config: CursorConfiguration, model: ICursorSimpleModel, position: Position): CursorPosition {
 		const pos = config.stickyTabStops
-			? MoveOperations.rightPositionAtomicSoftTabs(model, lineNumber, column, config.tabSize, config.indentSize)
-			: MoveOperations.rightPosition(model, lineNumber, column);
+			? MoveOperations.rightPositionAtomicSoftTabs(model, position.lineNumber, position.column, config.tabSize, config.indentSize)
+			: MoveOperations.rightPosition(model, position.lineNumber, position.column);
 		return new CursorPosition(pos.lineNumber, pos.column, 0);
 	}
 
@@ -105,7 +139,9 @@ export class MoveOperations {
 			lineNumber = cursor.selection.endLineNumber;
 			column = cursor.selection.endColumn;
 		} else {
-			let r = MoveOperations.right(config, model, cursor.position.lineNumber, cursor.position.column + (noOfColumns - 1));
+			const pos = cursor.position.delta(undefined, noOfColumns - 1);
+			const normalizedPos = model.normalizePosition(MoveOperations.clipPositionColumn(pos, model), PositionNormalizationAffinity.Right);
+			const r = MoveOperations.right(config, model, normalizedPos);
 			lineNumber = r.lineNumber;
 			column = r.column;
 		}
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
@@ -1256,6 +1256,27 @@ export interface ITextModel {
 	 * @internal
 	 */
 	getAttachedEditorCount(): number;
+
+	/**
+	 * 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.
+	 * @internal
+	 */
+	normalizePosition(position: Position, affinity: PositionNormalizationAffinity): Position;
+}
+
+/**
+ * @internal
+ */
+export const enum PositionNormalizationAffinity {
+	/**
+	 * Prefers the left most position.
+	*/
+	Left = 0,
+	/**
+	 * Prefers the right most position.
+	*/
+	Right = 1,
 }
 
 /**
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
@@ -3028,6 +3028,9 @@ export class TextModel extends Disposable implements model.ITextModel {
 	}
 
 	//#endregion
+	normalizePosition(position: Position, affinity: model.PositionNormalizationAffinity): Position {
+		return position;
+	}
 }
 
 //#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
@@ -8,7 +8,7 @@ import { WrappingIndent } from 'vs/editor/common/config/editorOptions';
 import { 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 } from 'vs/editor/common/model';
+import { EndOfLinePreference, IActiveIndentGuideInfo, IModelDecoration, IModelDeltaDecoration, ITextModel, PositionNormalizationAffinity } 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';
@@ -46,6 +46,7 @@ export interface ISplitLine {
 	getModelColumnOfViewPosition(outputLineIndex: number, outputColumn: number): number;
 	getViewPositionOfModelPosition(deltaLineNumber: number, inputColumn: number): Position;
 	getViewLineNumberOfModelPosition(deltaLineNumber: number, inputColumn: number): number;
+	normalizePosition(model: ISimpleModel, modelLineNumber: number, outputLineIndex: number, outputPosition: Position, affinity: PositionNormalizationAffinity): Position;
 }
 
 export interface IViewModelLinesCollection extends IDisposable {
@@ -75,6 +76,8 @@ 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;
 }
 
 export class CoordinatesConverter implements ICoordinatesConverter {
@@ -971,6 +974,15 @@ export class SplitLinesCollection implements IViewModelLinesCollection {
 
 		return finalResult;
 	}
+
+	normalizePosition(position: Position, affinity: PositionNormalizationAffinity): Position {
+		const viewLineNumber = this._toValidViewLineNumber(position.lineNumber);
+		const r = this.prefixSumComputer.getIndexOf(viewLineNumber - 1);
+		const lineIndex = r.index;
+		const remainder = r.remainder;
+
+		return this.lines[lineIndex].normalizePosition(this.model, lineIndex + 1, remainder, position, affinity);
+	}
 }
 
 class VisibleIdentitySplitLine implements ISplitLine {
@@ -1046,6 +1058,10 @@ class VisibleIdentitySplitLine implements ISplitLine {
 	public getViewLineNumberOfModelPosition(deltaLineNumber: number, _inputColumn: number): number {
 		return deltaLineNumber;
 	}
+
+	public normalizePosition(model: ISimpleModel, modelLineNumber: number, outputLineIndex: number, outputPosition: Position, affinity: PositionNormalizationAffinity): Position {
+		return outputPosition;
+	}
 }
 
 class InvisibleIdentitySplitLine implements ISplitLine {
@@ -1108,6 +1124,10 @@ class InvisibleIdentitySplitLine implements ISplitLine {
 	public getViewLineNumberOfModelPosition(_deltaLineNumber: number, _inputColumn: number): number {
 		throw new Error('Not supported');
 	}
+
+	public normalizePosition(model: ISimpleModel, modelLineNumber: number, outputLineIndex: number, outputPosition: Position, affinity: PositionNormalizationAffinity): Position {
+		throw new Error('Not supported');
+	}
 }
 
 export class SplitLine implements ISplitLine {
@@ -1190,6 +1210,10 @@ export class SplitLine implements ISplitLine {
 		if (!this._isVisible) {
 			throw new Error('Not supported');
 		}
+		return this._getViewLineMinColumn(outputLineIndex);
+	}
+
+	private _getViewLineMinColumn(outputLineIndex: number): number {
 		if (outputLineIndex > 0) {
 			return this._lineBreakData.wrappedTextIndentLength + 1;
 		}
@@ -1200,7 +1224,7 @@ export class SplitLine implements ISplitLine {
 		if (!this._isVisible) {
 			throw new Error('Not supported');
 		}
-		return this.getViewLineContent(model, modelLineNumber, outputLineIndex).length + 1;
+		return this.getViewLineLength(model, modelLineNumber, outputLineIndex) + 1;
 	}
 
 	public getViewLineData(model: ISimpleModel, modelLineNumber: number, outputLineIndex: number): ViewLineData {
@@ -1298,6 +1322,21 @@ export class SplitLine implements ISplitLine {
 		const r = LineBreakData.getOutputPositionOfInputOffset(this._lineBreakData.breakOffsets, inputColumn - 1);
 		return (deltaLineNumber + r.outputLineIndex);
 	}
+
+	public normalizePosition(model: ISimpleModel, modelLineNumber: number, outputLineIndex: number, outputPosition: Position, affinity: PositionNormalizationAffinity): Position {
+		if (affinity === PositionNormalizationAffinity.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) {
+			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;
+	}
 }
 
 let _spaces: string[] = [''];
@@ -1532,6 +1571,10 @@ export class IdentityLinesCollection implements IViewModelLinesCollection {
 	public getDecorationsInRange(range: Range, ownerId: number, filterOutValidation: boolean): IModelDecoration[] {
 		return this.model.getDecorationsInRange(range, ownerId, filterOutValidation);
 	}
+
+	normalizePosition(position: Position, affinity: PositionNormalizationAffinity): Position {
+		return this.model.normalizePosition(position, affinity);
+	}
 }
 
 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
@@ -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 } from 'vs/editor/common/model';
+import { EndOfLinePreference, IActiveIndentGuideInfo, ITextModel, TrackedRangeStickiness, TextModelResolvedOptions, IIdentifiedSingleEditOperation, ICursorStateComputer, PositionNormalizationAffinity } 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';
@@ -1037,4 +1037,8 @@ export class ViewModel extends Disposable implements IViewModel {
 			this._eventDispatcher.endEmitViewEvents();
 		}
 	}
+
+	normalizePosition(position: Position, affinity: PositionNormalizationAffinity): Position {
+		return this._lines.normalizePosition(position, affinity);
+	}
 }
diff --git a/src/vs/editor/contrib/caretOperations/transpose.ts b/src/vs/editor/contrib/caretOperations/transpose.ts
--- a/src/vs/editor/contrib/caretOperations/transpose.ts
+++ b/src/vs/editor/contrib/caretOperations/transpose.ts
@@ -63,8 +63,8 @@ class TransposeLettersAction extends EditorAction {
 				selection.getPosition() :
 				MoveOperations.rightPosition(model, selection.getPosition().lineNumber, selection.getPosition().column);
 
-			let middlePosition = MoveOperations.leftPosition(model, endPosition.lineNumber, endPosition.column);
-			let beginPosition = MoveOperations.leftPosition(model, middlePosition.lineNumber, middlePosition.column);
+			let middlePosition = MoveOperations.leftPosition(model, endPosition);
+			let beginPosition = MoveOperations.leftPosition(model, middlePosition);
 
 			let leftChar = model.getValueInRange(Range.fromPositions(beginPosition, middlePosition));
 			let rightChar = model.getValueInRange(Range.fromPositions(middlePosition, endPosition));


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
@@ -2691,6 +2691,75 @@ suite('Editor Controller - Regression tests', () => {
 
 		model.dispose();
 	});
+
+	test('issue #105730: move left behaves differently for multiple cursors', () => {
+		const model = createTextModel('asdfghjkl, asdfghjkl, asdfghjkl, ');
+
+		withTestCodeEditor(
+			null,
+			{
+				model: model,
+				wordWrap: 'wordWrapColumn',
+				wordWrapColumn: 24
+			},
+			(editor, viewModel) => {
+				viewModel.setSelections('test', [
+					new Selection(1, 10, 1, 12),
+					new Selection(1, 21, 1, 23),
+					new Selection(1, 32, 1, 34)
+				]);
+				moveLeft(editor, viewModel, false);
+				assertCursor(viewModel, [
+					new Selection(1, 10, 1, 10),
+					new Selection(1, 21, 1, 21),
+					new Selection(1, 32, 1, 32)
+				]);
+
+				viewModel.setSelections('test', [
+					new Selection(1, 10, 1, 12),
+					new Selection(1, 21, 1, 23),
+					new Selection(1, 32, 1, 34)
+				]);
+				moveLeft(editor, viewModel, true);
+				assertCursor(viewModel, [
+					new Selection(1, 10, 1, 11),
+					new Selection(1, 21, 1, 22),
+					new Selection(1, 32, 1, 33)
+				]);
+			});
+	});
+
+	test('issue #105730: move right should always skip wrap point', () => {
+		const model = createTextModel('asdfghjkl, asdfghjkl, asdfghjkl, \nasdfghjkl,');
+
+		withTestCodeEditor(
+			null,
+			{
+				model: model,
+				wordWrap: 'wordWrapColumn',
+				wordWrapColumn: 24
+			},
+			(editor, viewModel) => {
+				viewModel.setSelections('test', [
+					new Selection(1, 22, 1, 22)
+				]);
+				moveRight(editor, viewModel, false);
+				moveRight(editor, viewModel, false);
+				assertCursor(viewModel, [
+					new Selection(1, 24, 1, 24),
+				]);
+
+				viewModel.setSelections('test', [
+					new Selection(1, 22, 1, 22)
+				]);
+				moveRight(editor, viewModel, true);
+				moveRight(editor, viewModel, true);
+				assertCursor(viewModel, [
+					new Selection(1, 22, 1, 24),
+				]);
+			}
+		);
+	});
 });
 
 suite('Editor Controller - Cursor Configuration', () => {