diff --git a/README.md b/README.md index 42e8718f6a6..4ad0b15d5c7 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ VSCodeVim is a Vim emulator for [Visual Studio Code](https://code.visualstudio.c - [🖱️ Multi-Cursor Mode](#️-multi-cursor-mode) - [🔌 Emulated Plugins](#-emulated-plugins) - [vim-airline](#vim-airline) + - [vim-leap](#vim-leap) - [vim-easymotion](#vim-easymotion) - [vim-surround](#vim-surround) - [vim-commentary](#vim-commentary) @@ -521,6 +522,16 @@ Change the color of the status bar based on the current mode. Once enabled, conf "vim.statusBarColors.easymotioninputmode": "#007ACC", "vim.statusBarColors.surroundinputmode": "#007ACC", ``` +### vim-leap + +Based on [vim-leap](https://github.com/ggandor/leap.nvim) and configured through the following settings: + +| Setting | Description | Type | Default Value | Value | +| -------------------------- | -------------------------------------------- | ------- | ------------------------------ | ------------------ | +| vim.leap | Enable/disable leap plugin | Boolean | false | | +| vim.leapShowMarkerPosition | Set the position of the marker point display | String | "after" | "after" , "target" | +| vim.leapLabels | The characters used for jump marker name | String | "sklyuiopnm,qwertzxcvbahdgjf;" | | +| vim.leapCaseSensitive | Whether to consider case in search patterns | Boolean | false | | ### vim-easymotion diff --git a/package.json b/package.json index a9962f5ff2a..513139e461a 100644 --- a/package.json +++ b/package.json @@ -621,6 +621,26 @@ "markdownDescription": "Enable the [CamelCaseMotion](https://github.com/bkad/CamelCaseMotion) plugin for Vim.", "default": false }, + "vim.leap": { + "type": "boolean", + "markdownDescription": "Enable the [Leap](https://github.com/ggandor/leap.nvim) plugin for Vim.", + "default": false + }, + "vim.leapShowMarkerPosition": { + "type": "string", + "markdownDescription": "Set the position of the marker point display for Leap markers", + "default": "after" + }, + "vim.leapLabels": { + "type": "string", + "markdownDescription": "Set the characters used for jump marker label.", + "default": "sklyuiopnm,qwertzxcvbahdgjf;" + }, + "vim.leapCaseSensitive": { + "type": "boolean", + "markdownDescription": "Set to consider case sensitive in search patterns", + "default": false + }, "vim.easymotion": { "type": "boolean", "markdownDescription": "Enable the [EasyMotion](https://github.com/easymotion/vim-easymotion) plugin for Vim.", diff --git a/src/actions/commands/actions.ts b/src/actions/commands/actions.ts index 9284d3a7d75..4a24875ed4c 100644 --- a/src/actions/commands/actions.ts +++ b/src/actions/commands/actions.ts @@ -902,11 +902,15 @@ class CommandClearLine extends BaseCommand { // Don't clash with sneak public override doesActionApply(vimState: VimState, keysPressed: string[]): boolean { - return super.doesActionApply(vimState, keysPressed) && !configuration.sneak; + return ( + super.doesActionApply(vimState, keysPressed) && !configuration.sneak && !configuration.leap + ); } public override couldActionApply(vimState: VimState, keysPressed: string[]): boolean { - return super.couldActionApply(vimState, keysPressed) && !configuration.sneak; + return ( + super.couldActionApply(vimState, keysPressed) && !configuration.sneak && !configuration.leap + ); } } @@ -2214,6 +2218,7 @@ class ActionChangeChar extends BaseCommand { return ( super.doesActionApply(vimState, keysPressed) && !configuration.sneak && + !configuration.leap && !vimState.recordedState.operator ); } @@ -2222,6 +2227,7 @@ class ActionChangeChar extends BaseCommand { return ( super.couldActionApply(vimState, keysPressed) && !configuration.sneak && + !configuration.leap && !vimState.recordedState.operator ); } diff --git a/src/actions/include-all.ts b/src/actions/include-all.ts index bacd9cd3585..289fe1dd5bd 100644 --- a/src/actions/include-all.ts +++ b/src/actions/include-all.ts @@ -23,3 +23,4 @@ import './plugins/sneak'; import './plugins/replaceWithRegister'; import './plugins/surround'; import './plugins/targets/targets'; +import './plugins/leap/register'; diff --git a/src/actions/include-plugins.ts b/src/actions/include-plugins.ts index 4922a363d57..113da031c49 100644 --- a/src/actions/include-plugins.ts +++ b/src/actions/include-plugins.ts @@ -6,3 +6,4 @@ import './plugins/sneak'; import './plugins/replaceWithRegister'; import './plugins/surround'; import './plugins/targets/targets'; +import './plugins/leap/register'; diff --git a/src/actions/plugins/leap/LeapAction.ts b/src/actions/plugins/leap/LeapAction.ts new file mode 100644 index 00000000000..04277219fdf --- /dev/null +++ b/src/actions/plugins/leap/LeapAction.ts @@ -0,0 +1,139 @@ +import { Mode } from '../../../mode/mode'; +import { BaseCommand, RegisterAction } from '../../base'; +import { Position } from 'vscode'; +import { VimState } from '../../../state/vimState'; +import { configuration } from '../../../configuration/configuration'; +import { LeapSearchDirection, createLeap } from './leap'; +import { getMatches, generateMarkerRegex, generatePrepareRegex } from './match'; +import { StatusBar } from '../../../statusBar'; +import { Marker } from './Marker'; +import { VimError, ErrorCode } from '../../../error'; + +@RegisterAction +export class LeapPrepareAction extends BaseCommand { + modes = [Mode.Normal]; + keys = [ + ['s', ''], + ['S', ''], + ]; + + public override doesActionApply(vimState: VimState, keysPressed: string[]) { + return super.doesActionApply(vimState, keysPressed) && configuration.leap; + } + + public override async exec(cursorPosition: Position, vimState: VimState): Promise { + if (!configuration.leap) return; + + if (this.keysPressed[1] === '\n') { + this.execRepeatLastSearch(vimState); + } else { + await this.execPrepare(cursorPosition, vimState); + } + } + + private async execPrepare(cursorPosition: Position, vimState: VimState) { + const direction = this.getDirection(); + const firstSearchString = this.keysPressed[1]; + + const leap = createLeap(vimState, direction, firstSearchString); + vimState.leap = leap; + vimState.leap.previousMode = vimState.currentMode; + + const matches = getMatches( + generatePrepareRegex(firstSearchString), + direction, + cursorPosition, + vimState.document + ); + + vimState.leap.createMarkers(matches); + vimState.leap.showMarkers(); + await vimState.setCurrentMode(Mode.LeapPrepareMode); + } + + private execRepeatLastSearch(vimState: VimState) { + if (vimState.leap?.leapAction) { + vimState.leap.isRepeatLastSearch = true; + vimState.leap.direction = this.getDirection(); + vimState.leap.leapAction.fire(); + } else { + StatusBar.displayError(vimState, VimError.fromCode(ErrorCode.LeapNoPreviousSearch)); + } + } + + private getDirection() { + return this.keysPressed[0] === 's' ? LeapSearchDirection.Backward : LeapSearchDirection.Forward; + } +} + +@RegisterAction +export class LeapAction extends BaseCommand { + modes = [Mode.LeapPrepareMode]; + keys = ['']; + override isJump = true; + private vimState!: VimState; + private searchString: string = ''; + public override async exec(cursorPosition: Position, vimState: VimState): Promise { + if (!configuration.leap) return; + this.vimState = vimState; + this.searchString = vimState.leap.firstSearchString + this.keysPressed[0]; + const markers: Marker[] = this.getMarkers(cursorPosition); + + if (markers.length === 0) { + await this.handleNoFoundMarkers(); + return; + } + + // When the leapAction is executed, it needs to be logged + // This is to repeat the last search command + // As long as it is recorded, it means that the search was successfully executed once. + // As long as the search has been executed successfully, it will be ok when we execute "repeat last search". + vimState.leap.leapAction = this; + + if (markers.length === 1) { + await this.handleOneMarkers(markers[0]); + return; + } + + await this.handleMultipleMarkers(); + } + private async handleMultipleMarkers() { + this.vimState.leap.keepMarkersBySearchString(this.searchString); + await this.vimState.setCurrentMode(Mode.LeapMode); + } + + private async handleOneMarkers(marker: Marker) { + this.vimState.cursorStopPosition = marker.matchPosition; + this.vimState.leap.cleanupMarkers(); + await this.vimState.setCurrentMode(this.vimState.leap.previousMode); + } + + private async handleNoFoundMarkers() { + StatusBar.displayError( + this.vimState, + VimError.fromCode(ErrorCode.LeapNoFoundSearchString, this.searchString) + ); + this.vimState.leap.cleanupMarkers(); + await this.vimState.setCurrentMode(this.vimState.leap.previousMode); + } + + private getMarkers(cursorPosition: Position) { + if (this.vimState.leap.isRepeatLastSearch) { + const matches = getMatches( + generateMarkerRegex(this.searchString), + this.vimState.leap.direction!, + cursorPosition, + this.vimState.document + ); + this.vimState.leap.createMarkers(matches); + this.vimState.leap.showMarkers(); + return this.vimState.leap.markers; + } else { + return this.vimState.leap.findMarkersBySearchString(this.searchString); + } + } + + public fire() { + this.exec(this.vimState.cursorStopPosition, this.vimState); + } +} diff --git a/src/actions/plugins/leap/Marker.ts b/src/actions/plugins/leap/Marker.ts new file mode 100644 index 00000000000..029232aca14 --- /dev/null +++ b/src/actions/plugins/leap/Marker.ts @@ -0,0 +1,147 @@ +import { Match } from './match'; +import * as vscode from 'vscode'; +import { configuration } from './../../../configuration/configuration'; + +export class Marker { + private decoration!: MarkerDecoration; + label: string = ''; + searchString: string; + matchPosition: vscode.Position; + constructor({ position, searchString }: Match, editor: vscode.TextEditor) { + this.matchPosition = position; + this.searchString = searchString; + this.decoration = new MarkerDecoration(editor, this); + } + + get prefix() { + return this.label.length > 1 ? this.label[0] : ''; + } + + deletePrefix() { + this.label = this.label.slice(-1); + } + + update() { + this.show(); + } + + show() { + this.decoration.show(); + } + + dispose() { + this.decoration.dispose(); + } +} + +class MarkerDecoration { + private range!: vscode.Range; + private editor!: vscode.TextEditor; + private marker!: Marker; + private textEditorDecorationType: vscode.TextEditorDecorationType; + + private static backgroundColors = ['#ccff88', '#99ccff']; + constructor(editor: vscode.TextEditor, marker: Marker) { + this.editor = editor; + this.marker = marker; + this.textEditorDecorationType = vscode.window.createTextEditorDecorationType({}); + this.createRange(); + } + + private createRange() { + let position = this.marker.matchPosition; + if (configuration.leapShowMarkerPosition === 'after') { + position = new vscode.Position(position.line, position.character + 2); + } + this.range = new vscode.Range( + position.line, + position.character, + position.line, + position.character + ); + } + + private calcDecorationBackgroundColor() { + const labels = configuration.leapLabels.split('').reverse().join(''); + + let index = 0; + if (this.marker.prefix) { + const prefixIndex = labels.indexOf(this.marker.prefix); + if (prefixIndex !== -1) { + index = (prefixIndex + 1) % 2 === 0 ? 0 : 1; + } + } + + return MarkerDecoration.backgroundColors[index]; + } + + show() { + this.editor.setDecorations(this.textEditorDecorationType, this.getRangesOrOptions()); + } + + private getRangesOrOptions() { + const secondCharRenderOptions: vscode.ThemableDecorationInstanceRenderOptions = { + before: { + contentText: this.marker.label, + backgroundColor: this.calcDecorationBackgroundColor(), + color: '#000000', + margin: `0 -1ch 0 0; + position: absolute; + font-weight: normal;`, + height: '100%', + }, + }; + + return [ + { + range: this.range, + renderOptions: { + dark: secondCharRenderOptions, + light: secondCharRenderOptions, + }, + }, + ]; + } + + dispose() { + this.textEditorDecorationType.dispose(); + } +} + +export function generateMarkerNames(count: number) { + const leapLabels = configuration.leapLabels; + const result = []; + + const prefixCount = Math.floor(count / leapLabels.length); + const prefixes = leapLabels + .slice(0 - prefixCount) + .split('') + .reverse() + .join(''); + + const firstGroupValues = leapLabels.slice(0, leapLabels.length - prefixCount); + const secondGroupValues = leapLabels; + + for (let i = 0; i < count; i++) { + let value; + let prefixIndex; + const isFirstGroup = i < firstGroupValues.length; + if (isFirstGroup) { + value = firstGroupValues[i % firstGroupValues.length]; + prefixIndex = Math.floor(i / firstGroupValues.length); + } else { + const ii = i - firstGroupValues.length; + value = secondGroupValues[ii % secondGroupValues.length]; + prefixIndex = Math.floor(ii / secondGroupValues.length) + 1; + } + + const prefixValue = prefixIndex === 0 ? '' : prefixes[prefixIndex - 1]; + result.push(prefixValue + value); + } + + return result; +} + +export function createMarker(match: Match, editor: vscode.TextEditor) { + return new Marker(match, editor); +} diff --git a/src/actions/plugins/leap/MoveLeapAction.ts b/src/actions/plugins/leap/MoveLeapAction.ts new file mode 100644 index 00000000000..f085cd78e4b --- /dev/null +++ b/src/actions/plugins/leap/MoveLeapAction.ts @@ -0,0 +1,66 @@ +import { Position } from 'vscode'; +import { BaseCommand, RegisterAction } from '../../base'; +import { VimState } from '../../../state/vimState'; +import { Mode } from '../../../mode/mode'; +import { Marker } from './Marker'; + +@RegisterAction +export class MoveLeapAction extends BaseCommand { + modes = [Mode.LeapMode]; + keys = ['']; + override isJump = true; + + private vimState!: VimState; + public override async exec(position: Position, vimState: VimState): Promise { + const searchString = this.keysPressed[0]; + if (!searchString) return; + this.vimState = vimState; + + const marker = vimState.leap.findMarkerByName(searchString); + if (marker) { + await this.handleDirectFoundMarker(marker); + } else if (vimState.leap.isPrefixOfMarker(searchString)) { + this.handleIsPrefixOfMarker(searchString); + } else { + await this.handleNoFoundMarker(); + } + } + + private async handleDirectFoundMarker(marker: Marker) { + this.vimState.leap.cleanupMarkers(); + this.vimState.cursorStopPosition = marker.matchPosition; + await this.vimState.setCurrentMode(this.vimState.leap.previousMode); + } + + private async handleIsPrefixOfMarker(searchString: string) { + this.vimState.leap.keepMarkersByPrefix(searchString); + this.vimState.leap.deletePrefixOfMarkers(); + } + + private async handleNoFoundMarker() { + this.vimState.leap.cleanupMarkers(); + await this.vimState.setCurrentMode(this.vimState.leap.previousMode); + } +} + +@RegisterAction +class CommandEscLeapMode extends BaseCommand { + modes = [Mode.LeapMode]; + keys = ['']; + + public override async exec(position: Position, vimState: VimState): Promise { + vimState.leap.cleanupMarkers(); + await vimState.setCurrentMode(vimState.leap.previousMode); + } +} + +@RegisterAction +class CommandEscLeapPrepareMode extends BaseCommand { + modes = [Mode.LeapPrepareMode]; + keys = ['']; + + public override async exec(position: Position, vimState: VimState): Promise { + vimState.leap.cleanupMarkers(); + await vimState.setCurrentMode(vimState.leap.previousMode); + } +} diff --git a/src/actions/plugins/leap/leap.ts b/src/actions/plugins/leap/leap.ts new file mode 100644 index 00000000000..086914113c1 --- /dev/null +++ b/src/actions/plugins/leap/leap.ts @@ -0,0 +1,129 @@ +import { Mode } from '../../../mode/mode'; +import { Match } from './match'; +import { Marker, createMarker, generateMarkerNames } from './Marker'; +import { VimState } from '../../../state/vimState'; +import { LeapAction } from './LeapAction'; + +export enum LeapSearchDirection { + Forward = -1, + Backward = 1, +} + +export class Leap { + markers: Marker[] = []; + vimState: VimState; + previousMode!: Mode; + isRepeatLastSearch: boolean = false; + direction?: LeapSearchDirection; + leapAction?: LeapAction; + firstSearchString?: string; + + constructor(vimState: VimState) { + this.vimState = vimState; + } + + public createMarkers(matches: Match[]) { + this.markers = matches.map((match) => { + return createMarker(match, this.vimState.editor); + }); + + this.setMarkersName(); + + return this.markers; + } + + private setMarkersName() { + const map: { [key: string]: Marker[] } = {}; + + this.markers.forEach((marker) => { + const key = marker.searchString; + if (!map[key]) { + map[key] = []; + } + + map[key].push(marker); + }); + + Object.keys(map).forEach((key) => { + const group = map[key]; + const markerNames = generateMarkerNames(group.length); + group.forEach((marker, index) => { + marker.label = markerNames[index]; + }); + }); + } + + public findMarkerByName(name: string) { + return this.markers.find((marker: Marker) => { + return marker.label === name; + }); + } + + public findMarkersBySearchString(searchString: string) { + return this.markers.filter((marker) => { + return marker.searchString === searchString; + }); + } + + public keepMarkersByPrefix(prefix: string) { + this.markers = this.markers + .map((marker) => { + if (marker.prefix !== prefix) marker.dispose(); + return marker; + }) + .filter((marker) => { + return marker.prefix === prefix; + }); + } + + public keepMarkersBySearchString(searchString: string) { + this.markers = this.markers + .map((marker) => { + if (marker.searchString !== searchString) marker.dispose(); + return marker; + }) + .filter((marker) => { + return marker.searchString === searchString; + }); + } + + public deletePrefixOfMarkers() { + this.markers.forEach((marker: Marker) => { + marker.deletePrefix(); + marker.update(); + }); + } + + public isPrefixOfMarker(character: string) { + return this.markers.some((marker) => { + return marker.prefix === character; + }); + } + + public cleanupMarkers() { + if (this.markers.length === 0) return; + this.markers.forEach((marker) => { + marker.dispose(); + }); + + this.markers = []; + } + + public showMarkers() { + this.markers.forEach((marker) => { + marker.show(); + }); + } +} + +export function createLeap( + vimState: VimState, + direction: LeapSearchDirection = LeapSearchDirection.Backward, + firstSearchString: string = '' +) { + const leap = new Leap(vimState); + leap.direction = direction; + leap.firstSearchString = firstSearchString; + + return leap; +} diff --git a/src/actions/plugins/leap/match.ts b/src/actions/plugins/leap/match.ts new file mode 100644 index 00000000000..66c5ae20b68 --- /dev/null +++ b/src/actions/plugins/leap/match.ts @@ -0,0 +1,105 @@ +import { Position } from 'vscode'; +import { LeapSearchDirection } from './leap'; +import * as vscode from 'vscode'; +import { configuration } from '../../../configuration/configuration'; + +export interface Match { + position: Position; + searchString: string; +} + +const needEscapeStrings: string = '$()*+.[]?\\^{}|'; +function escapeString(str: string) { + return needEscapeStrings.includes(str) ? '\\' + str : str; +} + +function getFlags() { + const caseSensitiveFlag = configuration.leapCaseSensitive ? '' : 'i'; + return `g${caseSensitiveFlag}`; +} + +export function generatePrepareRegex(rawSearchString: string) { + const searchCharacter = escapeString(rawSearchString); + const pattern = `${searchCharacter}[\\s\\S]|${searchCharacter}$`; + return new RegExp(pattern, getFlags()); +} + +export function generateMarkerRegex(searchString: string) { + function getPattern(rawSearchString: string) { + const searchChars = rawSearchString.split('').map(escapeString); + + const firstChar = searchChars[0]; + const secondChar = searchChars[1]; + let pattern = ''; + if (secondChar === ' ') { + pattern = firstChar + '\\s' + '|' + firstChar + '$'; + } else { + pattern = firstChar + secondChar; + } + + return pattern; + } + + return new RegExp(getPattern(searchString), getFlags()); +} + +const MATCH_LINE_COUNT = 50; +const MATCH_COUNT_LIMIT = 400; +export function getMatches( + searchRegex: RegExp, + direction: LeapSearchDirection, + position: Position, + document: vscode.TextDocument +) { + const matches: Match[] = []; + + const lineStart = position.line; + const lineEnd = + direction === LeapSearchDirection.Backward + ? Math.min(position.line + MATCH_LINE_COUNT, document.lineCount - 1) + : Math.max(position.line - MATCH_LINE_COUNT, 0); + + function checkPosition(lineCount: number, index: number) { + return ( + (lineCount === lineStart && + (direction === LeapSearchDirection.Forward + ? index < position.character - 1 + : index > position.character)) || + lineCount !== lineStart + ); + } + + function calcCurrentLineMatches(lineCount: number) { + const lineText = document.lineAt(lineCount).text; + let result = searchRegex.exec(lineText); + const lineMatches: Match[] = []; + + while (result) { + if (checkPosition(lineCount, result.index)) { + const pos = new Position(lineCount, result.index); + const rawText = result[0].length === 1 ? result[0] + ' ' : result[0]; + const searchString = configuration.leapCaseSensitive ? rawText : rawText.toLowerCase(); + lineMatches.push({ position: pos, searchString }); + if (searchString[0] === searchString[1]) { + searchRegex.lastIndex--; + } + } + result = searchRegex.exec(lineText); + } + + return lineMatches; + } + + const s = direction === LeapSearchDirection.Backward ? lineStart : lineEnd; + const e = direction === LeapSearchDirection.Backward ? lineEnd : lineStart; + + for (let i = s; i <= e; i++) { + matches.push(...calcCurrentLineMatches(i)); + } + + if (direction === LeapSearchDirection.Forward) matches.reverse(); + + if (matches.length > MATCH_COUNT_LIMIT) matches.length = MATCH_COUNT_LIMIT; + + return matches; +} diff --git a/src/actions/plugins/leap/register.ts b/src/actions/plugins/leap/register.ts new file mode 100644 index 00000000000..155043b4667 --- /dev/null +++ b/src/actions/plugins/leap/register.ts @@ -0,0 +1,2 @@ +import './LeapAction'; +import './MoveLeapAction'; diff --git a/src/actions/plugins/sneak.ts b/src/actions/plugins/sneak.ts index 17ee52f6ad7..03a3b9fd486 100644 --- a/src/actions/plugins/sneak.ts +++ b/src/actions/plugins/sneak.ts @@ -12,6 +12,10 @@ export class SneakForward extends BaseMovement { ]; override isJump = true; + public override doesActionApply(vimState: VimState, keysPressed: string[]) { + return super.doesActionApply(vimState, keysPressed) && configuration.sneak; + } + public override couldActionApply(vimState: VimState, keysPressed: string[]): boolean { const startingLetter = vimState.recordedState.operator === undefined ? 's' : 'z'; @@ -79,6 +83,10 @@ export class SneakBackward extends BaseMovement { ]; override isJump = true; + public override doesActionApply(vimState: VimState, keysPressed: string[]) { + return super.doesActionApply(vimState, keysPressed) && configuration.sneak; + } + public override couldActionApply(vimState: VimState, keysPressed: string[]): boolean { const startingLetter = vimState.recordedState.operator === undefined ? 'S' : 'Z'; diff --git a/src/configuration/configuration.ts b/src/configuration/configuration.ts index b2d4f34242d..ebbea80da26 100644 --- a/src/configuration/configuration.ts +++ b/src/configuration/configuration.ts @@ -261,6 +261,11 @@ class Configuration implements IConfiguration { easymotionKeys = 'hklyuiopnm,qwertzxcvbasdgjf;'; easymotionJumpToAnywhereRegex = '\\b[A-Za-z0-9]|[A-Za-z0-9]\\b|_.|#.|[a-z][A-Z]'; + leap = false; + leapShowMarkerPosition = 'after'; + leapLabels = 'sklyuiopnm,qwertzxcvbahdgjf;'; + leapCaseSensitive = false; + targets: ITargetsConfiguration = { enable: false, diff --git a/src/error.ts b/src/error.ts index d7fb9a1db99..88fc9fa4fb5 100644 --- a/src/error.ts +++ b/src/error.ts @@ -35,6 +35,8 @@ export enum ErrorCode { AtEndOfChangeList = 663, ChangeListIsEmpty = 664, NoPreviouslyUsedRegister = 748, + LeapNoPreviousSearch = 800, + LeapNoFoundSearchString = 801, } export const ErrorMessage: IErrorMessage = { @@ -70,6 +72,8 @@ export const ErrorMessage: IErrorMessage = { 663: 'At end of changelist', 664: 'changelist is empty', 748: 'No previously used register', + 800: 'No previous search', + 801: 'Not found', }; export class VimError extends Error { diff --git a/src/mode/mode.ts b/src/mode/mode.ts index fdd14c2dbe7..6e31237ab1f 100644 --- a/src/mode/mode.ts +++ b/src/mode/mode.ts @@ -15,6 +15,8 @@ export enum Mode { SurroundInputMode, OperatorPendingMode, // Pseudo-Mode, used only when remapping. DON'T SET TO THIS MODE Disabled, + LeapPrepareMode, + LeapMode, } export enum VSCodeVimCursorType { diff --git a/src/mode/modeHandler.ts b/src/mode/modeHandler.ts index ae208160a66..b97bd90244f 100644 --- a/src/mode/modeHandler.ts +++ b/src/mode/modeHandler.ts @@ -1645,6 +1645,10 @@ export class ModeHandler implements vscode.Disposable, IModeHandler { this.vimState.easyMotion.updateDecorations(this.vimState.editor); } + if (this.currentMode !== Mode.LeapPrepareMode && this.currentMode !== Mode.LeapMode) { + this.vimState.leap?.cleanupMarkers(); + } + StatusBar.clear(this.vimState, false); // NOTE: this is not being awaited to save the 15-20ms block - I think this is fine @@ -1727,6 +1731,10 @@ function getCursorType(vimState: VimState, mode: Mode): VSCodeVimCursorType { return VSCodeVimCursorType.Block; case Mode.EasyMotionInputMode: return VSCodeVimCursorType.Block; + case Mode.LeapPrepareMode: + return VSCodeVimCursorType.Block; + case Mode.LeapMode: + return VSCodeVimCursorType.Block; case Mode.SurroundInputMode: return getCursorType(vimState, vimState.surround!.previousMode); case Mode.OperatorPendingMode: diff --git a/src/state/vimState.ts b/src/state/vimState.ts index 07d998873c8..286d5275a13 100644 --- a/src/state/vimState.ts +++ b/src/state/vimState.ts @@ -3,6 +3,7 @@ import * as vscode from 'vscode'; import { IMovement } from '../actions/baseMotion'; import { configuration } from '../configuration/configuration'; import { IEasyMotion } from '../actions/plugins/easymotion/types'; +import { Leap } from '../actions/plugins/leap/leap'; import { HistoryTracker } from './../history/historyTracker'; import { Logger } from '../util/logger'; import { Mode } from '../mode/mode'; @@ -61,6 +62,8 @@ export class VimState implements vscode.Disposable { public easyMotion: IEasyMotion; + public leap!: Leap; + public readonly documentUri: vscode.Uri; public editor: vscode.TextEditor; diff --git a/src/statusBar.ts b/src/statusBar.ts index bb388a98c9b..f0954036656 100644 --- a/src/statusBar.ts +++ b/src/statusBar.ts @@ -185,6 +185,10 @@ export function statusBarText(vimState: VimState) { return '-- EASYMOTION --'; case Mode.EasyMotionInputMode: return '-- EASYMOTION INPUT --'; + case Mode.LeapMode: + return '-- LEAP --'; + case Mode.LeapPrepareMode: + return '-- LEAP PREPARE --'; case Mode.SurroundInputMode: return '-- SURROUND INPUT --'; case Mode.Disabled: @@ -252,6 +256,14 @@ export function statusBarCommandText(vimState: VimState): string { case Mode.Insert: case Mode.Replace: return vimState.recordedState.pendingCommandString; + case Mode.LeapPrepareMode: + case Mode.LeapMode: + if (vimState.leap?.isRepeatLastSearch) { + const searchString = + vimState.leap.firstSearchString + vimState.leap.leapAction!.keysPressed[0]; + return 's' + searchString; + } + return vimState.recordedState.commandString; case Mode.Normal: case Mode.Disabled: return vimState.recordedState.commandString; diff --git a/test/plugins/leap.test.ts b/test/plugins/leap.test.ts new file mode 100644 index 00000000000..a044fa1bbbf --- /dev/null +++ b/test/plugins/leap.test.ts @@ -0,0 +1,129 @@ +import { Configuration } from '../testConfiguration'; +import { newTest } from '../testSimplifier'; +import { cleanUpWorkspace, setupWorkspace } from '../testUtils'; + +function leapCommand(key: 's' | 'S', searchStrings: string[], jumpKeys: string[] = []) { + return [key, ...searchStrings, ...jumpKeys].join(''); +} + +suite('leap plugin', () => { + setup(async () => { + const configuration = new Configuration(); + configuration.leap = true; + + await setupWorkspace(configuration); + }); + + teardown(cleanUpWorkspace); + + suite('to backward', async () => { + newTest({ + title: 'move first marker position', + start: ['|cccabcabfg'], + keysPressed: leapCommand('s', ['a', 'b'], ['s']), + end: ['ccc|abcabfg'], + }); + + newTest({ + title: 'move second marker position', + start: ['|cccabcabfg'], + keysPressed: leapCommand('s', ['a', 'b'], ['k']), + end: ['cccabc|abfg'], + }); + + newTest({ + title: 'marker name is not exist', + start: ['|cccabcabfg'], + keysPressed: leapCommand('s', ['a', 'b'], ['l']), + end: ['|cccabcabfg'], + }); + + newTest({ + title: 'directly jump target position when only have one marker', + start: ['|cccabcabfg'], + keysPressed: leapCommand('s', ['f', 'g']), + end: ['cccabcab|fg'], + }); + + newTest({ + title: 'cursor should not change when no marker', + start: ['|cccabcabfg'], + keysPressed: leapCommand('s', ['z', 'z']), + end: ['|cccabcabfg'], + }); + }); + + suite('to forward', async () => { + newTest({ + title: 'move first marker position', + start: ['cccabcabf|g'], + keysPressed: leapCommand('S', ['a', 'b'], ['s']), + end: ['cccabc|abfg'], + }); + + newTest({ + title: 'move second marker position', + start: ['cccabcabf|g'], + keysPressed: leapCommand('S', ['a', 'b'], ['k']), + end: ['ccc|abcabfg'], + }); + + newTest({ + title: 'marker name is not exist', + start: ['cccabcabf|g'], + keysPressed: leapCommand('S', ['a', 'b'], ['z']), + end: ['cccabcabf|g'], + }); + + newTest({ + title: 'directly jump target position when only have one marker', + start: ['fgcccabca|b'], + keysPressed: leapCommand('S', ['f', 'g']), + end: ['|fgcccabcab'], + }); + + newTest({ + title: 'cursor should not change when no marker', + start: ['cccabcabf|g'], + keysPressed: leapCommand('S', ['z', 'z']), + end: ['cccabcabf|g'], + }); + + newTest({ + title: 'should not match when cursor position is search string', + start: ['lsdjaflonkdjfjo|n'], + keysPressed: leapCommand('S', ['o', 'n'], ['s']), + end: ['lsdjafl|onkdjfjon'], + }); + }); + + suite('last repeat search', async () => { + newTest({ + title: 'to forward', + start: ['onabonabc|d'], + keysPressed: [...leapCommand('S', ['o', 'n'], ['s']), ...leapCommand('S', ['\n'])].join(''), + end: ['|onabonabcd'], + }); + + newTest({ + title: 'to backward', + start: ['|abonabonabcd'], + keysPressed: [...leapCommand('s', ['o', 'n'], ['s']), ...leapCommand('s', ['\n'])].join(''), + end: ['abonab|onabcd'], + }); + + newTest({ + title: 'different direction', + start: ['|abonabonab'], + keysPressed: [...leapCommand('s', ['o', 'n'], ['k']), ...leapCommand('S', ['\n'])].join(''), + end: ['ab|onabonab'], + }); + }); + + newTest({ + title: 'continuous two matching characters', + start: ['|boolean'], + keysPressed: leapCommand('s', ['o', 'l']), + end: ['bo|olean'], + }); +}); diff --git a/test/testConfiguration.ts b/test/testConfiguration.ts index 504fcfa8fc6..367b5d92eee 100644 --- a/test/testConfiguration.ts +++ b/test/testConfiguration.ts @@ -26,6 +26,10 @@ export class Configuration implements IConfiguration { sneak = false; sneakUseIgnorecaseAndSmartcase = false; sneakReplacesF = false; + leap = false; + leapShowMarkerPosition = 'after'; + leapLabels = 'sklyuiopnm,qwertzxcvbahdgjf;'; + leapCaseSensitive = false; surround = false; argumentObjectSeparators = [',']; argumentObjectOpeningDelimiters = ['(', '['];