diff --git a/.gitignore b/.gitignore index 0660cbe05e1..3ef7d9e858d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ out node_modules typings -*.swp +*.sw? diff --git a/src/action/deleteAction.ts b/src/action/deleteAction.ts deleted file mode 100644 index 50e947cca3f..00000000000 --- a/src/action/deleteAction.ts +++ /dev/null @@ -1,22 +0,0 @@ -"use strict"; - -import * as vscode from 'vscode'; -import {TextEditor} from './../textEditor'; -import {Motion} from './../motion/motion'; - -export class DeleteAction { - public static async Character(motion : Motion): Promise { - let start = motion.position; - let end = start.translate(0, 1); - let range = new vscode.Range(start, end); - let isEOL = motion.position.isLineEnd(); - - await TextEditor.delete(range); - - if (isEOL) { - return motion.left().move(); - } else { - return motion.move(); - } - } -} \ No newline at end of file diff --git a/src/mode/mode.ts b/src/mode/mode.ts index 4e06b5b2a8d..074e772b1cd 100644 --- a/src/mode/mode.ts +++ b/src/mode/mode.ts @@ -1,6 +1,7 @@ "use strict"; import {Motion} from './../motion/motion'; +import {Position} from './../motion/position'; export enum ModeName { Normal, @@ -45,9 +46,31 @@ export abstract class Mode { this.keyHistory = []; } + protected keyToNewPosition: { [key: string]: (motion: Position) => Promise; } = { + "h" : async (c) => { return c.getLeft(); }, + "j" : async (c) => { return c.getDown(0); }, + "k" : async (c) => { return c.getUp(0); }, + "l" : async (c) => { return c.getRight(); }, + // "^" : async () => { return vscode.commands.executeCommand("cursorHome"); }, + "gg" : async (c) => { + return new Position(0, Position.getFirstNonBlankCharAtLine(0), null); }, + "G" : async (c) => { + const lastLine = c.getDocumentEnd().line; + + return new Position(lastLine, Position.getFirstNonBlankCharAtLine(lastLine), null); + }, + "$" : async (c) => { return c.getLineEnd(); }, + "0" : async (c) => { return c.getLineBegin(); }, + "w" : async (c) => { return c.getWordRight(); }, + "e" : async (c) => { return c.getCurrentWordEnd(); }, + "b" : async (c) => { return c.getWordLeft(); }, + "}" : async (c) => { return c.getCurrentParagraphEnd(); }, + "{" : async (c) => { return c.getCurrentParagraphBeginning(); } + }; + abstract shouldBeActivated(key : string, currentMode : ModeName) : boolean; - abstract handleActivation(key : string) : Promise<{}>; + abstract handleActivation(key : string) : Promise; - abstract handleKeyEvent(key : string) : Promise<{}>; + abstract handleKeyEvent(key : string) : Promise; } \ No newline at end of file diff --git a/src/mode/modeHandler.ts b/src/mode/modeHandler.ts index 3dc19d42e07..88d6aae2845 100644 --- a/src/mode/modeHandler.ts +++ b/src/mode/modeHandler.ts @@ -7,6 +7,7 @@ import {Mode, ModeName} from './mode'; import {Motion, MotionMode} from './../motion/motion'; import {NormalMode} from './modeNormal'; import {InsertMode} from './modeInsert'; +import {VisualMode} from './modeVisual'; import {Configuration} from '../configuration'; export class ModeHandler implements vscode.Disposable { @@ -20,25 +21,22 @@ export class ModeHandler implements vscode.Disposable { this._motion = new Motion(null); this._modes = [ - new NormalMode(this._motion), + new NormalMode(this._motion, this), new InsertMode(this._motion), + new VisualMode(this._motion, this), ]; this.setCurrentModeByName(ModeName.Normal); } get currentMode() : Mode { - let currentMode = this._modes.find((mode, index) => { - return mode.isActive; - }); - - return currentMode; + return this._modes.find(mode => mode.isActive); } setCurrentModeByName(modeName : ModeName) { - this._modes.forEach(mode => { + for (let mode of this._modes) { mode.isActive = (mode.name === modeName); - }); + } switch (modeName) { case ModeName.Insert: @@ -88,7 +86,7 @@ export class ModeHandler implements vscode.Disposable { this._statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left); } - this._statusBarItem.text = (text) ? '-- ' + text + ' --' : ''; + this._statusBarItem.text = text ? `--${text}--` : ''; this._statusBarItem.show(); } diff --git a/src/mode/modeInsert.ts b/src/mode/modeInsert.ts index 6f4d5b9f5b9..084d808490d 100644 --- a/src/mode/modeInsert.ts +++ b/src/mode/modeInsert.ts @@ -42,16 +42,15 @@ export class InsertMode extends Mode { return key in this.activationKeyHandler; } - handleActivation(key : string) : Promise<{}> { - return this.activationKeyHandler[key](this.motion); + async handleActivation(key : string): Promise { + await this.activationKeyHandler[key](this.motion); } - async handleKeyEvent(key : string) : Promise<{}> { + async handleKeyEvent(key : string) : Promise { this.keyHistory.push(key); await TextEditor.insert(this.resolveKeyValue(key)); - - return vscode.commands.executeCommand("editor.action.triggerSuggest"); + await vscode.commands.executeCommand("editor.action.triggerSuggest"); } // Some keys have names that are different to their value. diff --git a/src/mode/modeNormal.ts b/src/mode/modeNormal.ts index ffe48c63f23..440fffe37a9 100644 --- a/src/mode/modeNormal.ts +++ b/src/mode/modeNormal.ts @@ -6,10 +6,11 @@ import * as vscode from 'vscode'; import {ModeName, Mode} from './mode'; import {showCmdLine} from './../cmd_line/main'; import {Motion} from './../motion/motion'; -import {DeleteAction} from './../action/deleteAction'; +import {ModeHandler} from './modeHandler'; +import {DeleteOperator} from './../operator/delete'; export class NormalMode extends Mode { - private keyHandler : { [key : string] : (motion : Motion) => Promise<{}>; } = { + protected keyHandler : { [key : string] : (motion : Motion) => Promise<{}>; } = { ":" : async () => { return showCmdLine(""); }, "u" : async () => { return vscode.commands.executeCommand("undo"); }, "ctrl+r" : async () => { return vscode.commands.executeCommand("redo"); }, @@ -37,30 +38,32 @@ export class NormalMode extends Mode { "dd" : async () => { return vscode.commands.executeCommand("editor.action.deleteLines"); }, "dw" : async () => { return vscode.commands.executeCommand("deleteWordRight"); }, "db" : async () => { return vscode.commands.executeCommand("deleteWordLeft"); }, - "x" : async (m) => { return DeleteAction.Character(m); }, + "x" : async (m) => { await new DeleteOperator(this._modeHandler).run(m.position, m.position.getRight()); return {}; }, "X" : async (m) => { return vscode.commands.executeCommand("deleteLeft"); }, "esc": async () => { return vscode.commands.executeCommand("workbench.action.closeMessages"); } }; - constructor(motion : Motion) { + private _modeHandler: ModeHandler; + + constructor(motion : Motion, modeHandler: ModeHandler) { super(ModeName.Normal, motion); + + this._modeHandler = modeHandler; } shouldBeActivated(key : string, currentMode : ModeName) : boolean { return (key === 'esc' || key === 'ctrl+[' || key === "ctrl+c"); } - async handleActivation(key : string): Promise<{}> { + async handleActivation(key : string): Promise { this.motion.left().move(); - - return this.motion; } - async handleKeyEvent(key : string): Promise<{}> { + async handleKeyEvent(key : string): Promise { this.keyHistory.push(key); let keyHandled = false; - let keysPressed : string; + let keysPressed: string; for (let window = this.keyHistory.length; window > 0; window--) { keysPressed = _.takeRight(this.keyHistory, window).join(''); @@ -72,7 +75,7 @@ export class NormalMode extends Mode { if (keyHandled) { this.keyHistory = []; - return this.keyHandler[keysPressed](this.motion); + await this.keyHandler[keysPressed](this.motion); } } } diff --git a/src/mode/modeVisual.ts b/src/mode/modeVisual.ts new file mode 100644 index 00000000000..f2319759a56 --- /dev/null +++ b/src/mode/modeVisual.ts @@ -0,0 +1,140 @@ +"use strict"; + +import * as _ from 'lodash'; + +import { ModeName, Mode } from './mode'; +import { Motion} from './../motion/motion'; +import { Position } from './../motion/position'; +import { Operator } from './../operator/operator'; +import { DeleteOperator } from './../operator/delete'; +import { ModeHandler } from './modeHandler.ts'; +import { ChangeOperator } from './../operator/change'; + +export class VisualMode extends Mode { + /** + * The part of the selection that stays in the same place when motions are applied. + */ + private _selectionStart: Position; + + /** + * The part of the selection that moves. + */ + private _selectionStop : Position; + private _modeHandler : ModeHandler; + + private _keysToOperators: { [key: string]: Operator }; + + constructor(motion: Motion, modeHandler: ModeHandler) { + super(ModeName.Visual, motion); + + this._modeHandler = modeHandler; + this._keysToOperators = { + // TODO: use DeleteOperator.key() + + // TODO: Don't pass in mode handler to DeleteOperators, + // simply allow the operators to say what mode they transition into. + 'd': new DeleteOperator(modeHandler), + 'x': new DeleteOperator(modeHandler), + 'c': new ChangeOperator(modeHandler) + }; + } + + shouldBeActivated(key: string, currentMode: ModeName): boolean { + return key === "v"; + } + + async handleActivation(key: string): Promise { + this._selectionStart = this.motion.position; + this._selectionStop = this._selectionStart; + + this.motion.select(this._selectionStart, this._selectionStop); + } + + handleDeactivation(): void { + super.handleDeactivation(); + + this.motion.moveTo(this._selectionStop.line, this._selectionStop.character); + } + + /** + * TODO: + * + * Eventually, the following functions should be moved into a unified + * key handler and dispatcher thing. + */ + + private async _handleMotion(): Promise { + let keyHandled = false; + let keysPressed: string; + + for (let window = this.keyHistory.length; window > 0; window--) { + keysPressed = _.takeRight(this.keyHistory, window).join(''); + if (this.keyToNewPosition[keysPressed] !== undefined) { + keyHandled = true; + break; + } + } + + if (keyHandled) { + this._selectionStop = await this.keyToNewPosition[keysPressed](this._selectionStop); + + this.motion.moveTo(this._selectionStart.line, this._selectionStart.character); + + /** + * Always select the letter that we started visual mode on, no matter + * if we are in front or behind it. Imagine that we started visual mode + * with some text like this: + * + * abc|def + * + * (The | represents the cursor.) If we now press w, we'll select def, + * but if we hit b we expect to select abcd, so we need to getRight() on the + * start of the selection when it precedes where we started visual mode. + */ + + // TODO this could be abstracted out + if (this._selectionStart.compareTo(this._selectionStop) <= 0) { + this.motion.select(this._selectionStart, this._selectionStop); + } else { + this.motion.select(this._selectionStart.getRight(), this._selectionStop); + } + + this.keyHistory = []; + } + + return keyHandled; + } + + private async _handleOperator(): Promise { + let keysPressed: string; + let operator: Operator; + + for (let window = this.keyHistory.length; window > 0; window--) { + keysPressed = _.takeRight(this.keyHistory, window).join(''); + if (this._keysToOperators[keysPressed] !== undefined) { + operator = this._keysToOperators[keysPressed]; + break; + } + } + + if (operator) { + if (this._selectionStart.compareTo(this._selectionStop) <= 0) { + await operator.run(this._selectionStart, this._selectionStop.getRight()); + } else { + await operator.run(this._selectionStart.getRight(), this._selectionStop); + } + } + + return !!operator; + } + + async handleKeyEvent(key: string): Promise { + this.keyHistory.push(key); + + const wasMotion = await this._handleMotion(); + + if (!wasMotion) { + await this._handleOperator(); + } + } +} diff --git a/src/motion/motion.ts b/src/motion/motion.ts index f438a21179a..f1baf01d34c 100644 --- a/src/motion/motion.ts +++ b/src/motion/motion.ts @@ -37,6 +37,10 @@ export class Motion implements vscode.Disposable { return this._position; } + public set position(val: Position) { + this._position = val; + } + public constructor(mode: MotionMode) { // initialize to current position let currentPosition = vscode.window.activeTextEditor.selection.active; @@ -100,7 +104,24 @@ export class Motion implements vscode.Disposable { let selection = new vscode.Selection(this.position, this.position); vscode.window.activeTextEditor.selection = selection; - let range = new vscode.Range(this.position, this.position.translate(0, 1)); + this.highlightBlock(this.position); + + return this; + } + + /** + * Allows us to simulate a block cursor by highlighting a 1 character + * space at the provided position in a lighter color. + */ + private highlightBlock(start: Position): void { + this.highlightRange(start, start.getRight()); + } + + /** + * Highlights the range from start to end in the color of a block cursor. + */ + private highlightRange(start: Position, end: Position): void { + let range = new vscode.Range(start, end); vscode.window.activeTextEditor.revealRange(range, vscode.TextEditorRevealType.InCenterIfOutsideViewport); switch (this._motionMode) { @@ -114,8 +135,14 @@ export class Motion implements vscode.Disposable { vscode.window.activeTextEditor.setDecorations(this._caretDecoration, []); break; } + } - return this; + public select(from: Position, to: Position): void { + let selection = new vscode.Selection(from, to); + + vscode.window.activeTextEditor.selection = selection; + + this.highlightBlock(to); } public left() : Motion { diff --git a/src/operator/change.ts b/src/operator/change.ts new file mode 100644 index 00000000000..9e2a463f43a --- /dev/null +++ b/src/operator/change.ts @@ -0,0 +1,25 @@ +"use strict"; + +import { Position } from './../motion/position'; +import { DeleteOperator } from './delete'; +import { ModeHandler } from './../mode/modeHandler.ts'; +import { ModeName } from './../mode/mode'; + +export class ChangeOperator { + private _modeHandler: ModeHandler; + + constructor(modeHandler: ModeHandler) { + this._modeHandler = modeHandler; + } + + public key(): string { return "d"; } + + /** + * Run this operator on a range. + */ + public async run(start: Position, end: Position): Promise { + await new DeleteOperator(this._modeHandler).run(start, end); + + this._modeHandler.setCurrentModeByName(ModeName.Insert); + } +} \ No newline at end of file diff --git a/src/operator/delete.ts b/src/operator/delete.ts new file mode 100644 index 00000000000..183c83b756d --- /dev/null +++ b/src/operator/delete.ts @@ -0,0 +1,43 @@ +"use strict"; + +import { Position } from './../motion/position'; +import { TextEditor } from './../textEditor'; +import { ModeHandler } from './../mode/modeHandler.ts'; +import { ModeName } from './../mode/mode'; + +import * as vscode from 'vscode'; + +export class DeleteOperator { + private _modeHandler: ModeHandler; + + constructor(modeHandler: ModeHandler) { + this._modeHandler = modeHandler; + } + + public key(): string { return "d"; } + + /** + * Run this operator on a range. + */ + public async run(start: Position, end: Position): Promise { + + // Imagine we have selected everything with an X in + // the following text (there is no character on the + // second line at all, just a block cursor): + + // XXXXXXX + // X + // + // If we delete this range, we want to delete the entire first and + // second lines. Therefore we have to advance the cursor to the next + // line. + + if (TextEditor.getLineAt(end).text === "") { + end = end.getDown(0); + } + + await TextEditor.delete(new vscode.Range(start, end)); + + this._modeHandler.setCurrentModeByName(ModeName.Normal); + } +} \ No newline at end of file diff --git a/src/operator/operator.ts b/src/operator/operator.ts new file mode 100644 index 00000000000..9ee591533ec --- /dev/null +++ b/src/operator/operator.ts @@ -0,0 +1,15 @@ +"use strict"; + +import { Position } from './../motion/position'; + +export abstract class Operator { + /** + * What key triggers this operator? + */ + abstract key(): string; + + /** + * Run this operator on a range. + */ + abstract run(start: Position, stop: Position): Promise; +} \ No newline at end of file diff --git a/src/operator/yank.ts b/src/operator/yank.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/textEditor.ts b/src/textEditor.ts index bf3e39df1f1..9fc9209cec7 100644 --- a/src/textEditor.ts +++ b/src/textEditor.ts @@ -71,6 +71,10 @@ export class TextEditor { return vscode.window.activeTextEditor.document.lineAt(position); } + static getSelection(): vscode.Range { + return vscode.window.activeTextEditor.selection; + } + static isFirstLine(position : vscode.Position): boolean { return position.line === 0; } diff --git a/test/mode/modeNormal.test.ts b/test/mode/modeNormal.test.ts index 5da24f04bc2..fb15415a835 100644 --- a/test/mode/modeNormal.test.ts +++ b/test/mode/modeNormal.test.ts @@ -6,17 +6,20 @@ import {NormalMode} from '../../src/mode/modeNormal'; import {ModeName} from '../../src/mode/mode'; import {Motion, MotionMode} from '../../src/motion/motion'; import {TextEditor} from '../../src/textEditor'; +import {ModeHandler} from '../../src/mode/modeHandler'; suite("Mode Normal", () => { let motion : Motion; let modeNormal : NormalMode; + let modeHandler: ModeHandler; setup(async () => { await setupWorkspace(); - motion = new Motion(MotionMode.Cursor); - modeNormal = new NormalMode(motion); + modeHandler = new ModeHandler(); + motion = new Motion(MotionMode.Cursor); + modeNormal = new NormalMode(motion, modeHandler); }); teardown(cleanUpWorkspace); diff --git a/test/mode/modeVisual.test.ts b/test/mode/modeVisual.test.ts new file mode 100644 index 00000000000..c72242a934c --- /dev/null +++ b/test/mode/modeVisual.test.ts @@ -0,0 +1,117 @@ +"use strict"; + +import * as assert from 'assert'; +import {ModeHandler} from '../../src/mode/modeHandler'; +import {setupWorkspace, cleanUpWorkspace, assertEqualLines} from './../testUtils'; +import {VisualMode} from '../../src/mode/modeVisual'; +import {ModeName} from '../../src/mode/mode'; +import {Motion, MotionMode} from '../../src/motion/motion'; +import {TextEditor} from '../../src/textEditor'; + +suite("Mode Visual", () => { + let motion: Motion; + let visualMode: VisualMode; + let modeHandler: ModeHandler; + + setup(async () => { + await setupWorkspace(); + + modeHandler = new ModeHandler(); + motion = new Motion(MotionMode.Cursor); + visualMode = new VisualMode(motion, modeHandler); + }); + + teardown(cleanUpWorkspace); + + test("can be activated", () => { + assert.equal(visualMode.shouldBeActivated("v", ModeName.Normal), true, "v didn't trigger visual mode..."); + }); + + test("Can handle w", async () => { + await TextEditor.insert("test test test\ntest\n"); + + motion.moveTo(0, 0); + + await visualMode.handleActivation('v'); + await visualMode.handleKeyEvent("w"); + + const sel = TextEditor.getSelection(); + + assert.equal(sel.start.character, 0); + assert.equal(sel.start.line, 0); + + // The input cursor comes BEFORE the block cursor. Try it out, this + // is how Vim works. + assert.equal(sel.end.character, 5); + assert.equal(sel.end.line, 0); + }); + + test("Can handle wd", async () => { + await TextEditor.insert("one two three"); + motion.moveTo(0, 0); + + await visualMode.handleActivation('v'); + await visualMode.handleKeyEvent("w"); + await visualMode.handleKeyEvent("d"); + + assertEqualLines(["wo three"]); + }); + + test("Can handle x", async () => { + await TextEditor.insert("one two three"); + motion.moveTo(0, 0); + + await visualMode.handleActivation('v'); + await visualMode.handleKeyEvent("x"); + + assertEqualLines(["ne two three"]); + }); + + test("can do vwd in middle of sentence", async () => { + await TextEditor.insert("one two three foar"); + motion.moveTo(0, 4); + + await visualMode.handleActivation('v'); + await visualMode.handleKeyEvent("w"); + await visualMode.handleKeyEvent("d"); + + assertEqualLines(["one hree foar"]); + }); + + test("handles case where we go from selecting on right side to selecting on left side", async () => { + await TextEditor.insert("one two three"); + motion.moveTo(0, 4); + + await visualMode.handleActivation('v'); + await visualMode.handleKeyEvent("w"); + await visualMode.handleKeyEvent("b"); + await visualMode.handleKeyEvent("b"); + await visualMode.handleKeyEvent("d"); + + assertEqualLines(["wo three"]); + }); + + test("delete operator handles empty line", async () => { + await TextEditor.insert("one two\n\nthree four"); + motion.moveTo(0, 0); + + await visualMode.handleActivation('v'); + await visualMode.handleKeyEvent("}"); + await visualMode.handleKeyEvent("d"); + + assertEqualLines(["three four"]); + }); + + test("Change operator", async () => { + await TextEditor.insert("one two three"); + motion.moveTo(0, 0); + + await visualMode.handleActivation('v'); + await visualMode.handleKeyEvent("w"); + await visualMode.handleKeyEvent("c"); + + assertEqualLines(["wo three"]); + + assert.equal(((visualMode as any)._modeHandler as ModeHandler).currentMode.name, ModeName.Insert); + }); +}); diff --git a/test/testUtils.ts b/test/testUtils.ts index 3c2e592d099..5f6370162e0 100644 --- a/test/testUtils.ts +++ b/test/testUtils.ts @@ -40,7 +40,7 @@ export async function setupWorkspace(): Promise { assert.ok(vscode.window.activeTextEditor); } -export function cleanUpWorkspace(): Promise { +export async function cleanUpWorkspace(): Promise { // https://github.com/Microsoft/vscode/blob/master/extensions/vscode-api-tests/src/utils.ts return new Promise((c, e) => { if (vscode.window.visibleTextEditors.length === 0) {