Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,12 +197,15 @@ const vimPlugin = ViewPlugin.fromClass(
decorations = Decoration.none;
waitForCopy = false;
handleKey(e: KeyboardEvent, view: EditorView) {
const key = CodeMirror.vimKey(e);
const cm = this.cm;
if (!key) return;
const rawKey = CodeMirror.vimKey(e);
if (!rawKey) return;

const cm = this.cm;
let vim = cm.state.vim;
if (!vim) return;

const key = vim.expectLiteralNext ? rawKey : Vim.langmapRemapKey(rawKey);

// clear search highlight
if (
key == "<Esc>" &&
Expand Down
118 changes: 103 additions & 15 deletions src/vim.js
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,8 @@ export function initVim(CodeMirror) {
{ keys: 'T<character>', type: 'motion', motion: 'moveTillCharacter', motionArgs: { forward: false }},
{ keys: ';', type: 'motion', motion: 'repeatLastCharacterSearch', motionArgs: { forward: true }},
{ keys: ',', type: 'motion', motion: 'repeatLastCharacterSearch', motionArgs: { forward: false }},
{ keys: '\'<character>', type: 'motion', motion: 'goToMark', motionArgs: {toJumplist: true, linewise: true}},
{ keys: '`<character>', type: 'motion', motion: 'goToMark', motionArgs: {toJumplist: true}},
{ keys: '\'<register>', type: 'motion', motion: 'goToMark', motionArgs: {toJumplist: true, linewise: true}},
{ keys: '`<register>', type: 'motion', motion: 'goToMark', motionArgs: {toJumplist: true}},
{ keys: ']`', type: 'motion', motion: 'jumpToMark', motionArgs: { forward: true } },
{ keys: '[`', type: 'motion', motion: 'jumpToMark', motionArgs: { forward: false } },
{ keys: ']\'', type: 'motion', motion: 'jumpToMark', motionArgs: { forward: true, linewise: true } },
Expand Down Expand Up @@ -220,18 +220,18 @@ export function initVim(CodeMirror) {
{ keys: 'p', type: 'action', action: 'paste', isEdit: true, actionArgs: { after: true, isEdit: true }},
{ keys: 'P', type: 'action', action: 'paste', isEdit: true, actionArgs: { after: false, isEdit: true }},
{ keys: 'r<character>', type: 'action', action: 'replace', isEdit: true },
{ keys: '@<character>', type: 'action', action: 'replayMacro' },
{ keys: 'q<character>', type: 'action', action: 'enterMacroRecordMode' },
{ keys: '@<register>', type: 'action', action: 'replayMacro' },
{ keys: 'q<register>', type: 'action', action: 'enterMacroRecordMode' },
// Handle Replace-mode as a special case of insert mode.
{ keys: 'R', type: 'action', action: 'enterInsertMode', isEdit: true, actionArgs: { replace: true }, context: 'normal'},
{ keys: 'R', type: 'operator', operator: 'change', operatorArgs: { linewise: true, fullLine: true }, context: 'visual', exitVisualBlock: true},
{ keys: 'u', type: 'action', action: 'undo', context: 'normal' },
{ keys: 'u', type: 'operator', operator: 'changeCase', operatorArgs: {toLower: true}, context: 'visual', isEdit: true },
{ keys: 'U', type: 'operator', operator: 'changeCase', operatorArgs: {toLower: false}, context: 'visual', isEdit: true },
{ keys: '<C-r>', type: 'action', action: 'redo' },
{ keys: 'm<character>', type: 'action', action: 'setMark' },
{ keys: '"<character>', type: 'action', action: 'setRegister' },
{ keys: '<C-r><character>', type: 'action', action: 'insertRegister', context: 'insert', isEdit: true },
{ keys: 'm<register>', type: 'action', action: 'setMark' },
{ keys: '"<register>', type: 'action', action: 'setRegister' },
{ keys: '<C-r><register>', type: 'action', action: 'insertRegister', context: 'insert', isEdit: true },
Comment thread
Opisek marked this conversation as resolved.
{ keys: '<C-o>', type: 'action', action: 'oneNormalCommand', context: 'insert' },
{ keys: 'zz', type: 'action', action: 'scrollToCursor', actionArgs: { position: 'center' }},
{ keys: 'z.', type: 'action', action: 'scrollToCursor', actionArgs: { position: 'center' }, motion: 'moveToFirstNonWhiteSpaceCharacter' },
Expand All @@ -245,8 +245,8 @@ export function initVim(CodeMirror) {
{ keys: '<C-t>', type: 'action', action: 'indent', actionArgs: { indentRight: true }, context: 'insert' },
{ keys: '<C-d>', type: 'action', action: 'indent', actionArgs: { indentRight: false }, context: 'insert' },
// Text object motions
{ keys: 'a<character>', type: 'motion', motion: 'textObjectManipulation' },
{ keys: 'i<character>', type: 'motion', motion: 'textObjectManipulation', motionArgs: { textObjectInner: true }},
{ keys: 'a<register>', type: 'motion', motion: 'textObjectManipulation' },
{ keys: 'i<register>', type: 'motion', motion: 'textObjectManipulation', motionArgs: { textObjectInner: true }},
// Search
{ keys: '/', type: 'search', searchArgs: { forward: true, querySrc: 'prompt', toJumplist: true }},
{ keys: '?', type: 'search', searchArgs: { forward: false, querySrc: 'prompt', toJumplist: true }},
Expand Down Expand Up @@ -303,6 +303,14 @@ export function initVim(CodeMirror) {
{ name: 'global', shortName: 'g' }
];

/**
* Langmap
* Determines how to interpret keystrokes in Normal and Visual mode.
* Useful for people who use a different keyboard layout than QWERTY
*/
var langmap;
updateLangmap('');

function enterVimMode(cm) {
cm.setOption('disableInput', true);
cm.setOption('showCursorWhenSelecting', false);
Expand Down Expand Up @@ -733,7 +741,11 @@ export function initVim(CodeMirror) {
lastPastedText: null,
sel: {},
// Buffer-local/window-local values of vim options.
options: {}
options: {},
// Whether the next character should be interpreted literally
// Necassary for correct implementation of f<character>, r<character> etc.
// in terms of langmaps.
expectLiteralNext: false
};
}
return cm.state.vim;
Expand Down Expand Up @@ -836,6 +848,12 @@ export function initVim(CodeMirror) {
}
}
},
langmap: function(langmapString, remapCtrl = true) {
updateLangmap(langmapString, remapCtrl);
},
langmapRemapKey: function(key) {
return langmapRemapKey(key);
},
// TODO: Expose setOption and getOption as instance methods. Need to decide how to namespace
// them, or somehow make them work with the existing CodeMirror setOption/getOption API.
setOption: setOption,
Expand Down Expand Up @@ -870,6 +888,7 @@ export function initVim(CodeMirror) {
*/
findKey: function(cm, key, origin) {
var vim = maybeInitVimState(cm);

function handleMacroRecording() {
var macroModeState = vimGlobalState.macroModeState;
if (macroModeState.isRecording) {
Expand Down Expand Up @@ -910,6 +929,7 @@ export function initVim(CodeMirror) {

if (match.type == 'none') { clearInputState(cm); return false; }
else if (match.type == 'partial') {
if (match.expectLiteralNext) vim.expectLiteralNext = true;
if (lastInsertModeKeyTimer) { window.clearTimeout(lastInsertModeKeyTimer); }
lastInsertModeKeyTimer = keysAreChars && window.setTimeout(
function() { if (vim.insertMode && vim.inputState.keyBuffer.length) { clearInputState(cm); } },
Expand All @@ -928,6 +948,7 @@ export function initVim(CodeMirror) {
}
return !keysAreChars;
}
vim.expectLiteralNext = false;

if (lastInsertModeKeyTimer) { window.clearTimeout(lastInsertModeKeyTimer); }
if (match.command && changeQueue) {
Expand Down Expand Up @@ -961,8 +982,12 @@ export function initVim(CodeMirror) {
}
var match = commandDispatcher.matchCommand(mainKey, defaultKeymap, vim.inputState, context);
if (match.type == 'none') { clearInputState(cm); return false; }
else if (match.type == 'partial') { return true; }
else if (match.type == 'partial') {
if (match.expectLiteralNext) vim.expectLiteralNext = true;
return true;
}
else if (match.type == 'clear') { clearInputState(cm); return true; }
vim.expectLiteralNext = false;

vim.inputState.keyBuffer.length = 0;
keysMatcher = /^(\d*)(.*)$/.exec(keys);
Expand Down Expand Up @@ -1123,6 +1148,63 @@ export function initVim(CodeMirror) {
}
}

// langmap support
function updateLangmap(langmapString, remapCtrl = true) {
if (langmap != null && langmap.string == langmapString) {
langmap.remapCtrl = remapCtrl;
return;
}
langmap = parseLangmap(langmapString, remapCtrl);
}
function langmapIsLiteralMode(vim) {
// Determine if keystrokes should be interpreted literally
return vim.insertMode;
}
function parseLangmap(langmapString, remapCtrl) {
// From :help langmap
/*
The 'langmap' option is a list of parts, separated with commas. Each
part can be in one of two forms:
1. A list of pairs. Each pair is a "from" character immediately
followed by the "to" character. Examples: "aA", "aAbBcC".
2. A list of "from" characters, a semi-colon and a list of "to"
characters. Example: "abc;ABC"
*/

let keymap = {};
if (langmapString == '') return { keymap: keymap, string: '', remapCtrl: remapCtrl };

function getEscaped(list) {
return list.split(/\\?(.)/).filter(Boolean);
}
langmapString.split(/((?:[^\\,]|\\.)+),/).map(part => {
if (!part) return;
const semicolon = part.split(/((?:[^\\;]|\\.)+);/);
if (semicolon.length == 3) {
const from = getEscaped(semicolon[1]);
const to = getEscaped(semicolon[2]);
if (from.length !== to.length) return; // skip over malformed part
for (let i = 0; i < from.length; ++i) keymap[from[i]] = to[i];
} else if (semicolon.length == 1) {
const pairs = getEscaped(part);
if (pairs.length % 2 !== 0) return; // skip over malformed part
for (let i = 0; i < pairs.length; i += 2) keymap[pairs[i]] = pairs[i + 1];
}
});

return { keymap: keymap, string: langmapString, remapCtrl: remapCtrl };
}
function langmapRemapKey(key) {
console.log(`remapCtrl: ${langmap.remapCtrl}`);;
if (key.length == 1) {
return key in langmap.keymap ? langmap.keymap[key] : key;
} else if (langmap.remapCtrl && key.match(/<C-.>/)) {
return key[3] in langmap.keymap ? `<C-${langmap.keymap[key[3]]}>` : key;
} else {
return key;
}
}

// Represents the current input state.
function InputState() {
this.prefixRepeat = [];
Expand Down Expand Up @@ -1159,6 +1241,7 @@ export function initVim(CodeMirror) {

function clearInputState(cm, reason) {
cm.state.vim.inputState = new InputState();
cm.state.vim.expectLiteralNext = false;
CodeMirror.signal(cm, 'vim-command-done', reason);
}

Expand Down Expand Up @@ -1366,7 +1449,10 @@ export function initVim(CodeMirror) {
if (!matches.full && !matches.partial) {
return {type: 'none'};
} else if (!matches.full && matches.partial) {
return {type: 'partial'};
return {
type: 'partial',
expectLiteralNext: matches.partial.length == 1 && matches.partial[0].keys.slice(-11) == '<character>' // langmap literal logic
};
}

var bestMatch;
Expand All @@ -1376,7 +1462,7 @@ export function initVim(CodeMirror) {
bestMatch = match;
}
}
if (bestMatch.keys.slice(-11) == '<character>') {
if (bestMatch.keys.slice(-11) == '<character>' || bestMatch.keys.slice(-10) == '<register>') {
var character = lastChar(keys);
if (!character || character.length > 1) return {type: 'clear'};
inputState.selectedCharacter = character;
Expand Down Expand Up @@ -3197,9 +3283,11 @@ export function initVim(CodeMirror) {
};
}
function commandMatch(pressed, mapped) {
if (mapped.slice(-11) == '<character>') {
const isLastCharacter = mapped.slice(-11) == '<character>';
const isLastRegister = mapped.slice(-10) == '<register>';
if (isLastCharacter || isLastRegister) {
// Last character matches anything.
var prefixLen = mapped.length - 11;
var prefixLen = mapped.length - (isLastCharacter ? 11 : 10);
var pressedPrefix = pressed.slice(0, prefixLen);
var mappedPrefix = mapped.slice(0, prefixLen);
return pressedPrefix == mappedPrefix && pressed.length > prefixLen ? 'full' :
Expand Down
106 changes: 106 additions & 0 deletions test/vim_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5557,6 +5557,112 @@ testVim('<C-r>_insert_mode', function(cm, vim, helpers) {
eq('456 123 ', cm.getValue());
}, { value: '123 456 ' });

//
// test correct langmap function
//
const dvorakLangmap = "'q,\\,w,.e,pr,yt,fy,gu,ci,ro,lp,/[,=],aa,os,ed,uf,ig,dh,hj,tk,nl,s\\;,-',\\;z,qx,jc,kv,xb,bn,mm,w\\,,v.,z/,[-,]=,\"Q,<W,>E,PR,YT,FY,GU,CI,RO,LP,?{,+},AA,OS,ED,UF,IG,DH,HJ,TK,NL,S:,_\",:Z,QX,JC,KV,XB,BN,MM,W<,V>,Z?";
// this test makes sure that remapping works on an example binding
testVim('langmap_dd', function(cm, vim, helpers) {
CodeMirror.Vim.langmap(dvorakLangmap);

cm.setCursor(0, 3);
var expectedBuffer = cm.getRange(new Pos(0, 0),
new Pos(1, 0));
var expectedLineCount = cm.lineCount() - 1;

helpers.doKeys('e', 'e');

eq(expectedLineCount, cm.lineCount());
var register = helpers.getRegisterController().getRegister();
eq(expectedBuffer, register.toString());
is(register.linewise);
helpers.assertCursorAt(0, lines[1].textStart);
});
// this test serves two functions:
// - make sure that "dd" is **not** interpreted as delete line (correct unmapping)
// - make sure that "dd" **is** interpreted as move left twice (correct mapping)
testVim('langmap_hh', function(cm, vim, helpers) {
CodeMirror.Vim.langmap(dvorakLangmap);

const startPos = word1.end;
const endPos = offsetCursor(word1.end, 0, -2);

cm.setCursor(startPos);
helpers.doKeys('d', 'd');
helpers.assertCursorAt(endPos);
});
// this test serves two functions:
// - make sure tha the register is properly remapped so that special registers aren't mixed up
// - make sure that recording and replaying macros works without "double remapping"
testVim('langmap_qqddq@q', function(cm, vim, helpers) {
CodeMirror.Vim.langmap(dvorakLangmap);

cm.setCursor(0, 3);
var expectedBuffer = cm.getRange(new Pos(1, 0),
new Pos(2, 0));
var expectedLineCount = cm.lineCount() - 2;

helpers.doKeys('\'\'', 'e', 'e', '\'', '@\'');

eq(expectedLineCount, cm.lineCount());
var register = helpers.getRegisterController().getRegister();
eq(expectedBuffer, register.toString());
is(register.linewise);
helpers.assertCursorAt(0, lines[2].textStart);
});
// this test makes sure that <character> directives are interpreted literally
testVim('langmap_fd', function(cm, vim, helpers) {
CodeMirror.Vim.langmap(dvorakLangmap);

cm.setCursor(0, 0);
helpers.doKeys('u', 'd');
helpers.assertCursorAt(0, 4);
});
// this test makes sure that markers work properly
testVim('langmap_mark', function(cm, vim, helpers) {
CodeMirror.Vim.langmap(dvorakLangmap);

cm.setCursor(2, 2);
helpers.doKeys('m', '\'');
cm.setCursor(0, 0);
helpers.doKeys('`', '\'');
helpers.assertCursorAt(2, 2);
cm.setCursor(2, 0);
cm.replaceRange(' h', cm.getCursor());
cm.setCursor(0, 0);
helpers.doKeys('-', '\'');
helpers.assertCursorAt(2, 3);
});
// check that ctrl remapping works properly
testVim('langmap_visual_block', function(cm, vim, helpers) {
CodeMirror.Vim.langmap(dvorakLangmap);

cm.setCursor(0, 1);
helpers.doKeys('<C-k>', '2', 'h', 'n', 'n', 'n', 'j');
helpers.doKeys('hello');
eq('1hello\n5hello\nahellofg', cm.getValue());
helpers.doKeys('<Esc>');
cm.setCursor(2, 3);
helpers.doKeys('<C-k>', '2', 't', 'd', 'J');
helpers.doKeys('world');
eq('1hworld\n5hworld\nahworld', cm.getValue());
}, {value: '1234\n5678\nabcdefg'});
// check that ctrl remapping can be disabled
testVim('langmap_visual_block_no_ctrl_remap', function(cm, vim, helpers) {
CodeMirror.Vim.langmap(dvorakLangmap, false);

cm.setCursor(0, 1);
helpers.doKeys('<C-v>', '2', 'h', 'n', 'n', 'n', 'j');
helpers.doKeys('hello');
eq('1hello\n5hello\nahellofg', cm.getValue());
helpers.doKeys('<Esc>');
cm.setCursor(2, 3);
helpers.doKeys('<C-v>', '2', 't', 'd', 'J');
helpers.doKeys('world');
eq('1hworld\n5hworld\nahworld', cm.getValue());
}, {value: '1234\n5678\nabcdefg'});


async function delay(t) {
return await new Promise(resolve => setTimeout(resolve, t));
}
Expand Down