Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/preferences/browser/keybindingWidgets.ts
3296 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import './media/keybindings.css';
7
import * as nls from '../../../../nls.js';
8
import { OS } from '../../../../base/common/platform.js';
9
import { Disposable, toDisposable, DisposableStore } from '../../../../base/common/lifecycle.js';
10
import { Event, Emitter } from '../../../../base/common/event.js';
11
import { KeybindingLabel } from '../../../../base/browser/ui/keybindingLabel/keybindingLabel.js';
12
import { Widget } from '../../../../base/browser/ui/widget.js';
13
import { KeyCode } from '../../../../base/common/keyCodes.js';
14
import { ResolvedKeybinding } from '../../../../base/common/keybindings.js';
15
import * as dom from '../../../../base/browser/dom.js';
16
import * as aria from '../../../../base/browser/ui/aria/aria.js';
17
import { IKeyboardEvent, StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js';
18
import { FastDomNode, createFastDomNode } from '../../../../base/browser/fastDomNode.js';
19
import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js';
20
import { IContextViewService } from '../../../../platform/contextview/browser/contextView.js';
21
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
22
import { ICodeEditor, IOverlayWidget, IOverlayWidgetPosition } from '../../../../editor/browser/editorBrowser.js';
23
import { asCssVariable, editorWidgetBackground, editorWidgetForeground, widgetShadow } from '../../../../platform/theme/common/colorRegistry.js';
24
import { ScrollType } from '../../../../editor/common/editorCommon.js';
25
import { SearchWidget, SearchOptions } from './preferencesWidgets.js';
26
import { Promises, timeout } from '../../../../base/common/async.js';
27
import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';
28
import { defaultInputBoxStyles, defaultKeybindingLabelStyles } from '../../../../platform/theme/browser/defaultStyles.js';
29
30
export interface KeybindingsSearchOptions extends SearchOptions {
31
recordEnter?: boolean;
32
quoteRecordedKeys?: boolean;
33
}
34
35
export class KeybindingsSearchWidget extends SearchWidget {
36
37
private _chords: ResolvedKeybinding[] | null;
38
private _inputValue: string;
39
40
private readonly recordDisposables = this._register(new DisposableStore());
41
42
private _onKeybinding = this._register(new Emitter<ResolvedKeybinding[] | null>());
43
readonly onKeybinding: Event<ResolvedKeybinding[] | null> = this._onKeybinding.event;
44
45
private _onEnter = this._register(new Emitter<void>());
46
readonly onEnter: Event<void> = this._onEnter.event;
47
48
private _onEscape = this._register(new Emitter<void>());
49
readonly onEscape: Event<void> = this._onEscape.event;
50
51
private _onBlur = this._register(new Emitter<void>());
52
readonly onBlur: Event<void> = this._onBlur.event;
53
54
constructor(parent: HTMLElement, options: KeybindingsSearchOptions,
55
@IContextViewService contextViewService: IContextViewService,
56
@IInstantiationService instantiationService: IInstantiationService,
57
@IContextKeyService contextKeyService: IContextKeyService,
58
@IKeybindingService keybindingService: IKeybindingService,
59
) {
60
super(parent, options, contextViewService, instantiationService, contextKeyService, keybindingService);
61
62
this._register(toDisposable(() => this.stopRecordingKeys()));
63
64
this._chords = null;
65
this._inputValue = '';
66
}
67
68
override clear(): void {
69
this._chords = null;
70
super.clear();
71
}
72
73
startRecordingKeys(): void {
74
this.recordDisposables.add(dom.addDisposableListener(this.inputBox.inputElement, dom.EventType.KEY_DOWN, (e: KeyboardEvent) => this._onKeyDown(new StandardKeyboardEvent(e))));
75
this.recordDisposables.add(dom.addDisposableListener(this.inputBox.inputElement, dom.EventType.BLUR, () => this._onBlur.fire()));
76
this.recordDisposables.add(dom.addDisposableListener(this.inputBox.inputElement, dom.EventType.INPUT, () => {
77
// Prevent other characters from showing up
78
this.setInputValue(this._inputValue);
79
}));
80
}
81
82
stopRecordingKeys(): void {
83
this._chords = null;
84
this.recordDisposables.clear();
85
}
86
87
setInputValue(value: string): void {
88
this._inputValue = value;
89
this.inputBox.value = this._inputValue;
90
}
91
92
private _onKeyDown(keyboardEvent: IKeyboardEvent): void {
93
keyboardEvent.preventDefault();
94
keyboardEvent.stopPropagation();
95
const options = this.options as KeybindingsSearchOptions;
96
if (!options.recordEnter && keyboardEvent.equals(KeyCode.Enter)) {
97
this._onEnter.fire();
98
return;
99
}
100
if (keyboardEvent.equals(KeyCode.Escape)) {
101
this._onEscape.fire();
102
return;
103
}
104
this.printKeybinding(keyboardEvent);
105
}
106
107
private printKeybinding(keyboardEvent: IKeyboardEvent): void {
108
const keybinding = this.keybindingService.resolveKeyboardEvent(keyboardEvent);
109
const info = `code: ${keyboardEvent.browserEvent.code}, keyCode: ${keyboardEvent.browserEvent.keyCode}, key: ${keyboardEvent.browserEvent.key} => UI: ${keybinding.getAriaLabel()}, user settings: ${keybinding.getUserSettingsLabel()}, dispatch: ${keybinding.getDispatchChords()[0]}`;
110
const options = this.options as KeybindingsSearchOptions;
111
112
if (!this._chords) {
113
this._chords = [];
114
}
115
116
// 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 regression
117
const hasIncompleteChord = this._chords.length > 0 && this._chords[this._chords.length - 1].getDispatchChords()[0] === null;
118
if (hasIncompleteChord) {
119
this._chords[this._chords.length - 1] = keybinding;
120
} else {
121
if (this._chords.length === 2) { // TODO: limit chords # to 2 for now
122
this._chords = [];
123
}
124
this._chords.push(keybinding);
125
}
126
127
const value = this._chords.map((keybinding) => keybinding.getUserSettingsLabel() || '').join(' ');
128
this.setInputValue(options.quoteRecordedKeys ? `"${value}"` : value);
129
130
this.inputBox.inputElement.title = info;
131
this._onKeybinding.fire(this._chords);
132
}
133
}
134
135
export class DefineKeybindingWidget extends Widget {
136
137
private static readonly WIDTH = 400;
138
private static readonly HEIGHT = 110;
139
140
private _domNode: FastDomNode<HTMLElement>;
141
private _keybindingInputWidget: KeybindingsSearchWidget;
142
private _outputNode: HTMLElement;
143
private _showExistingKeybindingsNode: HTMLElement;
144
private readonly _keybindingDisposables = this._register(new DisposableStore());
145
146
private _chords: ResolvedKeybinding[] | null = null;
147
private _isVisible: boolean = false;
148
149
private _onHide = this._register(new Emitter<void>());
150
151
private _onDidChange = this._register(new Emitter<string>());
152
onDidChange: Event<string> = this._onDidChange.event;
153
154
private _onShowExistingKeybindings = this._register(new Emitter<string | null>());
155
readonly onShowExistingKeybidings: Event<string | null> = this._onShowExistingKeybindings.event;
156
157
constructor(
158
parent: HTMLElement | null,
159
@IInstantiationService private readonly instantiationService: IInstantiationService,
160
) {
161
super();
162
163
this._domNode = createFastDomNode(document.createElement('div'));
164
this._domNode.setDisplay('none');
165
this._domNode.setClassName('defineKeybindingWidget');
166
this._domNode.setWidth(DefineKeybindingWidget.WIDTH);
167
this._domNode.setHeight(DefineKeybindingWidget.HEIGHT);
168
169
const message = nls.localize('defineKeybinding.initial', "Press desired key combination and then press ENTER.");
170
dom.append(this._domNode.domNode, dom.$('.message', undefined, message));
171
172
this._domNode.domNode.style.backgroundColor = asCssVariable(editorWidgetBackground);
173
this._domNode.domNode.style.color = asCssVariable(editorWidgetForeground);
174
this._domNode.domNode.style.boxShadow = `0 2px 8px ${asCssVariable(widgetShadow)}`;
175
176
this._keybindingInputWidget = this._register(this.instantiationService.createInstance(KeybindingsSearchWidget, this._domNode.domNode, { ariaLabel: message, history: new Set([]), inputBoxStyles: defaultInputBoxStyles }));
177
this._keybindingInputWidget.startRecordingKeys();
178
this._register(this._keybindingInputWidget.onKeybinding(keybinding => this.onKeybinding(keybinding)));
179
this._register(this._keybindingInputWidget.onEnter(() => this.hide()));
180
this._register(this._keybindingInputWidget.onEscape(() => this.clearOrHide()));
181
this._register(this._keybindingInputWidget.onBlur(() => this.onCancel()));
182
183
this._outputNode = dom.append(this._domNode.domNode, dom.$('.output'));
184
this._showExistingKeybindingsNode = dom.append(this._domNode.domNode, dom.$('.existing'));
185
186
if (parent) {
187
dom.append(parent, this._domNode.domNode);
188
}
189
}
190
191
get domNode(): HTMLElement {
192
return this._domNode.domNode;
193
}
194
195
define(): Promise<string | null> {
196
this._keybindingInputWidget.clear();
197
return Promises.withAsyncBody<string | null>(async (c) => {
198
if (!this._isVisible) {
199
this._isVisible = true;
200
this._domNode.setDisplay('block');
201
202
this._chords = null;
203
this._keybindingInputWidget.setInputValue('');
204
dom.clearNode(this._outputNode);
205
dom.clearNode(this._showExistingKeybindingsNode);
206
207
// Input is not getting focus without timeout in safari
208
// https://github.com/microsoft/vscode/issues/108817
209
await timeout(0);
210
211
this._keybindingInputWidget.focus();
212
}
213
const disposable = this._onHide.event(() => {
214
c(this.getUserSettingsLabel());
215
disposable.dispose();
216
});
217
});
218
}
219
220
layout(layout: dom.Dimension): void {
221
const top = Math.round((layout.height - DefineKeybindingWidget.HEIGHT) / 2);
222
this._domNode.setTop(top);
223
224
const left = Math.round((layout.width - DefineKeybindingWidget.WIDTH) / 2);
225
this._domNode.setLeft(left);
226
}
227
228
printExisting(numberOfExisting: number): void {
229
if (numberOfExisting > 0) {
230
const existingElement = dom.$('span.existingText');
231
const 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);
232
dom.append(existingElement, document.createTextNode(text));
233
aria.alert(text);
234
this._showExistingKeybindingsNode.appendChild(existingElement);
235
existingElement.onmousedown = (e) => { e.preventDefault(); };
236
existingElement.onmouseup = (e) => { e.preventDefault(); };
237
existingElement.onclick = () => { this._onShowExistingKeybindings.fire(this.getUserSettingsLabel()); };
238
}
239
}
240
241
private onKeybinding(keybinding: ResolvedKeybinding[] | null): void {
242
this._keybindingDisposables.clear();
243
this._chords = keybinding;
244
dom.clearNode(this._outputNode);
245
dom.clearNode(this._showExistingKeybindingsNode);
246
247
const firstLabel = this._keybindingDisposables.add(new KeybindingLabel(this._outputNode, OS, defaultKeybindingLabelStyles));
248
firstLabel.set(this._chords?.[0] ?? undefined);
249
250
if (this._chords) {
251
for (let i = 1; i < this._chords.length; i++) {
252
this._outputNode.appendChild(document.createTextNode(nls.localize('defineKeybinding.chordsTo', "chord to")));
253
const chordLabel = this._keybindingDisposables.add(new KeybindingLabel(this._outputNode, OS, defaultKeybindingLabelStyles));
254
chordLabel.set(this._chords[i]);
255
}
256
}
257
258
const label = this.getUserSettingsLabel();
259
if (label) {
260
this._onDidChange.fire(label);
261
}
262
}
263
264
private getUserSettingsLabel(): string | null {
265
let label: string | null = null;
266
if (this._chords) {
267
label = this._chords.map(keybinding => keybinding.getUserSettingsLabel()).join(' ');
268
}
269
return label;
270
}
271
272
private onCancel(): void {
273
this._chords = null;
274
this.hide();
275
}
276
277
private clearOrHide(): void {
278
if (this._chords === null) {
279
this.hide();
280
} else {
281
this._chords = null;
282
this._keybindingInputWidget.clear();
283
dom.clearNode(this._outputNode);
284
dom.clearNode(this._showExistingKeybindingsNode);
285
}
286
}
287
288
private hide(): void {
289
this._domNode.setDisplay('none');
290
this._isVisible = false;
291
this._onHide.fire();
292
}
293
}
294
295
export class DefineKeybindingOverlayWidget extends Disposable implements IOverlayWidget {
296
297
private static readonly ID = 'editor.contrib.defineKeybindingWidget';
298
299
private readonly _widget: DefineKeybindingWidget;
300
301
constructor(private _editor: ICodeEditor,
302
@IInstantiationService instantiationService: IInstantiationService
303
) {
304
super();
305
306
this._widget = this._register(instantiationService.createInstance(DefineKeybindingWidget, null));
307
this._editor.addOverlayWidget(this);
308
}
309
310
getId(): string {
311
return DefineKeybindingOverlayWidget.ID;
312
}
313
314
getDomNode(): HTMLElement {
315
return this._widget.domNode;
316
}
317
318
getPosition(): IOverlayWidgetPosition {
319
return {
320
preference: null
321
};
322
}
323
324
override dispose(): void {
325
this._editor.removeOverlayWidget(this);
326
super.dispose();
327
}
328
329
start(): Promise<string | null> {
330
if (this._editor.hasModel()) {
331
this._editor.revealPositionInCenterIfOutsideViewport(this._editor.getPosition(), ScrollType.Smooth);
332
}
333
const layoutInfo = this._editor.getLayoutInfo();
334
this._widget.layout(new dom.Dimension(layoutInfo.width, layoutInfo.height));
335
return this._widget.define();
336
}
337
}
338
339