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.
This commit is contained in:
Howard Jing 2020-09-07 17:21:50 -04:00 committed by Marijn Haverbeke
parent fd2e32250e
commit b6da8bf16b
3 changed files with 311 additions and 14 deletions

View File

@ -56,6 +56,7 @@ int getchar(void)
}
</textarea></form>
<div style="font-size: 13px; width: 300px; height: 30px;">Key buffer: <span id="command-display"></span></div>
<div style="font-size: 13px; width: 300px; height: 30px;">Vim mode: <span id="vim-mode"></span></div>
<p>The vim keybindings are enabled by including <code><a
href="../keymap/vim.js">keymap/vim.js</a></code> and setting the
@ -101,12 +102,16 @@ become a complete vim implementation</p>
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);
});
</script>
</article>

View File

@ -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());

View File

@ -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', '<Esc>');
helpers.assertCursorAt(0, 15);
// gn when cursor is at end of match
helpers.doKeys('gn', '<Esc>');
helpers.doKeys('<Esc>');
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', '<Esc>');
helpers.assertCursorAt(0, 11);
// gN when cursor is at end of match
helpers.doKeys('e', 'gN', '<Esc>');
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', '<Esc>');
// 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', '<Esc>');
// 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', '<Esc>');
helpers.assertCursorAt(0, 11);
// gn when cursor is at end of match
helpers.doKeys('e', 'gn', '<Esc>');
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', '<Esc>');
helpers.assertCursorAt(0, 15);
// gN when cursor is at end of match
helpers.doKeys('gN', '<Esc>');
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', '<Esc>');
// 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', '<Esc>');
// 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('*');