Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/base/browser/ui/menu/menubar.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 * as browser from '../../browser.js';
7
import * as DOM from '../../dom.js';
8
import { StandardKeyboardEvent } from '../../keyboardEvent.js';
9
import { StandardMouseEvent } from '../../mouseEvent.js';
10
import { EventType, Gesture, GestureEvent } from '../../touch.js';
11
import { cleanMnemonic, HorizontalDirection, IMenuDirection, IMenuOptions, IMenuStyles, Menu, MENU_ESCAPED_MNEMONIC_REGEX, MENU_MNEMONIC_REGEX, VerticalDirection } from './menu.js';
12
import { ActionRunner, IAction, IActionRunner, Separator, SubmenuAction } from '../../../common/actions.js';
13
import { asArray } from '../../../common/arrays.js';
14
import { RunOnceScheduler } from '../../../common/async.js';
15
import { Codicon } from '../../../common/codicons.js';
16
import { ThemeIcon } from '../../../common/themables.js';
17
import { Emitter, Event } from '../../../common/event.js';
18
import { KeyCode, KeyMod, ScanCode, ScanCodeUtils } from '../../../common/keyCodes.js';
19
import { ResolvedKeybinding } from '../../../common/keybindings.js';
20
import { Disposable, DisposableStore, dispose, IDisposable } from '../../../common/lifecycle.js';
21
import { isMacintosh } from '../../../common/platform.js';
22
import * as strings from '../../../common/strings.js';
23
import './menubar.css';
24
import * as nls from '../../../../nls.js';
25
import { mainWindow } from '../../window.js';
26
27
const $ = DOM.$;
28
29
export interface IMenuBarOptions {
30
enableMnemonics?: boolean;
31
disableAltFocus?: boolean;
32
visibility?: string;
33
getKeybinding?: (action: IAction) => ResolvedKeybinding | undefined;
34
alwaysOnMnemonics?: boolean;
35
compactMode?: IMenuDirection;
36
actionRunner?: IActionRunner;
37
getCompactMenuActions?: () => IAction[];
38
}
39
40
export interface MenuBarMenu {
41
actions: IAction[];
42
label: string;
43
}
44
45
interface MenuBarMenuWithElements extends MenuBarMenu {
46
titleElement?: HTMLElement;
47
buttonElement?: HTMLElement;
48
}
49
50
enum MenubarState {
51
HIDDEN,
52
VISIBLE,
53
FOCUSED,
54
OPEN
55
}
56
57
export class MenuBar extends Disposable {
58
59
static readonly OVERFLOW_INDEX: number = -1;
60
61
private menus: MenuBarMenuWithElements[];
62
63
private overflowMenu!: MenuBarMenuWithElements & { titleElement: HTMLElement; buttonElement: HTMLElement };
64
65
private focusedMenu: {
66
index: number;
67
holder?: HTMLElement;
68
widget?: Menu;
69
} | undefined;
70
71
private focusToReturn: HTMLElement | undefined;
72
private menuUpdater: RunOnceScheduler;
73
74
// Input-related
75
private _mnemonicsInUse: boolean = false;
76
private openedViaKeyboard: boolean = false;
77
private awaitingAltRelease: boolean = false;
78
private ignoreNextMouseUp: boolean = false;
79
private mnemonics: Map<string, number>;
80
81
private updatePending: boolean = false;
82
private _focusState: MenubarState;
83
private actionRunner: IActionRunner;
84
85
private readonly _onVisibilityChange: Emitter<boolean>;
86
private readonly _onFocusStateChange: Emitter<boolean>;
87
88
private numMenusShown: number = 0;
89
private overflowLayoutScheduled: IDisposable | undefined = undefined;
90
91
private readonly menuDisposables = this._register(new DisposableStore());
92
93
constructor(private container: HTMLElement, private options: IMenuBarOptions, private menuStyle: IMenuStyles) {
94
super();
95
96
this.container.setAttribute('role', 'menubar');
97
if (this.isCompact) {
98
this.container.classList.add('compact');
99
}
100
101
this.menus = [];
102
this.mnemonics = new Map<string, number>();
103
104
this._focusState = MenubarState.VISIBLE;
105
106
this._onVisibilityChange = this._register(new Emitter<boolean>());
107
this._onFocusStateChange = this._register(new Emitter<boolean>());
108
109
this.createOverflowMenu();
110
111
this.menuUpdater = this._register(new RunOnceScheduler(() => this.update(), 200));
112
113
this.actionRunner = this.options.actionRunner ?? this._register(new ActionRunner());
114
this._register(this.actionRunner.onWillRun(() => {
115
this.setUnfocusedState();
116
}));
117
118
this._register(DOM.ModifierKeyEmitter.getInstance().event(this.onModifierKeyToggled, this));
119
120
this._register(DOM.addDisposableListener(this.container, DOM.EventType.KEY_DOWN, (e) => {
121
const event = new StandardKeyboardEvent(e as KeyboardEvent);
122
let eventHandled = true;
123
const key = !!e.key ? e.key.toLocaleLowerCase() : '';
124
125
const tabNav = isMacintosh && !this.isCompact;
126
127
if (event.equals(KeyCode.LeftArrow) || (tabNav && event.equals(KeyCode.Tab | KeyMod.Shift))) {
128
this.focusPrevious();
129
} else if (event.equals(KeyCode.RightArrow) || (tabNav && event.equals(KeyCode.Tab))) {
130
this.focusNext();
131
} else if (event.equals(KeyCode.Escape) && this.isFocused && !this.isOpen) {
132
this.setUnfocusedState();
133
} else if (!this.isOpen && !event.ctrlKey && this.options.enableMnemonics && this.mnemonicsInUse && this.mnemonics.has(key)) {
134
const menuIndex = this.mnemonics.get(key)!;
135
this.onMenuTriggered(menuIndex, false);
136
} else {
137
eventHandled = false;
138
}
139
140
// Never allow default tab behavior when not compact
141
if (!this.isCompact && (event.equals(KeyCode.Tab | KeyMod.Shift) || event.equals(KeyCode.Tab))) {
142
event.preventDefault();
143
}
144
145
if (eventHandled) {
146
event.preventDefault();
147
event.stopPropagation();
148
}
149
}));
150
151
const window = DOM.getWindow(this.container);
152
this._register(DOM.addDisposableListener(window, DOM.EventType.MOUSE_DOWN, () => {
153
// This mouse event is outside the menubar so it counts as a focus out
154
if (this.isFocused) {
155
this.setUnfocusedState();
156
}
157
}));
158
159
this._register(DOM.addDisposableListener(this.container, DOM.EventType.FOCUS_IN, (e) => {
160
const event = e as FocusEvent;
161
162
if (event.relatedTarget) {
163
if (!this.container.contains(event.relatedTarget as HTMLElement)) {
164
this.focusToReturn = event.relatedTarget as HTMLElement;
165
}
166
}
167
}));
168
169
this._register(DOM.addDisposableListener(this.container, DOM.EventType.FOCUS_OUT, (e) => {
170
const event = e as FocusEvent;
171
172
// We are losing focus and there is no related target, e.g. webview case
173
if (!event.relatedTarget) {
174
this.setUnfocusedState();
175
}
176
// We are losing focus and there is a target, reset focusToReturn value as not to redirect
177
else if (event.relatedTarget && !this.container.contains(event.relatedTarget as HTMLElement)) {
178
this.focusToReturn = undefined;
179
this.setUnfocusedState();
180
}
181
}));
182
183
this._register(DOM.addDisposableListener(window, DOM.EventType.KEY_DOWN, (e: KeyboardEvent) => {
184
if (!this.options.enableMnemonics || !e.altKey || e.ctrlKey || e.defaultPrevented) {
185
return;
186
}
187
188
const key = e.key.toLocaleLowerCase();
189
if (!this.mnemonics.has(key)) {
190
return;
191
}
192
193
this.mnemonicsInUse = true;
194
this.updateMnemonicVisibility(true);
195
196
const menuIndex = this.mnemonics.get(key)!;
197
this.onMenuTriggered(menuIndex, false);
198
}));
199
200
this.setUnfocusedState();
201
}
202
203
push(arg: MenuBarMenu | MenuBarMenu[]): void {
204
const menus: MenuBarMenu[] = asArray(arg);
205
206
menus.forEach((menuBarMenu) => {
207
const menuIndex = this.menus.length;
208
const cleanMenuLabel = cleanMnemonic(menuBarMenu.label);
209
210
const mnemonicMatches = MENU_MNEMONIC_REGEX.exec(menuBarMenu.label);
211
212
// Register mnemonics
213
if (mnemonicMatches) {
214
const mnemonic = !!mnemonicMatches[1] ? mnemonicMatches[1] : mnemonicMatches[3];
215
216
this.registerMnemonic(this.menus.length, mnemonic);
217
}
218
219
if (this.isCompact) {
220
this.menus.push(menuBarMenu);
221
} else {
222
const buttonElement = $('div.menubar-menu-button', { 'role': 'menuitem', 'tabindex': -1, 'aria-label': cleanMenuLabel, 'aria-haspopup': true });
223
const titleElement = $('div.menubar-menu-title', { 'role': 'none', 'aria-hidden': true });
224
225
buttonElement.appendChild(titleElement);
226
this.container.insertBefore(buttonElement, this.overflowMenu.buttonElement);
227
228
this.updateLabels(titleElement, buttonElement, menuBarMenu.label);
229
230
this._register(DOM.addDisposableListener(buttonElement, DOM.EventType.KEY_UP, (e) => {
231
const event = new StandardKeyboardEvent(e as KeyboardEvent);
232
let eventHandled = true;
233
234
if ((event.equals(KeyCode.DownArrow) || event.equals(KeyCode.Enter)) && !this.isOpen) {
235
this.focusedMenu = { index: menuIndex };
236
this.openedViaKeyboard = true;
237
this.focusState = MenubarState.OPEN;
238
} else {
239
eventHandled = false;
240
}
241
242
if (eventHandled) {
243
event.preventDefault();
244
event.stopPropagation();
245
}
246
}));
247
248
this._register(Gesture.addTarget(buttonElement));
249
this._register(DOM.addDisposableListener(buttonElement, EventType.Tap, (e: GestureEvent) => {
250
// Ignore this touch if the menu is touched
251
if (this.isOpen && this.focusedMenu && this.focusedMenu.holder && DOM.isAncestor(e.initialTarget as HTMLElement, this.focusedMenu.holder)) {
252
return;
253
}
254
255
this.ignoreNextMouseUp = false;
256
this.onMenuTriggered(menuIndex, true);
257
258
e.preventDefault();
259
e.stopPropagation();
260
}));
261
262
this._register(DOM.addDisposableListener(buttonElement, DOM.EventType.MOUSE_DOWN, (e: MouseEvent) => {
263
// Ignore non-left-click
264
const mouseEvent = new StandardMouseEvent(DOM.getWindow(buttonElement), e);
265
if (!mouseEvent.leftButton) {
266
e.preventDefault();
267
return;
268
}
269
270
if (!this.isOpen) {
271
// Open the menu with mouse down and ignore the following mouse up event
272
this.ignoreNextMouseUp = true;
273
this.onMenuTriggered(menuIndex, true);
274
} else {
275
this.ignoreNextMouseUp = false;
276
}
277
278
e.preventDefault();
279
e.stopPropagation();
280
}));
281
282
this._register(DOM.addDisposableListener(buttonElement, DOM.EventType.MOUSE_UP, (e) => {
283
if (e.defaultPrevented) {
284
return;
285
}
286
287
if (!this.ignoreNextMouseUp) {
288
if (this.isFocused) {
289
this.onMenuTriggered(menuIndex, true);
290
}
291
} else {
292
this.ignoreNextMouseUp = false;
293
}
294
}));
295
296
this._register(DOM.addDisposableListener(buttonElement, DOM.EventType.MOUSE_ENTER, () => {
297
if (this.isOpen && !this.isCurrentMenu(menuIndex)) {
298
buttonElement.focus();
299
this.cleanupCustomMenu();
300
this.showCustomMenu(menuIndex, false);
301
} else if (this.isFocused && !this.isOpen) {
302
this.focusedMenu = { index: menuIndex };
303
buttonElement.focus();
304
}
305
}));
306
307
this.menus.push({
308
label: menuBarMenu.label,
309
actions: menuBarMenu.actions,
310
buttonElement: buttonElement,
311
titleElement: titleElement
312
});
313
}
314
});
315
}
316
317
createOverflowMenu(): void {
318
const label = this.isCompact ? nls.localize('mAppMenu', 'Application Menu') : nls.localize('mMore', 'More');
319
const buttonElement = $('div.menubar-menu-button', { 'role': 'menuitem', 'tabindex': this.isCompact ? 0 : -1, 'aria-label': label, 'aria-haspopup': true });
320
const titleElement = $('div.menubar-menu-title.toolbar-toggle-more' + ThemeIcon.asCSSSelector(Codicon.menuBarMore), { 'role': 'none', 'aria-hidden': true });
321
322
buttonElement.appendChild(titleElement);
323
this.container.appendChild(buttonElement);
324
buttonElement.style.visibility = 'hidden';
325
326
this._register(DOM.addDisposableListener(buttonElement, DOM.EventType.KEY_UP, (e) => {
327
const event = new StandardKeyboardEvent(e as KeyboardEvent);
328
let eventHandled = true;
329
330
const triggerKeys = [KeyCode.Enter];
331
if (!this.isCompact) {
332
triggerKeys.push(KeyCode.DownArrow);
333
} else {
334
triggerKeys.push(KeyCode.Space);
335
336
if (this.options.compactMode?.horizontal === HorizontalDirection.Right) {
337
triggerKeys.push(KeyCode.RightArrow);
338
} else if (this.options.compactMode?.horizontal === HorizontalDirection.Left) {
339
triggerKeys.push(KeyCode.LeftArrow);
340
}
341
}
342
343
if ((triggerKeys.some(k => event.equals(k)) && !this.isOpen)) {
344
this.focusedMenu = { index: MenuBar.OVERFLOW_INDEX };
345
this.openedViaKeyboard = true;
346
this.focusState = MenubarState.OPEN;
347
} else {
348
eventHandled = false;
349
}
350
351
if (eventHandled) {
352
event.preventDefault();
353
event.stopPropagation();
354
}
355
}));
356
357
this._register(Gesture.addTarget(buttonElement));
358
this._register(DOM.addDisposableListener(buttonElement, EventType.Tap, (e: GestureEvent) => {
359
// Ignore this touch if the menu is touched
360
if (this.isOpen && this.focusedMenu && this.focusedMenu.holder && DOM.isAncestor(e.initialTarget as HTMLElement, this.focusedMenu.holder)) {
361
return;
362
}
363
364
this.ignoreNextMouseUp = false;
365
this.onMenuTriggered(MenuBar.OVERFLOW_INDEX, true);
366
367
e.preventDefault();
368
e.stopPropagation();
369
}));
370
371
this._register(DOM.addDisposableListener(buttonElement, DOM.EventType.MOUSE_DOWN, (e) => {
372
// Ignore non-left-click
373
const mouseEvent = new StandardMouseEvent(DOM.getWindow(buttonElement), e);
374
if (!mouseEvent.leftButton) {
375
e.preventDefault();
376
return;
377
}
378
379
if (!this.isOpen) {
380
// Open the menu with mouse down and ignore the following mouse up event
381
this.ignoreNextMouseUp = true;
382
this.onMenuTriggered(MenuBar.OVERFLOW_INDEX, true);
383
} else {
384
this.ignoreNextMouseUp = false;
385
}
386
387
e.preventDefault();
388
e.stopPropagation();
389
}));
390
391
this._register(DOM.addDisposableListener(buttonElement, DOM.EventType.MOUSE_UP, (e) => {
392
if (e.defaultPrevented) {
393
return;
394
}
395
396
if (!this.ignoreNextMouseUp) {
397
if (this.isFocused) {
398
this.onMenuTriggered(MenuBar.OVERFLOW_INDEX, true);
399
}
400
} else {
401
this.ignoreNextMouseUp = false;
402
}
403
}));
404
405
this._register(DOM.addDisposableListener(buttonElement, DOM.EventType.MOUSE_ENTER, () => {
406
if (this.isOpen && !this.isCurrentMenu(MenuBar.OVERFLOW_INDEX)) {
407
this.overflowMenu.buttonElement.focus();
408
this.cleanupCustomMenu();
409
this.showCustomMenu(MenuBar.OVERFLOW_INDEX, false);
410
} else if (this.isFocused && !this.isOpen) {
411
this.focusedMenu = { index: MenuBar.OVERFLOW_INDEX };
412
buttonElement.focus();
413
}
414
}));
415
416
this.overflowMenu = {
417
buttonElement: buttonElement,
418
titleElement: titleElement,
419
label: 'More',
420
actions: []
421
};
422
}
423
424
updateMenu(menu: MenuBarMenu): void {
425
const menuToUpdate = this.menus.filter(menuBarMenu => menuBarMenu.label === menu.label);
426
if (menuToUpdate && menuToUpdate.length) {
427
menuToUpdate[0].actions = menu.actions;
428
}
429
}
430
431
override dispose(): void {
432
super.dispose();
433
434
this.menus.forEach(menuBarMenu => {
435
menuBarMenu.titleElement?.remove();
436
menuBarMenu.buttonElement?.remove();
437
});
438
439
this.overflowMenu.titleElement.remove();
440
this.overflowMenu.buttonElement.remove();
441
442
dispose(this.overflowLayoutScheduled);
443
this.overflowLayoutScheduled = undefined;
444
}
445
446
blur(): void {
447
this.setUnfocusedState();
448
}
449
450
getWidth(): number {
451
if (!this.isCompact && this.menus) {
452
const left = this.menus[0].buttonElement!.getBoundingClientRect().left;
453
const right = this.hasOverflow ? this.overflowMenu.buttonElement.getBoundingClientRect().right : this.menus[this.menus.length - 1].buttonElement!.getBoundingClientRect().right;
454
return right - left;
455
}
456
457
return 0;
458
}
459
460
getHeight(): number {
461
return this.container.clientHeight;
462
}
463
464
toggleFocus(): void {
465
if (!this.isFocused && this.options.visibility !== 'hidden') {
466
this.mnemonicsInUse = true;
467
this.focusedMenu = { index: this.numMenusShown > 0 ? 0 : MenuBar.OVERFLOW_INDEX };
468
this.focusState = MenubarState.FOCUSED;
469
} else if (!this.isOpen) {
470
this.setUnfocusedState();
471
}
472
}
473
474
private updateOverflowAction(): void {
475
if (!this.menus || !this.menus.length) {
476
return;
477
}
478
479
const overflowMenuOnlyClass = 'overflow-menu-only';
480
481
// Remove overflow only restriction to allow the most space
482
this.container.classList.toggle(overflowMenuOnlyClass, false);
483
484
const sizeAvailable = this.container.offsetWidth;
485
let currentSize = 0;
486
let full = this.isCompact;
487
const prevNumMenusShown = this.numMenusShown;
488
this.numMenusShown = 0;
489
490
const showableMenus = this.menus.filter(menu => menu.buttonElement !== undefined && menu.titleElement !== undefined) as (MenuBarMenuWithElements & { titleElement: HTMLElement; buttonElement: HTMLElement })[];
491
for (const menuBarMenu of showableMenus) {
492
if (!full) {
493
const size = menuBarMenu.buttonElement.offsetWidth;
494
if (currentSize + size > sizeAvailable) {
495
full = true;
496
} else {
497
currentSize += size;
498
this.numMenusShown++;
499
if (this.numMenusShown > prevNumMenusShown) {
500
menuBarMenu.buttonElement.style.visibility = 'visible';
501
}
502
}
503
}
504
505
if (full) {
506
menuBarMenu.buttonElement.style.visibility = 'hidden';
507
}
508
}
509
510
511
// If below minimium menu threshold, show the overflow menu only as hamburger menu
512
if (this.numMenusShown - 1 <= showableMenus.length / 4) {
513
for (const menuBarMenu of showableMenus) {
514
menuBarMenu.buttonElement.style.visibility = 'hidden';
515
}
516
517
full = true;
518
this.numMenusShown = 0;
519
currentSize = 0;
520
}
521
522
// Overflow
523
if (this.isCompact) {
524
this.overflowMenu.actions = [];
525
for (let idx = this.numMenusShown; idx < this.menus.length; idx++) {
526
this.overflowMenu.actions.push(new SubmenuAction(`menubar.submenu.${this.menus[idx].label}`, this.menus[idx].label, this.menus[idx].actions || []));
527
}
528
529
const compactMenuActions = this.options.getCompactMenuActions?.();
530
if (compactMenuActions && compactMenuActions.length) {
531
this.overflowMenu.actions.push(new Separator());
532
this.overflowMenu.actions.push(...compactMenuActions);
533
}
534
535
this.overflowMenu.buttonElement.style.visibility = 'visible';
536
} else if (full) {
537
// Can't fit the more button, need to remove more menus
538
while (currentSize + this.overflowMenu.buttonElement.offsetWidth > sizeAvailable && this.numMenusShown > 0) {
539
this.numMenusShown--;
540
const size = showableMenus[this.numMenusShown].buttonElement.offsetWidth;
541
showableMenus[this.numMenusShown].buttonElement.style.visibility = 'hidden';
542
currentSize -= size;
543
}
544
545
this.overflowMenu.actions = [];
546
for (let idx = this.numMenusShown; idx < showableMenus.length; idx++) {
547
this.overflowMenu.actions.push(new SubmenuAction(`menubar.submenu.${showableMenus[idx].label}`, showableMenus[idx].label, showableMenus[idx].actions || []));
548
}
549
550
if (this.overflowMenu.buttonElement.nextElementSibling !== showableMenus[this.numMenusShown].buttonElement) {
551
this.overflowMenu.buttonElement.remove();
552
this.container.insertBefore(this.overflowMenu.buttonElement, showableMenus[this.numMenusShown].buttonElement);
553
}
554
555
this.overflowMenu.buttonElement.style.visibility = 'visible';
556
} else {
557
this.overflowMenu.buttonElement.remove();
558
this.container.appendChild(this.overflowMenu.buttonElement);
559
this.overflowMenu.buttonElement.style.visibility = 'hidden';
560
}
561
562
// If we are only showing the overflow, add this class to avoid taking up space
563
this.container.classList.toggle(overflowMenuOnlyClass, this.numMenusShown === 0);
564
}
565
566
private updateLabels(titleElement: HTMLElement, buttonElement: HTMLElement, label: string): void {
567
const cleanMenuLabel = cleanMnemonic(label);
568
569
// Update the button label to reflect mnemonics
570
571
if (this.options.enableMnemonics) {
572
const cleanLabel = strings.escape(label);
573
574
// This is global so reset it
575
MENU_ESCAPED_MNEMONIC_REGEX.lastIndex = 0;
576
let escMatch = MENU_ESCAPED_MNEMONIC_REGEX.exec(cleanLabel);
577
578
// We can't use negative lookbehind so we match our negative and skip
579
while (escMatch && escMatch[1]) {
580
escMatch = MENU_ESCAPED_MNEMONIC_REGEX.exec(cleanLabel);
581
}
582
583
const replaceDoubleEscapes = (str: string) => str.replace(/&amp;&amp;/g, '&amp;');
584
585
if (escMatch) {
586
titleElement.textContent = '';
587
titleElement.append(
588
strings.ltrim(replaceDoubleEscapes(cleanLabel.substr(0, escMatch.index)), ' '),
589
$('mnemonic', { 'aria-hidden': 'true' }, escMatch[3]),
590
strings.rtrim(replaceDoubleEscapes(cleanLabel.substr(escMatch.index + escMatch[0].length)), ' ')
591
);
592
} else {
593
titleElement.textContent = replaceDoubleEscapes(cleanLabel).trim();
594
}
595
} else {
596
titleElement.textContent = cleanMenuLabel.replace(/&&/g, '&');
597
}
598
599
const mnemonicMatches = MENU_MNEMONIC_REGEX.exec(label);
600
601
// Register mnemonics
602
if (mnemonicMatches) {
603
const mnemonic = !!mnemonicMatches[1] ? mnemonicMatches[1] : mnemonicMatches[3];
604
605
if (this.options.enableMnemonics) {
606
buttonElement.setAttribute('aria-keyshortcuts', 'Alt+' + mnemonic.toLocaleLowerCase());
607
} else {
608
buttonElement.removeAttribute('aria-keyshortcuts');
609
}
610
}
611
}
612
613
update(options?: IMenuBarOptions): void {
614
if (options) {
615
this.options = options;
616
}
617
618
// Don't update while using the menu
619
if (this.isFocused) {
620
this.updatePending = true;
621
return;
622
}
623
624
this.menus.forEach(menuBarMenu => {
625
if (!menuBarMenu.buttonElement || !menuBarMenu.titleElement) {
626
return;
627
}
628
629
this.updateLabels(menuBarMenu.titleElement, menuBarMenu.buttonElement, menuBarMenu.label);
630
});
631
632
if (!this.overflowLayoutScheduled) {
633
this.overflowLayoutScheduled = DOM.scheduleAtNextAnimationFrame(DOM.getWindow(this.container), () => {
634
this.updateOverflowAction();
635
this.overflowLayoutScheduled = undefined;
636
});
637
}
638
639
this.setUnfocusedState();
640
}
641
642
private registerMnemonic(menuIndex: number, mnemonic: string): void {
643
this.mnemonics.set(mnemonic.toLocaleLowerCase(), menuIndex);
644
}
645
646
private hideMenubar(): void {
647
if (this.container.style.display !== 'none') {
648
this.container.style.display = 'none';
649
this._onVisibilityChange.fire(false);
650
}
651
}
652
653
private showMenubar(): void {
654
if (this.container.style.display !== 'flex') {
655
this.container.style.display = 'flex';
656
this._onVisibilityChange.fire(true);
657
658
this.updateOverflowAction();
659
}
660
}
661
662
private get focusState(): MenubarState {
663
return this._focusState;
664
}
665
666
private set focusState(value: MenubarState) {
667
if (this._focusState >= MenubarState.FOCUSED && value < MenubarState.FOCUSED) {
668
// Losing focus, update the menu if needed
669
670
if (this.updatePending) {
671
this.menuUpdater.schedule();
672
this.updatePending = false;
673
}
674
}
675
676
if (value === this._focusState) {
677
return;
678
}
679
680
const isVisible = this.isVisible;
681
const isOpen = this.isOpen;
682
const isFocused = this.isFocused;
683
684
this._focusState = value;
685
686
switch (value) {
687
case MenubarState.HIDDEN:
688
if (isVisible) {
689
this.hideMenubar();
690
}
691
692
if (isOpen) {
693
this.cleanupCustomMenu();
694
}
695
696
if (isFocused) {
697
this.focusedMenu = undefined;
698
699
if (this.focusToReturn) {
700
this.focusToReturn.focus();
701
this.focusToReturn = undefined;
702
}
703
}
704
705
706
break;
707
case MenubarState.VISIBLE:
708
if (!isVisible) {
709
this.showMenubar();
710
}
711
712
if (isOpen) {
713
this.cleanupCustomMenu();
714
}
715
716
if (isFocused) {
717
if (this.focusedMenu) {
718
if (this.focusedMenu.index === MenuBar.OVERFLOW_INDEX) {
719
this.overflowMenu.buttonElement.blur();
720
} else {
721
this.menus[this.focusedMenu.index].buttonElement?.blur();
722
}
723
}
724
725
this.focusedMenu = undefined;
726
727
if (this.focusToReturn) {
728
this.focusToReturn.focus();
729
this.focusToReturn = undefined;
730
}
731
}
732
733
break;
734
case MenubarState.FOCUSED:
735
if (!isVisible) {
736
this.showMenubar();
737
}
738
739
if (isOpen) {
740
this.cleanupCustomMenu();
741
}
742
743
if (this.focusedMenu) {
744
// When the menu is toggled on, it may be in compact state and trying to
745
// focus the first menu. In this case we should focus the overflow instead.
746
if (this.focusedMenu.index === 0 && this.numMenusShown === 0) {
747
this.focusedMenu.index = MenuBar.OVERFLOW_INDEX;
748
}
749
750
if (this.focusedMenu.index === MenuBar.OVERFLOW_INDEX) {
751
this.overflowMenu.buttonElement.focus();
752
} else {
753
this.menus[this.focusedMenu.index].buttonElement?.focus();
754
}
755
}
756
break;
757
case MenubarState.OPEN:
758
if (!isVisible) {
759
this.showMenubar();
760
}
761
762
if (this.focusedMenu) {
763
this.cleanupCustomMenu();
764
this.showCustomMenu(this.focusedMenu.index, this.openedViaKeyboard);
765
}
766
break;
767
}
768
769
this._focusState = value;
770
this._onFocusStateChange.fire(this.focusState >= MenubarState.FOCUSED);
771
}
772
773
get isVisible(): boolean {
774
return this.focusState >= MenubarState.VISIBLE;
775
}
776
777
private get isFocused(): boolean {
778
return this.focusState >= MenubarState.FOCUSED;
779
}
780
781
private get isOpen(): boolean {
782
return this.focusState >= MenubarState.OPEN;
783
}
784
785
private get hasOverflow(): boolean {
786
return this.isCompact || this.numMenusShown < this.menus.length;
787
}
788
789
private get isCompact(): boolean {
790
return this.options.compactMode !== undefined;
791
}
792
793
private setUnfocusedState(): void {
794
if (this.options.visibility === 'toggle' || this.options.visibility === 'hidden') {
795
this.focusState = MenubarState.HIDDEN;
796
} else if (this.options.visibility === 'classic' && browser.isFullscreen(mainWindow)) {
797
this.focusState = MenubarState.HIDDEN;
798
} else {
799
this.focusState = MenubarState.VISIBLE;
800
}
801
802
this.ignoreNextMouseUp = false;
803
this.mnemonicsInUse = false;
804
this.updateMnemonicVisibility(false);
805
}
806
807
private focusPrevious(): void {
808
809
if (!this.focusedMenu || this.numMenusShown === 0) {
810
return;
811
}
812
813
814
let newFocusedIndex = (this.focusedMenu.index - 1 + this.numMenusShown) % this.numMenusShown;
815
if (this.focusedMenu.index === MenuBar.OVERFLOW_INDEX) {
816
newFocusedIndex = this.numMenusShown - 1;
817
} else if (this.focusedMenu.index === 0 && this.hasOverflow) {
818
newFocusedIndex = MenuBar.OVERFLOW_INDEX;
819
}
820
821
if (newFocusedIndex === this.focusedMenu.index) {
822
return;
823
}
824
825
if (this.isOpen) {
826
this.cleanupCustomMenu();
827
this.showCustomMenu(newFocusedIndex);
828
} else if (this.isFocused) {
829
this.focusedMenu.index = newFocusedIndex;
830
if (newFocusedIndex === MenuBar.OVERFLOW_INDEX) {
831
this.overflowMenu.buttonElement.focus();
832
} else {
833
this.menus[newFocusedIndex].buttonElement?.focus();
834
}
835
}
836
}
837
838
private focusNext(): void {
839
if (!this.focusedMenu || this.numMenusShown === 0) {
840
return;
841
}
842
843
let newFocusedIndex = (this.focusedMenu.index + 1) % this.numMenusShown;
844
if (this.focusedMenu.index === MenuBar.OVERFLOW_INDEX) {
845
newFocusedIndex = 0;
846
} else if (this.focusedMenu.index === this.numMenusShown - 1) {
847
newFocusedIndex = MenuBar.OVERFLOW_INDEX;
848
}
849
850
if (newFocusedIndex === this.focusedMenu.index) {
851
return;
852
}
853
854
if (this.isOpen) {
855
this.cleanupCustomMenu();
856
this.showCustomMenu(newFocusedIndex);
857
} else if (this.isFocused) {
858
this.focusedMenu.index = newFocusedIndex;
859
if (newFocusedIndex === MenuBar.OVERFLOW_INDEX) {
860
this.overflowMenu.buttonElement.focus();
861
} else {
862
this.menus[newFocusedIndex].buttonElement?.focus();
863
}
864
}
865
}
866
867
private updateMnemonicVisibility(visible: boolean): void {
868
if (this.menus) {
869
this.menus.forEach(menuBarMenu => {
870
if (menuBarMenu.titleElement && menuBarMenu.titleElement.children.length) {
871
const child = menuBarMenu.titleElement.children.item(0) as HTMLElement;
872
if (child) {
873
child.style.textDecoration = (this.options.alwaysOnMnemonics || visible) ? 'underline' : '';
874
}
875
}
876
});
877
}
878
}
879
880
private get mnemonicsInUse(): boolean {
881
return this._mnemonicsInUse;
882
}
883
884
private set mnemonicsInUse(value: boolean) {
885
this._mnemonicsInUse = value;
886
}
887
888
private get shouldAltKeyFocus(): boolean {
889
if (isMacintosh) {
890
return false;
891
}
892
893
if (!this.options.disableAltFocus) {
894
return true;
895
}
896
897
if (this.options.visibility === 'toggle') {
898
return true;
899
}
900
901
return false;
902
}
903
904
public get onVisibilityChange(): Event<boolean> {
905
return this._onVisibilityChange.event;
906
}
907
908
public get onFocusStateChange(): Event<boolean> {
909
return this._onFocusStateChange.event;
910
}
911
912
private onMenuTriggered(menuIndex: number, clicked: boolean) {
913
if (this.isOpen) {
914
if (this.isCurrentMenu(menuIndex)) {
915
this.setUnfocusedState();
916
} else {
917
this.cleanupCustomMenu();
918
this.showCustomMenu(menuIndex, this.openedViaKeyboard);
919
}
920
} else {
921
this.focusedMenu = { index: menuIndex };
922
this.openedViaKeyboard = !clicked;
923
this.focusState = MenubarState.OPEN;
924
}
925
}
926
927
private onModifierKeyToggled(modifierKeyStatus: DOM.IModifierKeyStatus): void {
928
const allModifiersReleased = !modifierKeyStatus.altKey && !modifierKeyStatus.ctrlKey && !modifierKeyStatus.shiftKey && !modifierKeyStatus.metaKey;
929
930
if (this.options.visibility === 'hidden') {
931
return;
932
}
933
934
// Prevent alt-key default if the menu is not hidden and we use alt to focus
935
if (modifierKeyStatus.event && this.shouldAltKeyFocus) {
936
if (ScanCodeUtils.toEnum(modifierKeyStatus.event.code) === ScanCode.AltLeft) {
937
modifierKeyStatus.event.preventDefault();
938
}
939
}
940
941
// Alt key pressed while menu is focused. This should return focus away from the menubar
942
if (this.isFocused && modifierKeyStatus.lastKeyPressed === 'alt' && modifierKeyStatus.altKey) {
943
this.setUnfocusedState();
944
this.mnemonicsInUse = false;
945
this.awaitingAltRelease = true;
946
}
947
948
// Clean alt key press and release
949
if (allModifiersReleased && modifierKeyStatus.lastKeyPressed === 'alt' && modifierKeyStatus.lastKeyReleased === 'alt') {
950
if (!this.awaitingAltRelease) {
951
if (!this.isFocused && this.shouldAltKeyFocus) {
952
this.mnemonicsInUse = true;
953
this.focusedMenu = { index: this.numMenusShown > 0 ? 0 : MenuBar.OVERFLOW_INDEX };
954
this.focusState = MenubarState.FOCUSED;
955
} else if (!this.isOpen) {
956
this.setUnfocusedState();
957
}
958
}
959
}
960
961
// Alt key released
962
if (!modifierKeyStatus.altKey && modifierKeyStatus.lastKeyReleased === 'alt') {
963
this.awaitingAltRelease = false;
964
}
965
966
if (this.options.enableMnemonics && this.menus && !this.isOpen) {
967
this.updateMnemonicVisibility((!this.awaitingAltRelease && modifierKeyStatus.altKey) || this.mnemonicsInUse);
968
}
969
}
970
971
private isCurrentMenu(menuIndex: number): boolean {
972
if (!this.focusedMenu) {
973
return false;
974
}
975
976
return this.focusedMenu.index === menuIndex;
977
}
978
979
private cleanupCustomMenu(): void {
980
if (this.focusedMenu) {
981
// Remove focus from the menus first
982
if (this.focusedMenu.index === MenuBar.OVERFLOW_INDEX) {
983
this.overflowMenu.buttonElement.focus();
984
} else {
985
this.menus[this.focusedMenu.index].buttonElement?.focus();
986
}
987
988
if (this.focusedMenu.holder) {
989
this.focusedMenu.holder.parentElement?.classList.remove('open');
990
991
this.focusedMenu.holder.remove();
992
}
993
994
this.focusedMenu.widget?.dispose();
995
996
this.focusedMenu = { index: this.focusedMenu.index };
997
}
998
this.menuDisposables.clear();
999
}
1000
1001
private showCustomMenu(menuIndex: number, selectFirst = true): void {
1002
const actualMenuIndex = menuIndex >= this.numMenusShown ? MenuBar.OVERFLOW_INDEX : menuIndex;
1003
const customMenu = actualMenuIndex === MenuBar.OVERFLOW_INDEX ? this.overflowMenu : this.menus[actualMenuIndex];
1004
1005
if (!customMenu.actions || !customMenu.buttonElement || !customMenu.titleElement) {
1006
return;
1007
}
1008
1009
const menuHolder = $('div.menubar-menu-items-holder', { 'title': '' });
1010
1011
customMenu.buttonElement.classList.add('open');
1012
1013
const titleBoundingRect = customMenu.titleElement.getBoundingClientRect();
1014
const titleBoundingRectZoom = DOM.getDomNodeZoomLevel(customMenu.titleElement);
1015
1016
if (this.options.compactMode?.horizontal === HorizontalDirection.Right) {
1017
menuHolder.style.left = `${titleBoundingRect.left + this.container.clientWidth}px`;
1018
} else if (this.options.compactMode?.horizontal === HorizontalDirection.Left) {
1019
const windowWidth = DOM.getWindow(this.container).innerWidth;
1020
menuHolder.style.right = `${windowWidth - titleBoundingRect.left}px`;
1021
menuHolder.style.left = 'auto';
1022
} else {
1023
menuHolder.style.left = `${titleBoundingRect.left * titleBoundingRectZoom}px`;
1024
}
1025
1026
if (this.options.compactMode?.vertical === VerticalDirection.Above) {
1027
// TODO@benibenj Do not hardcode the height of the menu holder
1028
menuHolder.style.top = `${titleBoundingRect.top - this.menus.length * 30 + this.container.clientHeight}px`;
1029
} else if (this.options.compactMode?.vertical === VerticalDirection.Below) {
1030
menuHolder.style.top = `${titleBoundingRect.top}px`;
1031
} else {
1032
menuHolder.style.top = `${titleBoundingRect.bottom * titleBoundingRectZoom}px`;
1033
}
1034
1035
customMenu.buttonElement.appendChild(menuHolder);
1036
1037
const menuOptions: IMenuOptions = {
1038
getKeyBinding: this.options.getKeybinding,
1039
actionRunner: this.actionRunner,
1040
enableMnemonics: this.options.alwaysOnMnemonics || (this.mnemonicsInUse && this.options.enableMnemonics),
1041
ariaLabel: customMenu.buttonElement.getAttribute('aria-label') ?? undefined,
1042
expandDirection: this.isCompact ? this.options.compactMode : { horizontal: HorizontalDirection.Right, vertical: VerticalDirection.Below },
1043
useEventAsContext: true
1044
};
1045
1046
const menuWidget = this.menuDisposables.add(new Menu(menuHolder, customMenu.actions, menuOptions, this.menuStyle));
1047
this.menuDisposables.add(menuWidget.onDidCancel(() => {
1048
this.focusState = MenubarState.FOCUSED;
1049
}));
1050
1051
if (actualMenuIndex !== menuIndex) {
1052
menuWidget.trigger(menuIndex - this.numMenusShown);
1053
} else {
1054
menuWidget.focus(selectFirst);
1055
}
1056
1057
this.focusedMenu = {
1058
index: actualMenuIndex,
1059
holder: menuHolder,
1060
widget: menuWidget
1061
};
1062
}
1063
}
1064
1065