Skip to content

Commit 9f979b2

Browse files
authored
Fixes #2217. Support langmap (#8541)
1 parent 8415827 commit 9f979b2

File tree

14 files changed

+311
-24
lines changed

14 files changed

+311
-24
lines changed

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1149,6 +1149,10 @@
11491149
"type": "string",
11501150
"description": "Path to the shell to use for `!` and `:!` commands.",
11511151
"default": ""
1152+
},
1153+
"vim.langmap": {
1154+
"type": "string",
1155+
"description": "Language map for alternate keyboard layouts. When you are typing text in Insert (or Replace, etc.) mode, the characters are inserted derectly. Otherwise, they are translated based on the provided map."
11521156
}
11531157
}
11541158
},

src/actions/base.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { isTextTransformation } from '../transformations/transformations';
66
import { configuration } from './../configuration/configuration';
77
import { Mode } from './../mode/mode';
88
import { VimState } from './../state/vimState';
9+
import { isLiteralMode, unmapLiteral } from '../configuration/langmap';
910

1011
export abstract class BaseAction implements IBaseAction {
1112
abstract readonly actionType: ActionType;
@@ -41,7 +42,7 @@ export abstract class BaseAction implements IBaseAction {
4142
/**
4243
* The sequence of keys you use to trigger the action, or a list of such sequences.
4344
*/
44-
public abstract readonly keys: readonly string[] | readonly string[][];
45+
public abstract keys: readonly string[] | readonly string[][];
4546

4647
/**
4748
* The keys pressed at the time that this action was triggered.
@@ -51,6 +52,7 @@ export abstract class BaseAction implements IBaseAction {
5152

5253
private static readonly isSingleNumber: RegExp = /^[0-9]$/;
5354
private static readonly isSingleAlpha: RegExp = /^[a-zA-Z]$/;
55+
private static readonly isMacroRegister: RegExp = /^[0-9a-zA-Z]$/;
5456

5557
/**
5658
* Is this action valid in the current Vim state?
@@ -125,7 +127,9 @@ export abstract class BaseAction implements IBaseAction {
125127
continue;
126128
} else if (left === '<alpha>' && this.isSingleAlpha.test(right)) {
127129
continue;
128-
} else if (left === '<character>' && !Notation.IsControlKey(right)) {
130+
} else if (left === '<macro>' && this.isMacroRegister.test(right)) {
131+
continue;
132+
} else if (['<character>', '<register>'].includes(left) && !Notation.IsControlKey(right)) {
129133
continue;
130134
} else {
131135
return false;
@@ -265,7 +269,9 @@ export function getRelevantAction(
265269
// I think we can make `doesActionApply` and `couldActionApply` static...
266270
const action = new actionType();
267271
if (action.doesActionApply(vimState, keysPressed)) {
268-
action.keysPressed = vimState.recordedState.actionKeys.slice(0);
272+
action.keysPressed = isLiteralMode(vimState.currentMode)
273+
? [...vimState.recordedState.actionKeys]
274+
: unmapLiteral(action.keys, vimState.recordedState.actionKeys);
269275
return action;
270276
}
271277

src/actions/commands/actions.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,7 @@ export class CommandNumber extends BaseCommand {
290290
@RegisterAction
291291
export class CommandRegister extends BaseCommand {
292292
modes = [Mode.Normal, Mode.Visual, Mode.VisualLine, Mode.VisualBlock];
293-
keys = ['"', '<character>'];
293+
keys = ['"', '<register>'];
294294
override name = 'cmd_register';
295295
override isCompleteAction = false;
296296

@@ -310,8 +310,7 @@ export class CommandRegister extends BaseCommand {
310310
class CommandRecordMacro extends BaseCommand {
311311
modes = [Mode.Normal, Mode.Visual, Mode.VisualLine];
312312
keys = [
313-
['q', '<alpha>'],
314-
['q', '<number>'],
313+
['q', '<macro>'],
315314
['q', '"'],
316315
];
317316

@@ -391,7 +390,7 @@ class CommandExecuteLastMacro extends BaseCommand {
391390
@RegisterAction
392391
class CommandExecuteMacro extends BaseCommand {
393392
modes = [Mode.Normal, Mode.Visual, Mode.VisualLine];
394-
keys = ['@', '<character>'];
393+
keys = ['@', '<register>'];
395394
override runsOnceForEachCountPrefix = true;
396395
override createsUndoPoint = true;
397396

@@ -582,7 +581,7 @@ class CommandCmdA extends BaseCommand {
582581

583582
@RegisterAction
584583
class MarkCommand extends BaseCommand {
585-
keys = ['m', '<character>'];
584+
keys = ['m', '<register>'];
586585
modes = [Mode.Normal];
587586

588587
public override async exec(position: Position, vimState: VimState): Promise<void> {

src/actions/commands/commandLine.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -289,11 +289,12 @@ class CommandInsertRegisterContentInCommandLine extends CommandLineAction {
289289
override isCompleteAction = false;
290290

291291
protected async run(vimState: VimState, commandLine: CommandLine): Promise<void> {
292-
if (!Register.isValidRegister(this.keysPressed[1])) {
292+
const registerKey = this.keysPressed[1];
293+
if (!Register.isValidRegister(registerKey)) {
293294
return;
294295
}
295296

296-
vimState.recordedState.registerName = this.keysPressed[1];
297+
vimState.recordedState.registerName = registerKey;
297298
const register = await Register.get(vimState.recordedState.registerName, this.multicursorIndex);
298299
if (register === undefined) {
299300
StatusBar.displayError(

src/actions/commands/insert.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -335,16 +335,14 @@ class CommandInsertRegisterContent extends BaseCommand {
335335
override isCompleteAction = false;
336336

337337
public override async exec(position: Position, vimState: VimState): Promise<void> {
338-
if (!Register.isValidRegister(this.keysPressed[1])) {
338+
const registerKey = this.keysPressed[1];
339+
if (!Register.isValidRegister(registerKey)) {
339340
return;
340341
}
341342

342-
const register = await Register.get(this.keysPressed[1], this.multicursorIndex);
343+
const register = await Register.get(registerKey, this.multicursorIndex);
343344
if (register === undefined) {
344-
StatusBar.displayError(
345-
vimState,
346-
VimError.fromCode(ErrorCode.NothingInRegister, this.keysPressed[1]),
347-
);
345+
StatusBar.displayError(vimState, VimError.fromCode(ErrorCode.NothingInRegister, registerKey));
348346
return;
349347
}
350348

src/actions/motion.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -564,7 +564,7 @@ class CommandPreviousSearchMatch extends BaseMovement {
564564

565565
@RegisterAction
566566
class MarkMovementBOL extends BaseMovement {
567-
keys = ["'", '<character>'];
567+
keys = ["'", '<register>'];
568568
override isJump = true;
569569

570570
public override async execAction(position: Position, vimState: VimState): Promise<Position> {
@@ -591,7 +591,7 @@ class MarkMovementBOL extends BaseMovement {
591591

592592
@RegisterAction
593593
class MarkMovement extends BaseMovement {
594-
keys = ['`', '<character>'];
594+
keys = ['`', '<register>'];
595595
override isJump = true;
596596

597597
public override async execAction(position: Position, vimState: VimState): Promise<Position> {

src/cmd_line/commands/set.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,13 @@ export class SetCommand extends ExCommand {
154154
this.operation = operation;
155155
}
156156

157+
// Listeners for options that need to be updated when they change
158+
private static listeners: { [key: string]: Array<() => void> } = {};
159+
static addListener(option: string, listener: () => void) {
160+
if (!(option in SetCommand.listeners)) SetCommand.listeners[option] = [];
161+
SetCommand.listeners[option].push(listener);
162+
}
163+
157164
async execute(vimState: VimState): Promise<void> {
158165
if (this.operation.option === undefined) {
159166
// TODO: Show all options that differ from their default value
@@ -288,6 +295,12 @@ export class SetCommand extends ExCommand {
288295
const guard: never = this.operation;
289296
throw new Error('Got unexpected SetOperation.type');
290297
}
298+
299+
if (option in SetCommand.listeners) {
300+
for (const listener of SetCommand.listeners[option]) {
301+
listener();
302+
}
303+
}
291304
}
292305

293306
private showOption(vimState: VimState, option: string, value: boolean | string | number) {

src/configuration/configuration.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,11 @@ class Configuration implements IConfiguration {
110110
'underline-thin': vscode.TextEditorCursorStyle.UnderlineThin,
111111
};
112112

113+
private loadListeners: Array<() => void> = [];
114+
public addLoadListener(listener: () => void): void {
115+
this.loadListeners.push(listener);
116+
}
117+
113118
public async load(): Promise<ValidatorResults> {
114119
const vimConfigs: { [key: string]: any } = Globals.isTesting
115120
? Globals.mockConfiguration
@@ -194,6 +199,10 @@ class Configuration implements IConfiguration {
194199
void VSCodeContext.set('vim.overrideCopy', this.overrideCopy);
195200
void VSCodeContext.set('vim.overrideCtrlC', this.overrideCopy || this.useCtrlKeys);
196201

202+
// workaround for circular dependency that would
203+
// prevent packaging if we simply called `updateLangmap(configuration.langmap);`
204+
this.loadListeners.forEach((listener) => listener());
205+
197206
return validatorResults;
198207
}
199208

@@ -481,6 +490,11 @@ class Configuration implements IConfiguration {
481490
visualModeKeyBindingsMap: Map<string, IKeyRemapping> = new Map();
482491
commandLineModeKeyBindingsMap: Map<string, IKeyRemapping> = new Map();
483492

493+
// langmap
494+
langmapBindingsMap: Map<string, string> = new Map();
495+
langmapReverseBindingsMap: Map<string, string> = new Map();
496+
langmap = '';
497+
484498
get textwidth(): number {
485499
const textwidth = this.getConfiguration('vim').get('textwidth', 80);
486500

src/configuration/iconfiguration.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,4 +445,6 @@ export interface IConfiguration {
445445
* Path to the shell to use for `!` and `:!` commands.
446446
*/
447447
shell: string;
448+
449+
langmap: string;
448450
}

src/configuration/langmap.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { SetCommand } from '../cmd_line/commands/set';
2+
import { Mode } from '../mode/mode';
3+
import { configuration } from './configuration';
4+
5+
const nonMatchable = /<(any|leader|number|alpha|character|register|macro)>/;
6+
const literalKeys = /<(any|number|alpha|character)>/; // do not treat <register> or <macro> as literal!
7+
const literalModes = [
8+
Mode.Insert,
9+
Mode.Replace,
10+
Mode.CommandlineInProgress,
11+
Mode.SearchInProgressMode,
12+
];
13+
14+
let lastLangmapString = '';
15+
16+
SetCommand.addListener('langmap', () => {
17+
updateLangmap(configuration.langmap);
18+
});
19+
configuration.addLoadListener(() => {
20+
updateLangmap(configuration.langmap);
21+
});
22+
updateLangmap(configuration.langmap);
23+
24+
export function updateLangmap(langmapString: string) {
25+
if (lastLangmapString === langmapString) return;
26+
const { bindings, reverseBindings } = parseLangmap(langmapString);
27+
28+
lastLangmapString = langmapString;
29+
configuration.langmap = langmapString;
30+
configuration.langmapBindingsMap = bindings;
31+
configuration.langmapReverseBindingsMap = reverseBindings;
32+
}
33+
34+
/**
35+
* From :help langmap
36+
* The 'langmap' option is a list of parts, separated with commas. Each
37+
* part can be in one of two forms:
38+
* 1. A list of pairs. Each pair is a "from" character immediately
39+
* followed by the "to" character. Examples: "aA", "aAbBcC".
40+
* 2. A list of "from" characters, a semi-colon and a list of "to"
41+
* characters. Example: "abc;ABC"
42+
*/
43+
function parseLangmap(langmapString: string): {
44+
bindings: Map<string, string>;
45+
reverseBindings: Map<string, string>;
46+
} {
47+
if (!langmapString) return { bindings: new Map(), reverseBindings: new Map() };
48+
49+
const bindings: Map<string, string> = new Map();
50+
const reverseBindings: Map<string, string> = new Map();
51+
52+
const getEscaped = (list: string) => {
53+
return list.split(/\\?(.)/).filter(Boolean);
54+
};
55+
langmapString.split(/((?:[^\\,]|\\.)+),/).map((part) => {
56+
if (!part) return;
57+
const semicolon = part.split(/((?:[^\\;]|\\.)+);/);
58+
if (semicolon.length === 3) {
59+
const from = getEscaped(semicolon[1]);
60+
const to = getEscaped(semicolon[2]);
61+
if (from.length !== to.length) return; // skip over malformed part
62+
for (let i = 0; i < from.length; ++i) {
63+
bindings.set(from[i], to[i]);
64+
reverseBindings.set(to[i], from[i]);
65+
}
66+
} else if (semicolon.length === 1) {
67+
const pairs = getEscaped(part);
68+
if (pairs.length % 2 !== 0) return; // skip over malformed part
69+
for (let i = 0; i < pairs.length; i += 2) {
70+
bindings.set(pairs[i], pairs[i + 1]);
71+
reverseBindings.set(pairs[i + 1], pairs[i]);
72+
}
73+
}
74+
});
75+
76+
return { bindings, reverseBindings };
77+
}
78+
79+
export function isLiteralMode(mode: Mode): boolean {
80+
return literalModes.includes(mode);
81+
}
82+
83+
function map(langmap: Map<string, string>, key: string): string {
84+
// Notice that we're not currently remapping <C-> combinations.
85+
// From my experience, Vim doesn't handle ctrl remapping either.
86+
// It's possible that it's caused by my exact keyboard setup.
87+
// We might need to revisit this in the future, in case some user needs it.
88+
if (key.length !== 1) return key;
89+
return langmap.get(key) || key;
90+
}
91+
92+
export function remapKey(key: string): string {
93+
return map(configuration.langmapBindingsMap, key);
94+
}
95+
96+
function unmapKey(key: string): string {
97+
return map(configuration.langmapReverseBindingsMap, key);
98+
}
99+
100+
// This is needed for bindings like "fa".
101+
// We expect this to jump to the next occurence of "a".
102+
// Thus, we need to revert "a" to its unmapped state.
103+
export function unmapLiteral(
104+
reference: readonly string[] | readonly string[][],
105+
keys: readonly string[],
106+
): string[] {
107+
if (reference.length === 0 || keys.length === 0) return [];
108+
109+
// find best matching if there are multiple
110+
if (Array.isArray(reference[0])) {
111+
for (const possibility of reference as string[][]) {
112+
if (possibility.length !== keys.length) continue;
113+
let allMatch = true;
114+
for (let i = 0; i < possibility.length; ++i) {
115+
if (nonMatchable.test(possibility[i])) continue;
116+
if (possibility[i] !== keys[i]) {
117+
allMatch = false;
118+
break;
119+
}
120+
}
121+
if (allMatch) return unmapLiteral(possibility, keys);
122+
}
123+
}
124+
125+
const unmapped = [...keys];
126+
for (let i = 0; i < keys.length; ++i) {
127+
if (literalKeys.test((reference as string[])[i])) {
128+
unmapped[i] = unmapKey(keys[i]);
129+
}
130+
}
131+
return unmapped;
132+
}

0 commit comments

Comments
 (0)