diff --git a/.gitignore b/.gitignore index 2957885e7aa..ac1a90c0917 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ out +testing node_modules *.sw? .vscode-test @@ -6,3 +7,4 @@ node_modules *.vsix *.log testing +test2 diff --git a/.vscode/settings.json b/.vscode/settings.json index 4bf3490fd8d..6edc3342af7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -13,8 +13,12 @@ } ], + "vim.hlsearch": false, + "editor.cursorStyle": "block", + "typescript.tsdk": "./node_modules/typescript/lib", // we want to use the TS server from our node_modules folder to control its version "editor.tabSize": 2, "editor.insertSpaces": true, + "vim.useCtrlKeys": true, "files.trimTrailingWhitespace": true } \ No newline at end of file diff --git a/README.md b/README.md index b8e66140604..e4b083e2f85 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ We're super friendly people if you want to drop by and talk to us on our [Slack * Correct undo/redo state * Marks * Vim Options +* Multiple Cursor mode (allows multiple simultaneous cursors to receive Vim commands. It's like macros, but in real time. Allows `/` search, has independent clipboards for each cursor, etc.) ## Roadmap diff --git a/extension.ts b/extension.ts index 486b6c3e9eb..c073d2cfa74 100644 --- a/extension.ts +++ b/extension.ts @@ -10,7 +10,7 @@ import * as vscode from 'vscode'; import * as _ from "lodash"; import { showCmdLine } from './src/cmd_line/main'; import { ModeHandler } from './src/mode/modeHandler'; -import { TaskQueue } from './src/taskQueue'; +import { taskQueue } from './src/taskQueue'; import { Position } from './src/motion/position'; import { Globals } from './src/globals'; import { AngleBracketNotation } from './src/notation'; @@ -66,8 +66,6 @@ let extensionContext: vscode.ExtensionContext; let modeHandlerToEditorIdentity: { [key: string]: ModeHandler } = {}; let previousActiveEditorId: EditorIdentity = new EditorIdentity(); -let taskQueue = new TaskQueue(); - export async function getAndUpdateModeHandler(): Promise { const oldHandler = modeHandlerToEditorIdentity[previousActiveEditorId.toString()]; const activeEditorId = new EditorIdentity(vscode.window.activeTextEditor); diff --git a/src/actions/actions.ts b/src/actions/actions.ts index eef6b94c983..5aa472917a2 100644 --- a/src/actions/actions.ts +++ b/src/actions/actions.ts @@ -1,15 +1,20 @@ -import { VimSpecialCommands, VimState, SearchState, SearchDirection, ReplaceState } from './../mode/modeHandler'; +import { VimState, SearchState, SearchDirection, ReplaceState } from './../mode/modeHandler'; import { ModeName } from './../mode/mode'; import { VisualBlockInsertionType } from './../mode/modeVisualBlock'; +import { Range } from './../motion/range'; import { TextEditor, EditorScrollByUnit, EditorScrollDirection, CursorMovePosition, CursorMoveByUnit } from './../textEditor'; import { Register, RegisterMode } from './../register/register'; import { NumericString } from './../number/numericString'; -import { Position } from './../motion/position'; +import { Position, PositionDiff } from './../motion/position'; import { PairMatcher } from './../matching/matcher'; import { QuoteMatcher } from './../matching/quoteMatcher'; import { TagMatcher } from './../matching/tagMatcher'; import { Tab, TabCommand } from './../cmd_line/commands/tab'; import { Configuration } from './../configuration/configuration'; +import { + waitForCursorUpdatesToHappen, + getAllCursors } from '../util'; +import { EqualitySet } from './../misc/equalitySet'; import * as vscode from 'vscode'; import * as clipboard from 'copy-paste'; @@ -66,8 +71,15 @@ const compareKeypressSequence = function (one: string[], two: string[]): boolean * The result of a (more sophisticated) Movement. */ export interface IMovement { - start : Position; - stop : Position; + /** + * The location the cursor will end up. + */ + cursorPosition : Position; + + /** + * The range we will supply to an operator in the future, if one is run. + */ + operatingRange : Range; /** * Whether this motion succeeded. Some commands, like fx when 'x' can't be found, @@ -81,8 +93,8 @@ export interface IMovement { } export function isIMovement(o: IMovement | Position): o is IMovement { - return (o as IMovement).start !== undefined && - (o as IMovement).stop !== undefined; + return (o as IMovement).cursorPosition !== undefined && + (o as IMovement).operatingRange !== undefined; } export class BaseAction { @@ -150,7 +162,8 @@ export abstract class BaseMovement extends BaseAction { ModeName.Normal, ModeName.Visual, ModeName.VisualLine, - ModeName.VisualBlock]; + ModeName.VisualBlock, + ]; isMotion = true; @@ -219,23 +232,29 @@ export abstract class BaseMovement extends BaseAction { position = temporaryResult; } else if (isIMovement(temporaryResult)) { if (result instanceof Position) { + + // TODO(johnfn): Study - does this code even ever get run..? result = { - start : new Position(0, 0), - stop : new Position(0, 0), - failed : false + cursorPosition : new Position(0, 0), + operatingRange : new Range(new Position(0, 0), new Position(0, 0)), + failed : false }; + + continue; } result.failed = result.failed || temporaryResult.failed; if (firstIteration) { - (result as IMovement).start = temporaryResult.start; + result.operatingRange = new Range(result.cursorPosition, result.cursorPosition); } if (lastIteration) { - (result as IMovement).stop = temporaryResult.stop; - } else { - position = temporaryResult.stop.getRightThroughLineBreaks(); + result.operatingRange = result.operatingRange.withNewStop(temporaryResult.cursorPosition); + } + + if (!lastIteration) { + position = temporaryResult.cursorPosition.getRightThroughLineBreaks(); } } } @@ -254,6 +273,17 @@ export abstract class BaseCommand extends BaseAction { */ isCompleteAction = true; + supportsMultiCursor = true; + + multicursorIndex: number | undefined = undefined; + + /** + * In multi-cursor mode, do we run this command for every cursor, or just once? + */ + public runsOnceForEveryCursor(): boolean { + return true; + } + canBePrefixedWithCount = false; canBeRepeatedWithDot = false; @@ -269,12 +299,30 @@ export abstract class BaseCommand extends BaseAction { * Run the command the number of times VimState wants us to. */ public async execCount(position: Position, vimState: VimState): Promise { - let timesToRepeat = this.canBePrefixedWithCount ? vimState.recordedState.count || 1 : 1; + if (!this.runsOnceForEveryCursor()) { + let timesToRepeat = this.canBePrefixedWithCount ? vimState.recordedState.count || 1 : 1; + + for (let i = 0; i < timesToRepeat; i++) { + vimState = await this.exec(position, vimState); + } - for (let i = 0; i < timesToRepeat; i++) { - vimState = await this.exec(position, vimState); + return vimState; } + let timesToRepeat = this.canBePrefixedWithCount ? vimState.recordedState.count || 1 : 1; + + vimState.allCursors = await vimState.allCursors.asyncMap(async (cursor, i) => { + this.multicursorIndex = i; + + vimState.allCursors = new EqualitySet([ cursor ]); + + for (let j = 0; j < timesToRepeat; j++) { + vimState = await this.exec(cursor.getStop(), vimState); + } + + return new Range(vimState.cursorStartPosition, vimState.cursorPosition); + }); + return vimState; } } @@ -282,6 +330,12 @@ export abstract class BaseCommand extends BaseAction { export class BaseOperator extends BaseAction { canBeRepeatedWithDot = true; + /** + * If this is being run in multi cursor mode, the index of the cursor + * this operator is being applied to. + */ + multicursorIndex: number | undefined = undefined; + /** * Run this operator on a range, returning the new location of the cursor. */ @@ -360,6 +414,7 @@ class CommandNumber extends BaseCommand { modes = [ModeName.Normal, ModeName.Visual, ModeName.VisualLine]; keys = [""]; isCompleteAction = false; + runsOnceForEveryCursor() { return false; } public async exec(position: Position, vimState: VimState): Promise { const number = parseInt(this.keysPressed[0], 10); @@ -460,14 +515,24 @@ class CommandEsc extends BaseCommand { ModeName.VisualLine, ModeName.VisualBlockInsertMode, ModeName.VisualBlock, + ModeName.Normal, + ModeName.SearchInProgressMode, ModeName.SearchInProgressMode ]; keys = [""]; + runsOnceForEveryCursor() { return false; } + public async exec(position: Position, vimState: VimState): Promise { if (vimState.currentMode !== ModeName.Visual && - vimState.currentMode !== ModeName.VisualLine) { - vimState.cursorPosition = position.getLeft(); + vimState.currentMode !== ModeName.VisualLine && + vimState.currentMode !== ModeName.Normal) { + + // Normally, you don't have to iterate over all cursors, + // as that is handled for you by the state machine. ESC is + // a special case since runsOnceForEveryCursor is false. + + vimState.allCursors = vimState.allCursors.map(x => x.getLeft()); } if (vimState.currentMode === ModeName.SearchInProgressMode) { @@ -476,6 +541,12 @@ class CommandEsc extends BaseCommand { } } + if (vimState.currentMode === ModeName.Normal && vimState.isMultiCursor) { + vimState.isMultiCursor = false; + + vimState.allCursors = new EqualitySet([ vimState.allCursors.first() ]); + } + vimState.currentMode = ModeName.Normal; return vimState; @@ -489,15 +560,21 @@ class CommandEscReplaceMode extends BaseCommand { public async exec(position: Position, vimState: VimState): Promise { const timesToRepeat = vimState.replaceState!.timesToRepeat; + let textToAdd = ""; for (let i = 1; i < timesToRepeat; i++) { - await TextEditor.insert(vimState.replaceState!.newChars.join(""), position); - position = Position.FromVSCodePosition(vscode.window.activeTextEditor.selection.start); + textToAdd += vimState.replaceState!.newChars.join(""); } - vimState.cursorStartPosition = position.getLeft(); - vimState.cursorPosition = position.getLeft(); + vimState.recordedState.transformations.push({ + type : "insertText", + text : textToAdd, + position: position, + diff : PositionDiff.NewDiff(0, -1), + }); + vimState.currentMode = ModeName.Normal; + return vimState; } } @@ -739,7 +816,8 @@ class CommandReplaceAtCursor extends BaseCommand { } public async execCount(position: Position, vimState: VimState): Promise { - let timesToRepeat = this.canBePrefixedWithCount ? vimState.recordedState.count || 1 : 1; + let timesToRepeat = vimState.recordedState.count || 1; + vimState.currentMode = ModeName.Replace; vimState.replaceState = new ReplaceState(position, timesToRepeat); @@ -755,23 +833,36 @@ class CommandReplaceInReplaceMode extends BaseCommand { public async exec(position: Position, vimState: VimState): Promise { const char = this.keysPressed[0]; - const replaceState = vimState.replaceState!; if (char === "") { if (position.isBeforeOrEqual(replaceState.replaceCursorStartPosition)) { + // If you backspace before the beginning of where you started to replace, + // just move the cursor back. + vimState.cursorPosition = position.getLeft(); vimState.cursorStartPosition = position.getLeft(); } else if (position.line > replaceState.replaceCursorStartPosition.line || position.character > replaceState.originalChars.length) { - const newPosition = await TextEditor.backspace(position); - vimState.cursorPosition = newPosition; - vimState.cursorStartPosition = newPosition; + + vimState.recordedState.transformations.push({ + type : "deleteText", + position : position, + }); + + vimState.cursorPosition = position.getLeft(); + vimState.cursorStartPosition = position.getLeft(); } else { - await TextEditor.replace(new vscode.Range(position.getLeft(), position), replaceState.originalChars[position.character - 1]); - const leftPosition = position.getLeft(); - vimState.cursorPosition = leftPosition; - vimState.cursorStartPosition = leftPosition; + vimState.recordedState.transformations.push({ + type : "replaceText", + text : replaceState.originalChars[position.character - 1], + start : position.getLeft(), + end : position, + diff : PositionDiff.NewDiff(0, -1), + }); + + vimState.cursorPosition = position.getLeft(); + vimState.cursorStartPosition = position.getLeft(); } replaceState.newChars.pop(); @@ -780,7 +871,12 @@ class CommandReplaceInReplaceMode extends BaseCommand { vimState = await new DeleteOperator().run(vimState, position, position); } - await TextEditor.insertAt(char, position); + vimState.recordedState.transformations.push({ + type : "insertText", + text : char, + position: position, + }); + replaceState.newChars.push(char); vimState.cursorStartPosition = Position.FromVSCodePosition(vscode.window.activeTextEditor.selection.start); @@ -844,6 +940,7 @@ class RightArrowInReplaceMode extends ArrowsInReplaceMode { class CommandInsertInSearchMode extends BaseCommand { modes = [ModeName.SearchInProgressMode]; keys = [""]; + runsOnceForEveryCursor() { return this.keysPressed[0] === '\n'; } public async exec(position: Position, vimState: VimState): Promise { const key = this.keysPressed[0]; @@ -854,7 +951,7 @@ class CommandInsertInSearchMode extends BaseCommand { searchState.searchString = searchState.searchString.slice(0, -1); } else if (key === "\n") { vimState.currentMode = ModeName.Normal; - vimState.cursorPosition = searchState.getNextSearchMatchPosition(searchState.searchCursorStartPosition).pos; + vimState.cursorPosition = searchState.getNextSearchMatchPosition(vimState.cursorPosition).pos; // Repeat the previous search if no new string is entered if (searchState.searchString === "") { @@ -882,10 +979,6 @@ class CommandInsertInSearchMode extends BaseCommand { searchState.searchString += this.keysPressed[0]; } - // console.log(vimState.searchString); (TODO: Show somewhere!) - - vimState.cursorPosition = searchState.getNextSearchMatchPosition(searchState.searchCursorStartPosition).pos; - return vimState; } } @@ -974,22 +1067,30 @@ class CommandInsertInInsertMode extends BaseCommand { modes = [ModeName.Insert]; keys = [""]; - // TODO - I am sure this can be improved. - // The hard case is . where we have to track cursor pos since we don't - // update the view public async exec(position: Position, vimState: VimState): Promise { const char = this.keysPressed[this.keysPressed.length - 1]; if (char === "") { - const newPosition = await TextEditor.backspace(position); + vimState.recordedState.transformations.push({ + type : "deleteText", + position : position, + }); - vimState.cursorPosition = newPosition; - vimState.cursorStartPosition = newPosition; + vimState.cursorPosition = vimState.cursorPosition.getLeft(); + vimState.cursorStartPosition = vimState.cursorStartPosition.getLeft(); } else { - await TextEditor.insert(char, vimState.cursorPosition); - - vimState.cursorStartPosition = Position.FromVSCodePosition(vscode.window.activeTextEditor.selection.start); - vimState.cursorPosition = Position.FromVSCodePosition(vscode.window.activeTextEditor.selection.start); + if (vimState.isMultiCursor) { + vimState.recordedState.transformations.push({ + type : "insertText", + text : char, + position : vimState.cursorPosition, + }); + } else { + vimState.recordedState.transformations.push({ + type : "insertTextVSCode", + text : char, + }); + } } return vimState; @@ -1025,6 +1126,7 @@ class CommandCtrlUInInsertMode extends BaseCommand { await TextEditor.delete(new vscode.Range(start, stop)); vimState.cursorPosition = start; vimState.cursorStartPosition = start; + return vimState; } } @@ -1087,75 +1189,72 @@ export class DeleteOperator extends BaseOperator { * Deletes from the position of start to 1 past the position of end. */ public async delete(start: Position, end: Position, currentMode: ModeName, - registerMode: RegisterMode, vimState: VimState, yank = true): Promise { - if (registerMode === RegisterMode.LineWise) { - start = start.getLineBegin(); - end = end.getLineEnd(); - } + registerMode: RegisterMode, vimState: VimState, yank = true): Promise { + if (registerMode === RegisterMode.LineWise) { + start = start.getLineBegin(); + end = end.getLineEnd(); + } - end = new Position(end.line, end.character + 1); + end = new Position(end.line, end.character + 1); - const isOnLastLine = end.line === TextEditor.getLineCount() - 1; + const isOnLastLine = end.line === TextEditor.getLineCount() - 1; - // Vim does this weird thing where it allows you to select and delete - // the newline character, which it places 1 past the last character - // in the line. Here we interpret a character position 1 past the end - // as selecting the newline character. Don't allow this in visual block mode - if (vimState.currentMode !== ModeName.VisualBlock) { - if (end.character === TextEditor.getLineAt(end).text.length + 1) { - end = end.getDown(0); - } + // Vim does this weird thing where it allows you to select and delete + // the newline character, which it places 1 past the last character + // in the line. Here we interpret a character position 1 past the end + // as selecting the newline character. Don't allow this in visual block mode + if (vimState.currentMode !== ModeName.VisualBlock) { + if (end.character === TextEditor.getLineAt(end).text.length + 1) { + end = end.getDown(0); } + } - // If we delete linewise to the final line of the document, we expect the line - // to be removed. This is actually a special case because the newline - // character we've selected to delete is the newline on the end of the document, - // but we actually delete the newline on the second to last line. - - // Just writing about this is making me more confused. -_- - if (isOnLastLine && - start.line !== 0 && - registerMode === RegisterMode.LineWise) { - start = start.getPreviousLineBegin().getLineEnd(); - } + // If we delete linewise to the final line of the document, we expect the line + // to be removed. This is actually a special case because the newline + // character we've selected to delete is the newline on the end of the document, + // but we actually delete the newline on the second to last line. - let text = vscode.window.activeTextEditor.document.getText(new vscode.Range(start, end)); + // Just writing about this is making me more confused. -_- + if (isOnLastLine && + start.line !== 0 && + registerMode === RegisterMode.LineWise) { + start = start.getPreviousLineBegin().getLineEnd(); + } - if (registerMode === RegisterMode.LineWise) { - // slice final newline in linewise mode - linewise put will add it back. - text = text.endsWith("\r\n") ? text.slice(0, -2) : text.slice(0, -1); - } + let text = vscode.window.activeTextEditor.document.getText(new vscode.Range(start, end)); - if (yank) { - Register.put(text, vimState); - } + if (registerMode === RegisterMode.LineWise) { + // slice final newline in linewise mode - linewise put will add it back. + text = text.endsWith("\r\n") ? text.slice(0, -2) : text.slice(0, -1); + } - await TextEditor.delete(new vscode.Range(start, end)); + if (yank) { + Register.put(text, vimState); + } - let resultingPosition: Position; + let diff = PositionDiff.NewDiff(0, 0); - if (currentMode === ModeName.Visual) { - resultingPosition = Position.EarlierOf(start, end); - } + if (registerMode === RegisterMode.LineWise) { + diff = PositionDiff.NewBOLDiff(); + } - if (start.character > TextEditor.getLineAt(start).text.length) { - resultingPosition = start.getLeft(); - } else { - resultingPosition = start; - } + const rangeToDelete = new Range(start, end); - if (registerMode === RegisterMode.LineWise) { - resultingPosition = resultingPosition.getLineBegin(); - } + vimState.recordedState.transformations.push({ + type : "deleteRange", + range : rangeToDelete, + diff : diff, + }); - return resultingPosition; + if (!rangeToDelete.contains(vimState.cursorPosition)) { + console.log("!!!"); + } } public async run(vimState: VimState, start: Position, end: Position, yank = true): Promise { - const result = await this.delete(start, end, vimState.currentMode, vimState.effectiveRegisterMode(), vimState, yank); + await this.delete(start, end, vimState.currentMode, vimState.effectiveRegisterMode(), vimState, yank); vimState.currentMode = ModeName.Normal; - vimState.cursorPosition = result; return vimState; } @@ -1179,40 +1278,50 @@ export class YankOperator extends BaseOperator { public async run(vimState: VimState, start: Position, end: Position): Promise { const originalMode = vimState.currentMode; - if (start.compareTo(end) <= 0) { - end = new Position(end.line, end.character + 1); - } else { - const tmp = start; - start = end; - end = tmp; - end = new Position(end.line, end.character + 1); - } + if (start.compareTo(end) <= 0) { + end = new Position(end.line, end.character + 1); + } else { + const tmp = start; + start = end; + end = tmp; - if (vimState.currentRegisterMode === RegisterMode.LineWise) { - start = start.getLineBegin(); - end = end.getLineEnd(); - } + end = new Position(end.line, end.character + 1); + } - let text = TextEditor.getText(new vscode.Range(start, end)); + if (vimState.currentRegisterMode === RegisterMode.LineWise) { + start = start.getLineBegin(); + end = end.getLineEnd(); + } - // If we selected the newline character, add it as well. - if (vimState.currentMode === ModeName.Visual && - end.character === TextEditor.getLineAt(end).text.length + 1) { - text = text + "\n"; - } + let text = TextEditor.getText(new vscode.Range(start, end)); + + // If we selected the newline character, add it as well. + if (vimState.currentMode === ModeName.Visual && + end.character === TextEditor.getLineAt(end).text.length + 1) { + text = text + "\n"; + } + if (!vimState.isMultiCursor) { Register.put(text, vimState); + } else { + if (this.multicursorIndex === 0) { + Register.put([], vimState); + } - vimState.currentMode = ModeName.Normal; + Register.add(text, vimState); + } + + vimState.currentMode = ModeName.Normal; + vimState.cursorStartPosition = start; if (originalMode === ModeName.Normal) { - vimState.cursorPosition = vimState.cursorPositionJustBeforeAnythingHappened; + vimState.allCursors = vimState.cursorPositionsJustBeforeAnythingHappened.map(x => new Range(x, x)); } else { vimState.cursorPosition = start; } - return vimState; + return vimState; } } @@ -1339,22 +1448,22 @@ export class ChangeOperator extends BaseOperator { public modes = [ModeName.Normal, ModeName.Visual, ModeName.VisualLine]; public async run(vimState: VimState, start: Position, end: Position): Promise { - const isEndOfLine = end.character === TextEditor.getLineAt(end).text.length - 1; - let state = vimState; - - // If we delete to EOL, the block cursor would end on the final character, - // which means the insert cursor would be one to the left of the end of - // the line. - if (Position.getLineLength(TextEditor.getLineAt(start).lineNumber) !== 0) { - state = await new DeleteOperator().run(vimState, start, end); - } - state.currentMode = ModeName.Insert; + const isEndOfLine = end.character === TextEditor.getLineAt(end).text.length - 1; - if (isEndOfLine) { - state.cursorPosition = state.cursorPosition.getRight(); - } + // If we delete to EOL, the block cursor would end on the final character, + // which means the insert cursor would be one to the left of the end of + // the line. + if (Position.getLineLength(TextEditor.getLineAt(start).lineNumber) !== 0) { + vimState = await new DeleteOperator().run(vimState, start, end); + } + + vimState.currentMode = ModeName.Insert; + + if (isEndOfLine) { + vimState.cursorPosition = vimState.cursorPosition.getRight(); + } - return state; + return vimState; } } @@ -1368,10 +1477,26 @@ export class PutCommand extends BaseCommand { public async exec(position: Position, vimState: VimState, after: boolean = false, adjustIndent: boolean = false): Promise { const register = await Register.get(vimState); const dest = after ? position : position.getRight(); - let text = register.text; + let text: string | undefined; + + if (vimState.isMultiCursor) { + if (this.multicursorIndex === undefined) { + console.log("ERROR: no multi cursor index when calling PutCommand#exec"); + + throw new Error("Bad!"); + } + + if (vimState.isMultiCursor && typeof register.text === "object") { + text = register.text[this.multicursorIndex!]; + } + } else { + if (typeof register.text === "object") { + return await this.execVisualBlockPaste(register.text, position, vimState, after); + } + } - if (typeof text === "object") { - return await this.execVisualBlockPaste(text, position, vimState, after); + if (!text) { + text = register.text as string; } if (register.registerMode === RegisterMode.CharacterWise) { @@ -1528,9 +1653,21 @@ export class PutCommandVisual extends BaseCommand { canBePrefixedWithDot = true; public async exec(position: Position, vimState: VimState, after: boolean = false): Promise { - const result = await new DeleteOperator().run(vimState, vimState.cursorStartPosition, vimState.cursorPosition, false); + const positionToPasteAt = Position.EarlierOf(vimState.cursorPosition, vimState.cursorStartPosition); + let toPaste = (await Register.get(vimState)).text; - return await new PutCommand().exec(result.cursorPosition, result, true); + if (Array.isArray(toPaste)) { + toPaste = toPaste.join("\n"); + } + + vimState.recordedState.transformations.push({ + type : "replaceText", + text : toPaste, + start : positionToPasteAt, + end : positionToPasteAt.advancePositionByText(toPaste), + }); + + return vimState; } // TODO - execWithCount @@ -1581,7 +1718,10 @@ export class PutBeforeCommand extends BaseCommand { public modes = [ModeName.Normal]; public async exec(position: Position, vimState: VimState): Promise { - const result = await new PutCommand().exec(position, vimState, true); + const command = new PutCommand(); + command.multicursorIndex = this.multicursorIndex; + + const result = await command.exec(position, vimState, true); if (vimState.effectiveRegisterMode() === RegisterMode.LineWise) { result.cursorPosition = result.cursorPosition.getPreviousLineBegin(); @@ -1635,9 +1775,12 @@ export class PutBeforeWithIndentCommand extends BaseCommand { class CommandShowCommandLine extends BaseCommand { modes = [ModeName.Normal, ModeName.Visual, ModeName.VisualLine]; keys = [":"]; + runsOnceForEveryCursor() { return false; } public async exec(position: Position, vimState: VimState): Promise { - vimState.commandAction = VimSpecialCommands.ShowCommandLine; + vimState.recordedState.transformations.push({ + type: "showCommandLine" + }); if (vimState.currentMode === ModeName.Normal) { vimState.commandInitialText = ""; @@ -1655,7 +1798,9 @@ class CommandDot extends BaseCommand { keys = ["."]; public async exec(position: Position, vimState: VimState): Promise { - vimState.commandAction = VimSpecialCommands.Dot; + vimState.recordedState.transformations.push({ + type: "dot" + }); return vimState; } @@ -1794,13 +1939,15 @@ class CommandGoToOtherEndOfHighlightedText extends BaseCommand { class CommandUndo extends BaseCommand { modes = [ModeName.Normal]; keys = ["u"]; + runsOnceForEveryCursor() { return false; } public async exec(position: Position, vimState: VimState): Promise { - const newPosition = await vimState.historyTracker.goBackHistoryStep(); + const newPositions = await vimState.historyTracker.goBackHistoryStep(); - if (newPosition !== undefined) { - vimState.cursorPosition = newPosition; + if (newPositions !== undefined) { + vimState.allCursors = newPositions.map(x => new Range(x, x)); } + vimState.alteredHistory = true; return vimState; } @@ -1810,13 +1957,15 @@ class CommandUndo extends BaseCommand { class CommandRedo extends BaseCommand { modes = [ModeName.Normal]; keys = [""]; + runsOnceForEveryCursor() { return false; } public async exec(position: Position, vimState: VimState): Promise { - const newPosition = await vimState.historyTracker.goForwardHistoryStep(); + const newPositions = await vimState.historyTracker.goForwardHistoryStep(); - if (newPosition !== undefined) { - vimState.cursorPosition = newPosition; + if (newPositions !== undefined) { + vimState.allCursors = newPositions.map(x => new Range(x, x)); } + vimState.alteredHistory = true; return vimState; } @@ -1890,6 +2039,9 @@ class CommandVisualMode extends BaseCommand { public async exec(position: Position, vimState: VimState): Promise { vimState.currentMode = ModeName.Visual; + console.log('go to visual mode!'); + console.log(vimState.allCursors.toString()); + return vimState; } } @@ -1900,7 +2052,6 @@ class CommandVisualBlockMode extends BaseCommand { keys = [""]; public async exec(position: Position, vimState: VimState): Promise { - if (vimState.currentMode === ModeName.VisualBlock) { vimState.currentMode = ModeName.Normal; } else { @@ -2031,12 +2182,16 @@ class CommandInsertAtLineEnd extends BaseCommand { class CommandInsertNewLineAbove extends BaseCommand { modes = [ModeName.Normal]; keys = ["O"]; + runsOnceForEveryCursor() { return false; } public async exec(position: Position, vimState: VimState): Promise { - await vscode.commands.executeCommand("editor.action.insertLineBefore"); - vimState.currentMode = ModeName.Insert; - vimState.cursorPosition = new Position(position.line, TextEditor.getLineAt(position).text.length); + await vscode.commands.executeCommand('editor.action.insertLineBefore'); + + await waitForCursorUpdatesToHappen(); + + vimState.allCursors = getAllCursors(); + return vimState; } } @@ -2045,14 +2200,15 @@ class CommandInsertNewLineAbove extends BaseCommand { class CommandInsertNewLineBefore extends BaseCommand { modes = [ModeName.Normal]; keys = ["o"]; + runsOnceForEveryCursor() { return false; } public async exec(position: Position, vimState: VimState): Promise { - await vscode.commands.executeCommand("editor.action.insertLineAfter"); - vimState.currentMode = ModeName.Insert; - vimState.cursorPosition = new Position( - position.line + 1, - TextEditor.getLineAt(new Position(position.line + 1, 0)).text.length); + await vscode.commands.executeCommand('editor.action.insertLineAfter'); + + await waitForCursorUpdatesToHappen(); + + vimState.allCursors = getAllCursors(); return vimState; } @@ -2271,7 +2427,7 @@ class MoveFindForward extends BaseMovement { let result = position.findForwards(toFind, count); if (!result) { - return { start: position, stop: position, failed: true }; + return { cursorPosition: position, operatingRange: new Range(position, position), failed: true }; } if (vimState.recordedState.operator) { @@ -2296,7 +2452,7 @@ class MoveFindBackward extends BaseMovement { let result = position.findBackwards(toFind, count); if (!result) { - return { start: position, stop: position, failed: true }; + return { cursorPosition: position, operatingRange: new Range(position, position), failed: true }; } return result; @@ -2318,7 +2474,7 @@ class MoveTilForward extends BaseMovement { let result = position.tilForwards(toFind, count); if (!result) { - return { start: position, stop: position, failed: true }; + return { cursorPosition: position, operatingRange: new Range(position, position), failed: true }; } if (vimState.recordedState.operator) { @@ -2343,7 +2499,7 @@ class MoveTilBackward extends BaseMovement { let result = position.tilBackwards(toFind, count); if (!result) { - return { start: position, stop: position, failed: true }; + return { cursorPosition: position, operatingRange: new Range(position, position), failed: true }; } return result; @@ -2449,13 +2605,12 @@ abstract class MoveByScreenLine extends BaseMovement { if (vimState.currentMode === ModeName.Normal) { return Position.FromVSCodePosition(vscode.window.activeTextEditor.selection.active); } else { - /** - * cursorMove command is handling the selection for us. - * So we are not following our design principal (do no real movement inside an action) here. - */ + let start = Position.FromVSCodePosition(vscode.window.activeTextEditor.selection.start); + let stop = Position.FromVSCodePosition(vscode.window.activeTextEditor.selection.end); + return { - start: Position.FromVSCodePosition(vscode.window.activeTextEditor.selection.start), - stop: Position.FromVSCodePosition(vscode.window.activeTextEditor.selection.end) + cursorPosition: stop, + operatingRange: new Range(start, stop), }; } } @@ -2468,9 +2623,12 @@ abstract class MoveByScreenLine extends BaseMovement { value: this.value }); + let start = Position.FromVSCodePosition(vscode.window.activeTextEditor.selection.start); + let stop = Position.FromVSCodePosition(vscode.window.activeTextEditor.selection.end); + return { - start: Position.FromVSCodePosition(vscode.window.activeTextEditor.selection.start), - stop: Position.FromVSCodePosition(vscode.window.activeTextEditor.selection.end) + cursorPosition: stop, + operatingRange: new Range(start, stop), }; } } @@ -2653,9 +2811,9 @@ class MoveNonBlankLast extends BaseMovement { } return { - start: vimState.cursorStartPosition, - stop: stop, - registerMode: RegisterMode.LineWise + cursorPosition: stop, + operatingRange: new Range(vimState.cursorStartPosition, stop), + registerMode: RegisterMode.LineWise, }; } } @@ -3060,9 +3218,12 @@ class ActionReplaceCharacter extends BaseCommand { const toReplace = this.keysPressed[1]; const state = await new DeleteOperator().run(vimState, position, position); - await TextEditor.insertAt(toReplace, position); - - state.cursorPosition = position; + vimState.recordedState.transformations.push({ + type : "insertText", + text : toReplace, + diff : PositionDiff.NewDiff(0, -1), + position: position, + }); return state; } @@ -3074,12 +3235,7 @@ class ActionReplaceCharacter extends BaseCommand { return vimState; } - for (let i = 0; i < timesToRepeat; i++) { - vimState = await this.exec(position, vimState); - position = position.getRight(); - } - - return vimState; + return super.execCount(position, vimState); } } @@ -3087,6 +3243,7 @@ class ActionReplaceCharacter extends BaseCommand { class ActionReplaceCharacterVisualBlock extends BaseCommand { modes = [ModeName.VisualBlock]; keys = ["r", ""]; + runsOnceForEveryCursor() { return false; } canBeRepeatedWithDot = true; public async exec(position: Position, vimState: VimState): Promise { @@ -3107,6 +3264,7 @@ class ActionXVisualBlock extends BaseCommand { modes = [ModeName.VisualBlock]; keys = ["x"]; canBeRepeatedWithDot = true; + runsOnceForEveryCursor() { return false; } public async exec(position: Position, vimState: VimState): Promise { @@ -3115,7 +3273,8 @@ class ActionXVisualBlock extends BaseCommand { vimState = await new DeleteOperator().run(vimState, start, new Position(end.line, end.character - 1)); } - vimState.cursorPosition = vimState.cursorStartPosition; + vimState.allCursors = new EqualitySet([ new Range(position, position) ]); + return vimState; } } @@ -3125,12 +3284,14 @@ class ActionDVisualBlock extends ActionXVisualBlock { modes = [ModeName.VisualBlock]; keys = ["d"]; canBeRepeatedWithDot = true; + runsOnceForEveryCursor() { return false; } } @RegisterAction class ActionGoToInsertVisualBlockMode extends BaseCommand { modes = [ModeName.VisualBlock]; keys = ["I"]; + runsOnceForEveryCursor() { return false; } public async exec(position: Position, vimState: VimState): Promise { if (vimState.cursorPosition.character < vimState.cursorStartPosition.character) { @@ -3148,6 +3309,7 @@ class ActionGoToInsertVisualBlockMode extends BaseCommand { class ActionChangeInVisualBlockMode extends BaseCommand { modes = [ModeName.VisualBlock]; keys = ["c"]; + runsOnceForEveryCursor() { return false; } public async exec(position: Position, vimState: VimState): Promise { const deleteOperator = new DeleteOperator(); @@ -3170,7 +3332,8 @@ class ActionChangeInVisualBlockMode extends BaseCommand { @RegisterAction class ActionChangeToEOLInVisualBlockMode extends BaseCommand { modes = [ModeName.VisualBlock]; -keys = ["C"]; + keys = ["C"]; + runsOnceForEveryCursor() { return false; } public async exec(position: Position, vimState: VimState): Promise { const deleteOperator = new DeleteOperator(); @@ -3192,6 +3355,7 @@ keys = ["C"]; class ActionGoToInsertVisualBlockModeAppend extends BaseCommand { modes = [ModeName.VisualBlock]; keys = ["A"]; + runsOnceForEveryCursor() { return false; } public async exec(position: Position, vimState: VimState): Promise { if (vimState.cursorPosition.character < vimState.cursorStartPosition.character) { @@ -3211,6 +3375,7 @@ export class YankVisualBlockMode extends BaseOperator { public keys = ["y"]; public modes = [ModeName.VisualBlock]; canBeRepeatedWithDot = false; + runsOnceForEveryCursor() { return false; } public async run(vimState: VimState, start: Position, end: Position): Promise { let toCopy: string[] = []; @@ -3232,6 +3397,7 @@ export class YankVisualBlockMode extends BaseOperator { class InsertInInsertVisualBlockMode extends BaseCommand { modes = [ModeName.VisualBlockInsertMode]; keys = [""]; + runsOnceForEveryCursor() { return false; } public async exec(position: Position, vimState: VimState): Promise { let char = this.keysPressed[0]; @@ -3250,16 +3416,27 @@ class InsertInInsertVisualBlockMode extends BaseCommand { const insertPos = insertAtStart ? start : end; if (char === '') { - await TextEditor.backspace(insertPos.getLeft()); + vimState.recordedState.transformations.push({ + type : "deleteText", + position : insertPos.getLeft(), + }); posChange = -1; } else { + let positionToInsert: Position; + if (vimState.recordedState.visualBlockInsertionType === VisualBlockInsertionType.Append) { - await TextEditor.insert(this.keysPressed[0], insertPos.getLeft()); + positionToInsert = insertPos.getLeft(); } else { - await TextEditor.insert(this.keysPressed[0], insertPos); + positionToInsert = insertPos; } + vimState.recordedState.transformations.push({ + type : "insertText", + text : char, + position: positionToInsert + }); + posChange = 1; } } @@ -3283,10 +3460,13 @@ class MoveDD extends BaseMovement { keys = ["d"]; public async execActionWithCount(position: Position, vimState: VimState, count: number): Promise { + let start = position.getLineBegin(); + let stop = position.getDownByCount(Math.max(0, count - 1)).getLineEnd(); + return { - start : position.getLineBegin(), - stop : position.getDownByCount(Math.max(0, count - 1)).getLineEnd(), - registerMode : RegisterMode.LineWise + cursorPosition: stop, + operatingRange: new Range(start, stop), + registerMode : RegisterMode.LineWise, }; } } @@ -3297,10 +3477,13 @@ class MoveYY extends BaseMovement { keys = ["y"]; public async execActionWithCount(position: Position, vimState: VimState, count: number): Promise { + let start = position.getLineBegin(); + let stop = position.getDownByCount(Math.max(0, count - 1)).getLineEnd(); + return { - start : position.getLineBegin(), - stop : position.getDownByCount(Math.max(0, count - 1)).getLineEnd(), - registerMode: RegisterMode.LineWise, + cursorPosition: stop, + operatingRange: new Range(start, stop), + registerMode : RegisterMode.LineWise, }; } } @@ -3311,9 +3494,12 @@ class MoveCC extends BaseMovement { keys = ["c"]; public async execActionWithCount(position: Position, vimState: VimState, count: number): Promise { + let start = position.getLineBeginRespectingIndent(); + let stop = position.getDownByCount(Math.max(0, count - 1)).getLineEnd(); + return { - start : position.getLineBeginRespectingIndent(), - stop : position.getDownByCount(Math.max(0, count - 1)).getLineEnd(), + cursorPosition: stop, + operatingRange: new Range(start, stop), registerMode: RegisterMode.CharacterWise }; } @@ -3325,9 +3511,12 @@ class MoveIndent extends BaseMovement { keys = [">"]; public async execAction(position: Position, vimState: VimState): Promise { + let start = position.getLineBegin(); + let stop = position.getLineEnd(); + return { - start : position.getLineBegin(), - stop : position.getLineEnd(), + cursorPosition: stop, + operatingRange: new Range(start, stop) }; } } @@ -3338,9 +3527,12 @@ class MoveOutdent extends BaseMovement { keys = ["<"]; public async execAction(position: Position, vimState: VimState): Promise { + let start = position.getLineBegin(); + let stop = position.getLineEnd(); + return { - start : position.getLineBegin(), - stop : position.getLineEnd(), + cursorPosition: stop, + operatingRange: new Range(start, stop) }; } } @@ -3376,10 +3568,14 @@ abstract class TextObjectMovement extends BaseMovement { public async execActionForOperator(position: Position, vimState: VimState): Promise { const res = await this.execAction(position, vimState) as IMovement; + // Since we need to handle leading spaces, we cannot use MoveWordBegin.execActionForOperator // In normal mode, the character on the stop position will be the first character after the operator executed // and we do left-shifting in operator-pre-execution phase, here we need to right-shift the stop position accordingly. - res.stop = new Position(res.stop.line, res.stop.character + 1); + + res.operatingRange = res.operatingRange.withNewStop( + new Position(res.operatingRange.getStop().line, + res.operatingRange.getStop().character + 1)); return res; } @@ -3422,8 +3618,8 @@ class SelectWord extends TextObjectMovement { } return { - start: start, - stop: stop + cursorPosition: stop, + operatingRange: new Range(start, stop), }; } } @@ -3439,29 +3635,29 @@ class SelectABigWord extends TextObjectMovement { const currentChar = TextEditor.getLineAt(position).text[position.character]; if (/\s/.test(currentChar)) { - start = position.getLastBigWordEnd().getRight(); - stop = position.getCurrentBigWordEnd(); + start = position.getLastBigWordEnd().getRight(); + stop = position.getCurrentBigWordEnd(); } else { - start = position.getBigWordLeft(); - stop = position.getBigWordRight().getLeft(); + start = position.getBigWordLeft(); + stop = position.getBigWordRight().getLeft(); } if (vimState.currentMode === ModeName.Visual && !vimState.cursorPosition.isEqual(vimState.cursorStartPosition)) { - start = vimState.cursorStartPosition; + start = vimState.cursorStartPosition; - if (vimState.cursorPosition.isBefore(vimState.cursorStartPosition)) { - // If current cursor postion is before cursor start position, we are selecting words in reverser order. - if (/\s/.test(currentChar)) { - stop = position.getBigWordLeft(); - } else { - stop = position.getLastBigWordEnd().getRight(); - } + if (vimState.cursorPosition.isBefore(vimState.cursorStartPosition)) { + // If current cursor postion is before cursor start position, we are selecting words in reverser order. + if (/\s/.test(currentChar)) { + stop = position.getBigWordLeft(); + } else { + stop = position.getLastBigWordEnd().getRight(); } + } } return { - start: start, - stop: stop + cursorPosition: stop, + operatingRange: new Range(start, stop), }; } } @@ -3498,8 +3694,8 @@ class SelectInnerWord extends TextObjectMovement { } return { - start: start, - stop: stop + cursorPosition: stop, + operatingRange: new Range(start, stop), }; } } @@ -3536,8 +3732,8 @@ class SelectInnerBigWord extends TextObjectMovement { } return { - start: start, - stop: stop + cursorPosition: stop, + operatingRange: new Range(start, stop), }; } } @@ -3584,8 +3780,8 @@ class SelectSentence extends TextObjectMovement { } return { - start: start, - stop: stop + cursorPosition: stop, + operatingRange: new Range(start, stop), }; } } @@ -3624,8 +3820,8 @@ class SelectInnerSentence extends TextObjectMovement { } return { - start: start, - stop: stop + cursorPosition: stop, + operatingRange: new Range(start, stop), }; } } @@ -3649,9 +3845,11 @@ class SelectParagraph extends TextObjectMovement { } } + let stop = position.getCurrentParagraphEnd(); + return { - start: start, - stop: position.getCurrentParagraphEnd() + cursorPosition: stop, + operatingRange: new Range(start, stop), }; } } @@ -3683,8 +3881,8 @@ class SelectInnerParagraph extends TextObjectMovement { } return { - start: start, - stop: stop + cursorPosition: stop, + operatingRange: new Range(start, stop), }; } } @@ -3697,7 +3895,7 @@ class MoveToMatchingBracket extends BaseMovement { const text = TextEditor.getLineAt(position).text; const charToMatch = text[position.character]; const toFind = PairMatcher.pairings[charToMatch]; - const failure = { start: position, stop: position, failed: true }; + const failure = { cursorPosition: position, operatingRange: new Range(position, position), failed: true }; if (!toFind || !toFind.matchesWithPercentageMotion) { // If we're not on a match, go right until we find a @@ -3747,7 +3945,7 @@ abstract class MoveInsideCharacter extends BaseMovement { protected includeSurrounding = false; public async execAction(position: Position, vimState: VimState): Promise { - const failure = { start: position, stop: position, failed: true }; + const failure = { cursorPosition: position, operatingRange: new Range(position, position), failed: true }; const text = TextEditor.getLineAt(position).text; const closingChar = PairMatcher.pairings[this.charToMatch].match; const closedMatch = text[position.character] === closingChar; @@ -3773,8 +3971,8 @@ abstract class MoveInsideCharacter extends BaseMovement { } return { - start : startPos, - stop : endPos, + cursorPosition: endPos, + operatingRange: new Range(startPos, endPos), }; } } @@ -3922,8 +4120,8 @@ abstract class MoveQuoteMatch extends BaseMovement { if (start === -1 || end === -1 || end === start || end < position.character) { return { - start: position, - stop: position, + cursorPosition: position, + operatingRange: new Range(position, position), failed: true }; } @@ -3936,15 +4134,15 @@ abstract class MoveQuoteMatch extends BaseMovement { } return { - start: startPos, - stop: endPos + cursorPosition: endPos, + operatingRange: new Range(startPos, endPos), }; } public async execActionForOperator(position: Position, vimState: VimState): Promise { const res = await this.execAction(position, vimState); - res.stop = res.stop.getRight(); + res.operatingRange.withNewStop(res.operatingRange.getStop().getRight()); return res; } @@ -3997,7 +4195,7 @@ class MoveToUnclosedRoundBracketBackward extends MoveToMatchingBracket { keys = ["[", "("]; public async execAction(position: Position, vimState: VimState): Promise { - const failure = { start: position, stop: position, failed: true }; + const failure = { cursorPosition: position, operatingRange: new Range(position, position), failed: true }; const charToMatch = ")"; const result = PairMatcher.nextPairedChar(position.getLeftThroughLineBreaks(), charToMatch, false); @@ -4011,7 +4209,7 @@ class MoveToUnclosedRoundBracketForward extends MoveToMatchingBracket { keys = ["]", ")"]; public async execAction(position: Position, vimState: VimState): Promise { - const failure = { start: position, stop: position, failed: true }; + const failure = { cursorPosition: position, operatingRange: new Range(position, position), failed: true }; const charToMatch = "("; const result = PairMatcher.nextPairedChar(position.getRightThroughLineBreaks(), charToMatch, false); @@ -4025,7 +4223,7 @@ class MoveToUnclosedCurlyBracketBackward extends MoveToMatchingBracket { keys = ["[", "{"]; public async execAction(position: Position, vimState: VimState): Promise { - const failure = { start: position, stop: position, failed: true }; + const failure = { cursorPosition: position, operatingRange: new Range(position, position), failed: true }; const charToMatch = "}"; const result = PairMatcher.nextPairedChar(position.getLeftThroughLineBreaks(), charToMatch, false); @@ -4039,7 +4237,7 @@ class MoveToUnclosedCurlyBracketForward extends MoveToMatchingBracket { keys = ["]", "}"]; public async execAction(position: Position, vimState: VimState): Promise { - const failure = { start: position, stop: position, failed: true }; + const failure = { cursorPosition: position, operatingRange: new Range(position, position), failed: true }; const charToMatch = "{"; const result = PairMatcher.nextPairedChar(position.getRightThroughLineBreaks(), charToMatch, false); @@ -4125,6 +4323,21 @@ class ToggleCaseAndMoveForward extends BaseCommand { } } +@RegisterAction +class DebugActionForJohnfn extends BaseCommand { + modes = [ModeName.Normal, ModeName.Insert]; + keys = ["\\"]; + + public async exec(position: Position, vimState: VimState): Promise { + vimState.recordedState.transformations.push({ + type : "deleteRange", + range : new Range(new Position(0, 1), new Position(0, 5)), + }); + + return vimState; + } +} + abstract class IncrementDecrementNumberAction extends BaseCommand { modes = [ModeName.Normal]; canBeRepeatedWithDot = true; @@ -4196,26 +4409,22 @@ abstract class MoveTagMatch extends BaseMovement { const end = tagMatcher.findClosing(this.includeTag); if (start === undefined || end === undefined || end === start) { - return { - start: position, - stop: position, - failed: true - }; + return { cursorPosition: position, operatingRange: new Range(position, position), failed: true }; } let startPos = new Position(position.line, start); let endPos = new Position(position.line, end - 1); return { - start: startPos, - stop: endPos + cursorPosition: endPos, + operatingRange: new Range(startPos, endPos), }; } public async execActionForOperator(position: Position, vimState: VimState): Promise { const res = await this.execAction(position, vimState); - res.stop = res.stop.getRight(); + res.operatingRange = res.operatingRange.withNewStop(res.operatingRange.getStop().getRight()); return res; } diff --git a/src/history/historyTracker.ts b/src/history/historyTracker.ts index 95850e63650..e53a848fdd1 100644 --- a/src/history/historyTracker.ts +++ b/src/history/historyTracker.ts @@ -15,6 +15,7 @@ import * as _ from "lodash"; import { Position } from './../motion/position'; import { TextEditor } from './../textEditor'; +import { EqualitySet } from './../misc/equalitySet'; import DiffMatchPatch = require("diff-match-patch"); @@ -78,12 +79,12 @@ class HistoryStep { /** * The cursor position at the start of this history step. */ - cursorStart: Position | undefined; + cursorStart: EqualitySet | undefined; /** * The cursor position at the end of this history step so far. */ - cursorEnd: Position | undefined; + cursorEnd: EqualitySet | undefined; /** * The position of every mark at the start of this history step. @@ -93,15 +94,15 @@ class HistoryStep { constructor(init: { changes?: DocumentChange[], isFinished?: boolean, - cursorStart?: Position | undefined, - cursorEnd?: Position | undefined, + cursorStart?: EqualitySet | undefined, + cursorEnd?: EqualitySet | undefined, marks?: IMark[] }) { - this.changes = init.changes = []; + this.changes = init.changes = []; this.isFinished = init.isFinished || false; this.cursorStart = init.cursorStart || undefined; - this.cursorEnd = init.cursorEnd || undefined; - this.marks = init.marks || []; + this.cursorEnd = init.cursorEnd || undefined; + this.marks = init.marks || []; } /** @@ -192,10 +193,10 @@ export class HistoryTracker { */ private _initialize() { this.historySteps.push(new HistoryStep({ - changes : [new DocumentChange(new Position(0, 0), TextEditor.getAllText(), true)], + changes : [new DocumentChange(new Position(0, 0), TextEditor.getAllText(), true)], isFinished : true, - cursorStart: new Position(0, 0), - cursorEnd: new Position(0, 0) + cursorStart: new EqualitySet([ new Position(0, 0) ]), + cursorEnd : new EqualitySet([ new Position(0, 0) ]) })); this.finishCurrentStep(); @@ -336,7 +337,7 @@ export class HistoryTracker { * Determines what changed by diffing the document against what it * used to look like. */ - addChange(cursorPosition = new Position(0, 0)): void { + addChange(cursorPosition = new EqualitySet([ new Position(0, 0) ])): void { const newText = TextEditor.getAllText(); if (newText === this.oldText) { return; } @@ -449,7 +450,7 @@ export class HistoryTracker { * Essentially Undo or ctrl+z. Returns undefined if there's no more steps * back to go. */ - async goBackHistoryStep(): Promise { + async goBackHistoryStep(): Promise | undefined> { let step: HistoryStep; if (this.currentHistoryStepIndex === 0) { @@ -479,7 +480,7 @@ export class HistoryTracker { * Essentially Redo or ctrl+y. Returns undefined if there's no more steps * forward to go. */ - async goForwardHistoryStep(): Promise { + async goForwardHistoryStep(): Promise | undefined> { let step: HistoryStep; if (this.currentHistoryStepIndex === this.historySteps.length - 1) { @@ -497,14 +498,15 @@ export class HistoryTracker { return step.cursorStart; } - getLastHistoryEndPosition(): Position | undefined { + getLastHistoryEndPosition(): EqualitySet | undefined { if (this.currentHistoryStepIndex === 0) { return undefined; } + return this.historySteps[this.currentHistoryStepIndex].cursorEnd; } - setLastHistoryEndPosition(pos: Position) { + setLastHistoryEndPosition(pos: EqualitySet): void { this.historySteps[this.currentHistoryStepIndex].cursorEnd = pos; } diff --git a/src/misc/equalitySet.ts b/src/misc/equalitySet.ts new file mode 100644 index 00000000000..536f2579d25 --- /dev/null +++ b/src/misc/equalitySet.ts @@ -0,0 +1,86 @@ +/** + * A Set that determines equality by comparing toString() values. + */ +export class EqualitySet { + private data: Map; + + constructor(values: T[] = []) { + this.data = new Map(); + + for (const val of values) { + this.add(val); + } + } + + first(): T { + for (const f of this.data) { + return f[1]; + } + + throw new Error("No first element!"); + } + + add(item: T): void { + this.data.set(item.toString(), item); + } + + delete(item: T): T { + const toBeDeleted = this.data.get(item.toString()); + + this.data.delete(item.toString()); + + return toBeDeleted; + } + + values(): IterableIterator { + return this.data.values(); + } + + get size(): number { + return this.data.size; + } + + clear(): void { + this.data = new Map(); + } + + map(fn: (a: T) => U): EqualitySet { + let results: U[] = []; + const values = this.values(); + + for (const x of values) { + results.push(fn(x)); + } + + return new EqualitySet(results); + } + + async asyncMap(fn: (a: T, i?: number) => Promise): Promise> { + let results: U[] = []; + let i = 0; + const values = [...this.values()]; + + for (const x of values) { + results.push(await fn(x, i++)); + } + + return new EqualitySet(results); + } + + toString(): string { + const values = this.values(); + let result = "["; + + for (const x of values) { + result += x.toString() + ", "; + } + + result += "]"; + + return result; + } + + [Symbol.iterator](): IterableIterator { + return this.values(); + } +} diff --git a/src/mode/modeHandler.ts b/src/mode/modeHandler.ts index 8ec21c08eaa..09782b79337 100644 --- a/src/mode/modeHandler.ts +++ b/src/mode/modeHandler.ts @@ -4,13 +4,20 @@ import * as vscode from 'vscode'; import * as _ from 'lodash'; import { getAndUpdateModeHandler } from './../../extension'; +import { + isTextTransformation, + Transformation, + TextTransformations, +} from './../transformations/transformations'; import { Mode, ModeName, VSCodeVimCursorType } from './mode'; +import { EqualitySet } from './../misc/equalitySet'; import { InsertModeRemapper, OtherModesRemapper } from './remapper'; import { NormalMode } from './modeNormal'; import { InsertMode } from './modeInsert'; import { VisualBlockMode, VisualBlockInsertionType } from './modeVisualBlock'; import { InsertVisualBlockMode } from './modeInsertVisualBlock'; import { VisualMode } from './modeVisual'; +import { taskQueue } from './../taskQueue'; import { ReplaceMode } from './modeReplace'; import { SearchInProgressMode } from './modeSearchInProgress'; import { TextEditor } from './../textEditor'; @@ -19,25 +26,61 @@ import { HistoryTracker } from './../history/historyTracker'; import { BaseMovement, BaseCommand, Actions, BaseAction, BaseOperator, isIMovement, - KeypressState } from './../actions/actions'; -import { Position } from './../motion/position'; + KeypressState +} from './../actions/actions'; +import { Position, PositionDiff } from './../motion/position'; +import { Range } from './../motion/range'; import { RegisterMode } from './../register/register'; import { showCmdLine } from '../../src/cmd_line/main'; import { Configuration } from '../../src/configuration/configuration'; import { PairMatcher } from './../matching/matcher'; import { Globals } from '../../src/globals'; -export enum VimSpecialCommands { - Nothing, - ShowCommandLine, - Dot -} - export class ViewChange { public command: string; public args: any; } +export class VimCursor { + private cursor: Range; + + private operatingRange: Range | undefined; + + private constructor(start: Position, stop: Position) { + this.cursor = new Range(start, stop); + } + + public static NewByPosition(start: Position, stop: Position): VimCursor { + return new VimCursor(start, stop); + } + + public static NewByRange(range: Range): VimCursor { + return new VimCursor(range.getStart(), range.getStop()); + } + + public getCursor(): Range { + return this.cursor; + } + + public getOperatingRange(): Range | undefined { + return this.operatingRange; + } + + public withNewOperatingRange(operatingRange: Range): VimCursor { + const result = VimCursor.NewByRange(this.cursor); + + result.operatingRange = operatingRange; + return result; + } + + public withNewCursor(cursor: Range): VimCursor { + const result = VimCursor.NewByRange(cursor); + + result.operatingRange = this.operatingRange; + return result; + } +} + /** * The VimState class holds permanent state that carries over from action * to action. @@ -59,6 +102,11 @@ export class VimState { public historyTracker: HistoryTracker; + /** + * Are multiple cursors currently present? + */ + public isMultiCursor = false; + public static lastRepeatableMovement : BaseMovement | undefined = undefined; /** @@ -93,11 +141,13 @@ export class VimState { /** * The position the cursor will be when this action finishes. */ - // public cursorPosition = new Position(0, 0); - private _cursorPosition = new Position(0, 0); - public get cursorPosition(): Position { return this._cursorPosition; } - public set cursorPosition(v: Position) { - this._cursorPosition = v; + public get cursorPosition(): Position { + return this.allCursors.first().getCursor().getStop(); + } + public set cursorPosition(value: Position) { + const oldCursor = this.allCursors.delete(this.allCursors.first()); + + this.allCursors.add(oldCursor.withNewCursor(oldCursor.getCursor().withNewStop(value))); } /** @@ -105,9 +155,21 @@ export class VimState { * the range over which to run an Operator. May rarely be different than where the cursor * actually starts e.g. if you use the "aw" text motion in the middle of a word. */ - public cursorStartPosition = new Position(0, 0); + public get cursorStartPosition(): Position { + return this.allCursors.first().getCursor().getStart(); + } + public set cursorStartPosition(value: Position) { + const oldCursor = this.allCursors.delete(this.allCursors.first()); - public cursorPositionJustBeforeAnythingHappened = new Position(0, 0); + this.allCursors.add(oldCursor.withNewCursor(oldCursor.getCursor().withNewStart(value))); + } + + /** + * In Multi Cursor Mode, the position of every cursor. + */ + public allCursors: EqualitySet = new EqualitySet([ VimCursor.NewByPosition(new Position(0, 0), new Position(0, 0)) ]); + + public cursorPositionsJustBeforeAnythingHappened = new EqualitySet([ new Position(0, 0) ]); public searchState: SearchState | undefined = undefined; @@ -154,10 +216,7 @@ export class VimState { return VisualBlockMode.getBottomRightPosition(this.cursorStartPosition, this.cursorPosition); } - /** - * This is for oddball commands that don't manipulate text in any way. - */ - public commandAction = VimSpecialCommands.Nothing; + public registerName = '"'; public commandInitialText = ""; @@ -320,20 +379,26 @@ export class SearchState { } export class ReplaceState { - private _replaceCursorStartPosition: Position; - - public get replaceCursorStartPosition() { - return this._replaceCursorStartPosition; - } + /** + * The location of the cursor where you begun to replace characters. + */ + public replaceCursorStartPosition: Position; public originalChars: string[] = []; + /** + * The characters the user inserted in replace mode. Useful for when + * we repeat a replace action with . + */ public newChars: string[] = []; + /** + * Number of times we're going to repeat this replace action. + */ public timesToRepeat: number; constructor(startPosition: Position, timesToRepeat: number = 1) { - this._replaceCursorStartPosition = startPosition; + this.replaceCursorStartPosition = startPosition; this.timesToRepeat = timesToRepeat; let text = TextEditor.getLineAt(startPosition).text.substring(startPosition.character); @@ -354,23 +419,13 @@ export class ReplaceState { * * copy into q register * * delete operator * * word movement - * - * - * Or imagine the user types: - * - * vw$}}d - * - * Then the state would be - * * Visual mode action - * * (a list of all the motions you ran) - * * delete operator */ export class RecordedState { - constructor() { const useClipboard = Configuration.getInstance().useSystemClipboard; this.registerName = useClipboard ? '*' : '"'; } + /** * Keeps track of keys pressed for the next action. Comes in handy when parsing * multiple length movements, e.g. gg. @@ -379,10 +434,14 @@ export class RecordedState { public actionsRun: BaseAction[] = []; + public operatingRange: Range | undefined = undefined; + public hasRunOperator = false; public visualBlockInsertionType = VisualBlockInsertionType.Insert; + public transformations: Transformation[] = []; + /** * The operator (e.g. d, y, >) the user wants to run, if there is one. */ @@ -435,7 +494,7 @@ export class RecordedState { mode !== ModeName.SearchInProgressMode && (this.hasRunAMovement || ( mode === ModeName.Visual || - mode === ModeName.VisualLine)); + mode === ModeName.VisualLine )); } public get isInInitialState(): boolean { @@ -547,82 +606,133 @@ export class ModeHandler implements vscode.Disposable { } // Handle scenarios where mouse used to change current position. - vscode.window.onDidChangeTextEditorSelection(async (e) => { - let selection = e.selections[0]; + vscode.window.onDidChangeTextEditorSelection((e: vscode.TextEditorSelectionChangeEvent) => { + taskQueue.enqueueTask({ + promise: () => this.handleSelectionChange(e), + isRunning: false, + }); + }); + } - if (ModeHandler.IsTesting) { - return; - } + private async handleSelectionChange(e: vscode.TextEditorSelectionChangeEvent): Promise { + let selection = e.selections[0]; - if (e.textEditor.document.fileName !== this.fileName) { - return; - } + if (ModeHandler.IsTesting) { + return; + } - if (this.currentModeName === ModeName.VisualBlock) { - // Not worth it until we get a better API for this stuff. + if (e.textEditor.document.fileName !== this.fileName) { + return; + } - return; - } + if (this._vimState.focusChanged) { + this._vimState.focusChanged = false; - if (this._vimState.focusChanged) { - this._vimState.focusChanged = false; - return; - } + return; + } + + if (this._vimState.currentMode !== ModeName.VisualBlock && + this._vimState.currentMode !== ModeName.VisualBlockInsertMode && + e.selections.length > this._vimState.allCursors.size) { + // Hey, we just added a selection. Either trigger or update Multi Cursor Mode. - // See comment about whatILastSetTheSelectionTo. - if (this._vimState.whatILastSetTheSelectionTo.isEqual(selection)) { - return; + if (e.selections.length >= 2) { + this._vimState.currentMode = ModeName.Visual; + this._vimState.isMultiCursor = true; + + this.setCurrentModeByName(this._vimState); + } else { + // The selections ran together - go back to visual mode. + this._vimState.currentMode = ModeName.Visual; + this.setCurrentModeByName(this._vimState); + this._vimState.isMultiCursor = false; } - if (this._vimState.currentMode === ModeName.SearchInProgressMode || - this._vimState.currentMode === ModeName.VisualBlockInsertMode) { - return; + this._vimState.allCursors.clear(); + + for (const sel of e.selections) { + this._vimState.allCursors.add( + VimCursor.NewByRange(Range.FromVSCodeSelection(sel)) + ); } - if (selection) { - var newPosition = new Position(selection.active.line, selection.active.character); + await this.updateView(this._vimState); - if (newPosition.character >= newPosition.getLineEnd().character) { - newPosition = new Position(newPosition.line, Math.max(newPosition.getLineEnd().character, 0)); - } + return; + } - this._vimState.cursorPosition = newPosition; - this._vimState.cursorStartPosition = newPosition; + if (!e.kind || e.kind === vscode.TextEditorSelectionChangeKind.Command) { + return; + } - this._vimState.desiredColumn = newPosition.character; + if (this._vimState.isMultiCursor && e.selections.length === 1) { + this._vimState.isMultiCursor = false; + } - // start visual mode? + if (this._vimState.isMultiCursor) { + return; + } - if (!selection.anchor.isEqual(selection.active)) { - var selectionStart = new Position(selection.anchor.line, selection.anchor.character); + if (this.currentModeName === ModeName.VisualBlock || + this.currentModeName === ModeName.VisualBlockInsertMode) { + // Not worth it until we get a better API for this stuff. - if (selectionStart.character > selectionStart.getLineEnd().character) { - selectionStart = new Position(selectionStart.line, selectionStart.getLineEnd().character); - } + return; + } - this._vimState.cursorStartPosition = selectionStart; + // See comment about whatILastSetTheSelectionTo. + if (this._vimState.whatILastSetTheSelectionTo.isEqual(selection)) { + return; + } - if (selectionStart.compareTo(newPosition) > 0) { - this._vimState.cursorStartPosition = this._vimState.cursorStartPosition.getLeft(); - } + if (this._vimState.currentMode === ModeName.SearchInProgressMode || + this._vimState.currentMode === ModeName.VisualBlockInsertMode) { + return; + } - if (!this._vimState.getModeObject(this).isVisualMode) { - this._vimState.currentMode = ModeName.Visual; - this.setCurrentModeByName(this._vimState); + if (selection) { + var newPosition = new Position(selection.active.line, selection.active.character); - // double click mouse selection causes an extra character to be selected so take one less character - this._vimState.cursorPosition = this._vimState.cursorPosition.getLeft(); - } - } else { - if (this._vimState.currentMode !== ModeName.Insert) { - this._vimState.currentMode = ModeName.Normal; - this.setCurrentModeByName(this._vimState); - } + if (newPosition.character >= newPosition.getLineEnd().character) { + newPosition = new Position(newPosition.line, Math.max(newPosition.getLineEnd().character, 0)); + } + + this._vimState.cursorPosition = newPosition; + this._vimState.cursorStartPosition = newPosition; + + this._vimState.desiredColumn = newPosition.character; + + // start visual mode? + + if (!selection.anchor.isEqual(selection.active)) { + var selectionStart = new Position(selection.anchor.line, selection.anchor.character); + + if (selectionStart.character > selectionStart.getLineEnd().character) { + selectionStart = new Position(selectionStart.line, selectionStart.getLineEnd().character); + } + + this._vimState.cursorStartPosition = selectionStart; + + if (selectionStart.compareTo(newPosition) > 0) { + this._vimState.cursorStartPosition = this._vimState.cursorStartPosition.getLeft(); } - await this.updateView(this._vimState, false); + if (!this._vimState.getModeObject(this).isVisualMode) { + this._vimState.currentMode = ModeName.Visual; + this.setCurrentModeByName(this._vimState); + + // double click mouse selection causes an extra character to be selected so take one less character + this._vimState.cursorPosition = this._vimState.cursorPosition.getLeft(); + } + } else { + if (this._vimState.currentMode !== ModeName.Insert) { + this._vimState.currentMode = ModeName.Normal; + this.setCurrentModeByName(this._vimState); + } } - }); + + await this.updateView(this._vimState, false); + } } /** @@ -647,7 +757,7 @@ export class ModeHandler implements vscode.Disposable { } async handleKeyEvent(key: string): Promise { - this._vimState.cursorPositionJustBeforeAnythingHappened = this._vimState.cursorPosition; + this._vimState.cursorPositionsJustBeforeAnythingHappened = this._vimState.allCursors.map(x => x.getCursor().getStop()); try { let handled = false; @@ -677,7 +787,7 @@ export class ModeHandler implements vscode.Disposable { async handleKeyEventHelper(key: string, vimState: VimState): Promise { // Catch any text change not triggered by us (example: tab completion). - vimState.historyTracker.addChange(this._vimState.cursorPositionJustBeforeAnythingHappened); + vimState.historyTracker.addChange(this._vimState.cursorPositionsJustBeforeAnythingHappened); let recordedState = vimState.recordedState; @@ -699,23 +809,6 @@ export class ModeHandler implements vscode.Disposable { vimState = await this.runAction(vimState, recordedState, action); - // Updated desired column - - const movement = action instanceof BaseMovement ? action : undefined; - - if ((movement && !movement.doesntChangeDesiredColumn) || - (recordedState.command && - vimState.currentMode !== ModeName.VisualBlock && - vimState.currentMode !== ModeName.VisualBlockInsertMode)) { - // We check !operator here because e.g. d$ should NOT set the desired column to EOL. - - if (movement && movement.setsDesiredColumnToEOL && !recordedState.operator) { - vimState.desiredColumn = Number.POSITIVE_INFINITY; - } else { - vimState.desiredColumn = vimState.cursorPosition.character; - } - } - // Update view await this.updateView(vimState); @@ -726,8 +819,15 @@ export class ModeHandler implements vscode.Disposable { let ranRepeatableAction = false; let ranAction = false; + // If arrow keys or mouse was used prior to entering characters while in insert mode, create an undo point + // this needs to happen before any changes are made + + /* + TODO + // If arrow keys or mouse were in insert mode, create an undo point. // This needs to happen before any changes are made + let prevPos = vimState.historyTracker.getLastHistoryEndPosition(); if (prevPos !== undefined && !vimState.isRunningDotCommand) { if (vimState.cursorPositionJustBeforeAnythingHappened.line !== prevPos.line || @@ -737,6 +837,7 @@ export class ModeHandler implements vscode.Disposable { vimState.historyTracker.finishCurrentStep(); } } + */ if (action instanceof BaseMovement) { ({ vimState, recordedState } = await this.executeMovement(vimState, action)); @@ -746,9 +847,7 @@ export class ModeHandler implements vscode.Disposable { if (action instanceof BaseCommand) { vimState = await action.execCount(vimState.cursorPosition, vimState); - if (vimState.commandAction !== VimSpecialCommands.Nothing) { - await this.executeCommand(vimState); - } + await this.runAccumulatedTransformations(vimState); if (action.isCompleteAction) { ranAction = true; @@ -811,7 +910,7 @@ export class ModeHandler implements vscode.Disposable { this._vimState.alteredHistory = false; vimState.historyTracker.ignoreChange(); } else { - vimState.historyTracker.addChange(this._vimState.cursorPositionJustBeforeAnythingHappened); + vimState.historyTracker.addChange(this._vimState.cursorPositionsJustBeforeAnythingHappened); } } @@ -819,8 +918,6 @@ export class ModeHandler implements vscode.Disposable { vimState.historyTracker.finishCurrentStep(); } - // console.log(vimState.historyTracker.toString()); - recordedState.actionKeys = []; vimState.currentRegisterMode = RegisterMode.FigureItOutFromCurrentMode; @@ -830,23 +927,42 @@ export class ModeHandler implements vscode.Disposable { // Ensure cursor is within bounds - if (vimState.cursorPosition.line >= TextEditor.getLineCount()) { - vimState.cursorPosition = vimState.cursorPosition.getDocumentEnd(); - } + vimState.allCursors = vimState.allCursors.map(cursor => { + if (cursor.getCursor().getStop().line >= TextEditor.getLineCount()) { + return cursor.withNewCursor(cursor.getCursor().withNewStop(vimState.cursorPosition.getDocumentEnd())) + } - const currentLineLength = TextEditor.getLineAt(vimState.cursorPosition).text.length; + const currentLineLength = TextEditor.getLineAt(cursor.getCursor().getStop()).text.length; - if (vimState.currentMode === ModeName.Normal && - vimState.cursorPosition.character >= currentLineLength && - currentLineLength > 0) { - vimState.cursorPosition = new Position( - vimState.cursorPosition.line, - currentLineLength - 1 - ); - } + if (vimState.currentMode === ModeName.Normal && + cursor.getCursor().getStop().character >= currentLineLength && currentLineLength > 0) { + + return cursor.withNewCursor(cursor.getCursor().withNewStop(cursor.getCursor().getStop().getLineEnd().getLeft())); + } + + return cursor; + }); + + // Update the current history step to have the latest cursor position + + vimState.historyTracker.setLastHistoryEndPosition(vimState.allCursors.map(x => x.getCursor().getStop())); - // Update the current history step to have the latest cursor position incase it is needed - vimState.historyTracker.setLastHistoryEndPosition(vimState.cursorPosition); + // Updated desired column + + const movement = action instanceof BaseMovement ? action : undefined; + + if ((movement && !movement.doesntChangeDesiredColumn) || + (recordedState.command && + vimState.currentMode !== ModeName.VisualBlock && + vimState.currentMode !== ModeName.VisualBlockInsertMode)) { + // We check !operator here because e.g. d$ should NOT set the desired column to EOL. + + if (movement && movement.setsDesiredColumnToEOL && !recordedState.operator) { + vimState.desiredColumn = Number.POSITIVE_INFINITY; + } else { + vimState.desiredColumn = vimState.cursorPosition.character; + } + } return vimState; } @@ -856,38 +972,67 @@ export class ModeHandler implements vscode.Disposable { let recordedState = vimState.recordedState; - const result = await movement.execActionWithCount(vimState.cursorPosition, vimState, recordedState.count); + vimState.allCursors = await vimState.allCursors.asyncMap(async (cursor, i) => { + /** + * Essentially what we're doing here is pretending like the + * current VimState only has one cursor (the cursor that we just + * iterated to). + * + * We set the cursor position to be equal to the iterated one, + * and then set it back immediately after we're done. + * + * The slightly more complicated logic here allows us to write + * Action definitions without having to think about multiple + * cursors in almost all cases. + */ + const cursorPosition = cursor.getCursor().getStop(); + const old = vimState.cursorPosition; - if (result instanceof Position) { - vimState.cursorPosition = result; - } else if (isIMovement(result)) { - if (result.failed) { - vimState.recordedState = new RecordedState(); + vimState.cursorPosition = cursorPosition; + const result = await movement.execActionWithCount(cursorPosition, vimState, recordedState.count); + vimState.cursorPosition = old; + + if (movement.canBeRepeatedWithSemicolon(vimState, result)) { + VimState.lastRepeatableMovement = movement; } - vimState.cursorPosition = result.stop; - vimState.cursorStartPosition = result.start; + if (result instanceof Position) { + let newStart = cursor.getCursor().getStart(); - if (result.registerMode) { - vimState.currentRegisterMode = result.registerMode; - } - } + if (!vimState.getModeObject(this).isVisualMode && + !vimState.recordedState.operator) { - if (movement.canBeRepeatedWithSemicolon(vimState, result)) { - VimState.lastRepeatableMovement = movement; - } + newStart = result; + } - vimState.recordedState.count = 0; + return new Range(newStart, result); + } else if (isIMovement(result)) { + if (result.failed) { + vimState.recordedState = new RecordedState(); + } - let stop = vimState.cursorPosition; + if (result.registerMode) { + vimState.currentRegisterMode = result.registerMode; + } + + return result.operatingRange; + } + }); + + vimState.recordedState.count = 0; // Keep the cursor within bounds if (vimState.currentMode === ModeName.Normal && !recordedState.operator) { - if (stop.character >= Position.getLineLength(stop.line)) { - vimState.cursorPosition = stop.getLineEnd().getLeft(); - } + vimState.allCursors = vimState.allCursors.map(cursor => { + if (cursor.getCursor().getStop().character >= Position.getLineLength(cursor.getCursor().getStop().line)) { + return cursor.withNewStop(cursor.getStop().getLineEnd().getLeft()); + } + + return cursor; + }); } else { + let stop = vimState.cursorPosition; // Vim does this weird thing where it allows you to select and delete // the newline character, which it places 1 past the last character @@ -902,61 +1047,183 @@ export class ModeHandler implements vscode.Disposable { } private async executeOperator(vimState: VimState): Promise { - let start = vimState.cursorStartPosition; - let stop = vimState.cursorPosition; let recordedState = vimState.recordedState; if (!recordedState.operator) { throw new Error("what in god's name"); } - if (start.compareTo(stop) > 0) { - [start, stop] = [stop, start]; - } + const cachedMode = this._vimState.getModeObject(this); + const cachedRegister = vimState.currentRegisterMode; + + let i = 0; - if (!this._vimState.getModeObject(this).isVisualMode && - vimState.currentRegisterMode !== RegisterMode.LineWise) { + let resultingModeName: ModeName; + let startingModeName = vimState.currentMode; - if (Position.EarlierOf(start, stop) === start) { - stop = stop.getLeft(); - } else { - stop = stop.getRight(); + vimState.allCursors = await vimState.allCursors.asyncMap(async (cursor) => { + let start = cursor.getStart(), stop = cursor.getStop(); + + console.log("Before", start.toString()); + console.log("Before", stop.toString()); + + if (start.compareTo(stop) > 0) { + [start, stop] = [stop, start]; } - } - if (this.currentModeName === ModeName.VisualLine) { - start = start.getLineBegin(); - stop = stop.getLineEnd(); + if (!cachedMode.isVisualMode && cachedRegister !== RegisterMode.LineWise) { + if (Position.EarlierOf(start, stop) === start) { + stop = stop.getLeft(); + } else { + stop = stop.getRight(); + } + } + + if (this.currentModeName === ModeName.VisualLine) { + start = start.getLineBegin(); + stop = stop.getLineEnd(); + + vimState.currentRegisterMode = RegisterMode.LineWise; + } + + recordedState.operator.multicursorIndex = i++; + + vimState.currentMode = startingModeName; + vimState.allCursors = new EqualitySet([ cursor ]); + + vimState = await recordedState.operator.run(vimState, start, stop); - vimState.currentRegisterMode = RegisterMode.LineWise; + resultingModeName = vimState.currentMode; + + console.log("After", vimState.cursorPosition.toString()); + console.log("After", vimState.cursorStartPosition.toString()); + + return new Range( + vimState.cursorStartPosition, + vimState.cursorPosition + ); + }); + + // Keep track of all cursors (in the case of multi-cursor). + + const updatedSelections = await this.runAccumulatedTransformations(vimState); + + if (!updatedSelections) { + const selections: vscode.Selection[] = []; + + for (const cursor of vimState.allCursors) { + selections.push(new vscode.Selection( + cursor.getStart(), + cursor.getStop(), + )); + } + + vscode.window.activeTextEditor.selections = selections; } - return await recordedState.operator.run(vimState, start, stop); + return vimState; } - private async executeCommand(vimState: VimState): Promise { - const command = vimState.commandAction; + /** + * Runs all the transformations stored in vimState.recordedState.transformations, + * then clears them. Returns whether we got new selection positions or not. + */ + private async runAccumulatedTransformations(vimState: VimState): Promise { + const transformations = vimState.recordedState.transformations; + + if (transformations.length === 0) { + return false; + } + + const textTransformations: TextTransformations[] = + transformations.filter(x => isTextTransformation(x.type)) as any; + + const otherTransformations = transformations.filter(x => !isTextTransformation(x.type)); + + let accumulatedPositionDifferences: PositionDiff[] = []; + + // batch all text operations together as a single operation + // (this is primarily necessary for multi-cursor mode, since most + // actions will trigger at most one text operation). + await vscode.window.activeTextEditor.edit(edit => { + for (const command of textTransformations) { + switch (command.type) { + case "insertText": + edit.insert(command.position, command.text); + break; + + case "replaceText": + edit.replace(new vscode.Selection(command.end, command.start), command.text); + break; - vimState.commandAction = VimSpecialCommands.Nothing; + case "deleteText": + edit.delete(new vscode.Range(command.position, command.position.getLeftThroughLineBreaks())); + break; - switch (command) { - case VimSpecialCommands.ShowCommandLine: - await showCmdLine(vimState.commandInitialText, this); - break; - case VimSpecialCommands.Dot: - if (!vimState.previousFullAction) { - return vimState; // TODO(bell) + case "deleteRange": + edit.delete(new vscode.Selection(command.range.getStart(), command.range.getStop())); + break; } - const clonedAction = vimState.previousFullAction.clone(); + if (command.diff) { + accumulatedPositionDifferences.push(command.diff); + } + } + }); + + for (const command of otherTransformations) { + switch (command.type) { + case "insertTextVSCode": + await TextEditor.insert(command.text); + + vimState.cursorStartPosition = Position.FromVSCodePosition(vscode.window.activeTextEditor.selection.start); + vimState.cursorPosition = Position.FromVSCodePosition(vscode.window.activeTextEditor.selection.end); + break; + + case "showCommandLine": + await showCmdLine(vimState.commandInitialText, this); + break; + + case "dot": + if (!vimState.previousFullAction) { + return false; // TODO(bell) + } + + const clonedAction = vimState.previousFullAction.clone(); - await this.rerunRecordedState(vimState, vimState.previousFullAction); + await this.rerunRecordedState(vimState, vimState.previousFullAction); - vimState.previousFullAction = clonedAction; - break; + vimState.previousFullAction = clonedAction; + break; + } } - return vimState; + vimState.recordedState.transformations = []; + + // We handle multiple cursors in a different way in visual block mode, unfortunately. + // TODO - refactor that out! + if (vimState.currentMode !== ModeName.VisualBlockInsertMode) { + vimState.allCursors = new EqualitySet(); + + const selections = vscode.window.activeTextEditor.selections; + + for (let i = 0; i < selections.length; i++) { + let sel = selections[i]; + + if (accumulatedPositionDifferences.length > 0) { + const diff = accumulatedPositionDifferences[i]; + + sel = new vscode.Selection( + diff.addPosition(Position.FromVSCodePosition(sel.start)), + diff.addPosition(Position.FromVSCodePosition(sel.end)), + ); + } + + vimState.allCursors.add(Range.FromVSCodeSelection(sel)); + } + } + + return textTransformations.length > 0; } async rerunRecordedState(vimState: VimState, recordedState: RecordedState): Promise { @@ -971,6 +1238,8 @@ export class ModeHandler implements vscode.Disposable { for (let action of actions) { recordedState.actionsRun = actions.slice(0, ++i); vimState = await this.runAction(vimState, recordedState, action); + + await this.updateView(vimState, true); } vimState.isRunningDotCommand = false; @@ -1010,31 +1279,55 @@ export class ModeHandler implements vscode.Disposable { if (drawSelection) { let selections: vscode.Selection[]; - if (vimState.currentMode === ModeName.Visual) { - selections = [ new vscode.Selection(start, stop) ]; - } else if (vimState.currentMode === ModeName.VisualLine) { - selections = [ new vscode.Selection( - Position.EarlierOf(start, stop).getLineBegin(), - Position.LaterOf(start, stop).getLineEnd() - ) ]; - } else if (vimState.currentMode === ModeName.VisualBlock) { - selections = []; - - for (const { start: lineStart, end } of Position.IterateLine(vimState)) { - selections.push(new vscode.Selection( - lineStart, - end - )); + if (!vimState.isMultiCursor) { + if (vimState.currentMode === ModeName.Visual) { + selections = [ new vscode.Selection(start, stop) ]; + } else if (vimState.currentMode === ModeName.VisualLine) { + selections = [ new vscode.Selection( + Position.EarlierOf(start, stop).getLineBegin(), + Position.LaterOf(start, stop).getLineEnd() + ) ]; + } else if (vimState.currentMode === ModeName.VisualBlock) { + selections = []; + + for (const { start: lineStart, end } of Position.IterateLine(vimState)) { + selections.push(new vscode.Selection( + lineStart, + end + )); + } + } else { + selections = [ new vscode.Selection(stop, stop) ]; } } else { - selections = [ new vscode.Selection(stop, stop) ]; + // MultiCursor mode is active. + + if (vimState.currentMode === ModeName.Visual) { + selections = []; + + for (const cursor of vimState.allCursors) { + selections.push(new vscode.Selection(cursor.getStart(), cursor.getStop())); + } + } else if (vimState.currentMode === ModeName.Normal || + vimState.currentMode === ModeName.Insert || + vimState.currentMode === ModeName.SearchInProgressMode) { + selections = []; + + for (const cursor of vimState.allCursors) { + selections.push(new vscode.Selection(cursor.getStop(), cursor.getStop())); + } + } else { + console.error("This is pretty bad!"); + + selections = []; + } } this._vimState.whatILastSetTheSelectionTo = selections[0]; vscode.window.activeTextEditor.selections = selections; } - // Scroll to position of cursor + // Scroll to position of cursor TODO multi vscode.window.activeTextEditor.revealRange(new vscode.Range(vimState.cursorPosition, vimState.cursorPosition)); @@ -1044,10 +1337,9 @@ export class ModeHandler implements vscode.Disposable { if (Configuration.getInstance().useSolidBlockCursor) { if (this.currentMode.name !== ModeName.Insert) { - rangesToDraw.push(new vscode.Range( - vimState.cursorPosition, - vimState.cursorPosition.getRight() - )); + for (const cursor of vimState.allCursors) { + rangesToDraw.push(new vscode.Range(cursor.getStop(), cursor.getStop().getRight())); + } } } else { // Use native block cursor if possible. @@ -1110,7 +1402,7 @@ export class ModeHandler implements vscode.Disposable { if (this.currentMode.name === ModeName.SearchInProgressMode) { this.setupStatusBarItem(`Searching for: ${ this.vimState.searchState!.searchString }`); } else { - this.setupStatusBarItem(`-- ${ this.currentMode.text.toUpperCase() } --`); + this.setupStatusBarItem(`-- ${ this.currentMode.text.toUpperCase() } ${ this._vimState.isMultiCursor ? 'MULTI CURSOR' : '' } --`); } vscode.commands.executeCommand('setContext', 'vim.mode', this.currentMode.text); @@ -1164,4 +1456,4 @@ export class ModeHandler implements vscode.Disposable { dispose() { // do nothing } -} +} \ No newline at end of file diff --git a/src/mode/remapper.ts b/src/mode/remapper.ts index 94fc7263d07..9c4c126d5a8 100644 --- a/src/mode/remapper.ts +++ b/src/mode/remapper.ts @@ -61,12 +61,19 @@ class Remapper { const remapping = _.find(this._remappings, map => map.before.join("") === slice.join("")); if (remapping) { - // if we remapped e.g. jj to esc, we have to revert the inserted "jj" + // If we remapped e.g. jj to esc, we have to revert the inserted "jj" if (this._remappedModes.indexOf(ModeName.Insert) >= 0) { - // we subtract 1 because we haven't actually applied the last key. + // Revert every single inserted character. This is actually a bit of a + // hack since we aren't guaranteed that each insertion inserted only a + // single character. - await vimState.historyTracker.undoAndRemoveChanges(Math.max(0, this._mostRecentKeys.length - 1)); + // We subtract 1 because we haven't actually applied the last key. + + // TODO(johnfn) - study - actions need to be paired up with text changes... + // this is a complicated problem. + await vimState.historyTracker.undoAndRemoveChanges( + Math.max(0, (this._mostRecentKeys.length - 1) * vimState.allCursors.size)); } if (!this._recursive) { diff --git a/src/motion/position.ts b/src/motion/position.ts index 3c104c3a661..5f6757f3a5c 100644 --- a/src/motion/position.ts +++ b/src/motion/position.ts @@ -7,6 +7,103 @@ import { VimState } from './../mode/modeHandler'; import { VisualBlockMode } from './../mode/modeVisualBlock'; import { Configuration } from "./../configuration/configuration"; +/** + * Represents a difference between two positions. Add it to a position + * to get another position. Create it with the factory methods: + * + * - NewDiff + * - NewBOLDiff + */ +export class PositionDiff { + private _line: number; + private _character: number; + private _isBOLDiff: boolean; + + private constructor(line: number, character: number) { + this._line = line; + this._character = character; + } + + /** + * Creates a new PositionDiff. + */ + public static NewDiff(line: number, character: number): PositionDiff { + return new PositionDiff(line, character); + } + + /** + * Creates a new PositionDiff that always brings the cursor to the beginning of the line + * when applied to a position. + */ + public static NewBOLDiff(): PositionDiff { + const result = new PositionDiff(0, 0); + + result._isBOLDiff = true; + return result; + } + + /** + * Add this PositionDiff to another PositionDiff. + */ + public addDiff(other: PositionDiff) { + if (this._isBOLDiff || other._isBOLDiff) { + throw new Error("johnfn hasn't done this case yet and doesnt want to"); + } + + return PositionDiff.NewDiff( + this._line + other._line, + this._character + other._character + ); + } + + /** + * Adds a Position to this PositionDiff, returning a new PositionDiff. + */ + public addPosition(other: Position, { boundsCheck = true } = { } ): Position { + let resultChar = this.isBOLDiff() ? 0 : this.character() + other.character; + let resultLine = this.line() + other.line; + + if (boundsCheck) { + if (resultChar < 0) { resultChar = 0; } + if (resultLine < 0) { resultLine = 0; } + } + + return new Position( + resultLine, + resultChar + ); + } + + /** + * Difference in lines. + */ + public line(): number { + return this._line; + } + + /** + * Difference in characters. + */ + public character(): number { + return this._character; + } + + /** + * Does this diff move the position to the beginning of the line? + */ + public isBOLDiff(): boolean { + return this._isBOLDiff; + } + + public toString(): string { + if (this._isBOLDiff) { + return `[ Diff: BOL ]`; + } + + return `[ Diff: ${ this._line } ${ this._character } ]`; + } +} + export class Position extends vscode.Position { private static NonWordCharacters = Configuration.getInstance().iskeyword!; private static NonBigWordCharacters = ""; @@ -23,10 +120,18 @@ export class Position extends vscode.Position { this._sentenceEndRegex = /[\.!\?]{1}([ \n\t]+|$)/g; } + public toString(): string { + return `[${ this.line }, ${ this.character }]`; + } + public static FromVSCodePosition(pos: vscode.Position): Position { return new Position(pos.line, pos.character); } + public static IsPosition(x: any): x is Position { + return !!x.IsPosition; + } + /** * Returns which of the 2 provided Positions comes earlier in the document. */ @@ -37,6 +142,13 @@ export class Position extends vscode.Position { return p2; } + public isEarlierThan(other: Position): boolean { + if (this.line < other.line) { return true; } + if (this.line === other.line && this.character < other.character) { return true; } + + return false; + } + /** * Iterates over every position in the document starting at start, returning * at every position the current line text, character text, and a position object. @@ -152,6 +264,17 @@ export class Position extends vscode.Position { return p1; } + /** + * Subtracts another position from this one, returning the + * difference between the two. + */ + public subtract(other: Position): PositionDiff { + return PositionDiff.NewDiff( + this.line - other.line, + this.character - other.character, + ); + } + public setLocation(line: number, character: number) : Position { let position = new Position(line, character); return position; @@ -247,9 +370,15 @@ export class Position extends vscode.Position { * Get the position *count* lines down from this position, but not lower * than the end of the document. */ - public getDownByCount(count = 0): Position { + public getDownByCount(count = 0, { boundsCheck = true } = {}): Position { + console.log(boundsCheck); + + const line = boundsCheck ? + Math.min(TextEditor.getLineCount() - 1, this.line + count) : + this.line + count; + return new Position( - Math.min(TextEditor.getLineCount() - 1, this.line + count), + line, this.character ); } diff --git a/src/motion/range.ts b/src/motion/range.ts new file mode 100644 index 00000000000..5b2666accba --- /dev/null +++ b/src/motion/range.ts @@ -0,0 +1,106 @@ +"use strict"; + +import * as vscode from "vscode"; +import { Position } from "./position"; +import { IMovement } from './../actions/actions'; +import { EqualitySet } from './../misc/equalitySet'; + +export class Range { + private _start: Position; + private _stop: Position; + + constructor(start: Position, stop: Position) { + if (start.isAfter(stop)) { + [start, stop] = [stop, start]; + } + this._start = start; + this._stop = stop; + } + + /** + * Create a range from a VSCode selection. + */ + public static FromVSCodeSelection(e: vscode.Selection): Range { + return new Range( + Position.FromVSCodePosition(e.start), + Position.FromVSCodePosition(e.end) + ); + } + + public static *IterateRanges(set: EqualitySet): Iterable<{ start: Position; stop: Position; range: Range, i: number }> { + let i = 0; + + for (const range of set) { + yield { + i: i++, + range: range, + start: range._start, + stop: range._stop, + }; + } + } + + /** + * Create a range from an IMovement. + */ + public static FromIMovement(i: IMovement): Range { + return i.operatingRange; + } + + public getRight(count = 1): Range { + return new Range( + this._start.getRight(count), + this._stop.getRight(count) + ); + } + + public getDown(count = 1): Range { + return new Range( + this._start.getDownByCount(count), + this._stop.getDownByCount(count), + ); + } + + public getLeft(count = 1): Range { + return new Range( + this._start.getLeftByCount(count), + this._stop.getLeftByCount(count) + ); + } + + /** + * Does this range contain the specified position? + */ + public contains(position: Position): boolean { + return this._start.isBeforeOrEqual(position) && + this._stop.isAfterOrEqual(position); + } + + public getStart(): Position { + return this._start; + } + + public getStop(): Position { + return this._stop; + } + + /** + * Returns a new range object based on this range object, but with the start + * changed to the provided value. + */ + public withNewStart(start: Position): Range { + return new Range(start, this._stop); + } + + /** + * Returns a new range object based on this range object, but with the stop + * changed to the provided value. + */ + public withNewStop(stop: Position): Range { + return new Range(this._start, stop); + } + + public toString(): string { + return `[ ${ this._start.toString() }, ${ this._stop.toString() } ]`; + } +} \ No newline at end of file diff --git a/src/register/register.ts b/src/register/register.ts index 2234c3430ce..ce48ee1a18a 100644 --- a/src/register/register.ts +++ b/src/register/register.ts @@ -58,9 +58,7 @@ export class Register { }; } - public static putByKey(content: string | string[], register?: string, registerMode?: RegisterMode): void { - register = register || '"'; - + public static putByKey(content: string | string[], register = '"', registerMode = RegisterMode.FigureItOutFromCurrentMode): void { if (!Register.isValidRegister(register)) { throw new Error(`Invalid register ${register}`); } @@ -71,10 +69,25 @@ export class Register { Register.registers[register] = { text : content, - registerMode: registerMode || RegisterMode.FigureItOutFromCurrentMode, + registerMode: registerMode, }; } + public static add(content: string, vimState: VimState): void { + const register = vimState.recordedState.registerName; + + if (!Register.isValidRegister(register)) { + throw new Error(`Invalid register ${register}`); + } + + if (typeof Register.registers[register].text !== "string") { + // TODO - I don't know why this cast is necessary! + + (Register.registers[register].text as string[]).push(content); + } + } + + /** * Gets content from a register. If none is specified, uses the default * register ". @@ -104,6 +117,7 @@ export class Register { } }) ); + Register.registers[register].text = text; } diff --git a/src/taskQueue.ts b/src/taskQueue.ts index 2b9a4321fc2..d85cf373b5a 100644 --- a/src/taskQueue.ts +++ b/src/taskQueue.ts @@ -10,7 +10,7 @@ export interface IEnqueuedTask { * * Enqueue promises here. They will be run sequentially. */ -export class TaskQueue { +class TaskQueue { private _tasks: IEnqueuedTask[] = []; private async _runTasks(): Promise { @@ -53,3 +53,5 @@ export class TaskQueue { } } } + +export let taskQueue = new TaskQueue(); \ No newline at end of file diff --git a/src/textEditor.ts b/src/textEditor.ts index 837012f44ed..1d574080ef6 100644 --- a/src/textEditor.ts +++ b/src/textEditor.ts @@ -1,7 +1,6 @@ "use strict"; import * as vscode from "vscode"; -import { ModeHandler } from './mode/modeHandler'; import { Position } from './motion/position'; import { Configuration } from './configuration/configuration'; import { Globals } from './globals'; @@ -9,6 +8,10 @@ import { Globals } from './globals'; export class TextEditor { // TODO: Refactor args + /** + * Do not use this method! It has been deprecated. Use InsertTextTransformation + * (or possibly InsertTextVSCodeTransformation) instead. + */ static async insert(text: string, at: Position | undefined = undefined, letVSCodeHandleKeystrokes: boolean | undefined = undefined): Promise { // If we insert "blah(" with default:type, VSCode will insert the closing ). @@ -17,14 +20,19 @@ export class TextEditor { letVSCodeHandleKeystrokes = text.length === 1; } - if (at) { - vscode.window.activeTextEditor.selection = new vscode.Selection(at, at); - } + if (!letVSCodeHandleKeystrokes) { + const selections = vscode.window.activeTextEditor.selections.slice(0); + + await vscode.window.activeTextEditor.edit(editBuilder => { + if (!at) { + at = Position.FromVSCodePosition(vscode.window.activeTextEditor.selection.active); + } - if (ModeHandler.IsTesting || !letVSCodeHandleKeystrokes) { - return vscode.window.activeTextEditor.edit(editBuilder => { - editBuilder.insert(vscode.window.activeTextEditor.selection.active, text); + editBuilder.insert(at!, text); }); + + // maintain all selections in multi-cursor mode. + vscode.window.activeTextEditor.selections = selections; } else { await vscode.commands.executeCommand('default:type', { text }); } @@ -44,6 +52,10 @@ export class TextEditor { }); } + /** + * Do not use this method! It has been deprecated. Use DeleteTextTransformation + * instead. + */ static async backspace(position: Position): Promise { if (position.character === 0) { if (position.line > 0) { @@ -93,6 +105,10 @@ export class TextEditor { }); } + /** + * Do not use this method! It has been deprecated. Use ReplaceTextTransformation. + * instead. + */ static async replace(range: vscode.Range, text: string): Promise { return vscode.window.activeTextEditor.edit(editBuilder => { editBuilder.replace(range, text); @@ -249,4 +265,4 @@ export type CursorMovePosition = 'left' | 'right' | 'up' | 'down' | /** * Units for Cursor move 'by' argument */ -export type CursorMoveByUnit = 'line' | 'wrappedLine' | 'character' | 'halfLine'; \ No newline at end of file +export type CursorMoveByUnit = 'line' | 'wrappedLine' | 'character' | 'halfLine'; diff --git a/src/transformations/transformations.ts b/src/transformations/transformations.ts new file mode 100644 index 00000000000..9929283ab10 --- /dev/null +++ b/src/transformations/transformations.ts @@ -0,0 +1,169 @@ +import { Position, PositionDiff } from "./../motion/position"; +import { Range } from "./../motion/range"; + +/** + * This file contains definitions of objects that represent text + * additions/deletions/replacements on the document. You'll add them + * to vimState.recordedState.transformations and then they will be applied + * later on. + * + * We do it in this way so they can all be processed in parallel and merged + * if necessary. + */ + +/** + * Represents inserting text at a position in the document. + */ +export interface InsertTextTransformation { + /** + * Type of this insertion (used for type checking with discriminated + * union types). + */ + type : "insertText"; + + /** + * Text content of this insertion. + */ + text : string; + + /** + * The location to insert the text. + */ + position: Position; + + /** + * A position diff that will be added to the position of the cursor after + * the replace transformation has been applied. + * + * If you don't know what this is, just ignore it. You probably don't need it. + */ + diff?: PositionDiff; +} + +export interface ReplaceTextTransformation { + type: "replaceText"; + + /** + * Text to insert. + */ + text: string; + + /** + * Start of location to replace. + */ + start: Position; + + /** + * End of location to replace. + */ + end: Position; + + /** + * A position diff that will be added to the position of the cursor after + * the replace transformation has been applied. + * + * If you don't know what this is, just ignore it. You probably don't need it. + */ + diff?: PositionDiff; +} + +/** + * Represents inserting a character and allowing visual studio to do + * its post-character stuff if it wants. (e.g., if you type "(" this + * will automatically add the closing ")"). + */ +export interface InsertTextVSCodeTransformation { + type : "insertTextVSCode"; + + /** + * Text to insert. + */ + text : string; + + /** + * A position diff that will be added to the position of the cursor after + * the replace transformation has been applied. + * + * If you don't know what this is, just ignore it. You probably don't need it. + */ + diff?: PositionDiff; +} + +/** + * Represents deleting a character at a position in the document. + */ +export interface DeleteTextTransformation { + type : "deleteText"; + + /** + * Position at which to delete a character. + */ + position : Position; + + /** + * A position diff that will be added to the position of the cursor after + * the replace transformation has been applied. + * + * If you don't know what this is, just ignore it. You probably don't need it. + */ + diff?: PositionDiff; +} + + +/** + * Represents deleting a range of characters. + */ +export interface DeleteTextRangeTransformation { + type : "deleteRange"; + + /** + * Range of characters to delete. + */ + range: Range; + + /** + * A position diff that will be added to the position of the cursor after + * the replace transformation has been applied. + * + * If you don't know what this is, just ignore it. You probably don't need it. + */ + diff?: PositionDiff; +} + +/** + * Represents pressing ':' + */ +export interface ShowCommandLine { + type: "showCommandLine"; +} + +/** + * Represents pressing '.' + */ +export interface Dot { + type: "dot"; +} + +export type Transformation + = InsertTextTransformation + | InsertTextVSCodeTransformation + | ReplaceTextTransformation + | DeleteTextRangeTransformation + | DeleteTextTransformation + | ShowCommandLine + | Dot + | DeleteTextTransformation; + +export type TextTransformations + = InsertTextTransformation + | InsertTextVSCodeTransformation + | DeleteTextRangeTransformation + | DeleteTextTransformation + | ReplaceTextTransformation; + +export const isTextTransformation = (x: string) => { + return x === 'insertText' || + x === 'replaceText' || + x === 'deleteText' || + x === 'deleteRange'; +}; \ No newline at end of file diff --git a/src/util.ts b/src/util.ts index 70c77563075..825f6980d35 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,5 +1,8 @@ "use strict"; +import { EqualitySet } from './misc/equalitySet'; +import { Range } from './motion/range'; +import { Position } from './motion/position'; import * as vscode from 'vscode'; export async function showInfo(message : string): Promise<{}> { @@ -10,3 +13,31 @@ export async function showError(message : string): Promise<{}> { return vscode.window.showErrorMessage("Vim: " + message); } +/** + * This is certainly quite janky! The problem we're trying to solve + * is that writing editor.selection = new Position() won't immediately + * update the position of the cursor. So we have to wait! + */ +export async function waitForCursorUpdatesToHappen(): Promise { + // TODO - dispose! + + await new Promise((resolve, reject) => { + setTimeout(resolve, 100); + vscode.window.onDidChangeTextEditorSelection(x => { + resolve(); + }); + }); +} + +export async function wait(time: number): Promise { + await new Promise((resolve, reject) => { + setTimeout(resolve, time); + }); +} + +export function getAllCursors(): EqualitySet { + const cursorArray = vscode.window.activeTextEditor.selections.map(x => + new Range(Position.FromVSCodePosition(x.start), Position.FromVSCodePosition(x.end))); + + return new EqualitySet(cursorArray); +} \ No newline at end of file diff --git a/test/mode/modeNormal.test.ts b/test/mode/modeNormal.test.ts index e52dfc6f576..3cc58564616 100644 --- a/test/mode/modeNormal.test.ts +++ b/test/mode/modeNormal.test.ts @@ -79,14 +79,14 @@ suite("Mode Normal", () => { }); newTest({ - title: "Can handle dw across lines", + title: "Can handle dw across lines (1)", start: ['one |two', ' three'], keysPressed: 'dw', end: ["one| ", " three"] }); newTest({ - title: "Can handle dw across lines", + title: "Can handle dw across lines (2)", start: ['one |two', '', 'three'], keysPressed: 'dw', end: ["one| ", "", "three"] @@ -291,7 +291,7 @@ suite("Mode Normal", () => { endMode: ModeName.Insert }); - newTest({ + newTestOnly({ title: "Can handle 'ci(' with nested parentheses", start: ['call|(() => 5)'], keysPressed: 'ci(', @@ -1201,14 +1201,14 @@ suite("Mode Normal", () => { newTest({ title: "/ does not affect mark", start: ["|one", "twooo", "thurr"], - keysPressed: "ma/two'a", + keysPressed: "ma/two\n'a", end: ["|one", "twooo", "thurr"] }); newTest({ title: "/ can search with regex", start: ["|", "one two2o"], - keysPressed: "/o\\do", + keysPressed: "/o\\do\n", end: ["", "one tw|o2o"] }); diff --git a/test/mode/modeReplace.test.ts b/test/mode/modeReplace.test.ts index 968bcc354db..9110dccf198 100644 --- a/test/mode/modeReplace.test.ts +++ b/test/mode/modeReplace.test.ts @@ -48,7 +48,7 @@ suite("Mode Replace", () => { }); newTest({ - title: "Can handle R across lines and quite Replace Mode", + title: "Can handle R across lines and quit Replace Mode", start: ['123|456', '789'], keysPressed: 'Rabcd\nefg', end: ["123abcd", "ef|g", "789"] diff --git a/test/mode/normalModeTests/commands.test.ts b/test/mode/normalModeTests/commands.test.ts index 655e1e4378e..cf1c37c5f0f 100644 --- a/test/mode/normalModeTests/commands.test.ts +++ b/test/mode/normalModeTests/commands.test.ts @@ -9,7 +9,8 @@ suite("Mode Normal", () => { let modeHandler: ModeHandler = new ModeHandler(); let { - newTest + newTest, + newTestOnly } = getTestingFunctions(modeHandler); setup(async () => { diff --git a/test/testSimplifier.ts b/test/testSimplifier.ts index 09c3ce70c37..223bba43ef1 100644 --- a/test/testSimplifier.ts +++ b/test/testSimplifier.ts @@ -1,9 +1,12 @@ import * as assert from 'assert'; +import * as vscode from 'vscode'; import { ModeName } from '../src/mode/mode'; +import { HistoryTracker } from '../src/history/historyTracker'; import { Position } from '../src/motion/position'; import { ModeHandler } from '../src/mode/modeHandler'; import { TextEditor } from '../src/textEditor'; import { assertEqualLines } from './testUtils'; +import { waitForCursorUpdatesToHappen } from '../src/util'; export function getTestingFunctions(modeHandler: ModeHandler) { let testWithObject = testIt.bind(null, modeHandler); @@ -49,7 +52,14 @@ interface ITestObject { } class TestObjectHelper { + /** + * Position that the test says that the cursor starts at. + */ startPosition = new Position(0, 0); + + /** + * Position that the test says that the cursor ends at. + */ endPosition = new Position(0, 0); private _isValid = false; @@ -120,14 +130,13 @@ class TestObjectHelper { */ public getKeyPressesToMoveToStartPosition(): string[] { let ret = ''; - let linesToMove = this.startPosition.line - (this._testObject.start.length - 1); + let linesToMove = this.startPosition.line; let cursorPosAfterEsc = this._testObject.start[this._testObject.start.length - 1].replace('|', '').length - 1; let numCharsInCursorStartLine = this._testObject.start[this.startPosition.line].replace('|', '').length - 1; - let columnOnStartLine = Math.min(cursorPosAfterEsc, numCharsInCursorStartLine); - let charactersToMove = this.startPosition.character - columnOnStartLine; + let charactersToMove = this.startPosition.character; if (linesToMove > 0) { ret += Array(linesToMove + 1).join('j'); @@ -177,22 +186,33 @@ function tokenizeKeySequence(sequence: string): string[] { async function testIt(modeHandler: ModeHandler, testObj: ITestObject): Promise { let helper = new TestObjectHelper(testObj); + let editor = vscode.window.activeTextEditor; // Don't try this at home, kids. (modeHandler as any)._vimState.cursorPosition = new Position(0, 0); await modeHandler.handleKeyEvent(''); - // start: - // - await modeHandler.handleMultipleKeyEvents(helper.asVimInputText()); + // Insert all the text as a single action. + await editor.edit(builder => { + builder.insert(new Position(0, 0), testObj.start.join("\n").replace("|", "")); + }); + + await modeHandler.handleMultipleKeyEvents(['', 'g', 'g']); + + await waitForCursorUpdatesToHappen(); + + // Since we bypassed VSCodeVim to add text, we need to tell the history tracker + // that we added it. + modeHandler.vimState.historyTracker = new HistoryTracker(); + modeHandler.vimState.historyTracker.addChange(); + modeHandler.vimState.historyTracker.finishCurrentStep(); - // keysPressed: - // - await modeHandler.handleKeyEvent(''); // move cursor to start position using 'hjkl' await modeHandler.handleMultipleKeyEvents(helper.getKeyPressesToMoveToStartPosition()); + await waitForCursorUpdatesToHappen(); + // assumes key presses are single characters for now await modeHandler.handleMultipleKeyEvents(tokenizeKeySequence(testObj.keysPressed)); @@ -204,14 +224,6 @@ async function testIt(modeHandler: ModeHandler, testObj: ITestObject): Promise