Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/platform/keybinding/common/abstractKeybindingService.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 { WorkbenchActionExecutedClassification, WorkbenchActionExecutedEvent } from '../../../base/common/actions.js';
7
import * as arrays from '../../../base/common/arrays.js';
8
import { IntervalTimer, TimeoutTimer } from '../../../base/common/async.js';
9
import { illegalState } from '../../../base/common/errors.js';
10
import { Emitter, Event } from '../../../base/common/event.js';
11
import { IME } from '../../../base/common/ime.js';
12
import { KeyCode } from '../../../base/common/keyCodes.js';
13
import { Keybinding, ResolvedChord, ResolvedKeybinding, SingleModifierChord } from '../../../base/common/keybindings.js';
14
import { Disposable, IDisposable } from '../../../base/common/lifecycle.js';
15
import * as nls from '../../../nls.js';
16
17
import { ICommandService } from '../../commands/common/commands.js';
18
import { IContextKeyService, IContextKeyServiceTarget } from '../../contextkey/common/contextkey.js';
19
import { IKeybindingService, IKeyboardEvent, KeybindingsSchemaContribution } from './keybinding.js';
20
import { ResolutionResult, KeybindingResolver, ResultKind, NoMatchingKb } from './keybindingResolver.js';
21
import { ResolvedKeybindingItem } from './resolvedKeybindingItem.js';
22
import { ILogService } from '../../log/common/log.js';
23
import { INotificationService, IStatusHandle } from '../../notification/common/notification.js';
24
import { ITelemetryService } from '../../telemetry/common/telemetry.js';
25
26
interface CurrentChord {
27
keypress: string;
28
label: string | null;
29
}
30
31
const HIGH_FREQ_COMMANDS = /^(cursor|delete|undo|redo|tab|editor\.action\.clipboard)/;
32
33
export abstract class AbstractKeybindingService extends Disposable implements IKeybindingService {
34
35
public _serviceBrand: undefined;
36
37
protected readonly _onDidUpdateKeybindings: Emitter<void> = this._register(new Emitter<void>());
38
get onDidUpdateKeybindings(): Event<void> {
39
return this._onDidUpdateKeybindings ? this._onDidUpdateKeybindings.event : Event.None; // Sinon stubbing walks properties on prototype
40
}
41
42
/** recently recorded keypresses that can trigger a keybinding;
43
*
44
* example: say, there's "cmd+k cmd+i" keybinding;
45
* the user pressed "cmd+k" (before they press "cmd+i")
46
* "cmd+k" would be stored in this array, when on pressing "cmd+i", the service
47
* would invoke the command bound by the keybinding
48
*/
49
private _currentChords: CurrentChord[];
50
51
private _currentChordChecker: IntervalTimer;
52
private _currentChordStatusMessage: IStatusHandle | null;
53
private _ignoreSingleModifiers: KeybindingModifierSet;
54
private _currentSingleModifier: SingleModifierChord | null;
55
private _currentSingleModifierClearTimeout: TimeoutTimer;
56
protected _currentlyDispatchingCommandId: string | null;
57
58
protected _logging: boolean;
59
60
public get inChordMode(): boolean {
61
return this._currentChords.length > 0;
62
}
63
64
constructor(
65
private _contextKeyService: IContextKeyService,
66
protected _commandService: ICommandService,
67
protected _telemetryService: ITelemetryService,
68
private _notificationService: INotificationService,
69
protected _logService: ILogService,
70
) {
71
super();
72
73
this._currentChords = [];
74
this._currentChordChecker = new IntervalTimer();
75
this._currentChordStatusMessage = null;
76
this._ignoreSingleModifiers = KeybindingModifierSet.EMPTY;
77
this._currentSingleModifier = null;
78
this._currentSingleModifierClearTimeout = new TimeoutTimer();
79
this._currentlyDispatchingCommandId = null;
80
this._logging = false;
81
}
82
83
public override dispose(): void {
84
super.dispose();
85
}
86
87
protected abstract _getResolver(): KeybindingResolver;
88
protected abstract _documentHasFocus(): boolean;
89
public abstract resolveKeybinding(keybinding: Keybinding): ResolvedKeybinding[];
90
public abstract resolveKeyboardEvent(keyboardEvent: IKeyboardEvent): ResolvedKeybinding;
91
public abstract resolveUserBinding(userBinding: string): ResolvedKeybinding[];
92
public abstract registerSchemaContribution(contribution: KeybindingsSchemaContribution): IDisposable;
93
public abstract _dumpDebugInfo(): string;
94
public abstract _dumpDebugInfoJSON(): string;
95
96
public getDefaultKeybindingsContent(): string {
97
return '';
98
}
99
100
public toggleLogging(): boolean {
101
this._logging = !this._logging;
102
return this._logging;
103
}
104
105
protected _log(str: string): void {
106
if (this._logging) {
107
this._logService.info(`[KeybindingService]: ${str}`);
108
}
109
}
110
111
public getDefaultKeybindings(): readonly ResolvedKeybindingItem[] {
112
return this._getResolver().getDefaultKeybindings();
113
}
114
115
public getKeybindings(): readonly ResolvedKeybindingItem[] {
116
return this._getResolver().getKeybindings();
117
}
118
119
public customKeybindingsCount(): number {
120
return 0;
121
}
122
123
public lookupKeybindings(commandId: string): ResolvedKeybinding[] {
124
return arrays.coalesce(
125
this._getResolver().lookupKeybindings(commandId).map(item => item.resolvedKeybinding)
126
);
127
}
128
129
public lookupKeybinding(commandId: string, context?: IContextKeyService, enforceContextCheck = false): ResolvedKeybinding | undefined {
130
const result = this._getResolver().lookupPrimaryKeybinding(commandId, context || this._contextKeyService, enforceContextCheck);
131
if (!result) {
132
return undefined;
133
}
134
return result.resolvedKeybinding;
135
}
136
137
public dispatchEvent(e: IKeyboardEvent, target: IContextKeyServiceTarget): boolean {
138
return this._dispatch(e, target);
139
}
140
141
// TODO@ulugbekna: update namings to align with `_doDispatch`
142
// TODO@ulugbekna: this fn doesn't seem to take into account single-modifier keybindings, eg `shift shift`
143
public softDispatch(e: IKeyboardEvent, target: IContextKeyServiceTarget): ResolutionResult {
144
this._log(`/ Soft dispatching keyboard event`);
145
const keybinding = this.resolveKeyboardEvent(e);
146
if (keybinding.hasMultipleChords()) {
147
console.warn('keyboard event should not be mapped to multiple chords');
148
return NoMatchingKb;
149
}
150
const [firstChord,] = keybinding.getDispatchChords();
151
if (firstChord === null) {
152
// cannot be dispatched, probably only modifier keys
153
this._log(`\\ Keyboard event cannot be dispatched`);
154
return NoMatchingKb;
155
}
156
157
const contextValue = this._contextKeyService.getContext(target);
158
const currentChords = this._currentChords.map((({ keypress }) => keypress));
159
return this._getResolver().resolve(contextValue, currentChords, firstChord);
160
}
161
162
private _scheduleLeaveChordMode(): void {
163
const chordLastInteractedTime = Date.now();
164
this._currentChordChecker.cancelAndSet(() => {
165
166
if (!this._documentHasFocus()) {
167
// Focus has been lost => leave chord mode
168
this._leaveChordMode();
169
return;
170
}
171
172
if (Date.now() - chordLastInteractedTime > 5000) {
173
// 5 seconds elapsed => leave chord mode
174
this._leaveChordMode();
175
}
176
177
}, 500);
178
}
179
180
private _expectAnotherChord(firstChord: string, keypressLabel: string | null): void {
181
182
this._currentChords.push({ keypress: firstChord, label: keypressLabel });
183
184
switch (this._currentChords.length) {
185
case 0:
186
throw illegalState('impossible');
187
case 1:
188
// TODO@ulugbekna: revise this message and the one below (at least, fix terminology)
189
this._currentChordStatusMessage = this._notificationService.status(nls.localize('first.chord', "({0}) was pressed. Waiting for second key of chord...", keypressLabel));
190
break;
191
default: {
192
const fullKeypressLabel = this._currentChords.map(({ label }) => label).join(', ');
193
this._currentChordStatusMessage = this._notificationService.status(nls.localize('next.chord', "({0}) was pressed. Waiting for next key of chord...", fullKeypressLabel));
194
}
195
}
196
197
this._scheduleLeaveChordMode();
198
199
if (IME.enabled) {
200
IME.disable();
201
}
202
}
203
204
private _leaveChordMode(): void {
205
if (this._currentChordStatusMessage) {
206
this._currentChordStatusMessage.close();
207
this._currentChordStatusMessage = null;
208
}
209
this._currentChordChecker.cancel();
210
this._currentChords = [];
211
IME.enable();
212
}
213
214
public dispatchByUserSettingsLabel(userSettingsLabel: string, target: IContextKeyServiceTarget): void {
215
this._log(`/ Dispatching keybinding triggered via menu entry accelerator - ${userSettingsLabel}`);
216
const keybindings = this.resolveUserBinding(userSettingsLabel);
217
if (keybindings.length === 0) {
218
this._log(`\\ Could not resolve - ${userSettingsLabel}`);
219
} else {
220
this._doDispatch(keybindings[0], target, /*isSingleModiferChord*/false);
221
}
222
}
223
224
protected _dispatch(e: IKeyboardEvent, target: IContextKeyServiceTarget): boolean {
225
return this._doDispatch(this.resolveKeyboardEvent(e), target, /*isSingleModiferChord*/false);
226
}
227
228
protected _singleModifierDispatch(e: IKeyboardEvent, target: IContextKeyServiceTarget): boolean {
229
const keybinding = this.resolveKeyboardEvent(e);
230
const [singleModifier,] = keybinding.getSingleModifierDispatchChords();
231
232
if (singleModifier) {
233
234
if (this._ignoreSingleModifiers.has(singleModifier)) {
235
this._log(`+ Ignoring single modifier ${singleModifier} due to it being pressed together with other keys.`);
236
this._ignoreSingleModifiers = KeybindingModifierSet.EMPTY;
237
this._currentSingleModifierClearTimeout.cancel();
238
this._currentSingleModifier = null;
239
return false;
240
}
241
242
this._ignoreSingleModifiers = KeybindingModifierSet.EMPTY;
243
244
if (this._currentSingleModifier === null) {
245
// we have a valid `singleModifier`, store it for the next keyup, but clear it in 300ms
246
this._log(`+ Storing single modifier for possible chord ${singleModifier}.`);
247
this._currentSingleModifier = singleModifier;
248
this._currentSingleModifierClearTimeout.cancelAndSet(() => {
249
this._log(`+ Clearing single modifier due to 300ms elapsed.`);
250
this._currentSingleModifier = null;
251
}, 300);
252
return false;
253
}
254
255
if (singleModifier === this._currentSingleModifier) {
256
// bingo!
257
this._log(`/ Dispatching single modifier chord ${singleModifier} ${singleModifier}`);
258
this._currentSingleModifierClearTimeout.cancel();
259
this._currentSingleModifier = null;
260
return this._doDispatch(keybinding, target, /*isSingleModiferChord*/true);
261
}
262
263
this._log(`+ Clearing single modifier due to modifier mismatch: ${this._currentSingleModifier} ${singleModifier}`);
264
this._currentSingleModifierClearTimeout.cancel();
265
this._currentSingleModifier = null;
266
return false;
267
}
268
269
// When pressing a modifier and holding it pressed with any other modifier or key combination,
270
// the pressed modifiers should no longer be considered for single modifier dispatch.
271
const [firstChord,] = keybinding.getChords();
272
this._ignoreSingleModifiers = new KeybindingModifierSet(firstChord);
273
274
if (this._currentSingleModifier !== null) {
275
this._log(`+ Clearing single modifier due to other key up.`);
276
}
277
this._currentSingleModifierClearTimeout.cancel();
278
this._currentSingleModifier = null;
279
return false;
280
}
281
282
private _doDispatch(userKeypress: ResolvedKeybinding, target: IContextKeyServiceTarget, isSingleModiferChord = false): boolean {
283
let shouldPreventDefault = false;
284
285
if (userKeypress.hasMultipleChords()) { // warn - because user can press a single chord at a time
286
console.warn('Unexpected keyboard event mapped to multiple chords');
287
return false;
288
}
289
290
let userPressedChord: string | null = null;
291
let currentChords: string[] | null = null;
292
293
if (isSingleModiferChord) {
294
// The keybinding is the second keypress of a single modifier chord, e.g. "shift shift".
295
// A single modifier can only occur when the same modifier is pressed in short sequence,
296
// hence we disregard `_currentChord` and use the same modifier instead.
297
const [dispatchKeyname,] = userKeypress.getSingleModifierDispatchChords();
298
userPressedChord = dispatchKeyname;
299
currentChords = dispatchKeyname ? [dispatchKeyname] : []; // TODO@ulugbekna: in the `else` case we assign an empty array - make sure `resolve` can handle an empty array well
300
} else {
301
[userPressedChord,] = userKeypress.getDispatchChords();
302
currentChords = this._currentChords.map(({ keypress }) => keypress);
303
}
304
305
if (userPressedChord === null) {
306
this._log(`\\ Keyboard event cannot be dispatched in keydown phase.`);
307
// cannot be dispatched, probably only modifier keys
308
return shouldPreventDefault;
309
}
310
311
const contextValue = this._contextKeyService.getContext(target);
312
const keypressLabel = userKeypress.getLabel();
313
314
const resolveResult = this._getResolver().resolve(contextValue, currentChords, userPressedChord);
315
316
switch (resolveResult.kind) {
317
318
case ResultKind.NoMatchingKb: {
319
320
this._logService.trace('KeybindingService#dispatch', keypressLabel, `[ No matching keybinding ]`);
321
322
if (this.inChordMode) {
323
const currentChordsLabel = this._currentChords.map(({ label }) => label).join(', ');
324
this._log(`+ Leaving multi-chord mode: Nothing bound to "${currentChordsLabel}, ${keypressLabel}".`);
325
this._notificationService.status(nls.localize('missing.chord', "The key combination ({0}, {1}) is not a command.", currentChordsLabel, keypressLabel), { hideAfter: 10 * 1000 /* 10s */ });
326
this._leaveChordMode();
327
328
shouldPreventDefault = true;
329
}
330
return shouldPreventDefault;
331
}
332
333
case ResultKind.MoreChordsNeeded: {
334
335
this._logService.trace('KeybindingService#dispatch', keypressLabel, `[ Several keybindings match - more chords needed ]`);
336
337
shouldPreventDefault = true;
338
this._expectAnotherChord(userPressedChord, keypressLabel);
339
this._log(this._currentChords.length === 1 ? `+ Entering multi-chord mode...` : `+ Continuing multi-chord mode...`);
340
return shouldPreventDefault;
341
}
342
343
case ResultKind.KbFound: {
344
345
this._logService.trace('KeybindingService#dispatch', keypressLabel, `[ Will dispatch command ${resolveResult.commandId} ]`);
346
347
if (resolveResult.commandId === null || resolveResult.commandId === '') {
348
349
if (this.inChordMode) {
350
const currentChordsLabel = this._currentChords.map(({ label }) => label).join(', ');
351
this._log(`+ Leaving chord mode: Nothing bound to "${currentChordsLabel}, ${keypressLabel}".`);
352
this._notificationService.status(nls.localize('missing.chord', "The key combination ({0}, {1}) is not a command.", currentChordsLabel, keypressLabel), { hideAfter: 10 * 1000 /* 10s */ });
353
this._leaveChordMode();
354
shouldPreventDefault = true;
355
}
356
357
} else {
358
if (this.inChordMode) {
359
this._leaveChordMode();
360
}
361
362
if (!resolveResult.isBubble) {
363
shouldPreventDefault = true;
364
}
365
366
this._log(`+ Invoking command ${resolveResult.commandId}.`);
367
this._currentlyDispatchingCommandId = resolveResult.commandId;
368
try {
369
if (typeof resolveResult.commandArgs === 'undefined') {
370
this._commandService.executeCommand(resolveResult.commandId).then(undefined, err => this._notificationService.warn(err));
371
} else {
372
this._commandService.executeCommand(resolveResult.commandId, resolveResult.commandArgs).then(undefined, err => this._notificationService.warn(err));
373
}
374
} finally {
375
this._currentlyDispatchingCommandId = null;
376
}
377
378
if (!HIGH_FREQ_COMMANDS.test(resolveResult.commandId)) {
379
this._telemetryService.publicLog2<WorkbenchActionExecutedEvent, WorkbenchActionExecutedClassification>('workbenchActionExecuted', { id: resolveResult.commandId, from: 'keybinding', detail: userKeypress.getUserSettingsLabel() ?? undefined });
380
}
381
}
382
383
return shouldPreventDefault;
384
}
385
}
386
}
387
388
abstract enableKeybindingHoldMode(commandId: string): Promise<void> | undefined;
389
390
mightProducePrintableCharacter(event: IKeyboardEvent): boolean {
391
if (event.ctrlKey || event.metaKey) {
392
// ignore ctrl/cmd-combination but not shift/alt-combinatios
393
return false;
394
}
395
// weak check for certain ranges. this is properly implemented in a subclass
396
// with access to the KeyboardMapperFactory.
397
if ((event.keyCode >= KeyCode.KeyA && event.keyCode <= KeyCode.KeyZ)
398
|| (event.keyCode >= KeyCode.Digit0 && event.keyCode <= KeyCode.Digit9)) {
399
return true;
400
}
401
return false;
402
}
403
}
404
405
class KeybindingModifierSet {
406
407
public static EMPTY = new KeybindingModifierSet(null);
408
409
private readonly _ctrlKey: boolean;
410
private readonly _shiftKey: boolean;
411
private readonly _altKey: boolean;
412
private readonly _metaKey: boolean;
413
414
constructor(source: ResolvedChord | null) {
415
this._ctrlKey = source ? source.ctrlKey : false;
416
this._shiftKey = source ? source.shiftKey : false;
417
this._altKey = source ? source.altKey : false;
418
this._metaKey = source ? source.metaKey : false;
419
}
420
421
has(modifier: SingleModifierChord) {
422
switch (modifier) {
423
case 'ctrl': return this._ctrlKey;
424
case 'shift': return this._shiftKey;
425
case 'alt': return this._altKey;
426
case 'meta': return this._metaKey;
427
}
428
}
429
}
430
431