mirror of
https://github.com/microsoft/vscode.git
synced 2026-05-18 06:09:20 +01:00
347 lines
13 KiB
TypeScript
347 lines
13 KiB
TypeScript
/*---------------------------------------------------------------------------------------------
|
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
* Licensed under the MIT License. See License.txt in the project root for license information.
|
|
*--------------------------------------------------------------------------------------------*/
|
|
|
|
import { WorkbenchActionExecutedClassification, WorkbenchActionExecutedEvent } from 'vs/base/common/actions';
|
|
import * as arrays from 'vs/base/common/arrays';
|
|
import { IntervalTimer, TimeoutTimer } from 'vs/base/common/async';
|
|
import { Emitter, Event } from 'vs/base/common/event';
|
|
import { Keybinding, KeyCode, KeybindingModifier, ResolvedKeybinding, ResolvedKeybindingPart } from 'vs/base/common/keyCodes';
|
|
import { Disposable, IDisposable } from 'vs/base/common/lifecycle';
|
|
import * as nls from 'vs/nls';
|
|
import { ICommandService } from 'vs/platform/commands/common/commands';
|
|
import { IContextKeyService, IContextKeyServiceTarget } from 'vs/platform/contextkey/common/contextkey';
|
|
import { IKeybindingEvent, IKeybindingService, IKeyboardEvent, KeybindingsSchemaContribution } from 'vs/platform/keybinding/common/keybinding';
|
|
import { IResolveResult, KeybindingResolver } from 'vs/platform/keybinding/common/keybindingResolver';
|
|
import { ResolvedKeybindingItem } from 'vs/platform/keybinding/common/resolvedKeybindingItem';
|
|
import { ILogService } from 'vs/platform/log/common/log';
|
|
import { INotificationService } from 'vs/platform/notification/common/notification';
|
|
import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry';
|
|
|
|
interface CurrentChord {
|
|
keypress: string;
|
|
label: string | null;
|
|
}
|
|
|
|
const HIGH_FREQ_COMMANDS = /^(cursor|delete)/;
|
|
|
|
export abstract class AbstractKeybindingService extends Disposable implements IKeybindingService {
|
|
public _serviceBrand: undefined;
|
|
|
|
protected readonly _onDidUpdateKeybindings: Emitter<IKeybindingEvent> = this._register(new Emitter<IKeybindingEvent>());
|
|
get onDidUpdateKeybindings(): Event<IKeybindingEvent> {
|
|
return this._onDidUpdateKeybindings ? this._onDidUpdateKeybindings.event : Event.None; // Sinon stubbing walks properties on prototype
|
|
}
|
|
|
|
private _currentChord: CurrentChord | null;
|
|
private _currentChordChecker: IntervalTimer;
|
|
private _currentChordStatusMessage: IDisposable | null;
|
|
private _ignoreSingleModifiers: KeybindingModifierSet;
|
|
private _currentSingleModifier: KeybindingModifier | null;
|
|
private _currentSingleModifierClearTimeout: TimeoutTimer;
|
|
|
|
protected _logging: boolean;
|
|
|
|
public get inChordMode(): boolean {
|
|
return !!this._currentChord;
|
|
}
|
|
|
|
constructor(
|
|
private _contextKeyService: IContextKeyService,
|
|
protected _commandService: ICommandService,
|
|
protected _telemetryService: ITelemetryService,
|
|
private _notificationService: INotificationService,
|
|
protected _logService: ILogService,
|
|
) {
|
|
super();
|
|
|
|
this._currentChord = null;
|
|
this._currentChordChecker = new IntervalTimer();
|
|
this._currentChordStatusMessage = null;
|
|
this._ignoreSingleModifiers = KeybindingModifierSet.EMPTY;
|
|
this._currentSingleModifier = null;
|
|
this._currentSingleModifierClearTimeout = new TimeoutTimer();
|
|
this._logging = false;
|
|
}
|
|
|
|
public override dispose(): void {
|
|
super.dispose();
|
|
}
|
|
|
|
protected abstract _getResolver(): KeybindingResolver;
|
|
protected abstract _documentHasFocus(): boolean;
|
|
public abstract resolveKeybinding(keybinding: Keybinding): ResolvedKeybinding[];
|
|
public abstract resolveKeyboardEvent(keyboardEvent: IKeyboardEvent): ResolvedKeybinding;
|
|
public abstract resolveUserBinding(userBinding: string): ResolvedKeybinding[];
|
|
public abstract registerSchemaContribution(contribution: KeybindingsSchemaContribution): void;
|
|
public abstract _dumpDebugInfo(): string;
|
|
public abstract _dumpDebugInfoJSON(): string;
|
|
|
|
public getDefaultKeybindingsContent(): string {
|
|
return '';
|
|
}
|
|
|
|
public toggleLogging(): boolean {
|
|
this._logging = !this._logging;
|
|
return this._logging;
|
|
}
|
|
|
|
protected _log(str: string): void {
|
|
if (this._logging) {
|
|
this._logService.info(`[KeybindingService]: ${str}`);
|
|
}
|
|
}
|
|
|
|
public getDefaultKeybindings(): readonly ResolvedKeybindingItem[] {
|
|
return this._getResolver().getDefaultKeybindings();
|
|
}
|
|
|
|
public getKeybindings(): readonly ResolvedKeybindingItem[] {
|
|
return this._getResolver().getKeybindings();
|
|
}
|
|
|
|
public customKeybindingsCount(): number {
|
|
return 0;
|
|
}
|
|
|
|
public lookupKeybindings(commandId: string): ResolvedKeybinding[] {
|
|
return arrays.coalesce(
|
|
this._getResolver().lookupKeybindings(commandId).map(item => item.resolvedKeybinding)
|
|
);
|
|
}
|
|
|
|
public lookupKeybinding(commandId: string, context?: IContextKeyService): ResolvedKeybinding | undefined {
|
|
const result = this._getResolver().lookupPrimaryKeybinding(commandId, context || this._contextKeyService);
|
|
if (!result) {
|
|
return undefined;
|
|
}
|
|
return result.resolvedKeybinding;
|
|
}
|
|
|
|
public dispatchEvent(e: IKeyboardEvent, target: IContextKeyServiceTarget): boolean {
|
|
return this._dispatch(e, target);
|
|
}
|
|
|
|
public softDispatch(e: IKeyboardEvent, target: IContextKeyServiceTarget): IResolveResult | null {
|
|
const keybinding = this.resolveKeyboardEvent(e);
|
|
if (keybinding.isChord()) {
|
|
console.warn('Unexpected keyboard event mapped to a chord');
|
|
return null;
|
|
}
|
|
const [firstPart,] = keybinding.getDispatchParts();
|
|
if (firstPart === null) {
|
|
// cannot be dispatched, probably only modifier keys
|
|
return null;
|
|
}
|
|
|
|
const contextValue = this._contextKeyService.getContext(target);
|
|
const currentChord = this._currentChord ? this._currentChord.keypress : null;
|
|
return this._getResolver().resolve(contextValue, currentChord, firstPart);
|
|
}
|
|
|
|
private _enterChordMode(firstPart: string, keypressLabel: string | null): void {
|
|
this._currentChord = {
|
|
keypress: firstPart,
|
|
label: keypressLabel
|
|
};
|
|
this._currentChordStatusMessage = this._notificationService.status(nls.localize('first.chord', "({0}) was pressed. Waiting for second key of chord...", keypressLabel));
|
|
const chordEnterTime = Date.now();
|
|
this._currentChordChecker.cancelAndSet(() => {
|
|
|
|
if (!this._documentHasFocus()) {
|
|
// Focus has been lost => leave chord mode
|
|
this._leaveChordMode();
|
|
return;
|
|
}
|
|
|
|
if (Date.now() - chordEnterTime > 5000) {
|
|
// 5 seconds elapsed => leave chord mode
|
|
this._leaveChordMode();
|
|
}
|
|
|
|
}, 500);
|
|
}
|
|
|
|
private _leaveChordMode(): void {
|
|
if (this._currentChordStatusMessage) {
|
|
this._currentChordStatusMessage.dispose();
|
|
this._currentChordStatusMessage = null;
|
|
}
|
|
this._currentChordChecker.cancel();
|
|
this._currentChord = null;
|
|
}
|
|
|
|
public dispatchByUserSettingsLabel(userSettingsLabel: string, target: IContextKeyServiceTarget): void {
|
|
this._log(`/ Dispatching keybinding triggered via menu entry accelerator - ${userSettingsLabel}`);
|
|
const keybindings = this.resolveUserBinding(userSettingsLabel);
|
|
if (keybindings.length === 0) {
|
|
this._log(`\\ Could not resolve - ${userSettingsLabel}`);
|
|
} else {
|
|
this._doDispatch(keybindings[0], target, /*isSingleModiferChord*/false);
|
|
}
|
|
}
|
|
|
|
protected _dispatch(e: IKeyboardEvent, target: IContextKeyServiceTarget): boolean {
|
|
return this._doDispatch(this.resolveKeyboardEvent(e), target, /*isSingleModiferChord*/false);
|
|
}
|
|
|
|
protected _singleModifierDispatch(e: IKeyboardEvent, target: IContextKeyServiceTarget): boolean {
|
|
const keybinding = this.resolveKeyboardEvent(e);
|
|
const [singleModifier,] = keybinding.getSingleModifierDispatchParts();
|
|
|
|
if (singleModifier) {
|
|
|
|
if (this._ignoreSingleModifiers.has(singleModifier)) {
|
|
this._log(`+ Ignoring single modifier ${singleModifier} due to it being pressed together with other keys.`);
|
|
this._ignoreSingleModifiers = KeybindingModifierSet.EMPTY;
|
|
this._currentSingleModifierClearTimeout.cancel();
|
|
this._currentSingleModifier = null;
|
|
return false;
|
|
}
|
|
|
|
this._ignoreSingleModifiers = KeybindingModifierSet.EMPTY;
|
|
|
|
if (this._currentSingleModifier === null) {
|
|
// we have a valid `singleModifier`, store it for the next keyup, but clear it in 300ms
|
|
this._log(`+ Storing single modifier for possible chord ${singleModifier}.`);
|
|
this._currentSingleModifier = singleModifier;
|
|
this._currentSingleModifierClearTimeout.cancelAndSet(() => {
|
|
this._log(`+ Clearing single modifier due to 300ms elapsed.`);
|
|
this._currentSingleModifier = null;
|
|
}, 300);
|
|
return false;
|
|
}
|
|
|
|
if (singleModifier === this._currentSingleModifier) {
|
|
// bingo!
|
|
this._log(`/ Dispatching single modifier chord ${singleModifier} ${singleModifier}`);
|
|
this._currentSingleModifierClearTimeout.cancel();
|
|
this._currentSingleModifier = null;
|
|
return this._doDispatch(keybinding, target, /*isSingleModiferChord*/true);
|
|
}
|
|
|
|
this._log(`+ Clearing single modifier due to modifier mismatch: ${this._currentSingleModifier} ${singleModifier}`);
|
|
this._currentSingleModifierClearTimeout.cancel();
|
|
this._currentSingleModifier = null;
|
|
return false;
|
|
}
|
|
|
|
// When pressing a modifier and holding it pressed with any other modifier or key combination,
|
|
// the pressed modifiers should no longer be considered for single modifier dispatch.
|
|
const [firstPart,] = keybinding.getParts();
|
|
this._ignoreSingleModifiers = new KeybindingModifierSet(firstPart);
|
|
|
|
if (this._currentSingleModifier !== null) {
|
|
this._log(`+ Clearing single modifier due to other key up.`);
|
|
}
|
|
this._currentSingleModifierClearTimeout.cancel();
|
|
this._currentSingleModifier = null;
|
|
return false;
|
|
}
|
|
|
|
private _doDispatch(keybinding: ResolvedKeybinding, target: IContextKeyServiceTarget, isSingleModiferChord = false): boolean {
|
|
let shouldPreventDefault = false;
|
|
|
|
if (keybinding.isChord()) {
|
|
console.warn('Unexpected keyboard event mapped to a chord');
|
|
return false;
|
|
}
|
|
|
|
let firstPart: string | null = null; // the first keybinding i.e. Ctrl+K
|
|
let currentChord: string | null = null;// the "second" keybinding i.e. Ctrl+K "Ctrl+D"
|
|
|
|
if (isSingleModiferChord) {
|
|
const [dispatchKeyname,] = keybinding.getSingleModifierDispatchParts();
|
|
firstPart = dispatchKeyname;
|
|
currentChord = dispatchKeyname;
|
|
} else {
|
|
[firstPart,] = keybinding.getDispatchParts();
|
|
currentChord = this._currentChord ? this._currentChord.keypress : null;
|
|
}
|
|
|
|
if (firstPart === null) {
|
|
this._log(`\\ Keyboard event cannot be dispatched in keydown phase.`);
|
|
// cannot be dispatched, probably only modifier keys
|
|
return shouldPreventDefault;
|
|
}
|
|
|
|
const contextValue = this._contextKeyService.getContext(target);
|
|
const keypressLabel = keybinding.getLabel();
|
|
const resolveResult = this._getResolver().resolve(contextValue, currentChord, firstPart);
|
|
|
|
this._logService.trace('KeybindingService#dispatch', keypressLabel, resolveResult?.commandId);
|
|
|
|
if (resolveResult && resolveResult.enterChord) {
|
|
shouldPreventDefault = true;
|
|
this._enterChordMode(firstPart, keypressLabel);
|
|
return shouldPreventDefault;
|
|
}
|
|
|
|
if (this._currentChord) {
|
|
if (!resolveResult || !resolveResult.commandId) {
|
|
this._notificationService.status(nls.localize('missing.chord', "The key combination ({0}, {1}) is not a command.", this._currentChord.label, keypressLabel), { hideAfter: 10 * 1000 /* 10s */ });
|
|
shouldPreventDefault = true;
|
|
}
|
|
}
|
|
|
|
this._leaveChordMode();
|
|
|
|
if (resolveResult && resolveResult.commandId) {
|
|
if (!resolveResult.bubble) {
|
|
shouldPreventDefault = true;
|
|
}
|
|
if (typeof resolveResult.commandArgs === 'undefined') {
|
|
this._commandService.executeCommand(resolveResult.commandId).then(undefined, err => this._notificationService.warn(err));
|
|
} else {
|
|
this._commandService.executeCommand(resolveResult.commandId, resolveResult.commandArgs).then(undefined, err => this._notificationService.warn(err));
|
|
}
|
|
if (!HIGH_FREQ_COMMANDS.test(resolveResult.commandId)) {
|
|
this._telemetryService.publicLog2<WorkbenchActionExecutedEvent, WorkbenchActionExecutedClassification>('workbenchActionExecuted', { id: resolveResult.commandId, from: 'keybinding' });
|
|
}
|
|
}
|
|
|
|
return shouldPreventDefault;
|
|
}
|
|
|
|
mightProducePrintableCharacter(event: IKeyboardEvent): boolean {
|
|
if (event.ctrlKey || event.metaKey) {
|
|
// ignore ctrl/cmd-combination but not shift/alt-combinatios
|
|
return false;
|
|
}
|
|
// weak check for certain ranges. this is properly implemented in a subclass
|
|
// with access to the KeyboardMapperFactory.
|
|
if ((event.keyCode >= KeyCode.KEY_A && event.keyCode <= KeyCode.KEY_Z)
|
|
|| (event.keyCode >= KeyCode.KEY_0 && event.keyCode <= KeyCode.KEY_9)) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
class KeybindingModifierSet {
|
|
|
|
public static EMPTY = new KeybindingModifierSet(null);
|
|
|
|
private readonly _ctrlKey: boolean;
|
|
private readonly _shiftKey: boolean;
|
|
private readonly _altKey: boolean;
|
|
private readonly _metaKey: boolean;
|
|
|
|
constructor(source: ResolvedKeybindingPart | null) {
|
|
this._ctrlKey = source ? source.ctrlKey : false;
|
|
this._shiftKey = source ? source.shiftKey : false;
|
|
this._altKey = source ? source.altKey : false;
|
|
this._metaKey = source ? source.metaKey : false;
|
|
}
|
|
|
|
has(modifier: KeybindingModifier) {
|
|
switch (modifier) {
|
|
case 'ctrl': return this._ctrlKey;
|
|
case 'shift': return this._shiftKey;
|
|
case 'alt': return this._altKey;
|
|
case 'meta': return this._metaKey;
|
|
}
|
|
}
|
|
}
|