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 f6e3cdcba2a..f511ba86b0c 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ We're super friendly people if you want to drop by and talk to us on [Slack](htt * 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..999a5f7a978 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); @@ -220,7 +218,7 @@ async function handleKeyEvent(key: string): Promise { const mh = await getAndUpdateModeHandler(); taskQueue.enqueueTask({ - promise : async () => { await mh.handleKeyEvent(key); }, + promise : async () => { console.log(key); await mh.handleKeyEvent(key); }, isRunning : false }); } diff --git a/package.json b/package.json index 1c27bafe5ae..2732b71b03b 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "url": "https://github.com/VSCodeVim/Vim/issues" }, "engines": { - "vscode": "^1.0.0" + "vscode": "^1.5.0" }, "categories": [ "Other" @@ -46,6 +46,11 @@ "command": "extension.vim_escape", "when": "editorTextFocus && !inDebugRepl && !suggestWidgetVisible" }, + { + "key": "cmd+d", + "command": "extension.vim_cmd+d", + "when": "editorTextFocus && !inDebugRepl" + }, { "key": "Backspace", "command": "extension.vim_backspace", @@ -244,7 +249,8 @@ "child-process": "^1.0.2", "copy-paste": "^1.3.0", "diff-match-patch": "^1.0.0", - "lodash": "^4.12.0" + "lodash": "^4.12.0", + "vscode": "^0.11.16" }, "devDependencies": { "gulp": "^3.9.1", diff --git a/src/actions/actions.ts b/src/actions/actions.ts index fd9eaf4122e..1f258e10501 100644 --- a/src/actions/actions.ts +++ b/src/actions/actions.ts @@ -1,15 +1,18 @@ -import { VimSpecialCommands, VimState, SearchState, SearchDirection, ReplaceState } from './../mode/modeHandler'; +import { VimState, SearchState, SearchDirection, ReplaceState } from './../mode/modeHandler'; +import { VisualBlockMode } from './../mode/modeVisualBlock'; 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 } from '../util'; import * as vscode from 'vscode'; import * as clipboard from 'copy-paste'; @@ -150,7 +153,8 @@ export abstract class BaseMovement extends BaseAction { ModeName.Normal, ModeName.Visual, ModeName.VisualLine, - ModeName.VisualBlock]; + ModeName.VisualBlock, + ]; isMotion = true; @@ -254,6 +258,15 @@ export abstract class BaseCommand extends BaseAction { */ isCompleteAction = 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 +282,42 @@ 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; + let resultingCursors: Range[] = []; + let i = 0; + + const cursorsToIterateOver = vimState.allCursors + .map(x => new Range(x.start, x.stop)) + .sort((a, b) => a.start.line > b.start.line || (a.start.line === b.start.line && a.start.character > b.start.character) ? 1 : -1); + + for (const { start, stop } of cursorsToIterateOver) { + this.multicursorIndex = i++; + + vimState.cursorPosition = stop; + vimState.cursorStartPosition = start; + + for (let j = 0; j < timesToRepeat; j++) { + vimState = await this.exec(stop, vimState); + } + + resultingCursors.push(new Range( + vimState.cursorStartPosition, + vimState.cursorPosition, + )); + } + + vimState.allCursors = resultingCursors; + return vimState; } } @@ -282,6 +325,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 +409,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 +510,27 @@ 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(); + + // 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. + + for (let i = 0; i < vimState.allCursors.length; i++) { + vimState.allCursors[i] = vimState.allCursors[i].withNewStop( + vimState.allCursors[i].stop.getLeft() + ); + } } if (vimState.currentMode === ModeName.SearchInProgressMode) { @@ -476,8 +539,16 @@ class CommandEsc extends BaseCommand { } } + if (vimState.currentMode === ModeName.Normal && vimState.isMultiCursor) { + vimState.isMultiCursor = false; + } + vimState.currentMode = ModeName.Normal; + if (!vimState.isMultiCursor) { + vimState.allCursors = [ vimState.allCursors[0] ]; + } + 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 : new PositionDiff(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 : new PositionDiff(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,28 +1067,35 @@ 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; } - public toString(): string { return this.keysPressed[this.keysPressed.length - 1]; } @@ -1155,7 +1255,8 @@ export class DeleteOperator extends BaseOperator { const result = await this.delete(start, end, vimState.currentMode, vimState.effectiveRegisterMode(), vimState, yank); vimState.currentMode = ModeName.Normal; - vimState.cursorPosition = result; + vimState.cursorPosition = result; + vimState.cursorStartPosition = result; return vimState; } @@ -1179,40 +1280,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.cursorPositionJustBeforeAnythingHappened.map(x => new Range(x, x)); } else { vimState.cursorPosition = start; } - return vimState; + return vimState; } } @@ -1369,10 +1480,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 (typeof text === "object") { - return await this.execVisualBlockPaste(text, position, vimState, after); + 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 (!text) { + text = register.text as string; } if (register.registerMode === RegisterMode.CharacterWise) { @@ -1582,7 +1709,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(); @@ -1636,9 +1766,12 @@ export class PutBeforeWithIndentCommand extends BaseCommand { class CommandShowCommandLine extends BaseCommand { modes = [ModeName.Normal, ModeName.Visual, ModeName.VisualLine, ModeName.VisualBlock]; 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 = ""; @@ -1656,7 +1789,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; } @@ -1795,13 +1930,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; } @@ -1811,13 +1948,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; } @@ -1901,7 +2040,6 @@ class CommandVisualBlockMode extends BaseCommand { keys = [""]; public async exec(position: Position, vimState: VimState): Promise { - if (vimState.currentMode === ModeName.VisualBlock) { vimState.currentMode = ModeName.Normal; } else { @@ -2032,12 +2170,17 @@ 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 = vscode.window.activeTextEditor.selections.map(x => + new Range(Position.FromVSCodePosition(x.start), Position.FromVSCodePosition(x.end))); + return vimState; } } @@ -2046,14 +2189,16 @@ 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 = vscode.window.activeTextEditor.selections.map(x => + new Range(Position.FromVSCodePosition(x.start), Position.FromVSCodePosition(x.end))); return vimState; } @@ -3066,9 +3211,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 : new PositionDiff(0, -1), + position: position, + }); return state; } @@ -3080,12 +3228,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); } } @@ -3093,17 +3236,26 @@ 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 { - const toReplace = this.keysPressed[1]; + const toInsert = this.keysPressed[1]; for (const { pos } of Position.IterateBlock(vimState.topLeft, vimState.bottomRight)) { - vimState = await new DeleteOperator().run(vimState, pos, pos); - await TextEditor.insertAt(toReplace, pos); + vimState.recordedState.transformations.push({ + type : "replaceText", + text : toInsert, + start : pos, + end : pos.getRight(), + }); } - vimState.cursorPosition = position; + const topLeft = VisualBlockMode.getTopLeftPosition(vimState.cursorPosition, vimState.cursorStartPosition); + + vimState.allCursors = [ new Range(topLeft, topLeft) ]; + vimState.currentMode = ModeName.Normal; + return vimState; } } @@ -3113,15 +3265,22 @@ class ActionXVisualBlock extends BaseCommand { modes = [ModeName.VisualBlock]; keys = ["x"]; canBeRepeatedWithDot = true; + runsOnceForEveryCursor() { return false; } public async exec(position: Position, vimState: VimState): Promise { - - // Iterate in reverse so we don't lose track of indicies - for (const { start, end } of Position.IterateLine(vimState, { reverse: true })) { - vimState = await new DeleteOperator().run(vimState, start, new Position(end.line, end.character - 1)); + for (const { start, end } of Position.IterateLine(vimState)) { + vimState.recordedState.transformations.push({ + type : "deleteRange", + range : new Range(start, end), + manuallySetCursorPositions: true, + }); } - vimState.cursorPosition = vimState.cursorStartPosition; + const topLeft = VisualBlockMode.getTopLeftPosition(vimState.cursorPosition, vimState.cursorStartPosition); + + vimState.allCursors = [ new Range(topLeft, topLeft) ]; + vimState.currentMode = ModeName.Normal; + return vimState; } } @@ -3131,12 +3290,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) { @@ -3154,19 +3315,20 @@ 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(); - for (const { start, end } of Position.IterateLine(vimState)) { - await deleteOperator.delete(start, end.getLeft(), vimState.currentMode, vimState.effectiveRegisterMode(), vimState, true); + vimState.recordedState.transformations.push({ + type : "deleteRange", + range : new Range(start, end), + collapseRange: true, + }); } vimState.currentMode = ModeName.VisualBlockInsertMode; vimState.recordedState.visualBlockInsertionType = VisualBlockInsertionType.Insert; - vimState.cursorPosition = vimState.cursorPosition.getLeft(); - return vimState; } } @@ -3176,15 +3338,16 @@ 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(); - for (const { start } of Position.IterateLine(vimState)) { - // delete from start up to but not including the newline. - await deleteOperator.delete( - start, start.getLineEnd().getLeft(), vimState.currentMode, vimState.effectiveRegisterMode(), vimState, true); + vimState.recordedState.transformations.push({ + type: "deleteRange", + range: new Range(start, start.getLineEnd()), + collapseRange: true + }); } vimState.currentMode = ModeName.VisualBlockInsertMode; @@ -3198,6 +3361,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) { @@ -3217,6 +3381,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[] = []; @@ -3238,10 +3403,10 @@ 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]; - let posChange = 0; let insertAtStart = vimState.recordedState.visualBlockInsertionType === VisualBlockInsertionType.Insert; if (char === '\n') { @@ -3256,23 +3421,29 @@ class InsertInInsertVisualBlockMode extends BaseCommand { const insertPos = insertAtStart ? start : end; if (char === '') { - await TextEditor.backspace(insertPos.getLeft()); - - posChange = -1; + vimState.recordedState.transformations.push({ + type : "deleteText", + position : insertPos, + diff : new PositionDiff(0, -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; } - posChange = 1; + vimState.recordedState.transformations.push({ + type : "insertText", + text : char, + position: positionToInsert, + diff : new PositionDiff(0, 1), + }); } } - vimState.cursorStartPosition = vimState.cursorStartPosition.getRight(posChange); - vimState.cursorPosition = vimState.cursorPosition.getRight(posChange); - return vimState; } } @@ -4238,3 +4409,21 @@ class MoveAroundTag extends MoveTagMatch { keys = ["a", "t"]; includeTag = true; } + +@RegisterAction +class ActionOverrideCmdD extends BaseCommand { + modes = [ModeName.Normal, ModeName.Visual]; + keys = [""]; + runsOnceForEveryCursor() { return false; } + + public async exec(position: Position, vimState: VimState): Promise { + await vscode.commands.executeCommand('editor.action.addSelectionToNextFindMatch'); + await waitForCursorUpdatesToHappen(); + + vimState.allCursors = vscode.window.activeTextEditor.selections.map(x => + new Range(Position.FromVSCodePosition(x.start), Position.FromVSCodePosition(x.end))); + vimState.currentMode = ModeName.Visual; + + return vimState; + } +} diff --git a/src/history/historyTracker.ts b/src/history/historyTracker.ts index 95850e63650..18e38fa8315 100644 --- a/src/history/historyTracker.ts +++ b/src/history/historyTracker.ts @@ -78,12 +78,12 @@ class HistoryStep { /** * The cursor position at the start of this history step. */ - cursorStart: Position | undefined; + cursorStart: Position[] | undefined; /** * The cursor position at the end of this history step so far. */ - cursorEnd: Position | undefined; + cursorEnd: Position[] | undefined; /** * The position of every mark at the start of this history step. @@ -93,8 +93,8 @@ class HistoryStep { constructor(init: { changes?: DocumentChange[], isFinished?: boolean, - cursorStart?: Position | undefined, - cursorEnd?: Position | undefined, + cursorStart?: Position[] | undefined, + cursorEnd?: Position[] | undefined, marks?: IMark[] }) { this.changes = init.changes = []; @@ -194,8 +194,8 @@ export class HistoryTracker { this.historySteps.push(new HistoryStep({ changes : [new DocumentChange(new Position(0, 0), TextEditor.getAllText(), true)], isFinished : true, - cursorStart: new Position(0, 0), - cursorEnd: new Position(0, 0) + cursorStart: [ new Position(0, 0) ], + cursorEnd: [ new Position(0, 0) ] })); this.finishCurrentStep(); @@ -336,7 +336,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 Position(0, 0) ]): void { const newText = TextEditor.getAllText(); if (newText === this.oldText) { return; } @@ -449,7 +449,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 { let step: HistoryStep; if (this.currentHistoryStepIndex === 0) { @@ -479,7 +479,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 { let step: HistoryStep; if (this.currentHistoryStepIndex === this.historySteps.length - 1) { @@ -497,14 +497,15 @@ export class HistoryTracker { return step.cursorStart; } - getLastHistoryEndPosition(): Position | undefined { + getLastHistoryEndPosition(): Position[] | undefined { if (this.currentHistoryStepIndex === 0) { return undefined; } + return this.historySteps[this.currentHistoryStepIndex].cursorEnd; } - setLastHistoryEndPosition(pos: Position) { + setLastHistoryEndPosition(pos: Position[]) { this.historySteps[this.currentHistoryStepIndex].cursorEnd = pos; } diff --git a/src/mode/modeHandler.ts b/src/mode/modeHandler.ts index 8ec21c08eaa..ac16533f55d 100644 --- a/src/mode/modeHandler.ts +++ b/src/mode/modeHandler.ts @@ -4,6 +4,11 @@ import * as vscode from 'vscode'; import * as _ from 'lodash'; import { getAndUpdateModeHandler } from './../../extension'; +import { + isTextTransformation, + TextTransformations, + Transformation, +} from './../transformations/transformations'; import { Mode, ModeName, VSCodeVimCursorType } from './mode'; import { InsertModeRemapper, OtherModesRemapper } from './remapper'; import { NormalMode } from './modeNormal'; @@ -11,6 +16,7 @@ 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,20 +25,16 @@ 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; @@ -59,6 +61,11 @@ export class VimState { public historyTracker: HistoryTracker; + /** + * Are multiple cursors currently present? + */ + public isMultiCursor = false; + public static lastRepeatableMovement : BaseMovement | undefined = undefined; /** @@ -93,11 +100,11 @@ 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[0].stop; + } + public set cursorPosition(value: Position) { + this.allCursors[0] = this.allCursors[0].withNewStop(value); } /** @@ -105,9 +112,29 @@ 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[0].start; + } + public set cursorStartPosition(value: Position) { + this.allCursors[0] = this.allCursors[0].withNewStart(value); + } + + /** + * In Multi Cursor Mode, the position of every cursor. + */ + private _allCursors: Range[] = [ new Range(new Position(0, 0), new Position(0, 0)) ]; + + public get allCursors(): Range[] { + return this._allCursors; + } - public cursorPositionJustBeforeAnythingHappened = new Position(0, 0); + public set allCursors(value: Range[]) { + this._allCursors = value; + + this.isMultiCursor = this._allCursors.length > 1; + } + + public cursorPositionJustBeforeAnythingHappened = [ new Position(0, 0) ]; public searchState: SearchState | undefined = undefined; @@ -154,10 +181,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 +344,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); @@ -383,6 +413,8 @@ export class RecordedState { public visualBlockInsertionType = VisualBlockInsertionType.Insert; + public transformations: Transformation[] = []; + /** * The operator (e.g. d, y, >) the user wants to run, if there is one. */ @@ -435,7 +467,7 @@ export class RecordedState { mode !== ModeName.SearchInProgressMode && (this.hasRunAMovement || ( mode === ModeName.Visual || - mode === ModeName.VisualLine)); + mode === ModeName.VisualLine )); } public get isInInitialState(): boolean { @@ -547,82 +579,135 @@ 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.currentModeName === ModeName.VisualBlock || + this.currentModeName === ModeName.VisualBlockInsertMode) { + // AArrgghhhh - johnfn + + return; + } + + if (this.vimState.isMultiCursor) { + // AAAAAARGGHHHHH - johnfn - // See comment about whatILastSetTheSelectionTo. - if (this._vimState.whatILastSetTheSelectionTo.isEqual(selection)) { - return; + return; + } + + /* + if (this._vimState.currentMode !== ModeName.VisualBlock && + this._vimState.currentMode !== ModeName.VisualBlockInsertMode && + e.selections.length > this._vimState.allCursors.length) { + // Hey, we just added a selection. Either trigger or update Multi Cursor Mode. + + if (e.selections.length === 2) { + // 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 = []; + + for (const sel of e.selections) { + this._vimState.allCursors.push(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; + } + */ + + if (!e.kind || e.kind === vscode.TextEditorSelectionChangeKind.Command) { + return; + } + + if (this._vimState.isMultiCursor && e.selections.length === 1) { + this._vimState.isMultiCursor = false; + } - this._vimState.cursorPosition = newPosition; - this._vimState.cursorStartPosition = newPosition; + if (this._vimState.isMultiCursor) { + return; + } - this._vimState.desiredColumn = newPosition.character; + // See comment about whatILastSetTheSelectionTo. + if (this._vimState.whatILastSetTheSelectionTo.isEqual(selection)) { + return; + } - // start visual mode? + if (this._vimState.currentMode === ModeName.SearchInProgressMode || + this._vimState.currentMode === ModeName.VisualBlockInsertMode) { + return; + } - if (!selection.anchor.isEqual(selection.active)) { - var selectionStart = new Position(selection.anchor.line, selection.anchor.character); + if (selection) { + var newPosition = new Position(selection.active.line, selection.active.character); - if (selectionStart.character > selectionStart.getLineEnd().character) { - selectionStart = new Position(selectionStart.line, selectionStart.getLineEnd().character); - } + if (newPosition.character >= newPosition.getLineEnd().character) { + newPosition = new Position(newPosition.line, Math.max(newPosition.getLineEnd().character, 0)); + } - this._vimState.cursorStartPosition = selectionStart; + this._vimState.cursorPosition = newPosition; + this._vimState.cursorStartPosition = newPosition; - if (selectionStart.compareTo(newPosition) > 0) { - this._vimState.cursorStartPosition = this._vimState.cursorStartPosition.getLeft(); - } + this._vimState.desiredColumn = newPosition.character; - if (!this._vimState.getModeObject(this).isVisualMode) { - this._vimState.currentMode = ModeName.Visual; - this.setCurrentModeByName(this._vimState); + // start visual mode? - // 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 (!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); } - await this.updateView(this._vimState, false); + this._vimState.cursorStartPosition = selectionStart; + + if (selectionStart.compareTo(newPosition) > 0) { + this._vimState.cursorStartPosition = this._vimState.cursorStartPosition.getLeft(); + } + + 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 +732,7 @@ export class ModeHandler implements vscode.Disposable { } async handleKeyEvent(key: string): Promise { - this._vimState.cursorPositionJustBeforeAnythingHappened = this._vimState.cursorPosition; + this._vimState.cursorPositionJustBeforeAnythingHappened = this._vimState.allCursors.map(x => x.stop); try { let handled = false; @@ -699,23 +784,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 +794,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 +812,7 @@ export class ModeHandler implements vscode.Disposable { vimState.historyTracker.finishCurrentStep(); } } + */ if (action instanceof BaseMovement) { ({ vimState, recordedState } = await this.executeMovement(vimState, action)); @@ -746,9 +822,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.executeCommand(vimState); if (action.isCompleteAction) { ranAction = true; @@ -819,8 +893,6 @@ export class ModeHandler implements vscode.Disposable { vimState.historyTracker.finishCurrentStep(); } - // console.log(vimState.historyTracker.toString()); - recordedState.actionKeys = []; vimState.currentRegisterMode = RegisterMode.FigureItOutFromCurrentMode; @@ -830,23 +902,68 @@ export class ModeHandler implements vscode.Disposable { // Ensure cursor is within bounds - if (vimState.cursorPosition.line >= TextEditor.getLineCount()) { - vimState.cursorPosition = vimState.cursorPosition.getDocumentEnd(); + for (const { stop, i } of Range.IterateRanges(vimState.allCursors)) { + if (stop.line >= TextEditor.getLineCount()) { + vimState.allCursors[i] = vimState.allCursors[i].withNewStop( + vimState.cursorPosition.getDocumentEnd() + ); } - const currentLineLength = TextEditor.getLineAt(vimState.cursorPosition).text.length; + const currentLineLength = TextEditor.getLineAt(stop).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 && + stop.character >= currentLineLength && currentLineLength > 0) { + + vimState.allCursors[i] = vimState.allCursors[i].withNewStop( + stop.getLineEnd().getLeft() + ); + } } - // Update the current history step to have the latest cursor position incase it is needed - vimState.historyTracker.setLastHistoryEndPosition(vimState.cursorPosition); + // Update the current history step to have the latest cursor position + + vimState.historyTracker.setLastHistoryEndPosition(vimState.allCursors.map(x => x.stop)); + + // 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; + } + } + + // Make sure no two cursors are at the same location. + // This is a consequence of the fact that allCursors is not a Set. + + // TODO: It should be a set. + + const resultingList: Range[] = []; + + for (const cursor of vimState.allCursors) { + let shouldAddToList = true; + + for (const alreadyAddedCursor of resultingList) { + if (cursor.equals(alreadyAddedCursor)) { + shouldAddToList = false; + break; + } + } + + if (shouldAddToList) { + resultingList.push(cursor); + } + } + + vimState.allCursors = resultingList; return vimState; } @@ -856,38 +973,65 @@ export class ModeHandler implements vscode.Disposable { let recordedState = vimState.recordedState; - const result = await movement.execActionWithCount(vimState.cursorPosition, vimState, recordedState.count); + for (let i = 0; i < vimState.allCursors.length; 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 = vimState.allCursors[i].stop; + const old = vimState.cursorPosition; + + vimState.cursorPosition = cursorPosition; + const result = await movement.execActionWithCount(cursorPosition, vimState, recordedState.count); + vimState.cursorPosition = old; - if (result instanceof Position) { - vimState.cursorPosition = result; - } else if (isIMovement(result)) { - if (result.failed) { - vimState.recordedState = new RecordedState(); - } + if (result instanceof Position) { + vimState.allCursors[i] = vimState.allCursors[i].withNewStop(result); - vimState.cursorPosition = result.stop; - vimState.cursorStartPosition = result.start; + if (!vimState.getModeObject(this).isVisualMode && + !vimState.recordedState.operator) { - if (result.registerMode) { - vimState.currentRegisterMode = result.registerMode; + vimState.allCursors[i] = vimState.allCursors[i].withNewStart(result); + } + } else if (isIMovement(result)) { + if (result.failed) { + vimState.recordedState = new RecordedState(); + } + + vimState.allCursors[i] = Range.FromIMovement(result); + + if (result.registerMode) { + vimState.currentRegisterMode = result.registerMode; + } } - } - if (movement.canBeRepeatedWithSemicolon(vimState, result)) { - VimState.lastRepeatableMovement = movement; + if (movement.canBeRepeatedWithSemicolon(vimState, result)) { + VimState.lastRepeatableMovement = movement; + } } vimState.recordedState.count = 0; - let stop = vimState.cursorPosition; - // Keep the cursor within bounds if (vimState.currentMode === ModeName.Normal && !recordedState.operator) { - if (stop.character >= Position.getLineLength(stop.line)) { - vimState.cursorPosition = stop.getLineEnd().getLeft(); + for (const { stop, i } of Range.IterateRanges(vimState.allCursors)) { + if (stop.character >= Position.getLineLength(stop.line)) { + vimState.allCursors[i].withNewStop( + stop.getLineEnd().getLeft() + ); + } } } 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,60 +1046,204 @@ 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]; - } + let resultVimState = vimState; - if (!this._vimState.getModeObject(this).isVisualMode && - vimState.currentRegisterMode !== RegisterMode.LineWise) { + // TODO - if actions were more pure, this would be unnecessary. + const cachedMode = this._vimState.getModeObject(this); + const cachedRegister = vimState.currentRegisterMode; - if (Position.EarlierOf(start, stop) === start) { - stop = stop.getLeft(); - } else { - stop = stop.getRight(); + const resultingCursors: Range[] = []; + let i = 0; + + let resultingModeName: ModeName; + let startingModeName = vimState.currentMode; + + for (let { start, stop } of vimState.allCursors) { + if (start.compareTo(stop) > 0) { + [start, stop] = [stop, start]; } + + 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++; + + resultVimState.currentMode = startingModeName; + + resultVimState = await recordedState.operator.run(resultVimState, start, stop); + + resultingModeName = resultVimState.currentMode; + + resultingCursors.push(new Range( + resultVimState.cursorStartPosition, + resultVimState.cursorPosition + )); } - if (this.currentModeName === ModeName.VisualLine) { - start = start.getLineBegin(); - stop = stop.getLineEnd(); + // Keep track of all cursors (in the case of multi-cursor). + + resultVimState.allCursors = resultingCursors; + + const selections: vscode.Selection[] = []; - vimState.currentRegisterMode = RegisterMode.LineWise; + for (const cursor of vimState.allCursors) { + selections.push(new vscode.Selection( + cursor.start, + cursor.stop, + )); } - return await recordedState.operator.run(vimState, start, stop); + vscode.window.activeTextEditor.selections = selections; + + return resultVimState; } private async executeCommand(vimState: VimState): Promise { - const command = vimState.commandAction; + const transformations = vimState.recordedState.transformations; + + if (transformations.length === 0) { + return vimState; + } + + 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; + + case "deleteText": + edit.delete(new vscode.Range(command.position, command.position.getLeftThroughLineBreaks())); + break; + + case "deleteRange": + edit.delete(new vscode.Selection(command.range.start, command.range.stop)); + break; + } + + 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 vimState; // TODO(bell) + } + + const clonedAction = vimState.previousFullAction.clone(); + + await this.rerunRecordedState(vimState, vimState.previousFullAction); + + vimState.previousFullAction = clonedAction; + break; + } + } + + const selections = vscode.window.activeTextEditor.selections; - vimState.commandAction = VimSpecialCommands.Nothing; + const firstTransformation = transformations[0]; - switch (command) { - case VimSpecialCommands.ShowCommandLine: - await showCmdLine(vimState.commandInitialText, this); - break; - case VimSpecialCommands.Dot: - if (!vimState.previousFullAction) { - return vimState; // TODO(bell) + const manuallySetCursorPositions = firstTransformation.type === "deleteRange" && + firstTransformation.manuallySetCursorPositions; + + // We handle multiple cursors in a different way in visual block mode, unfortunately. + // TODO - refactor that out! + if (vimState.currentMode !== ModeName.VisualBlockInsertMode && + vimState.currentMode !== ModeName.VisualBlock && + !manuallySetCursorPositions) { + vimState.allCursors = []; + + const resultingCursors: Range[] = []; + + for (let i = 0; i < selections.length; i++) { + let sel = selections[i]; + + if (accumulatedPositionDifferences.length > 0) { + const diff = accumulatedPositionDifferences[i]; + + sel = new vscode.Selection( + Position.FromVSCodePosition(sel.start).add(diff), + Position.FromVSCodePosition(sel.end).add(diff) + ); + } else { + sel = new vscode.Selection( + Position.FromVSCodePosition(sel.start), + Position.FromVSCodePosition(sel.end), + ); } - const clonedAction = vimState.previousFullAction.clone(); + resultingCursors.push(Range.FromVSCodeSelection(sel)); + } - await this.rerunRecordedState(vimState, vimState.previousFullAction); + vimState.allCursors = resultingCursors; + } else { + if (accumulatedPositionDifferences.length > 0) { + vimState.cursorPosition = vimState.cursorPosition.add(accumulatedPositionDifferences[0]); + vimState.cursorStartPosition = vimState.cursorStartPosition.add(accumulatedPositionDifferences[0]); + } + } - vimState.previousFullAction = clonedAction; - break; + /** + * This is a bit of a hack because Visual Block Mode isn't fully on board with + * the new text transformation style yet. + * + * (TODO) + */ + if (firstTransformation.type === 'deleteRange') { + if (firstTransformation.collapseRange) { + vimState.cursorPosition = new Position(vimState.cursorPosition.line, vimState.cursorStartPosition.character); + } } + vimState.recordedState.transformations = []; + return vimState; } @@ -971,6 +1259,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 +1300,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 { start: cursorStart, stop: cursorStop } of vimState.allCursors) { + selections.push(new vscode.Selection(cursorStart, cursorStop)); + } + } else if (vimState.currentMode === ModeName.Normal || + vimState.currentMode === ModeName.Insert || + vimState.currentMode === ModeName.SearchInProgressMode) { + selections = []; + + for (const { stop: cursorStop } of vimState.allCursors) { + selections.push(new vscode.Selection(cursorStop, cursorStop)); + } + } 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 +1358,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 { stop: cursorStop } of vimState.allCursors) { + rangesToDraw.push(new vscode.Range(cursorStop, cursorStop.getRight())); + } } } else { // Use native block cursor if possible. @@ -1110,7 +1423,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 +1477,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..5a1095d8567 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.length)); } if (!this._recursive) { diff --git a/src/motion/position.ts b/src/motion/position.ts index 3c104c3a661..e99148887f7 100644 --- a/src/motion/position.ts +++ b/src/motion/position.ts @@ -7,6 +7,27 @@ 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. + */ +export class PositionDiff { + public line: number; + public character: number; + + constructor(line: number, character: number) { + this.line = line; + this.character = character; + } + + public add(other: PositionDiff) { + return new PositionDiff( + this.line + other.line, + this.character + other.character + ); + } +} + export class Position extends vscode.Position { private static NonWordCharacters = Configuration.getInstance().iskeyword!; private static NonBigWordCharacters = ""; @@ -23,6 +44,10 @@ 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); } @@ -37,6 +62,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 +184,36 @@ 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 new PositionDiff( + this.line - other.line, + this.character - other.character, + ); + } + + /** + * Adds a PositionDiff to this position, returning a new + * position. + */ + public add(other: PositionDiff, { boundsCheck = true } = { } ): Position { + let resultChar = 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 + ); + } + public setLocation(line: number, character: number) : Position { let position = new Position(line, character); return position; @@ -247,9 +309,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..915e6d045c7 --- /dev/null +++ b/src/motion/range.ts @@ -0,0 +1,91 @@ +"use strict"; + +import * as vscode from "vscode"; +import { Position } from "./position"; +import { IMovement } from './../actions/actions'; + +export class Range { + private _start: Position; + private _stop: Position; + + public get start(): Position { + return this._start; + } + + public get stop(): Position { + return this._stop; + } + + constructor(start: Position, stop: Position) { + 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(list: Range[]): Iterable<{ start: Position; stop: Position; range: Range, i: number }> { + for (let i = 0; i < list.length; i++) { + yield { + i, + range: list[i], + start: list[i]._start, + stop: list[i]._stop, + }; + } + } + + /** + * Create a range from an IMovement. + */ + public static FromIMovement(i: IMovement): Range { + // TODO: This shows a very clear need for refactoring after multi-cursor is merged! + + return new Range( + i.start, + i.stop + ); + } + + 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 equals(other: Range): boolean { + return this._start.isEqual(other._start) && + this._stop.isEqual(other._stop); + } + + /** + * Returns a new Range which is the same as this Range, but with the provided + * stop value. + */ + public withNewStop(stop: Position): Range { + return new Range(this._start, stop); + } + + /** + * Returns a new Range which is the same as this Range, but with the provided + * start value. + */ + public withNewStart(start: Position): Range { + return new Range(start, this._stop); + } +} \ No newline at end of file diff --git a/src/notation.ts b/src/notation.ts index 92e011e180f..93aac6a3956 100644 --- a/src/notation.ts +++ b/src/notation.ts @@ -1,6 +1,7 @@ export class AngleBracketNotation { private static _notationMap : { [key: string] : string[]; } = { 'C-': ['ctrl\\+', 'c\\-'], + 'D-': ['cmd\\+', 'd\\-'], 'Esc': ['escape', 'esc'], 'BS': ['backspace', 'bs'], 'Del': ['delete', 'del'], diff --git a/src/register/register.ts b/src/register/register.ts index 9b202dabf2d..ef2d541e47d 100644 --- a/src/register/register.ts +++ b/src/register/register.ts @@ -67,9 +67,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}`); } @@ -85,6 +83,21 @@ export class Register { }; } + 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 ". @@ -120,6 +133,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..83e280baa0a --- /dev/null +++ b/src/transformations/transformations.ts @@ -0,0 +1,189 @@ +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; + + collapseRange?: boolean; + + /** + * Please don't use this! It's a hack. + */ + manuallySetCursorPositions?: boolean; +} + +/** + * 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; + +/** + * Text Transformations + * + * Using these indicates that you want Visual Studio Code to execute your text + * actions as a batch operation. It's a bit tricky because we defer cursor updating + * behavior to whatever the batch operation returns, so if you update the cursor in your + * Action, VSCode will override whatever you did. + * + * If your cursor isn't ending up in the right place, you can adjust it by passing along + * a PositionDiff. + * + * (There are a LOT of weird edge cases with cursor behavior that we don't want to have to reimplement). + */ +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..9d16892deea 100644 --- a/src/util.ts +++ b/src/util.ts @@ -10,3 +10,24 @@ 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); + }); +} diff --git a/test/mode/modeNormal.test.ts b/test/mode/modeNormal.test.ts index e52dfc6f576..c168fa3b1ec 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"] @@ -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 dc50d141d5a..912f9c449f2 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