UNPKG

monaco-editor-core

Version:

A browser based code editor

299 lines (298 loc) • 11.6 kB
/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { implies, expressionsAreEqualWithConstantSubstitution } from '../../contextkey/common/contextkey.js'; // util definitions to make working with the above types easier within this module: export const NoMatchingKb = { kind: 0 /* ResultKind.NoMatchingKb */ }; const MoreChordsNeeded = { kind: 1 /* ResultKind.MoreChordsNeeded */ }; function KbFound(commandId, commandArgs, isBubble) { return { kind: 2 /* ResultKind.KbFound */, commandId, commandArgs, isBubble }; } //#endregion /** * Stores mappings from keybindings to commands and from commands to keybindings. * Given a sequence of chords, `resolve`s which keybinding it matches */ export class KeybindingResolver { constructor( /** built-in and extension-provided keybindings */ defaultKeybindings, /** user's keybindings */ overrides, log) { this._log = log; this._defaultKeybindings = defaultKeybindings; this._defaultBoundCommands = new Map(); for (const defaultKeybinding of defaultKeybindings) { const command = defaultKeybinding.command; if (command && command.charAt(0) !== '-') { this._defaultBoundCommands.set(command, true); } } this._map = new Map(); this._lookupMap = new Map(); this._keybindings = KeybindingResolver.handleRemovals([].concat(defaultKeybindings).concat(overrides)); for (let i = 0, len = this._keybindings.length; i < len; i++) { const k = this._keybindings[i]; if (k.chords.length === 0) { // unbound continue; } // substitute with constants that are registered after startup - https://github.com/microsoft/vscode/issues/174218#issuecomment-1437972127 const when = k.when?.substituteConstants(); if (when && when.type === 0 /* ContextKeyExprType.False */) { // when condition is false continue; } this._addKeyPress(k.chords[0], k); } } static _isTargetedForRemoval(defaultKb, keypress, when) { if (keypress) { for (let i = 0; i < keypress.length; i++) { if (keypress[i] !== defaultKb.chords[i]) { return false; } } } // `true` means always, as does `undefined` // so we will treat `true` === `undefined` if (when && when.type !== 1 /* ContextKeyExprType.True */) { if (!defaultKb.when) { return false; } if (!expressionsAreEqualWithConstantSubstitution(when, defaultKb.when)) { return false; } } return true; } /** * Looks for rules containing "-commandId" and removes them. */ static handleRemovals(rules) { // Do a first pass and construct a hash-map for removals const removals = new Map(); for (let i = 0, len = rules.length; i < len; i++) { const rule = rules[i]; if (rule.command && rule.command.charAt(0) === '-') { const command = rule.command.substring(1); if (!removals.has(command)) { removals.set(command, [rule]); } else { removals.get(command).push(rule); } } } if (removals.size === 0) { // There are no removals return rules; } // Do a second pass and keep only non-removed keybindings const result = []; for (let i = 0, len = rules.length; i < len; i++) { const rule = rules[i]; if (!rule.command || rule.command.length === 0) { result.push(rule); continue; } if (rule.command.charAt(0) === '-') { continue; } const commandRemovals = removals.get(rule.command); if (!commandRemovals || !rule.isDefault) { result.push(rule); continue; } let isRemoved = false; for (const commandRemoval of commandRemovals) { const when = commandRemoval.when; if (this._isTargetedForRemoval(rule, commandRemoval.chords, when)) { isRemoved = true; break; } } if (!isRemoved) { result.push(rule); continue; } } return result; } _addKeyPress(keypress, item) { const conflicts = this._map.get(keypress); if (typeof conflicts === 'undefined') { // There is no conflict so far this._map.set(keypress, [item]); this._addToLookupMap(item); return; } for (let i = conflicts.length - 1; i >= 0; i--) { const conflict = conflicts[i]; if (conflict.command === item.command) { continue; } // Test if the shorter keybinding is a prefix of the longer one. // If the shorter keybinding is a prefix, it effectively will shadow the longer one and is considered a conflict. let isShorterKbPrefix = true; for (let i = 1; i < conflict.chords.length && i < item.chords.length; i++) { if (conflict.chords[i] !== item.chords[i]) { // The ith step does not conflict isShorterKbPrefix = false; break; } } if (!isShorterKbPrefix) { continue; } if (KeybindingResolver.whenIsEntirelyIncluded(conflict.when, item.when)) { // `item` completely overwrites `conflict` // Remove conflict from the lookupMap this._removeFromLookupMap(conflict); } } conflicts.push(item); this._addToLookupMap(item); } _addToLookupMap(item) { if (!item.command) { return; } let arr = this._lookupMap.get(item.command); if (typeof arr === 'undefined') { arr = [item]; this._lookupMap.set(item.command, arr); } else { arr.push(item); } } _removeFromLookupMap(item) { if (!item.command) { return; } const arr = this._lookupMap.get(item.command); if (typeof arr === 'undefined') { return; } for (let i = 0, len = arr.length; i < len; i++) { if (arr[i] === item) { arr.splice(i, 1); return; } } } /** * Returns true if it is provable `a` implies `b`. */ static whenIsEntirelyIncluded(a, b) { if (!b || b.type === 1 /* ContextKeyExprType.True */) { return true; } if (!a || a.type === 1 /* ContextKeyExprType.True */) { return false; } return implies(a, b); } getKeybindings() { return this._keybindings; } lookupPrimaryKeybinding(commandId, context) { const items = this._lookupMap.get(commandId); if (typeof items === 'undefined' || items.length === 0) { return null; } if (items.length === 1) { return items[0]; } for (let i = items.length - 1; i >= 0; i--) { const item = items[i]; if (context.contextMatchesRules(item.when)) { return item; } } return items[items.length - 1]; } /** * Looks up a keybinding trigged as a result of pressing a sequence of chords - `[...currentChords, keypress]` * * Example: resolving 3 chords pressed sequentially - `cmd+k cmd+p cmd+i`: * `currentChords = [ 'cmd+k' , 'cmd+p' ]` and `keypress = `cmd+i` - last pressed chord */ resolve(context, currentChords, keypress) { const pressedChords = [...currentChords, keypress]; this._log(`| Resolving ${pressedChords}`); const kbCandidates = this._map.get(pressedChords[0]); if (kbCandidates === undefined) { // No bindings with such 0-th chord this._log(`\\ No keybinding entries.`); return NoMatchingKb; } let lookupMap = null; if (pressedChords.length < 2) { lookupMap = kbCandidates; } else { // Fetch all chord bindings for `currentChords` lookupMap = []; for (let i = 0, len = kbCandidates.length; i < len; i++) { const candidate = kbCandidates[i]; if (pressedChords.length > candidate.chords.length) { // # of pressed chords can't be less than # of chords in a keybinding to invoke continue; } let prefixMatches = true; for (let i = 1; i < pressedChords.length; i++) { if (candidate.chords[i] !== pressedChords[i]) { prefixMatches = false; break; } } if (prefixMatches) { lookupMap.push(candidate); } } } // check there's a keybinding with a matching when clause const result = this._findCommand(context, lookupMap); if (!result) { this._log(`\\ From ${lookupMap.length} keybinding entries, no when clauses matched the context.`); return NoMatchingKb; } // check we got all chords necessary to be sure a particular keybinding needs to be invoked if (pressedChords.length < result.chords.length) { // The chord sequence is not complete this._log(`\\ From ${lookupMap.length} keybinding entries, awaiting ${result.chords.length - pressedChords.length} more chord(s), when: ${printWhenExplanation(result.when)}, source: ${printSourceExplanation(result)}.`); return MoreChordsNeeded; } this._log(`\\ From ${lookupMap.length} keybinding entries, matched ${result.command}, when: ${printWhenExplanation(result.when)}, source: ${printSourceExplanation(result)}.`); return KbFound(result.command, result.commandArgs, result.bubble); } _findCommand(context, matches) { for (let i = matches.length - 1; i >= 0; i--) { const k = matches[i]; if (!KeybindingResolver._contextMatchesRules(context, k.when)) { continue; } return k; } return null; } static _contextMatchesRules(context, rules) { if (!rules) { return true; } return rules.evaluate(context); } } function printWhenExplanation(when) { if (!when) { return `no when condition`; } return `${when.serialize()}`; } function printSourceExplanation(kb) { return (kb.extensionId ? (kb.isBuiltinExtension ? `built-in extension ${kb.extensionId}` : `user extension ${kb.extensionId}`) : (kb.isDefault ? `built-in` : `user`)); }