UNPKG

@difizen/mana-core

Version:

858 lines (791 loc) 28.4 kB
import type { Event } from '@difizen/mana-common'; import { isOSX } from '@difizen/mana-common'; import { Emitter, Disposable, DisposableCollection } from '@difizen/mana-common'; import { contrib, inject, singleton, Syringe } from '@difizen/mana-syringe'; import type { Contribution } from '@difizen/mana-syringe'; import type { Application } from '../application'; import { ApplicationContribution } from '../application'; import { CommandRegistry, Command } from '../command'; import { DebugService } from '../common/debug'; import { KeyboardLayoutService } from '../keyboard/keyboard-layout-service'; import { KeyCode, KeySequence, Key } from '../keyboard/keys'; import { ContextKeyService } from './context-key-service'; import { KeybindingContribution } from './keybinding-proocol'; /** * A Keybinding binds a specific key sequence ({@link Keybinding#keybinding}) to trigger a command ({@link Keybinding#command}). A Keybinding optionally may * define a "when clause" ({@link Keybinding#when}) to specify in which context it becomes active. * @see KeyBindingRegistry */ export type Keybinding = { /** * Unique command identifier of the command to be triggered by this keybinding. */ command: string; /** * The key sequence for the keybinding as defined in packages/keymaps/README.md. */ keybinding: string; /** * The optional keybinding context where this binding belongs to. * If not specified, then this keybinding context belongs to the NOOP * keybinding context. * * @deprecated use `when` closure instead */ context?: string | undefined; /** * An optional clause defining the condition when the keybinding is active, e.g. based on the current focus. * See {@link https://code.visualstudio.com/docs/getstarted/keybindings#_when-clause-contexts} for more details. */ when?: string | undefined; /** * Optional arguments that will be passed to the command when it gets triggered via this keybinding. * Needs to be specified when the triggered command expects arguments to be passed to the command handler. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any args?: any; preventDefault?: boolean; stopPropagation?: boolean; }; export namespace Keybinding { /** * Compares two keybindings for equality. * Can optionally ignore the keybinding and/or args property in the comparison. * @param a The first Keybinding in the comparison * @param b The second Keybinding in the comparison * @param ignoreKeybinding Ignore the 'keybinding' property in the comparison * @param ignoreArgs Ignore the 'args' property in the comparison */ export function equals( a: Keybinding, b: Keybinding, ignoreKeybinding = false, ignoreArgs = false, ): boolean { if ( a.command === b.command && (a.context || '') === (b.context || '') && (a.when || '') === (b.when || '') && (ignoreKeybinding || a.keybinding === b.keybinding) && (ignoreArgs || (a.args || '') === (b.args || '')) ) { return true; } return false; } /** * Returns a new object only containing properties which * are described on the `Keybinding` API. * * @param binding the binding to create an API object for. */ export function apiObjectify(binding: Keybinding): Keybinding { return { command: binding.command, keybinding: binding.keybinding, context: binding.context, when: binding.when, args: binding.args, }; } /** * Returns with the string representation of the binding. * Any additional properties which are not described on * the `Keybinding` API will be ignored. * * @param binding the binding to stringify. */ export function stringify(binding: Keybinding): string { return JSON.stringify(apiObjectify(binding)); } /* Determine whether object is a KeyBinding */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export function is(arg: Keybinding | any): arg is Keybinding { return !!arg && arg === Object(arg) && 'command' in arg && 'keybinding' in arg; } } export enum KeybindingScope { DEFAULT, USER, WORKSPACE, END, } export namespace KeybindingScope { export const length = KeybindingScope.END - KeybindingScope.DEFAULT; } export type ResolvedKeybinding = { /** * The KeyboardLayoutService may transform the `keybinding` depending on the * user's keyboard layout. This property holds the transformed keybinding that * should be used in the UI. The value is undefined if the KeyboardLayoutService * has not been called yet to resolve the keybinding. */ resolved?: KeyCode[] | undefined; } & Keybinding; export type ScopedKeybinding = { /** Current keybinding scope */ scope: KeybindingScope; } & Keybinding; export const KeybindingContext = Syringe.defineToken('KeybindingContext'); export type KeybindingContext = { /** * The unique ID of the current context. */ readonly id: string; isEnabled: (arg: Keybinding) => boolean; }; export namespace KeybindingContexts { export const NOOP_CONTEXT: KeybindingContext = { id: 'noop.keybinding.context', isEnabled: () => true, }; export const DEFAULT_CONTEXT: KeybindingContext = { id: 'default.keybinding.context', isEnabled: () => false, }; } @singleton({ contrib: ApplicationContribution }) export class KeybindingRegistry implements ApplicationContribution { preventDefault?: boolean = false; stopPropagation?: boolean = false; static readonly PASSTHROUGH_PSEUDO_COMMAND = 'passthrough'; protected keySequence: KeySequence = []; protected readonly contexts: Record<string, KeybindingContext> = {}; protected readonly keymaps: ScopedKeybinding[][] = [ ...Array(KeybindingScope.length), ].map(() => []); protected emitter: Emitter<KeybindingRegistry.Match | undefined> = new Emitter(); protected readonly keyboardLayoutService: KeyboardLayoutService; protected readonly contextProvider: Contribution.Provider<KeybindingContext>; protected readonly commandRegistry: CommandRegistry; protected readonly contributions: Contribution.Provider<KeybindingContribution>; protected readonly logger: DebugService; protected readonly whenContextService: ContextKeyService; constructor( @inject(KeyboardLayoutService) keyboardLayoutService: KeyboardLayoutService, @contrib(KeybindingContext) contextProvider: Contribution.Provider<KeybindingContext>, @inject(CommandRegistry) commandRegistry: CommandRegistry, @contrib(KeybindingContribution) contributions: Contribution.Provider<KeybindingContribution>, @inject(DebugService) logger: DebugService, @inject(ContextKeyService) whenContextService: ContextKeyService, ) { this.keyboardLayoutService = keyboardLayoutService; this.contextProvider = contextProvider; this.commandRegistry = commandRegistry; this.contributions = contributions; this.logger = logger; this.whenContextService = whenContextService; } async onStart(app: Application): Promise<void> { await this.keyboardLayoutService.initialize(); this.keyboardLayoutService.onKeyboardLayoutChanged(() => { this.clearResolvedKeybindings(); this.keybindingsChanged.fire(undefined); }); this.registerContext(KeybindingContexts.NOOP_CONTEXT); this.registerContext(KeybindingContexts.DEFAULT_CONTEXT); this.registerContext(...this.contextProvider.getContributions()); for (const contribution of this.contributions.getContributions()) { contribution.registerKeybindings(this); } app.onKeyDown((event) => { if (app.inComposition !== true) { this.run(event); } }); } protected keybindingsChanged = new Emitter<void>(); /** * Event that is fired when the resolved keybindings change due to a different keyboard layout * or when a new keymap is being set */ get onKeybindingsChanged(): Event<void> { return this.keybindingsChanged.event; } /** * Registers the keybinding context arguments into the application. Fails when an already registered * context is being registered. * * @param contexts the keybinding contexts to register into the application. */ protected registerContext(...contexts: KeybindingContext[]): void { for (const context of contexts) { const { id } = context; if (this.contexts[id]) { this.logger(`A keybinding context with ID ${id} is already registered.`); } else { this.contexts[id] = context; } } } /** * Register a default keybinding to the registry. * * Keybindings registered later have higher priority during evaluation. * * @param binding the keybinding to be registered */ registerKeybinding(binding: Keybinding): Disposable { return this.doRegisterKeybinding(binding); } /** * Register multiple default keybindings to the registry * * @param bindings An array of keybinding to be registered */ registerKeybindings(...bindings: Keybinding[]): Disposable { return this.doRegisterKeybindings(bindings, KeybindingScope.DEFAULT); } /** * Unregister all keybindings from the registry that are bound to the key of the given keybinding * * @param binding a keybinding specifying the key to be unregistered */ unregisterKeybinding(binding: Keybinding): void; /** * Unregister all keybindings with the given key from the registry * * @param key a key to be unregistered */ unregisterKeybinding(key: string): void; /** * Unregister all existing keybindings for the given command * @param command the command to unregister all keybindings for */ unregisterKeybinding(command: Command): void; unregisterKeybinding(arg: Keybinding | string | Command): void { const keymap = this.keymaps[KeybindingScope.DEFAULT]; const filter = Command.is(arg) ? ({ command }: Keybinding) => command === arg.id : ({ keybinding }: Keybinding) => Keybinding.is(arg) ? keybinding === arg.keybinding : keybinding === arg; for (const binding of keymap.filter(filter)) { const idx = keymap.indexOf(binding); if (idx !== -1) { keymap.splice(idx, 1); } } } protected doRegisterKeybindings( bindings: Keybinding[], scope: KeybindingScope = KeybindingScope.DEFAULT, ): Disposable { const toDispose = new DisposableCollection(); for (const binding of bindings) { toDispose.push(this.doRegisterKeybinding(binding, scope)); } return toDispose; } protected doRegisterKeybinding( binding: Keybinding, scope: KeybindingScope = KeybindingScope.DEFAULT, ): Disposable { try { this.resolveKeybinding(binding); const scoped = Object.assign(binding, { scope }); this.insertBindingIntoScope(scoped, scope); return Disposable.create(() => { const index = this.keymaps[scope].indexOf(scoped); if (index !== -1) { this.keymaps[scope].splice(index, 1); } }); } catch (error) { this.logger( `Could not register keybinding:\n ${Keybinding.stringify(binding)}\n${error}`, ); return Disposable.NONE; } } /** * Ensures that keybindings are inserted in order of increasing length of binding to ensure that if a * user triggers a short keybinding (e.g. ctrl+k), the UI won't wait for a longer one (e.g. ctrl+k enter) */ protected insertBindingIntoScope( item: Keybinding & { scope: KeybindingScope }, scope: KeybindingScope, ): void { const scopedKeymap = this.keymaps[scope]; const getNumberOfKeystrokes = (binding: Keybinding): number => (binding.keybinding.trim().match(/\s/g)?.length ?? 0) + 1; const numberOfKeystrokesInBinding = getNumberOfKeystrokes(item); const indexOfFirstItemWithEqualStrokes = scopedKeymap.findIndex( (existingBinding) => getNumberOfKeystrokes(existingBinding) === numberOfKeystrokesInBinding, ); if (indexOfFirstItemWithEqualStrokes > -1) { scopedKeymap.splice(indexOfFirstItemWithEqualStrokes, 0, item); } else { scopedKeymap.push(item); } } /** * Ensure that the `resolved` property of the given binding is set by calling the KeyboardLayoutService. */ resolveKeybinding(binding: ResolvedKeybinding): KeyCode[] { if (!binding.resolved) { const sequence = KeySequence.parse(binding.keybinding); binding.resolved = sequence.map((code) => this.keyboardLayoutService.resolveKeyCode(code), ); } return binding.resolved; } /** * Clear all `resolved` properties of registered keybindings so the KeyboardLayoutService is called * again to resolve them. This is necessary when the user's keyboard layout has changed. */ protected clearResolvedKeybindings(): void { for (let i = KeybindingScope.DEFAULT; i < KeybindingScope.END; i++) { const bindings = this.keymaps[i]; for (let j = 0; j < bindings.length; j++) { const binding = bindings[j] as ResolvedKeybinding; binding.resolved = undefined; } } } /** * Checks whether a colliding {@link common.Keybinding} exists in a specific scope. * @param binding the keybinding to check * @param scope the keybinding scope to check * @returns true if there is a colliding keybinding */ containsKeybindingInScope( binding: Keybinding, scope = KeybindingScope.USER, ): boolean { const bindingKeySequence = this.resolveKeybinding(binding); const collisions = this.getKeySequenceCollisions( this.getUsableBindings(this.keymaps[scope]), bindingKeySequence, ).filter((b) => b.context === binding.context && !b.when && !binding.when); if (collisions.full.length > 0) { return true; } if (collisions.partial.length > 0) { return true; } if (collisions.shadow.length > 0) { return true; } return false; } /** * Get a user visible representation of a {@link common.Keybinding}. * @returns an array of strings representing all elements of the {@link KeySequence} defined by the {@link common.Keybinding} * @param keybinding the keybinding * @param separator the separator to be used to stringify {@link KeyCode}s that are part of the {@link KeySequence} */ acceleratorFor(keybinding: Keybinding, separator = ' '): string[] { const bindingKeySequence = this.resolveKeybinding(keybinding); return this.acceleratorForSequence(bindingKeySequence, separator); } /** * Get a user visible representation of a {@link KeySequence}. * @returns an array of strings representing all elements of the {@link KeySequence} * @param keySequence the keysequence * @param separator the separator to be used to stringify {@link KeyCode}s that are part of the {@link KeySequence} */ acceleratorForSequence(keySequence: KeySequence, separator = ' '): string[] { return keySequence.map((keyCode) => this.acceleratorForKeyCode(keyCode, separator)); } /** * Get a user visible representation of a key code (a key with modifiers). * @returns a string representing the {@link KeyCode} * @param keyCode the keycode * @param separator the separator used to separate keys (key and modifiers) in the returning string */ acceleratorForKeyCode(keyCode: KeyCode, separator = ' '): string { const keyCodeResult = []; if (keyCode.meta && isOSX) { keyCodeResult.push('Cmd'); } if (keyCode.ctrl) { keyCodeResult.push('Ctrl'); } if (keyCode.alt) { keyCodeResult.push('Alt'); } if (keyCode.shift) { keyCodeResult.push('Shift'); } if (keyCode.key) { keyCodeResult.push(this.acceleratorForKey(keyCode.key)); } return keyCodeResult.join(separator); } /** * Return a user visible representation of a single key. */ acceleratorForKey(key: Key): string { if (isOSX) { if (key === Key.ARROW_LEFT) { return '←'; } if (key === Key.ARROW_RIGHT) { return '→'; } if (key === Key.ARROW_UP) { return '↑'; } if (key === Key.ARROW_DOWN) { return '↓'; } } const keyString = this.keyboardLayoutService.getKeyboardCharacter(key); if ( (key.keyCode >= Key.KEY_A.keyCode && key.keyCode <= Key.KEY_Z.keyCode) || (key.keyCode >= Key.F1.keyCode && key.keyCode <= Key.F24.keyCode) ) { return keyString.toUpperCase(); } if (keyString.length > 1) { return keyString.charAt(0).toUpperCase() + keyString.slice(1); } return keyString; } /** * Finds collisions for a key sequence inside a list of bindings (error-free) * * @param bindings the reference bindings * @param candidate the sequence to match */ protected getKeySequenceCollisions( bindings: ScopedKeybinding[], candidate: KeySequence, ): KeybindingRegistry.KeybindingsResult { const result = new KeybindingRegistry.KeybindingsResult(); for (const binding of bindings) { try { const bindingKeySequence = this.resolveKeybinding(binding); const compareResult = KeySequence.compare(candidate, bindingKeySequence); switch (compareResult) { case KeySequence.CompareResult.FULL: { result.full.push(binding); break; } case KeySequence.CompareResult.PARTIAL: { result.partial.push(binding); break; } case KeySequence.CompareResult.SHADOW: { result.shadow.push(binding); break; } } } catch (error) { this.logger(error); } } return result; } /** * Get all keybindings associated to a commandId. * * @param commandId The ID of the command for which we are looking for keybindings. * @returns an array of {@link ScopedKeybinding} */ getKeybindingsForCommand(commandId: string): ScopedKeybinding[] { const result: ScopedKeybinding[] = []; for ( let scope = KeybindingScope.END - 1; scope >= KeybindingScope.DEFAULT; scope-- ) { this.keymaps[scope].forEach((binding) => { const command = this.commandRegistry.getCommand(binding.command); if (command) { if (command.id === commandId) { result.push({ ...binding, scope }); } } }); if (result.length > 0) { return result; } } return result; } protected isActive(binding: Keybinding): boolean { /* Pseudo commands like "passthrough" are always active (and not found in the command registry). */ if (this.isPseudoCommand(binding.command)) { return true; } const command = this.commandRegistry.getCommand(binding.command); return !!command && !!this.commandRegistry.getEnableHandler(command.id); } /** * Tries to execute a keybinding. * * @param binding to execute * @param event keyboard event. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars protected executeKeyBinding(binding: Keybinding, event: KeyboardEvent): void { if (this.isPseudoCommand(binding.command)) { /* Don't do anything, let the event propagate. */ } else { const command = this.commandRegistry.getCommand(binding.command); if (command) { if (this.commandRegistry.isEnabled(binding.command, binding.args)) { this.commandRegistry .executeCommand(binding.command, binding.args) .catch((e) => console.error('Failed to execute command:', e)); } /* Note that if a keybinding is in context but the command is not active we still stop the processing here. */ const { preventDefault = this.preventDefault, stopPropagation = this.stopPropagation, } = binding; if (preventDefault) { event.preventDefault(); } if (stopPropagation) { event.stopPropagation(); } } } } /** * Only execute if it has no context (global context) or if we're in that context. */ protected isEnabled(binding: Keybinding, event: KeyboardEvent): boolean { const context = binding.context && this.contexts[binding.context]; if (context && !context.isEnabled(binding)) { return false; } if ( binding.when && !this.whenContextService.match(binding.when, <HTMLElement>event.target) ) { return false; } return true; } dispatchCommand(id: string, target?: EventTarget): void { const keybindings = this.getKeybindingsForCommand(id); if (keybindings.length) { for (const keyCode of this.resolveKeybinding(keybindings[0])) { this.dispatchKeyDown(keyCode, target); } } } dispatchKeyDown( input: KeyboardEventInit | KeyCode | string, target: EventTarget = document.activeElement || window, ): void { const eventInit = this.asKeyboardEventInit(input); const emulatedKeyboardEvent = new KeyboardEvent('keydown', eventInit); target.dispatchEvent(emulatedKeyboardEvent); } protected asKeyboardEventInit( input: KeyboardEventInit | KeyCode | string, ): KeyboardEventInit & Partial<{ keyCode: number }> { if (typeof input === 'string') { return this.asKeyboardEventInit(KeyCode.createKeyCode(input)); } if (input instanceof KeyCode) { return { metaKey: input.meta, shiftKey: input.shift, altKey: input.alt, ctrlKey: input.ctrl, code: input.key && input.key.code, key: (input && input.character) || (input.key && input.key.code), keyCode: (input.key && input.key.keyCode) as number, }; } return input; } /** * Run the command matching to the given keyboard event. */ run(event: KeyboardEvent): void { if (event.defaultPrevented) { return; } const keyCode = KeyCode.createKeyCode(event, 'code'); /* Keycode is only a modifier, next keycode will be modifier + key. Ignore this one. */ if (keyCode.isModifierOnly()) { return; } this.keyboardLayoutService.validateKeyCode(keyCode); this.keySequence.push(keyCode); const match = this.matchKeybinding(this.keySequence, event); if (match && match.kind === 'partial') { /* Accumulate the keysequence */ // TODO: The effective scope of of prevent propagation. const { preventDefault = this.preventDefault, stopPropagation = this.stopPropagation, } = match.binding; if (preventDefault) { event.preventDefault(); } if (stopPropagation) { event.stopPropagation(); } this.emitter.fire(match); } else { if (match && match.kind === 'full') { this.executeKeyBinding(match.binding, event); } this.keySequence = []; this.emitter.fire(match); } } onMatchChange(callback: (match?: KeybindingRegistry.Match) => void) { this.emitter.event(callback); } /** * Match first binding in the current context. * Keybindings ordered by a scope and by a registration order within the scope. * * FIXME: * This method should run very fast since it happens on each keystroke. We should reconsider how keybindings are stored. * It should be possible to look up full and partial keybinding for given key sequence for constant time using some kind of tree. * Such tree should not contain disabled keybindings and be invalidated whenever the registry is changed. */ matchKeybinding( keySequence: KeySequence, event?: KeyboardEvent, ): KeybindingRegistry.Match { let disabled: Set<string> | undefined; const isEnabled = (binding: ScopedKeybinding) => { if (event && !this.isEnabled(binding, event)) { return false; } const { command, context, when, keybinding } = binding; if (!this.isUsable(binding)) { disabled = disabled || new Set<string>(); disabled.add( JSON.stringify({ command: command.substr(1), context, when, keybinding }), ); return false; } return !disabled?.has(JSON.stringify({ command, context, when, keybinding })); }; for (let scope = KeybindingScope.END; --scope >= KeybindingScope.DEFAULT; ) { for (const binding of this.keymaps[scope]) { const resolved = this.resolveKeybinding(binding); const compareResult = KeySequence.compare(keySequence, resolved); if (compareResult === KeySequence.CompareResult.FULL && isEnabled(binding)) { return { kind: 'full', binding }; } if (compareResult === KeySequence.CompareResult.PARTIAL && isEnabled(binding)) { return { kind: 'partial', binding }; } } } return undefined; } /** * Returns true if the binding is usable * @param binding Binding to be checked */ protected isUsable(binding: Keybinding): boolean { return binding.command.charAt(0) !== '-'; } /** * Return a new filtered array containing only the usable bindings among the input bindings * @param bindings Bindings to filter */ protected getUsableBindings<T extends Keybinding>(bindings: T[]): T[] { return bindings.filter((binding) => this.isUsable(binding)); } /** * Return true of string a pseudo-command id, in other words a command id * that has a special meaning and that we won't find in the command * registry. * * @param commandId commandId to test */ isPseudoCommand(commandId: string): boolean { return commandId === KeybindingRegistry.PASSTHROUGH_PSEUDO_COMMAND; } /** * Sets a new keymap replacing all existing {@link common.Keybinding}s in the given scope. * @param scope the keybinding scope * @param bindings an array containing the new {@link common.Keybinding}s */ setKeymap(scope: KeybindingScope, bindings: Keybinding[]): void { this.resetKeybindingsForScope(scope); this.toResetKeymap.set(scope, this.doRegisterKeybindings(bindings, scope)); this.keybindingsChanged.fire(undefined); } protected readonly toResetKeymap = new Map<KeybindingScope, Disposable>(); /** * Reset keybindings for a specific scope * @param scope scope to reset the keybindings for */ resetKeybindingsForScope(scope: KeybindingScope): void { const toReset = this.toResetKeymap.get(scope); if (toReset) { toReset.dispose(); } } /** * Reset keybindings for all scopes(only leaves the default keybindings mapped) */ resetKeybindings(): void { for (let i = KeybindingScope.DEFAULT + 1; i < KeybindingScope.END; i++) { this.keymaps[i] = []; } } /** * Get all {@link common.Keybinding}s for a {@link KeybindingScope}. * @returns an array of {@link common.ScopedKeybinding} * @param scope the keybinding scope to retrieve the {@link common.Keybinding}s for. */ getKeybindingsByScope(scope: KeybindingScope): ScopedKeybinding[] { return this.keymaps[scope]; } } export namespace KeybindingRegistry { export type Match = | { kind: 'full' | 'partial'; binding: ScopedKeybinding; preventDefault?: boolean; stopPropagation?: boolean; } | undefined; export class KeybindingsResult { full: ScopedKeybinding[] = []; partial: ScopedKeybinding[] = []; shadow: ScopedKeybinding[] = []; /** * Merge two results together inside `this` * * @param other the other KeybindingsResult to merge with * @return this */ merge(other: KeybindingsResult): KeybindingsResult { this.full.push(...other.full); this.partial.push(...other.partial); this.shadow.push(...other.shadow); return this; } /** * Returns a new filtered KeybindingsResult * * @param fn callback filter on the results * @return filtered new result */ filter(fn: (binding: Keybinding) => boolean): KeybindingsResult { const result = new KeybindingsResult(); result.full = this.full.filter(fn); result.partial = this.partial.filter(fn); result.shadow = this.shadow.filter(fn); return result; } } }