Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/platform/keybinding/common/keybindingResolver.ts
3296 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { implies, ContextKeyExpression, ContextKeyExprType, IContext, IContextKeyService, expressionsAreEqualWithConstantSubstitution } from '../../contextkey/common/contextkey.js';
7
import { ResolvedKeybindingItem } from './resolvedKeybindingItem.js';
8
9
//#region resolution-result
10
11
export const enum ResultKind {
12
/** No keybinding found this sequence of chords */
13
NoMatchingKb,
14
15
/** There're several keybindings that have the given sequence of chords as a prefix */
16
MoreChordsNeeded,
17
18
/** A single keybinding found to be dispatched/invoked */
19
KbFound
20
}
21
22
export type ResolutionResult =
23
| { kind: ResultKind.NoMatchingKb }
24
| { kind: ResultKind.MoreChordsNeeded }
25
| { kind: ResultKind.KbFound; commandId: string | null; commandArgs: any; isBubble: boolean };
26
27
28
// util definitions to make working with the above types easier within this module:
29
30
export const NoMatchingKb: ResolutionResult = { kind: ResultKind.NoMatchingKb };
31
const MoreChordsNeeded: ResolutionResult = { kind: ResultKind.MoreChordsNeeded };
32
function KbFound(commandId: string | null, commandArgs: any, isBubble: boolean): ResolutionResult {
33
return { kind: ResultKind.KbFound, commandId, commandArgs, isBubble };
34
}
35
36
//#endregion
37
38
/**
39
* Stores mappings from keybindings to commands and from commands to keybindings.
40
* Given a sequence of chords, `resolve`s which keybinding it matches
41
*/
42
export class KeybindingResolver {
43
private readonly _log: (str: string) => void;
44
private readonly _defaultKeybindings: ResolvedKeybindingItem[];
45
private readonly _keybindings: ResolvedKeybindingItem[];
46
private readonly _defaultBoundCommands: Map</* commandId */ string, boolean>;
47
private readonly _map: Map</* 1st chord's keypress */ string, ResolvedKeybindingItem[]>;
48
private readonly _lookupMap: Map</* commandId */ string, ResolvedKeybindingItem[]>;
49
50
constructor(
51
/** built-in and extension-provided keybindings */
52
defaultKeybindings: ResolvedKeybindingItem[],
53
/** user's keybindings */
54
overrides: ResolvedKeybindingItem[],
55
log: (str: string) => void
56
) {
57
this._log = log;
58
this._defaultKeybindings = defaultKeybindings;
59
60
this._defaultBoundCommands = new Map<string, boolean>();
61
for (const defaultKeybinding of defaultKeybindings) {
62
const command = defaultKeybinding.command;
63
if (command && command.charAt(0) !== '-') {
64
this._defaultBoundCommands.set(command, true);
65
}
66
}
67
68
this._map = new Map<string, ResolvedKeybindingItem[]>();
69
this._lookupMap = new Map<string, ResolvedKeybindingItem[]>();
70
71
this._keybindings = KeybindingResolver.handleRemovals(([] as ResolvedKeybindingItem[]).concat(defaultKeybindings).concat(overrides));
72
for (let i = 0, len = this._keybindings.length; i < len; i++) {
73
const k = this._keybindings[i];
74
if (k.chords.length === 0) {
75
// unbound
76
continue;
77
}
78
79
// substitute with constants that are registered after startup - https://github.com/microsoft/vscode/issues/174218#issuecomment-1437972127
80
const when = k.when?.substituteConstants();
81
82
if (when && when.type === ContextKeyExprType.False) {
83
// when condition is false
84
continue;
85
}
86
87
this._addKeyPress(k.chords[0], k);
88
}
89
}
90
91
private static _isTargetedForRemoval(defaultKb: ResolvedKeybindingItem, keypress: string[] | null, when: ContextKeyExpression | undefined): boolean {
92
if (keypress) {
93
for (let i = 0; i < keypress.length; i++) {
94
if (keypress[i] !== defaultKb.chords[i]) {
95
return false;
96
}
97
}
98
}
99
100
// `true` means always, as does `undefined`
101
// so we will treat `true` === `undefined`
102
if (when && when.type !== ContextKeyExprType.True) {
103
if (!defaultKb.when) {
104
return false;
105
}
106
if (!expressionsAreEqualWithConstantSubstitution(when, defaultKb.when)) {
107
return false;
108
}
109
}
110
return true;
111
112
}
113
114
/**
115
* Looks for rules containing "-commandId" and removes them.
116
*/
117
public static handleRemovals(rules: ResolvedKeybindingItem[]): ResolvedKeybindingItem[] {
118
// Do a first pass and construct a hash-map for removals
119
const removals = new Map</* commandId */ string, ResolvedKeybindingItem[]>();
120
for (let i = 0, len = rules.length; i < len; i++) {
121
const rule = rules[i];
122
if (rule.command && rule.command.charAt(0) === '-') {
123
const command = rule.command.substring(1);
124
if (!removals.has(command)) {
125
removals.set(command, [rule]);
126
} else {
127
removals.get(command)!.push(rule);
128
}
129
}
130
}
131
132
if (removals.size === 0) {
133
// There are no removals
134
return rules;
135
}
136
137
// Do a second pass and keep only non-removed keybindings
138
const result: ResolvedKeybindingItem[] = [];
139
for (let i = 0, len = rules.length; i < len; i++) {
140
const rule = rules[i];
141
142
if (!rule.command || rule.command.length === 0) {
143
result.push(rule);
144
continue;
145
}
146
if (rule.command.charAt(0) === '-') {
147
continue;
148
}
149
const commandRemovals = removals.get(rule.command);
150
if (!commandRemovals || !rule.isDefault) {
151
result.push(rule);
152
continue;
153
}
154
let isRemoved = false;
155
for (const commandRemoval of commandRemovals) {
156
const when = commandRemoval.when;
157
if (this._isTargetedForRemoval(rule, commandRemoval.chords, when)) {
158
isRemoved = true;
159
break;
160
}
161
}
162
if (!isRemoved) {
163
result.push(rule);
164
continue;
165
}
166
}
167
return result;
168
}
169
170
private _addKeyPress(keypress: string, item: ResolvedKeybindingItem): void {
171
172
const conflicts = this._map.get(keypress);
173
174
if (typeof conflicts === 'undefined') {
175
// There is no conflict so far
176
this._map.set(keypress, [item]);
177
this._addToLookupMap(item);
178
return;
179
}
180
181
for (let i = conflicts.length - 1; i >= 0; i--) {
182
const conflict = conflicts[i];
183
184
if (conflict.command === item.command) {
185
continue;
186
}
187
188
// Test if the shorter keybinding is a prefix of the longer one.
189
// If the shorter keybinding is a prefix, it effectively will shadow the longer one and is considered a conflict.
190
let isShorterKbPrefix = true;
191
for (let i = 1; i < conflict.chords.length && i < item.chords.length; i++) {
192
if (conflict.chords[i] !== item.chords[i]) {
193
// The ith step does not conflict
194
isShorterKbPrefix = false;
195
break;
196
}
197
}
198
if (!isShorterKbPrefix) {
199
continue;
200
}
201
202
if (KeybindingResolver.whenIsEntirelyIncluded(conflict.when, item.when)) {
203
// `item` completely overwrites `conflict`
204
// Remove conflict from the lookupMap
205
this._removeFromLookupMap(conflict);
206
}
207
}
208
209
conflicts.push(item);
210
this._addToLookupMap(item);
211
}
212
213
private _addToLookupMap(item: ResolvedKeybindingItem): void {
214
if (!item.command) {
215
return;
216
}
217
218
let arr = this._lookupMap.get(item.command);
219
if (typeof arr === 'undefined') {
220
arr = [item];
221
this._lookupMap.set(item.command, arr);
222
} else {
223
arr.push(item);
224
}
225
}
226
227
private _removeFromLookupMap(item: ResolvedKeybindingItem): void {
228
if (!item.command) {
229
return;
230
}
231
const arr = this._lookupMap.get(item.command);
232
if (typeof arr === 'undefined') {
233
return;
234
}
235
for (let i = 0, len = arr.length; i < len; i++) {
236
if (arr[i] === item) {
237
arr.splice(i, 1);
238
return;
239
}
240
}
241
}
242
243
/**
244
* Returns true if it is provable `a` implies `b`.
245
*/
246
public static whenIsEntirelyIncluded(a: ContextKeyExpression | null | undefined, b: ContextKeyExpression | null | undefined): boolean {
247
if (!b || b.type === ContextKeyExprType.True) {
248
return true;
249
}
250
if (!a || a.type === ContextKeyExprType.True) {
251
return false;
252
}
253
254
return implies(a, b);
255
}
256
257
public getDefaultBoundCommands(): Map<string, boolean> {
258
return this._defaultBoundCommands;
259
}
260
261
public getDefaultKeybindings(): readonly ResolvedKeybindingItem[] {
262
return this._defaultKeybindings;
263
}
264
265
public getKeybindings(): readonly ResolvedKeybindingItem[] {
266
return this._keybindings;
267
}
268
269
public lookupKeybindings(commandId: string): ResolvedKeybindingItem[] {
270
const items = this._lookupMap.get(commandId);
271
if (typeof items === 'undefined' || items.length === 0) {
272
return [];
273
}
274
275
// Reverse to get the most specific item first
276
const result: ResolvedKeybindingItem[] = [];
277
let resultLen = 0;
278
for (let i = items.length - 1; i >= 0; i--) {
279
result[resultLen++] = items[i];
280
}
281
return result;
282
}
283
284
public lookupPrimaryKeybinding(commandId: string, context: IContextKeyService, enforceContextCheck = false): ResolvedKeybindingItem | null {
285
const items = this._lookupMap.get(commandId);
286
if (typeof items === 'undefined' || items.length === 0) {
287
return null;
288
}
289
if (items.length === 1 && !enforceContextCheck) {
290
return items[0];
291
}
292
293
for (let i = items.length - 1; i >= 0; i--) {
294
const item = items[i];
295
if (context.contextMatchesRules(item.when)) {
296
return item;
297
}
298
}
299
300
if (enforceContextCheck) {
301
return null;
302
}
303
304
return items[items.length - 1];
305
}
306
307
/**
308
* Looks up a keybinding trigged as a result of pressing a sequence of chords - `[...currentChords, keypress]`
309
*
310
* Example: resolving 3 chords pressed sequentially - `cmd+k cmd+p cmd+i`:
311
* `currentChords = [ 'cmd+k' , 'cmd+p' ]` and `keypress = `cmd+i` - last pressed chord
312
*/
313
public resolve(context: IContext, currentChords: string[], keypress: string): ResolutionResult {
314
315
const pressedChords = [...currentChords, keypress];
316
317
this._log(`| Resolving ${pressedChords}`);
318
319
const kbCandidates = this._map.get(pressedChords[0]);
320
if (kbCandidates === undefined) {
321
// No bindings with such 0-th chord
322
this._log(`\\ No keybinding entries.`);
323
return NoMatchingKb;
324
}
325
326
let lookupMap: ResolvedKeybindingItem[] | null = null;
327
328
if (pressedChords.length < 2) {
329
lookupMap = kbCandidates;
330
} else {
331
// Fetch all chord bindings for `currentChords`
332
lookupMap = [];
333
for (let i = 0, len = kbCandidates.length; i < len; i++) {
334
335
const candidate = kbCandidates[i];
336
337
if (pressedChords.length > candidate.chords.length) { // # of pressed chords can't be less than # of chords in a keybinding to invoke
338
continue;
339
}
340
341
let prefixMatches = true;
342
for (let i = 1; i < pressedChords.length; i++) {
343
if (candidate.chords[i] !== pressedChords[i]) {
344
prefixMatches = false;
345
break;
346
}
347
}
348
if (prefixMatches) {
349
lookupMap.push(candidate);
350
}
351
}
352
}
353
354
// check there's a keybinding with a matching when clause
355
const result = this._findCommand(context, lookupMap);
356
if (!result) {
357
this._log(`\\ From ${lookupMap.length} keybinding entries, no when clauses matched the context.`);
358
return NoMatchingKb;
359
}
360
361
// check we got all chords necessary to be sure a particular keybinding needs to be invoked
362
if (pressedChords.length < result.chords.length) {
363
// The chord sequence is not complete
364
this._log(`\\ From ${lookupMap.length} keybinding entries, awaiting ${result.chords.length - pressedChords.length} more chord(s), when: ${printWhenExplanation(result.when)}, source: ${printSourceExplanation(result)}.`);
365
return MoreChordsNeeded;
366
}
367
368
this._log(`\\ From ${lookupMap.length} keybinding entries, matched ${result.command}, when: ${printWhenExplanation(result.when)}, source: ${printSourceExplanation(result)}.`);
369
370
return KbFound(result.command, result.commandArgs, result.bubble);
371
}
372
373
private _findCommand(context: IContext, matches: ResolvedKeybindingItem[]): ResolvedKeybindingItem | null {
374
for (let i = matches.length - 1; i >= 0; i--) {
375
const k = matches[i];
376
377
if (!KeybindingResolver._contextMatchesRules(context, k.when)) {
378
continue;
379
}
380
381
return k;
382
}
383
384
return null;
385
}
386
387
private static _contextMatchesRules(context: IContext, rules: ContextKeyExpression | null | undefined): boolean {
388
if (!rules) {
389
return true;
390
}
391
return rules.evaluate(context);
392
}
393
}
394
395
function printWhenExplanation(when: ContextKeyExpression | undefined): string {
396
if (!when) {
397
return `no when condition`;
398
}
399
return `${when.serialize()}`;
400
}
401
402
function printSourceExplanation(kb: ResolvedKeybindingItem): string {
403
return (
404
kb.extensionId
405
? (kb.isBuiltinExtension ? `built-in extension ${kb.extensionId}` : `user extension ${kb.extensionId}`)
406
: (kb.isDefault ? `built-in` : `user`)
407
);
408
}
409
410