Path: blob/main/src/vs/platform/keybinding/common/abstractKeybindingService.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { WorkbenchActionExecutedClassification, WorkbenchActionExecutedEvent } from '../../../base/common/actions.js';6import * as arrays from '../../../base/common/arrays.js';7import { IntervalTimer, TimeoutTimer } from '../../../base/common/async.js';8import { illegalState } from '../../../base/common/errors.js';9import { Emitter, Event } from '../../../base/common/event.js';10import { IME } from '../../../base/common/ime.js';11import { KeyCode } from '../../../base/common/keyCodes.js';12import { Keybinding, ResolvedChord, ResolvedKeybinding, SingleModifierChord } from '../../../base/common/keybindings.js';13import { Disposable, IDisposable } from '../../../base/common/lifecycle.js';14import * as nls from '../../../nls.js';1516import { ICommandService } from '../../commands/common/commands.js';17import { IContextKeyService, IContextKeyServiceTarget } from '../../contextkey/common/contextkey.js';18import { IKeybindingService, IKeyboardEvent, KeybindingsSchemaContribution } from './keybinding.js';19import { ResolutionResult, KeybindingResolver, ResultKind, NoMatchingKb } from './keybindingResolver.js';20import { ResolvedKeybindingItem } from './resolvedKeybindingItem.js';21import { ILogService } from '../../log/common/log.js';22import { INotificationService, IStatusHandle } from '../../notification/common/notification.js';23import { ITelemetryService } from '../../telemetry/common/telemetry.js';2425interface CurrentChord {26keypress: string;27label: string | null;28}2930const HIGH_FREQ_COMMANDS = /^(cursor|delete|undo|redo|tab|editor\.action\.clipboard)/;3132export abstract class AbstractKeybindingService extends Disposable implements IKeybindingService {3334public _serviceBrand: undefined;3536protected readonly _onDidUpdateKeybindings: Emitter<void> = this._register(new Emitter<void>());37get onDidUpdateKeybindings(): Event<void> {38return this._onDidUpdateKeybindings ? this._onDidUpdateKeybindings.event : Event.None; // Sinon stubbing walks properties on prototype39}4041/** recently recorded keypresses that can trigger a keybinding;42*43* example: say, there's "cmd+k cmd+i" keybinding;44* the user pressed "cmd+k" (before they press "cmd+i")45* "cmd+k" would be stored in this array, when on pressing "cmd+i", the service46* would invoke the command bound by the keybinding47*/48private _currentChords: CurrentChord[];4950private _currentChordChecker: IntervalTimer;51private _currentChordStatusMessage: IStatusHandle | null;52private _ignoreSingleModifiers: KeybindingModifierSet;53private _currentSingleModifier: SingleModifierChord | null;54private _currentSingleModifierClearTimeout: TimeoutTimer;55protected _currentlyDispatchingCommandId: string | null;5657protected _logging: boolean;5859public get inChordMode(): boolean {60return this._currentChords.length > 0;61}6263constructor(64private _contextKeyService: IContextKeyService,65protected _commandService: ICommandService,66protected _telemetryService: ITelemetryService,67private _notificationService: INotificationService,68protected _logService: ILogService,69) {70super();7172this._currentChords = [];73this._currentChordChecker = new IntervalTimer();74this._currentChordStatusMessage = null;75this._ignoreSingleModifiers = KeybindingModifierSet.EMPTY;76this._currentSingleModifier = null;77this._currentSingleModifierClearTimeout = new TimeoutTimer();78this._currentlyDispatchingCommandId = null;79this._logging = false;80}8182public override dispose(): void {83super.dispose();84}8586protected abstract _getResolver(): KeybindingResolver;87protected abstract _documentHasFocus(): boolean;88public abstract resolveKeybinding(keybinding: Keybinding): ResolvedKeybinding[];89public abstract resolveKeyboardEvent(keyboardEvent: IKeyboardEvent): ResolvedKeybinding;90public abstract resolveUserBinding(userBinding: string): ResolvedKeybinding[];91public abstract registerSchemaContribution(contribution: KeybindingsSchemaContribution): IDisposable;92public abstract _dumpDebugInfo(): string;93public abstract _dumpDebugInfoJSON(): string;9495public getDefaultKeybindingsContent(): string {96return '';97}9899public toggleLogging(): boolean {100this._logging = !this._logging;101return this._logging;102}103104protected _log(str: string): void {105if (this._logging) {106this._logService.info(`[KeybindingService]: ${str}`);107}108}109110public getDefaultKeybindings(): readonly ResolvedKeybindingItem[] {111return this._getResolver().getDefaultKeybindings();112}113114public getKeybindings(): readonly ResolvedKeybindingItem[] {115return this._getResolver().getKeybindings();116}117118public customKeybindingsCount(): number {119return 0;120}121122public lookupKeybindings(commandId: string): ResolvedKeybinding[] {123return arrays.coalesce(124this._getResolver().lookupKeybindings(commandId).map(item => item.resolvedKeybinding)125);126}127128public lookupKeybinding(commandId: string, context?: IContextKeyService, enforceContextCheck = false): ResolvedKeybinding | undefined {129const result = this._getResolver().lookupPrimaryKeybinding(commandId, context || this._contextKeyService, enforceContextCheck);130if (!result) {131return undefined;132}133return result.resolvedKeybinding;134}135136public dispatchEvent(e: IKeyboardEvent, target: IContextKeyServiceTarget): boolean {137return this._dispatch(e, target);138}139140// TODO@ulugbekna: update namings to align with `_doDispatch`141// TODO@ulugbekna: this fn doesn't seem to take into account single-modifier keybindings, eg `shift shift`142public softDispatch(e: IKeyboardEvent, target: IContextKeyServiceTarget): ResolutionResult {143this._log(`/ Soft dispatching keyboard event`);144const keybinding = this.resolveKeyboardEvent(e);145if (keybinding.hasMultipleChords()) {146console.warn('keyboard event should not be mapped to multiple chords');147return NoMatchingKb;148}149const [firstChord,] = keybinding.getDispatchChords();150if (firstChord === null) {151// cannot be dispatched, probably only modifier keys152this._log(`\\ Keyboard event cannot be dispatched`);153return NoMatchingKb;154}155156const contextValue = this._contextKeyService.getContext(target);157const currentChords = this._currentChords.map((({ keypress }) => keypress));158return this._getResolver().resolve(contextValue, currentChords, firstChord);159}160161private _scheduleLeaveChordMode(): void {162const chordLastInteractedTime = Date.now();163this._currentChordChecker.cancelAndSet(() => {164165if (!this._documentHasFocus()) {166// Focus has been lost => leave chord mode167this._leaveChordMode();168return;169}170171if (Date.now() - chordLastInteractedTime > 5000) {172// 5 seconds elapsed => leave chord mode173this._leaveChordMode();174}175176}, 500);177}178179private _expectAnotherChord(firstChord: string, keypressLabel: string | null): void {180181this._currentChords.push({ keypress: firstChord, label: keypressLabel });182183switch (this._currentChords.length) {184case 0:185throw illegalState('impossible');186case 1:187// TODO@ulugbekna: revise this message and the one below (at least, fix terminology)188this._currentChordStatusMessage = this._notificationService.status(nls.localize('first.chord', "({0}) was pressed. Waiting for second key of chord...", keypressLabel));189break;190default: {191const fullKeypressLabel = this._currentChords.map(({ label }) => label).join(', ');192this._currentChordStatusMessage = this._notificationService.status(nls.localize('next.chord', "({0}) was pressed. Waiting for next key of chord...", fullKeypressLabel));193}194}195196this._scheduleLeaveChordMode();197198if (IME.enabled) {199IME.disable();200}201}202203private _leaveChordMode(): void {204if (this._currentChordStatusMessage) {205this._currentChordStatusMessage.close();206this._currentChordStatusMessage = null;207}208this._currentChordChecker.cancel();209this._currentChords = [];210IME.enable();211}212213public dispatchByUserSettingsLabel(userSettingsLabel: string, target: IContextKeyServiceTarget): void {214this._log(`/ Dispatching keybinding triggered via menu entry accelerator - ${userSettingsLabel}`);215const keybindings = this.resolveUserBinding(userSettingsLabel);216if (keybindings.length === 0) {217this._log(`\\ Could not resolve - ${userSettingsLabel}`);218} else {219this._doDispatch(keybindings[0], target, /*isSingleModiferChord*/false);220}221}222223protected _dispatch(e: IKeyboardEvent, target: IContextKeyServiceTarget): boolean {224return this._doDispatch(this.resolveKeyboardEvent(e), target, /*isSingleModiferChord*/false);225}226227protected _singleModifierDispatch(e: IKeyboardEvent, target: IContextKeyServiceTarget): boolean {228const keybinding = this.resolveKeyboardEvent(e);229const [singleModifier,] = keybinding.getSingleModifierDispatchChords();230231if (singleModifier) {232233if (this._ignoreSingleModifiers.has(singleModifier)) {234this._log(`+ Ignoring single modifier ${singleModifier} due to it being pressed together with other keys.`);235this._ignoreSingleModifiers = KeybindingModifierSet.EMPTY;236this._currentSingleModifierClearTimeout.cancel();237this._currentSingleModifier = null;238return false;239}240241this._ignoreSingleModifiers = KeybindingModifierSet.EMPTY;242243if (this._currentSingleModifier === null) {244// we have a valid `singleModifier`, store it for the next keyup, but clear it in 300ms245this._log(`+ Storing single modifier for possible chord ${singleModifier}.`);246this._currentSingleModifier = singleModifier;247this._currentSingleModifierClearTimeout.cancelAndSet(() => {248this._log(`+ Clearing single modifier due to 300ms elapsed.`);249this._currentSingleModifier = null;250}, 300);251return false;252}253254if (singleModifier === this._currentSingleModifier) {255// bingo!256this._log(`/ Dispatching single modifier chord ${singleModifier} ${singleModifier}`);257this._currentSingleModifierClearTimeout.cancel();258this._currentSingleModifier = null;259return this._doDispatch(keybinding, target, /*isSingleModiferChord*/true);260}261262this._log(`+ Clearing single modifier due to modifier mismatch: ${this._currentSingleModifier} ${singleModifier}`);263this._currentSingleModifierClearTimeout.cancel();264this._currentSingleModifier = null;265return false;266}267268// When pressing a modifier and holding it pressed with any other modifier or key combination,269// the pressed modifiers should no longer be considered for single modifier dispatch.270const [firstChord,] = keybinding.getChords();271this._ignoreSingleModifiers = new KeybindingModifierSet(firstChord);272273if (this._currentSingleModifier !== null) {274this._log(`+ Clearing single modifier due to other key up.`);275}276this._currentSingleModifierClearTimeout.cancel();277this._currentSingleModifier = null;278return false;279}280281private _doDispatch(userKeypress: ResolvedKeybinding, target: IContextKeyServiceTarget, isSingleModiferChord = false): boolean {282let shouldPreventDefault = false;283284if (userKeypress.hasMultipleChords()) { // warn - because user can press a single chord at a time285console.warn('Unexpected keyboard event mapped to multiple chords');286return false;287}288289let userPressedChord: string | null = null;290let currentChords: string[] | null = null;291292if (isSingleModiferChord) {293// The keybinding is the second keypress of a single modifier chord, e.g. "shift shift".294// A single modifier can only occur when the same modifier is pressed in short sequence,295// hence we disregard `_currentChord` and use the same modifier instead.296const [dispatchKeyname,] = userKeypress.getSingleModifierDispatchChords();297userPressedChord = dispatchKeyname;298currentChords = dispatchKeyname ? [dispatchKeyname] : []; // TODO@ulugbekna: in the `else` case we assign an empty array - make sure `resolve` can handle an empty array well299} else {300[userPressedChord,] = userKeypress.getDispatchChords();301currentChords = this._currentChords.map(({ keypress }) => keypress);302}303304if (userPressedChord === null) {305this._log(`\\ Keyboard event cannot be dispatched in keydown phase.`);306// cannot be dispatched, probably only modifier keys307return shouldPreventDefault;308}309310const contextValue = this._contextKeyService.getContext(target);311const keypressLabel = userKeypress.getLabel();312313const resolveResult = this._getResolver().resolve(contextValue, currentChords, userPressedChord);314315switch (resolveResult.kind) {316317case ResultKind.NoMatchingKb: {318319this._logService.trace('KeybindingService#dispatch', keypressLabel, `[ No matching keybinding ]`);320321if (this.inChordMode) {322const currentChordsLabel = this._currentChords.map(({ label }) => label).join(', ');323this._log(`+ Leaving multi-chord mode: Nothing bound to "${currentChordsLabel}, ${keypressLabel}".`);324this._notificationService.status(nls.localize('missing.chord', "The key combination ({0}, {1}) is not a command.", currentChordsLabel, keypressLabel), { hideAfter: 10 * 1000 /* 10s */ });325this._leaveChordMode();326327shouldPreventDefault = true;328}329return shouldPreventDefault;330}331332case ResultKind.MoreChordsNeeded: {333334this._logService.trace('KeybindingService#dispatch', keypressLabel, `[ Several keybindings match - more chords needed ]`);335336shouldPreventDefault = true;337this._expectAnotherChord(userPressedChord, keypressLabel);338this._log(this._currentChords.length === 1 ? `+ Entering multi-chord mode...` : `+ Continuing multi-chord mode...`);339return shouldPreventDefault;340}341342case ResultKind.KbFound: {343344this._logService.trace('KeybindingService#dispatch', keypressLabel, `[ Will dispatch command ${resolveResult.commandId} ]`);345346if (resolveResult.commandId === null || resolveResult.commandId === '') {347348if (this.inChordMode) {349const currentChordsLabel = this._currentChords.map(({ label }) => label).join(', ');350this._log(`+ Leaving chord mode: Nothing bound to "${currentChordsLabel}, ${keypressLabel}".`);351this._notificationService.status(nls.localize('missing.chord', "The key combination ({0}, {1}) is not a command.", currentChordsLabel, keypressLabel), { hideAfter: 10 * 1000 /* 10s */ });352this._leaveChordMode();353shouldPreventDefault = true;354}355356} else {357if (this.inChordMode) {358this._leaveChordMode();359}360361if (!resolveResult.isBubble) {362shouldPreventDefault = true;363}364365this._log(`+ Invoking command ${resolveResult.commandId}.`);366this._currentlyDispatchingCommandId = resolveResult.commandId;367try {368if (typeof resolveResult.commandArgs === 'undefined') {369this._commandService.executeCommand(resolveResult.commandId).then(undefined, err => this._notificationService.warn(err));370} else {371this._commandService.executeCommand(resolveResult.commandId, resolveResult.commandArgs).then(undefined, err => this._notificationService.warn(err));372}373} finally {374this._currentlyDispatchingCommandId = null;375}376377if (!HIGH_FREQ_COMMANDS.test(resolveResult.commandId)) {378this._telemetryService.publicLog2<WorkbenchActionExecutedEvent, WorkbenchActionExecutedClassification>('workbenchActionExecuted', { id: resolveResult.commandId, from: 'keybinding', detail: userKeypress.getUserSettingsLabel() ?? undefined });379}380}381382return shouldPreventDefault;383}384}385}386387abstract enableKeybindingHoldMode(commandId: string): Promise<void> | undefined;388389mightProducePrintableCharacter(event: IKeyboardEvent): boolean {390if (event.ctrlKey || event.metaKey) {391// ignore ctrl/cmd-combination but not shift/alt-combinatios392return false;393}394// weak check for certain ranges. this is properly implemented in a subclass395// with access to the KeyboardMapperFactory.396if ((event.keyCode >= KeyCode.KeyA && event.keyCode <= KeyCode.KeyZ)397|| (event.keyCode >= KeyCode.Digit0 && event.keyCode <= KeyCode.Digit9)) {398return true;399}400return false;401}402}403404class KeybindingModifierSet {405406public static EMPTY = new KeybindingModifierSet(null);407408private readonly _ctrlKey: boolean;409private readonly _shiftKey: boolean;410private readonly _altKey: boolean;411private readonly _metaKey: boolean;412413constructor(source: ResolvedChord | null) {414this._ctrlKey = source ? source.ctrlKey : false;415this._shiftKey = source ? source.shiftKey : false;416this._altKey = source ? source.altKey : false;417this._metaKey = source ? source.metaKey : false;418}419420has(modifier: SingleModifierChord) {421switch (modifier) {422case 'ctrl': return this._ctrlKey;423case 'shift': return this._shiftKey;424case 'alt': return this._altKey;425case 'meta': return this._metaKey;426}427}428}429430431