From b6da8bf16b55bff0734b14f9eeb636f35b03e124 Mon Sep 17 00:00:00 2001 From: Howard Jing Date: Mon, 7 Sep 2020 17:21:50 -0400 Subject: [PATCH] Add vim emulation support for `gn` and `gN`. If we are given the following snippet of text: ``` A green green sky. _ ``` We can search for the word "green" with `/green`, and then use `gn` to select the next occurrence of "green" in visual mode. ``` A green green sky. ----- ``` Alternatively, we can use `cgn` and then enter the word "blue" to change the word "green" to "blue". ``` A blue green sky. ``` Then we can use the `.` operator to repeat the change: ``` A blue blue sky. ``` Addresses #3851. --- demo/vim.html | 7 +- keymap/vim.js | 149 +++++++++++++++++++++++++++++++++++++---- test/vim_test.js | 169 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 311 insertions(+), 14 deletions(-) diff --git a/demo/vim.html b/demo/vim.html index 2e874cdfa..172ab6f04 100644 --- a/demo/vim.html +++ b/demo/vim.html @@ -56,6 +56,7 @@ int getchar(void) }
Key buffer:
+
Vim mode:

The vim keybindings are enabled by including keymap/vim.js and setting the @@ -101,12 +102,16 @@ become a complete vim implementation

var keys = ''; CodeMirror.on(editor, 'vim-keypress', function(key) { keys = keys + key; - commandDisplay.innerHTML = keys; + commandDisplay.innerText = keys; }); CodeMirror.on(editor, 'vim-command-done', function(e) { keys = ''; commandDisplay.innerHTML = keys; }); + var vimMode = document.getElementById('vim-mode'); + CodeMirror.on(editor, 'vim-mode-change', function(e) { + vimMode.innerText = JSON.stringify(e); + }); diff --git a/keymap/vim.js b/keymap/vim.js index aca99cfbb..789e1e55b 100644 --- a/keymap/vim.js +++ b/keymap/vim.js @@ -141,6 +141,8 @@ { keys: 'gU', type: 'operator', operator: 'changeCase', operatorArgs: {toLower: false}, isEdit: true }, { keys: 'n', type: 'motion', motion: 'findNext', motionArgs: { forward: true, toJumplist: true }}, { keys: 'N', type: 'motion', motion: 'findNext', motionArgs: { forward: false, toJumplist: true }}, + { keys: 'gn', type: 'motion', motion: 'findAndSelectNextInclusive', motionArgs: { forward: true }}, + { keys: 'gN', type: 'motion', motion: 'findAndSelectNextInclusive', motionArgs: { forward: false }}, // Operator-Motion dual commands { keys: 'x', type: 'operatorMotion', operator: 'delete', motion: 'moveByCharacters', motionArgs: { forward: true }, operatorMotionArgs: { visualLine: false }}, { keys: 'X', type: 'operatorMotion', operator: 'delete', motion: 'moveByCharacters', motionArgs: { forward: false }, operatorMotionArgs: { visualLine: true }}, @@ -1576,7 +1578,7 @@ motionArgs.repeat = repeat; clearInputState(cm); if (motion) { - var motionResult = motions[motion](cm, origHead, motionArgs, vim); + var motionResult = motions[motion](cm, origHead, motionArgs, vim, inputState); vim.lastMotion = motions[motion]; if (!motionResult) { return; @@ -1774,6 +1776,87 @@ highlightSearchMatches(cm, query); return findNext(cm, prev/** prev */, query, motionArgs.repeat); }, + /** + * Find and select the next occurrence of the search query. If the cursor is currently + * within a match, then find and select the current match. Otherwise, find the next occurrence in the + * appropriate direction. + * + * This differs from `findNext` in the following ways: + * + * 1. Instead of only returning the "from", this returns a "from", "to" range. + * 2. If the cursor is currently inside a search match, this selects the current match + * instead of the next match. + * 3. If there is no associated operator, this will turn on visual mode. + */ + findAndSelectNextInclusive: function(cm, _head, motionArgs, vim, prevInputState) { + var state = getSearchState(cm); + var query = state.getQuery(); + + if (!query) { + return; + } + + var prev = !motionArgs.forward; + prev = (state.isReversed()) ? !prev : prev; + + // next: [from, to] | null + var next = findNextFromAndToInclusive(cm, prev, query, motionArgs.repeat, vim); + + // No matches. + if (!next) { + return; + } + + // If there's an operator that will be executed, return the selection. + if (prevInputState.operator) { + return next; + } + + // At this point, we know that there is no accompanying operator -- let's + // deal with visual mode in order to select an appropriate match. + + var from = next[0]; + // For whatever reason, when we use the "to" as returned by searchcursor.js directly, + // the resulting selection is extended by 1 char. Let's shrink it so that only the + // match is selected. + var to = Pos(next[1].line, next[1].ch - 1); + + if (vim.visualMode) { + // If we were in visualLine or visualBlock mode, get out of it. + if (vim.visualLine || vim.visualBlock) { + vim.visualLine = false; + vim.visualBlock = false; + CodeMirror.signal(cm, "vim-mode-change", {mode: "visual", subMode: ""}); + } + + // If we're currently in visual mode, we should extend the selection to include + // the search result. + var anchor = vim.sel.anchor; + if (anchor) { + if (state.isReversed()) { + if (motionArgs.forward) { + return [anchor, from]; + } + + return [anchor, to]; + } else { + if (motionArgs.forward) { + return [anchor, to]; + } + + return [anchor, from]; + } + } + } else { + // Let's turn visual mode on. + vim.visualMode = true; + vim.visualLine = false; + vim.visualBlock = false; + CodeMirror.signal(cm, "vim-mode-change", {mode: "visual", subMode: ""}); + } + + return prev ? [to, from] : [from, to]; + }, goToMark: function(cm, _head, motionArgs, vim) { var pos = getMarkPos(cm, vim, motionArgs.selectedCharacter); if (pos) { @@ -1869,8 +1952,8 @@ // move to previous/next line is triggered. if (line < first && cur.line == first){ return this.moveToStartOfLine(cm, head, motionArgs, vim); - }else if (line > last && cur.line == last){ - return this.moveToEol(cm, head, motionArgs, vim, true); + } else if (line > last && cur.line == last){ + return moveToEol(cm, head, motionArgs, vim, true); } if (motionArgs.toFirstChar){ endCh=findFirstNonWhiteSpaceCharacter(cm.getLine(line)); @@ -1972,16 +2055,8 @@ vim.lastHSPos = cm.charCoords(head,'div').left; return moveToColumn(cm, repeat); }, - moveToEol: function(cm, head, motionArgs, vim, keepHPos) { - var cur = head; - var retval= Pos(cur.line + motionArgs.repeat - 1, Infinity); - var end=cm.clipPos(retval); - end.ch--; - if (!keepHPos) { - vim.lastHPos = Infinity; - vim.lastHSPos = cm.charCoords(end,'div').left; - } - return retval; + moveToEol: function(cm, head, motionArgs, vim) { + return moveToEol(cm, head, motionArgs, vim, false); }, moveToFirstNonWhiteSpaceCharacter: function(cm, head) { // Go to the start of the line where the text begins, or the end for @@ -3609,6 +3684,18 @@ } } + function moveToEol(cm, head, motionArgs, vim, keepHPos) { + var cur = head; + var retval= Pos(cur.line + motionArgs.repeat - 1, Infinity); + var end=cm.clipPos(retval); + end.ch--; + if (!keepHPos) { + vim.lastHPos = Infinity; + vim.lastHSPos = cm.charCoords(end,'div').left; + } + return retval; + } + function moveToCharacter(cm, repeat, forward, character) { var cur = cm.getCursor(); var start = cur.ch; @@ -4350,6 +4437,42 @@ return cursor.from(); }); } + /** + * Pretty much the same as `findNext`, except for the following differences: + * + * 1. Before starting the search, move to the previous search. This way if our cursor is + * already inside a match, we should return the current match. + * 2. Rather than only returning the cursor's from, we return the cursor's from and to as a tuple. + */ + function findNextFromAndToInclusive(cm, prev, query, repeat, vim) { + if (repeat === undefined) { repeat = 1; } + return cm.operation(function() { + var pos = cm.getCursor(); + var cursor = cm.getSearchCursor(query, pos); + + // Go back one result to ensure that if the cursor is currently a match, we keep it. + var found = cursor.find(!prev); + + // If we haven't moved, go back one more (similar to if i==0 logic in findNext). + if (!vim.visualMode && found && cursorEqual(cursor.from(), pos)) { + cursor.find(!prev); + } + + for (var i = 0; i < repeat; i++) { + found = cursor.find(prev); + if (!found) { + // SearchCursor may have returned null because it hit EOF, wrap + // around and try again. + cursor = cm.getSearchCursor(query, + (prev) ? Pos(cm.lastLine()) : Pos(cm.firstLine(), 0) ); + if (!cursor.find(prev)) { + return; + } + } + } + return [cursor.from(), cursor.to()]; + }); + } function clearSearchHighlight(cm) { var state = getSearchState(cm); cm.removeOverlay(getSearchState(cm).getOverlay()); diff --git a/test/vim_test.js b/test/vim_test.js index 974304140..c75a1646c 100644 --- a/test/vim_test.js +++ b/test/vim_test.js @@ -2579,6 +2579,91 @@ testVim('/ and n/N', function(cm, vim, helpers) { helpers.doKeys('2', '/'); helpers.assertCursorAt(1, 6); }, { value: 'match nope match \n nope Match' }); +testVim('/ and gn selects the appropriate word', function(cm, vim, helpers) { + cm.openDialog = helpers.fakeOpenDialog('match'); + helpers.doKeys('/'); + helpers.assertCursorAt(0, 11); + + // gn should highlight the the current word while it is within a match. + + // gn when cursor is in beginning of match + helpers.doKeys('gn', ''); + helpers.assertCursorAt(0, 15); + + // gn when cursor is at end of match + helpers.doKeys('gn', ''); + helpers.doKeys(''); + helpers.assertCursorAt(0, 15); + + // consecutive gns should extend the selection + helpers.doKeys('gn'); + helpers.assertCursorAt(0, 16); + helpers.doKeys('gn'); + helpers.assertCursorAt(1, 11); + + // we should have selected the second and third "match" + helpers.doKeys('d'); + eq('match nope ', cm.getValue()); +}, { value: 'match nope match \n nope Match' }); +testVim('/ and gN selects the appropriate word', function(cm, vim, helpers) { + cm.openDialog = helpers.fakeOpenDialog('match'); + helpers.doKeys('/'); + helpers.assertCursorAt(0, 11); + + // gN when cursor is at beginning of match + helpers.doKeys('gN', ''); + helpers.assertCursorAt(0, 11); + + // gN when cursor is at end of match + helpers.doKeys('e', 'gN', ''); + helpers.assertCursorAt(0, 11); + + // consecutive gNs should extend the selection + helpers.doKeys('gN'); + helpers.assertCursorAt(0, 11); + helpers.doKeys('gN'); + helpers.assertCursorAt(0, 0); + + // we should have selected the first and second "match" + helpers.doKeys('d'); + eq(' \n nope Match', cm.getValue()); +}, { value: 'match nope match \n nope Match' }) +testVim('/ and gn with an associated operator', function(cm, vim, helpers) { + cm.openDialog = helpers.fakeOpenDialog('match'); + helpers.doKeys('/'); + helpers.assertCursorAt(0, 11); + + helpers.doKeys('c', 'gn', 'changed', ''); + + // change the current match. + eq('match nope changed \n nope Match', cm.getValue()); + + // change the next match. + helpers.doKeys('.'); + eq('match nope changed \n nope changed', cm.getValue()); + + // change the final match. + helpers.doKeys('.'); + eq('changed nope changed \n nope changed', cm.getValue()); +}, { value: 'match nope match \n nope Match' }); +testVim('/ and gN with an associated operator', function(cm, vim, helpers) { + cm.openDialog = helpers.fakeOpenDialog('match'); + helpers.doKeys('/'); + helpers.assertCursorAt(0, 11); + + helpers.doKeys('c', 'gN', 'changed', ''); + + // change the current match. + eq('match nope changed \n nope Match', cm.getValue()); + + // change the next match. + helpers.doKeys('.'); + eq('changed nope changed \n nope Match', cm.getValue()); + + // change the final match. + helpers.doKeys('.'); + eq('changed nope changed \n nope changed', cm.getValue()); +}, { value: 'match nope match \n nope Match' }); testVim('/_case', function(cm, vim, helpers) { cm.openDialog = helpers.fakeOpenDialog('Match'); helpers.doKeys('/'); @@ -2679,6 +2764,90 @@ testVim('? and n/N', function(cm, vim, helpers) { helpers.doKeys('2', '?'); helpers.assertCursorAt(0, 11); }, { value: 'match nope match \n nope Match' }); +testVim('? and gn selects the appropriate word', function(cm, vim, helpers) { + cm.openDialog = helpers.fakeOpenDialog('match'); + helpers.doKeys('?', 'n'); + helpers.assertCursorAt(0, 11); + + // gn should highlight the the current word while it is within a match. + + // gn when cursor is in beginning of match + helpers.doKeys('gn', ''); + helpers.assertCursorAt(0, 11); + + // gn when cursor is at end of match + helpers.doKeys('e', 'gn', ''); + helpers.assertCursorAt(0, 11); + + // consecutive gns should extend the selection + helpers.doKeys('gn'); + helpers.assertCursorAt(0, 11); + helpers.doKeys('gn'); + helpers.assertCursorAt(0, 0); + + // we should have selected the first and second "match" + helpers.doKeys('d'); + eq(' \n nope Match', cm.getValue()); +}, { value: 'match nope match \n nope Match' }); +testVim('? and gN selects the appropriate word', function(cm, vim, helpers) { + cm.openDialog = helpers.fakeOpenDialog('match'); + helpers.doKeys('?', 'n'); + helpers.assertCursorAt(0, 11); + + // gN when cursor is at beginning of match + helpers.doKeys('gN', ''); + helpers.assertCursorAt(0, 15); + + // gN when cursor is at end of match + helpers.doKeys('gN', ''); + helpers.assertCursorAt(0, 15); + + // consecutive gNs should extend the selection + helpers.doKeys('gN'); + helpers.assertCursorAt(0, 16); + helpers.doKeys('gN'); + helpers.assertCursorAt(1, 11); + + // we should have selected the second and third "match" + helpers.doKeys('d'); + eq('match nope ', cm.getValue()); +}, { value: 'match nope match \n nope Match' }) +testVim('? and gn with an associated operator', function(cm, vim, helpers) { + cm.openDialog = helpers.fakeOpenDialog('match'); + helpers.doKeys('?', 'n'); + helpers.assertCursorAt(0, 11); + + helpers.doKeys('c', 'gn', 'changed', ''); + + // change the current match. + eq('match nope changed \n nope Match', cm.getValue()); + + // change the next match. + helpers.doKeys('.'); + eq('changed nope changed \n nope Match', cm.getValue()); + + // change the final match. + helpers.doKeys('.'); + eq('changed nope changed \n nope changed', cm.getValue()); +}, { value: 'match nope match \n nope Match' }); +testVim('? and gN with an associated operator', function(cm, vim, helpers) { + cm.openDialog = helpers.fakeOpenDialog('match'); + helpers.doKeys('?', 'n'); + helpers.assertCursorAt(0, 11); + + helpers.doKeys('c', 'gN', 'changed', ''); + + // change the current match. + eq('match nope changed \n nope Match', cm.getValue()); + + // change the next match. + helpers.doKeys('.'); + eq('match nope changed \n nope changed', cm.getValue()); + + // change the final match. + helpers.doKeys('.'); + eq('changed nope changed \n nope changed', cm.getValue()); +}, { value: 'match nope match \n nope Match' }); testVim('*', function(cm, vim, helpers) { cm.setCursor(0, 9); helpers.doKeys('*');