Path: blob/main/src/vs/workbench/contrib/preferences/browser/keybindingWidgets.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 './media/keybindings.css';6import * as nls from '../../../../nls.js';7import { OS } from '../../../../base/common/platform.js';8import { Disposable, toDisposable, DisposableStore } from '../../../../base/common/lifecycle.js';9import { Event, Emitter } from '../../../../base/common/event.js';10import { KeybindingLabel } from '../../../../base/browser/ui/keybindingLabel/keybindingLabel.js';11import { Widget } from '../../../../base/browser/ui/widget.js';12import { KeyCode } from '../../../../base/common/keyCodes.js';13import { ResolvedKeybinding } from '../../../../base/common/keybindings.js';14import * as dom from '../../../../base/browser/dom.js';15import * as aria from '../../../../base/browser/ui/aria/aria.js';16import { IKeyboardEvent, StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js';17import { FastDomNode, createFastDomNode } from '../../../../base/browser/fastDomNode.js';18import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js';19import { IContextViewService } from '../../../../platform/contextview/browser/contextView.js';20import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';21import { ICodeEditor, IOverlayWidget, IOverlayWidgetPosition } from '../../../../editor/browser/editorBrowser.js';22import { asCssVariable, editorWidgetBackground, editorWidgetForeground, widgetShadow } from '../../../../platform/theme/common/colorRegistry.js';23import { ScrollType } from '../../../../editor/common/editorCommon.js';24import { SearchWidget, SearchOptions } from './preferencesWidgets.js';25import { Promises, timeout } from '../../../../base/common/async.js';26import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';27import { defaultInputBoxStyles, defaultKeybindingLabelStyles } from '../../../../platform/theme/browser/defaultStyles.js';2829export interface KeybindingsSearchOptions extends SearchOptions {30recordEnter?: boolean;31quoteRecordedKeys?: boolean;32}3334export class KeybindingsSearchWidget extends SearchWidget {3536private _chords: ResolvedKeybinding[] | null;37private _inputValue: string;3839private readonly recordDisposables = this._register(new DisposableStore());4041private _onKeybinding = this._register(new Emitter<ResolvedKeybinding[] | null>());42readonly onKeybinding: Event<ResolvedKeybinding[] | null> = this._onKeybinding.event;4344private _onEnter = this._register(new Emitter<void>());45readonly onEnter: Event<void> = this._onEnter.event;4647private _onEscape = this._register(new Emitter<void>());48readonly onEscape: Event<void> = this._onEscape.event;4950private _onBlur = this._register(new Emitter<void>());51readonly onBlur: Event<void> = this._onBlur.event;5253constructor(parent: HTMLElement, options: KeybindingsSearchOptions,54@IContextViewService contextViewService: IContextViewService,55@IInstantiationService instantiationService: IInstantiationService,56@IContextKeyService contextKeyService: IContextKeyService,57@IKeybindingService keybindingService: IKeybindingService,58) {59super(parent, options, contextViewService, instantiationService, contextKeyService, keybindingService);6061this._register(toDisposable(() => this.stopRecordingKeys()));6263this._chords = null;64this._inputValue = '';65}6667override clear(): void {68this._chords = null;69super.clear();70}7172startRecordingKeys(): void {73this.recordDisposables.add(dom.addDisposableListener(this.inputBox.inputElement, dom.EventType.KEY_DOWN, (e: KeyboardEvent) => this._onKeyDown(new StandardKeyboardEvent(e))));74this.recordDisposables.add(dom.addDisposableListener(this.inputBox.inputElement, dom.EventType.BLUR, () => this._onBlur.fire()));75this.recordDisposables.add(dom.addDisposableListener(this.inputBox.inputElement, dom.EventType.INPUT, () => {76// Prevent other characters from showing up77this.setInputValue(this._inputValue);78}));79}8081stopRecordingKeys(): void {82this._chords = null;83this.recordDisposables.clear();84}8586setInputValue(value: string): void {87this._inputValue = value;88this.inputBox.value = this._inputValue;89}9091private _onKeyDown(keyboardEvent: IKeyboardEvent): void {92keyboardEvent.preventDefault();93keyboardEvent.stopPropagation();94const options = this.options as KeybindingsSearchOptions;95if (!options.recordEnter && keyboardEvent.equals(KeyCode.Enter)) {96this._onEnter.fire();97return;98}99if (keyboardEvent.equals(KeyCode.Escape)) {100this._onEscape.fire();101return;102}103this.printKeybinding(keyboardEvent);104}105106private printKeybinding(keyboardEvent: IKeyboardEvent): void {107const keybinding = this.keybindingService.resolveKeyboardEvent(keyboardEvent);108const info = `code: ${keyboardEvent.browserEvent.code}, keyCode: ${keyboardEvent.browserEvent.keyCode}, key: ${keyboardEvent.browserEvent.key} => UI: ${keybinding.getAriaLabel()}, user settings: ${keybinding.getUserSettingsLabel()}, dispatch: ${keybinding.getDispatchChords()[0]}`;109const options = this.options as KeybindingsSearchOptions;110111if (!this._chords) {112this._chords = [];113}114115// TODO: note that we allow a keybinding "shift shift", but this widget doesn't allow input "shift shift" because the first "shift" will be incomplete - this is _not_ a regression116const hasIncompleteChord = this._chords.length > 0 && this._chords[this._chords.length - 1].getDispatchChords()[0] === null;117if (hasIncompleteChord) {118this._chords[this._chords.length - 1] = keybinding;119} else {120if (this._chords.length === 2) { // TODO: limit chords # to 2 for now121this._chords = [];122}123this._chords.push(keybinding);124}125126const value = this._chords.map((keybinding) => keybinding.getUserSettingsLabel() || '').join(' ');127this.setInputValue(options.quoteRecordedKeys ? `"${value}"` : value);128129this.inputBox.inputElement.title = info;130this._onKeybinding.fire(this._chords);131}132}133134export class DefineKeybindingWidget extends Widget {135136private static readonly WIDTH = 400;137private static readonly HEIGHT = 110;138139private _domNode: FastDomNode<HTMLElement>;140private _keybindingInputWidget: KeybindingsSearchWidget;141private _outputNode: HTMLElement;142private _showExistingKeybindingsNode: HTMLElement;143private readonly _keybindingDisposables = this._register(new DisposableStore());144145private _chords: ResolvedKeybinding[] | null = null;146private _isVisible: boolean = false;147148private _onHide = this._register(new Emitter<void>());149150private _onDidChange = this._register(new Emitter<string>());151onDidChange: Event<string> = this._onDidChange.event;152153private _onShowExistingKeybindings = this._register(new Emitter<string | null>());154readonly onShowExistingKeybidings: Event<string | null> = this._onShowExistingKeybindings.event;155156constructor(157parent: HTMLElement | null,158@IInstantiationService private readonly instantiationService: IInstantiationService,159) {160super();161162this._domNode = createFastDomNode(document.createElement('div'));163this._domNode.setDisplay('none');164this._domNode.setClassName('defineKeybindingWidget');165this._domNode.setWidth(DefineKeybindingWidget.WIDTH);166this._domNode.setHeight(DefineKeybindingWidget.HEIGHT);167168const message = nls.localize('defineKeybinding.initial', "Press desired key combination and then press ENTER.");169dom.append(this._domNode.domNode, dom.$('.message', undefined, message));170171this._domNode.domNode.style.backgroundColor = asCssVariable(editorWidgetBackground);172this._domNode.domNode.style.color = asCssVariable(editorWidgetForeground);173this._domNode.domNode.style.boxShadow = `0 2px 8px ${asCssVariable(widgetShadow)}`;174175this._keybindingInputWidget = this._register(this.instantiationService.createInstance(KeybindingsSearchWidget, this._domNode.domNode, { ariaLabel: message, history: new Set([]), inputBoxStyles: defaultInputBoxStyles }));176this._keybindingInputWidget.startRecordingKeys();177this._register(this._keybindingInputWidget.onKeybinding(keybinding => this.onKeybinding(keybinding)));178this._register(this._keybindingInputWidget.onEnter(() => this.hide()));179this._register(this._keybindingInputWidget.onEscape(() => this.clearOrHide()));180this._register(this._keybindingInputWidget.onBlur(() => this.onCancel()));181182this._outputNode = dom.append(this._domNode.domNode, dom.$('.output'));183this._showExistingKeybindingsNode = dom.append(this._domNode.domNode, dom.$('.existing'));184185if (parent) {186dom.append(parent, this._domNode.domNode);187}188}189190get domNode(): HTMLElement {191return this._domNode.domNode;192}193194define(): Promise<string | null> {195this._keybindingInputWidget.clear();196return Promises.withAsyncBody<string | null>(async (c) => {197if (!this._isVisible) {198this._isVisible = true;199this._domNode.setDisplay('block');200201this._chords = null;202this._keybindingInputWidget.setInputValue('');203dom.clearNode(this._outputNode);204dom.clearNode(this._showExistingKeybindingsNode);205206// Input is not getting focus without timeout in safari207// https://github.com/microsoft/vscode/issues/108817208await timeout(0);209210this._keybindingInputWidget.focus();211}212const disposable = this._onHide.event(() => {213c(this.getUserSettingsLabel());214disposable.dispose();215});216});217}218219layout(layout: dom.Dimension): void {220const top = Math.round((layout.height - DefineKeybindingWidget.HEIGHT) / 2);221this._domNode.setTop(top);222223const left = Math.round((layout.width - DefineKeybindingWidget.WIDTH) / 2);224this._domNode.setLeft(left);225}226227printExisting(numberOfExisting: number): void {228if (numberOfExisting > 0) {229const existingElement = dom.$('span.existingText');230const text = numberOfExisting === 1 ? nls.localize('defineKeybinding.oneExists', "1 existing command has this keybinding", numberOfExisting) : nls.localize('defineKeybinding.existing', "{0} existing commands have this keybinding", numberOfExisting);231dom.append(existingElement, document.createTextNode(text));232aria.alert(text);233this._showExistingKeybindingsNode.appendChild(existingElement);234existingElement.onmousedown = (e) => { e.preventDefault(); };235existingElement.onmouseup = (e) => { e.preventDefault(); };236existingElement.onclick = () => { this._onShowExistingKeybindings.fire(this.getUserSettingsLabel()); };237}238}239240private onKeybinding(keybinding: ResolvedKeybinding[] | null): void {241this._keybindingDisposables.clear();242this._chords = keybinding;243dom.clearNode(this._outputNode);244dom.clearNode(this._showExistingKeybindingsNode);245246const firstLabel = this._keybindingDisposables.add(new KeybindingLabel(this._outputNode, OS, defaultKeybindingLabelStyles));247firstLabel.set(this._chords?.[0] ?? undefined);248249if (this._chords) {250for (let i = 1; i < this._chords.length; i++) {251this._outputNode.appendChild(document.createTextNode(nls.localize('defineKeybinding.chordsTo', "chord to")));252const chordLabel = this._keybindingDisposables.add(new KeybindingLabel(this._outputNode, OS, defaultKeybindingLabelStyles));253chordLabel.set(this._chords[i]);254}255}256257const label = this.getUserSettingsLabel();258if (label) {259this._onDidChange.fire(label);260}261}262263private getUserSettingsLabel(): string | null {264let label: string | null = null;265if (this._chords) {266label = this._chords.map(keybinding => keybinding.getUserSettingsLabel()).join(' ');267}268return label;269}270271private onCancel(): void {272this._chords = null;273this.hide();274}275276private clearOrHide(): void {277if (this._chords === null) {278this.hide();279} else {280this._chords = null;281this._keybindingInputWidget.clear();282dom.clearNode(this._outputNode);283dom.clearNode(this._showExistingKeybindingsNode);284}285}286287private hide(): void {288this._domNode.setDisplay('none');289this._isVisible = false;290this._onHide.fire();291}292}293294export class DefineKeybindingOverlayWidget extends Disposable implements IOverlayWidget {295296private static readonly ID = 'editor.contrib.defineKeybindingWidget';297298private readonly _widget: DefineKeybindingWidget;299300constructor(private _editor: ICodeEditor,301@IInstantiationService instantiationService: IInstantiationService302) {303super();304305this._widget = this._register(instantiationService.createInstance(DefineKeybindingWidget, null));306this._editor.addOverlayWidget(this);307}308309getId(): string {310return DefineKeybindingOverlayWidget.ID;311}312313getDomNode(): HTMLElement {314return this._widget.domNode;315}316317getPosition(): IOverlayWidgetPosition {318return {319preference: null320};321}322323override dispose(): void {324this._editor.removeOverlayWidget(this);325super.dispose();326}327328start(): Promise<string | null> {329if (this._editor.hasModel()) {330this._editor.revealPositionInCenterIfOutsideViewport(this._editor.getPosition(), ScrollType.Smooth);331}332const layoutInfo = this._editor.getLayoutInfo();333this._widget.layout(new dom.Dimension(layoutInfo.width, layoutInfo.height));334return this._widget.define();335}336}337338339