diff --git a/src/index.ts b/src/index.ts index 830537c..d652311 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 == "" && diff --git a/src/vim.js b/src/vim.js index 3446a15..9d243cc 100644 --- a/src/vim.js +++ b/src/vim.js @@ -150,8 +150,8 @@ export function initVim(CodeMirror) { { keys: 'T', type: 'motion', motion: 'moveTillCharacter', motionArgs: { forward: false }}, { keys: ';', type: 'motion', motion: 'repeatLastCharacterSearch', motionArgs: { forward: true }}, { keys: ',', type: 'motion', motion: 'repeatLastCharacterSearch', motionArgs: { forward: false }}, - { keys: '\'', type: 'motion', motion: 'goToMark', motionArgs: {toJumplist: true, linewise: true}}, - { keys: '`', type: 'motion', motion: 'goToMark', motionArgs: {toJumplist: true}}, + { keys: '\'', type: 'motion', motion: 'goToMark', motionArgs: {toJumplist: true, linewise: true}}, + { keys: '`', 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 } }, @@ -220,8 +220,8 @@ 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', type: 'action', action: 'replace', isEdit: true }, - { keys: '@', type: 'action', action: 'replayMacro' }, - { keys: 'q', type: 'action', action: 'enterMacroRecordMode' }, + { keys: '@', type: 'action', action: 'replayMacro' }, + { keys: 'q', 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}, @@ -229,9 +229,9 @@ export function initVim(CodeMirror) { { 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: '', type: 'action', action: 'redo' }, - { keys: 'm', type: 'action', action: 'setMark' }, - { keys: '"', type: 'action', action: 'setRegister' }, - { keys: '', type: 'action', action: 'insertRegister', context: 'insert', isEdit: true }, + { keys: 'm', type: 'action', action: 'setMark' }, + { keys: '"', type: 'action', action: 'setRegister' }, + { keys: '', type: 'action', action: 'insertRegister', context: 'insert', isEdit: true }, { keys: '', 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' }, @@ -245,8 +245,8 @@ export function initVim(CodeMirror) { { keys: '', type: 'action', action: 'indent', actionArgs: { indentRight: true }, context: 'insert' }, { keys: '', type: 'action', action: 'indent', actionArgs: { indentRight: false }, context: 'insert' }, // Text object motions - { keys: 'a', type: 'motion', motion: 'textObjectManipulation' }, - { keys: 'i', type: 'motion', motion: 'textObjectManipulation', motionArgs: { textObjectInner: true }}, + { keys: 'a', type: 'motion', motion: 'textObjectManipulation' }, + { keys: 'i', 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 }}, @@ -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); @@ -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, r etc. + // in terms of langmaps. + expectLiteralNext: false }; } return cm.state.vim; @@ -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, @@ -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) { @@ -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); } }, @@ -928,6 +948,7 @@ export function initVim(CodeMirror) { } return !keysAreChars; } + vim.expectLiteralNext = false; if (lastInsertModeKeyTimer) { window.clearTimeout(lastInsertModeKeyTimer); } if (match.command && changeQueue) { @@ -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); @@ -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(//)) { + return key[3] in langmap.keymap ? `` : key; + } else { + return key; + } + } + // Represents the current input state. function InputState() { this.prefixRepeat = []; @@ -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); } @@ -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) == '' // langmap literal logic + }; } var bestMatch; @@ -1376,7 +1462,7 @@ export function initVim(CodeMirror) { bestMatch = match; } } - if (bestMatch.keys.slice(-11) == '') { + if (bestMatch.keys.slice(-11) == '' || bestMatch.keys.slice(-10) == '') { var character = lastChar(keys); if (!character || character.length > 1) return {type: 'clear'}; inputState.selectedCharacter = character; @@ -3197,9 +3283,11 @@ export function initVim(CodeMirror) { }; } function commandMatch(pressed, mapped) { - if (mapped.slice(-11) == '') { + const isLastCharacter = mapped.slice(-11) == ''; + const isLastRegister = mapped.slice(-10) == ''; + 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' : diff --git a/test/vim_test.js b/test/vim_test.js index e50b7e0..81ae11e 100644 --- a/test/vim_test.js +++ b/test/vim_test.js @@ -5557,6 +5557,112 @@ testVim('_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,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 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('', '2', 'h', 'n', 'n', 'n', 'j'); + helpers.doKeys('hello'); + eq('1hello\n5hello\nahellofg', cm.getValue()); + helpers.doKeys(''); + cm.setCursor(2, 3); + helpers.doKeys('', '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('', '2', 'h', 'n', 'n', 'n', 'j'); + helpers.doKeys('hello'); + eq('1hello\n5hello\nahellofg', cm.getValue()); + helpers.doKeys(''); + cm.setCursor(2, 3); + helpers.doKeys('', '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)); }