Path: blob/main/src/vs/platform/keybinding/common/keybindingResolver.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 { implies, ContextKeyExpression, ContextKeyExprType, IContext, IContextKeyService, expressionsAreEqualWithConstantSubstitution } from '../../contextkey/common/contextkey.js';6import { ResolvedKeybindingItem } from './resolvedKeybindingItem.js';78//#region resolution-result910export const enum ResultKind {11/** No keybinding found this sequence of chords */12NoMatchingKb,1314/** There're several keybindings that have the given sequence of chords as a prefix */15MoreChordsNeeded,1617/** A single keybinding found to be dispatched/invoked */18KbFound19}2021export type ResolutionResult =22| { kind: ResultKind.NoMatchingKb }23| { kind: ResultKind.MoreChordsNeeded }24| { kind: ResultKind.KbFound; commandId: string | null; commandArgs: any; isBubble: boolean };252627// util definitions to make working with the above types easier within this module:2829export const NoMatchingKb: ResolutionResult = { kind: ResultKind.NoMatchingKb };30const MoreChordsNeeded: ResolutionResult = { kind: ResultKind.MoreChordsNeeded };31function KbFound(commandId: string | null, commandArgs: any, isBubble: boolean): ResolutionResult {32return { kind: ResultKind.KbFound, commandId, commandArgs, isBubble };33}3435//#endregion3637/**38* Stores mappings from keybindings to commands and from commands to keybindings.39* Given a sequence of chords, `resolve`s which keybinding it matches40*/41export class KeybindingResolver {42private readonly _log: (str: string) => void;43private readonly _defaultKeybindings: ResolvedKeybindingItem[];44private readonly _keybindings: ResolvedKeybindingItem[];45private readonly _defaultBoundCommands: Map</* commandId */ string, boolean>;46private readonly _map: Map</* 1st chord's keypress */ string, ResolvedKeybindingItem[]>;47private readonly _lookupMap: Map</* commandId */ string, ResolvedKeybindingItem[]>;4849constructor(50/** built-in and extension-provided keybindings */51defaultKeybindings: ResolvedKeybindingItem[],52/** user's keybindings */53overrides: ResolvedKeybindingItem[],54log: (str: string) => void55) {56this._log = log;57this._defaultKeybindings = defaultKeybindings;5859this._defaultBoundCommands = new Map<string, boolean>();60for (const defaultKeybinding of defaultKeybindings) {61const command = defaultKeybinding.command;62if (command && command.charAt(0) !== '-') {63this._defaultBoundCommands.set(command, true);64}65}6667this._map = new Map<string, ResolvedKeybindingItem[]>();68this._lookupMap = new Map<string, ResolvedKeybindingItem[]>();6970this._keybindings = KeybindingResolver.handleRemovals(([] as ResolvedKeybindingItem[]).concat(defaultKeybindings).concat(overrides));71for (let i = 0, len = this._keybindings.length; i < len; i++) {72const k = this._keybindings[i];73if (k.chords.length === 0) {74// unbound75continue;76}7778// substitute with constants that are registered after startup - https://github.com/microsoft/vscode/issues/174218#issuecomment-143797212779const when = k.when?.substituteConstants();8081if (when && when.type === ContextKeyExprType.False) {82// when condition is false83continue;84}8586this._addKeyPress(k.chords[0], k);87}88}8990private static _isTargetedForRemoval(defaultKb: ResolvedKeybindingItem, keypress: string[] | null, when: ContextKeyExpression | undefined): boolean {91if (keypress) {92for (let i = 0; i < keypress.length; i++) {93if (keypress[i] !== defaultKb.chords[i]) {94return false;95}96}97}9899// `true` means always, as does `undefined`100// so we will treat `true` === `undefined`101if (when && when.type !== ContextKeyExprType.True) {102if (!defaultKb.when) {103return false;104}105if (!expressionsAreEqualWithConstantSubstitution(when, defaultKb.when)) {106return false;107}108}109return true;110111}112113/**114* Looks for rules containing "-commandId" and removes them.115*/116public static handleRemovals(rules: ResolvedKeybindingItem[]): ResolvedKeybindingItem[] {117// Do a first pass and construct a hash-map for removals118const removals = new Map</* commandId */ string, ResolvedKeybindingItem[]>();119for (let i = 0, len = rules.length; i < len; i++) {120const rule = rules[i];121if (rule.command && rule.command.charAt(0) === '-') {122const command = rule.command.substring(1);123if (!removals.has(command)) {124removals.set(command, [rule]);125} else {126removals.get(command)!.push(rule);127}128}129}130131if (removals.size === 0) {132// There are no removals133return rules;134}135136// Do a second pass and keep only non-removed keybindings137const result: ResolvedKeybindingItem[] = [];138for (let i = 0, len = rules.length; i < len; i++) {139const rule = rules[i];140141if (!rule.command || rule.command.length === 0) {142result.push(rule);143continue;144}145if (rule.command.charAt(0) === '-') {146continue;147}148const commandRemovals = removals.get(rule.command);149if (!commandRemovals || !rule.isDefault) {150result.push(rule);151continue;152}153let isRemoved = false;154for (const commandRemoval of commandRemovals) {155const when = commandRemoval.when;156if (this._isTargetedForRemoval(rule, commandRemoval.chords, when)) {157isRemoved = true;158break;159}160}161if (!isRemoved) {162result.push(rule);163continue;164}165}166return result;167}168169private _addKeyPress(keypress: string, item: ResolvedKeybindingItem): void {170171const conflicts = this._map.get(keypress);172173if (typeof conflicts === 'undefined') {174// There is no conflict so far175this._map.set(keypress, [item]);176this._addToLookupMap(item);177return;178}179180for (let i = conflicts.length - 1; i >= 0; i--) {181const conflict = conflicts[i];182183if (conflict.command === item.command) {184continue;185}186187// Test if the shorter keybinding is a prefix of the longer one.188// If the shorter keybinding is a prefix, it effectively will shadow the longer one and is considered a conflict.189let isShorterKbPrefix = true;190for (let i = 1; i < conflict.chords.length && i < item.chords.length; i++) {191if (conflict.chords[i] !== item.chords[i]) {192// The ith step does not conflict193isShorterKbPrefix = false;194break;195}196}197if (!isShorterKbPrefix) {198continue;199}200201if (KeybindingResolver.whenIsEntirelyIncluded(conflict.when, item.when)) {202// `item` completely overwrites `conflict`203// Remove conflict from the lookupMap204this._removeFromLookupMap(conflict);205}206}207208conflicts.push(item);209this._addToLookupMap(item);210}211212private _addToLookupMap(item: ResolvedKeybindingItem): void {213if (!item.command) {214return;215}216217let arr = this._lookupMap.get(item.command);218if (typeof arr === 'undefined') {219arr = [item];220this._lookupMap.set(item.command, arr);221} else {222arr.push(item);223}224}225226private _removeFromLookupMap(item: ResolvedKeybindingItem): void {227if (!item.command) {228return;229}230const arr = this._lookupMap.get(item.command);231if (typeof arr === 'undefined') {232return;233}234for (let i = 0, len = arr.length; i < len; i++) {235if (arr[i] === item) {236arr.splice(i, 1);237return;238}239}240}241242/**243* Returns true if it is provable `a` implies `b`.244*/245public static whenIsEntirelyIncluded(a: ContextKeyExpression | null | undefined, b: ContextKeyExpression | null | undefined): boolean {246if (!b || b.type === ContextKeyExprType.True) {247return true;248}249if (!a || a.type === ContextKeyExprType.True) {250return false;251}252253return implies(a, b);254}255256public getDefaultBoundCommands(): Map<string, boolean> {257return this._defaultBoundCommands;258}259260public getDefaultKeybindings(): readonly ResolvedKeybindingItem[] {261return this._defaultKeybindings;262}263264public getKeybindings(): readonly ResolvedKeybindingItem[] {265return this._keybindings;266}267268public lookupKeybindings(commandId: string): ResolvedKeybindingItem[] {269const items = this._lookupMap.get(commandId);270if (typeof items === 'undefined' || items.length === 0) {271return [];272}273274// Reverse to get the most specific item first275const result: ResolvedKeybindingItem[] = [];276let resultLen = 0;277for (let i = items.length - 1; i >= 0; i--) {278result[resultLen++] = items[i];279}280return result;281}282283public lookupPrimaryKeybinding(commandId: string, context: IContextKeyService, enforceContextCheck = false): ResolvedKeybindingItem | null {284const items = this._lookupMap.get(commandId);285if (typeof items === 'undefined' || items.length === 0) {286return null;287}288if (items.length === 1 && !enforceContextCheck) {289return items[0];290}291292for (let i = items.length - 1; i >= 0; i--) {293const item = items[i];294if (context.contextMatchesRules(item.when)) {295return item;296}297}298299if (enforceContextCheck) {300return null;301}302303return items[items.length - 1];304}305306/**307* Looks up a keybinding trigged as a result of pressing a sequence of chords - `[...currentChords, keypress]`308*309* Example: resolving 3 chords pressed sequentially - `cmd+k cmd+p cmd+i`:310* `currentChords = [ 'cmd+k' , 'cmd+p' ]` and `keypress = `cmd+i` - last pressed chord311*/312public resolve(context: IContext, currentChords: string[], keypress: string): ResolutionResult {313314const pressedChords = [...currentChords, keypress];315316this._log(`| Resolving ${pressedChords}`);317318const kbCandidates = this._map.get(pressedChords[0]);319if (kbCandidates === undefined) {320// No bindings with such 0-th chord321this._log(`\\ No keybinding entries.`);322return NoMatchingKb;323}324325let lookupMap: ResolvedKeybindingItem[] | null = null;326327if (pressedChords.length < 2) {328lookupMap = kbCandidates;329} else {330// Fetch all chord bindings for `currentChords`331lookupMap = [];332for (let i = 0, len = kbCandidates.length; i < len; i++) {333334const candidate = kbCandidates[i];335336if (pressedChords.length > candidate.chords.length) { // # of pressed chords can't be less than # of chords in a keybinding to invoke337continue;338}339340let prefixMatches = true;341for (let i = 1; i < pressedChords.length; i++) {342if (candidate.chords[i] !== pressedChords[i]) {343prefixMatches = false;344break;345}346}347if (prefixMatches) {348lookupMap.push(candidate);349}350}351}352353// check there's a keybinding with a matching when clause354const result = this._findCommand(context, lookupMap);355if (!result) {356this._log(`\\ From ${lookupMap.length} keybinding entries, no when clauses matched the context.`);357return NoMatchingKb;358}359360// check we got all chords necessary to be sure a particular keybinding needs to be invoked361if (pressedChords.length < result.chords.length) {362// The chord sequence is not complete363this._log(`\\ From ${lookupMap.length} keybinding entries, awaiting ${result.chords.length - pressedChords.length} more chord(s), when: ${printWhenExplanation(result.when)}, source: ${printSourceExplanation(result)}.`);364return MoreChordsNeeded;365}366367this._log(`\\ From ${lookupMap.length} keybinding entries, matched ${result.command}, when: ${printWhenExplanation(result.when)}, source: ${printSourceExplanation(result)}.`);368369return KbFound(result.command, result.commandArgs, result.bubble);370}371372private _findCommand(context: IContext, matches: ResolvedKeybindingItem[]): ResolvedKeybindingItem | null {373for (let i = matches.length - 1; i >= 0; i--) {374const k = matches[i];375376if (!KeybindingResolver._contextMatchesRules(context, k.when)) {377continue;378}379380return k;381}382383return null;384}385386private static _contextMatchesRules(context: IContext, rules: ContextKeyExpression | null | undefined): boolean {387if (!rules) {388return true;389}390return rules.evaluate(context);391}392}393394function printWhenExplanation(when: ContextKeyExpression | undefined): string {395if (!when) {396return `no when condition`;397}398return `${when.serialize()}`;399}400401function printSourceExplanation(kb: ResolvedKeybindingItem): string {402return (403kb.extensionId404? (kb.isBuiltinExtension ? `built-in extension ${kb.extensionId}` : `user extension ${kb.extensionId}`)405: (kb.isDefault ? `built-in` : `user`)406);407}408409410