Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/base/browser/ui/toolbar/toolbar.ts
5250 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 { IContextMenuProvider } from '../../contextmenu.js';
7
import { ActionBar, ActionsOrientation, IActionViewItemProvider } from '../actionbar/actionbar.js';
8
import { AnchorAlignment } from '../contextview/contextview.js';
9
import { DropdownMenuActionViewItem } from '../dropdown/dropdownActionViewItem.js';
10
import { Action, IAction, IActionRunner, Separator, SubmenuAction } from '../../../common/actions.js';
11
import { Codicon } from '../../../common/codicons.js';
12
import { ThemeIcon } from '../../../common/themables.js';
13
import { EventMultiplexer } from '../../../common/event.js';
14
import { ResolvedKeybinding } from '../../../common/keybindings.js';
15
import { Disposable, DisposableStore, toDisposable } from '../../../common/lifecycle.js';
16
import './toolbar.css';
17
import * as nls from '../../../../nls.js';
18
import { IHoverDelegate } from '../hover/hoverDelegate.js';
19
import { createInstantHoverDelegate } from '../hover/hoverDelegateFactory.js';
20
21
const ACTION_MIN_WIDTH = 20; /* 20px codicon */
22
const ACTION_PADDING = 4; /* 4px padding */
23
24
const ACTION_MIN_WIDTH_VAR = '--vscode-toolbar-action-min-width';
25
26
export interface IToolBarOptions {
27
orientation?: ActionsOrientation;
28
actionViewItemProvider?: IActionViewItemProvider;
29
ariaLabel?: string;
30
getKeyBinding?: (action: IAction) => ResolvedKeybinding | undefined;
31
actionRunner?: IActionRunner;
32
toggleMenuTitle?: string;
33
anchorAlignmentProvider?: () => AnchorAlignment;
34
renderDropdownAsChildElement?: boolean;
35
moreIcon?: ThemeIcon;
36
allowContextMenu?: boolean;
37
skipTelemetry?: boolean;
38
hoverDelegate?: IHoverDelegate;
39
trailingSeparator?: boolean;
40
41
/**
42
* If true, toggled primary items are highlighted with a background color.
43
*/
44
highlightToggledItems?: boolean;
45
46
/**
47
* Render action with icons (default: `true`)
48
*/
49
icon?: boolean;
50
51
/**
52
* Render action with label (default: `false`)
53
*/
54
label?: boolean;
55
56
/**
57
* Controls the responsive behavior of the primary group of the toolbar.
58
* - `enabled`: Whether the responsive behavior is enabled.
59
* - `kind`: The kind of responsive behavior to apply. Can be either `last` to only shrink the last item, or `all` to shrink all items equally.
60
* - `minItems`: The minimum number of items that should always be visible.
61
* - `actionMinWidth`: The minimum width of each action item. Defaults to `ACTION_MIN_WIDTH` (24px).
62
*/
63
responsiveBehavior?: { enabled: boolean; kind: 'last' | 'all'; minItems?: number; actionMinWidth?: number };
64
}
65
66
/**
67
* A widget that combines an action bar for primary actions and a dropdown for secondary actions.
68
*/
69
export class ToolBar extends Disposable {
70
private options: IToolBarOptions;
71
protected readonly actionBar: ActionBar;
72
private toggleMenuAction: ToggleMenuAction;
73
private toggleMenuActionViewItem: DropdownMenuActionViewItem | undefined;
74
private submenuActionViewItems: DropdownMenuActionViewItem[] = [];
75
private hasSecondaryActions: boolean = false;
76
private readonly element: HTMLElement;
77
78
private _onDidChangeDropdownVisibility = this._register(new EventMultiplexer<boolean>());
79
get onDidChangeDropdownVisibility() { return this._onDidChangeDropdownVisibility.event; }
80
private originalPrimaryActions: ReadonlyArray<IAction> = [];
81
private originalSecondaryActions: ReadonlyArray<IAction> = [];
82
private hiddenActions: { action: IAction; size: number }[] = [];
83
private readonly disposables = this._register(new DisposableStore());
84
private readonly actionMinWidth: number;
85
86
constructor(private readonly container: HTMLElement, contextMenuProvider: IContextMenuProvider, options: IToolBarOptions = { orientation: ActionsOrientation.HORIZONTAL }) {
87
super();
88
89
options.hoverDelegate = options.hoverDelegate ?? this._register(createInstantHoverDelegate());
90
this.options = options;
91
92
this.toggleMenuAction = this._register(new ToggleMenuAction(() => this.toggleMenuActionViewItem?.show(), options.toggleMenuTitle));
93
94
this.element = document.createElement('div');
95
this.element.className = 'monaco-toolbar';
96
container.appendChild(this.element);
97
98
this.actionBar = this._register(new ActionBar(this.element, {
99
orientation: options.orientation,
100
ariaLabel: options.ariaLabel,
101
actionRunner: options.actionRunner,
102
allowContextMenu: options.allowContextMenu,
103
highlightToggledItems: options.highlightToggledItems,
104
hoverDelegate: options.hoverDelegate,
105
actionViewItemProvider: (action, viewItemOptions) => {
106
if (action.id === ToggleMenuAction.ID) {
107
this.toggleMenuActionViewItem = new DropdownMenuActionViewItem(
108
action,
109
{ getActions: () => this.toggleMenuAction.menuActions },
110
contextMenuProvider,
111
{
112
actionViewItemProvider: this.options.actionViewItemProvider,
113
actionRunner: this.actionRunner,
114
keybindingProvider: this.options.getKeyBinding,
115
classNames: ThemeIcon.asClassNameArray(options.moreIcon ?? Codicon.toolBarMore),
116
anchorAlignmentProvider: this.options.anchorAlignmentProvider,
117
menuAsChild: !!this.options.renderDropdownAsChildElement,
118
skipTelemetry: this.options.skipTelemetry,
119
isMenu: true,
120
hoverDelegate: this.options.hoverDelegate
121
}
122
);
123
this.toggleMenuActionViewItem.setActionContext(this.actionBar.context);
124
this.disposables.add(this._onDidChangeDropdownVisibility.add(this.toggleMenuActionViewItem.onDidChangeVisibility));
125
126
return this.toggleMenuActionViewItem;
127
}
128
129
if (options.actionViewItemProvider) {
130
const result = options.actionViewItemProvider(action, viewItemOptions);
131
132
if (result) {
133
return result;
134
}
135
}
136
137
if (action instanceof SubmenuAction) {
138
const result = new DropdownMenuActionViewItem(
139
action,
140
action.actions,
141
contextMenuProvider,
142
{
143
actionViewItemProvider: this.options.actionViewItemProvider,
144
actionRunner: this.actionRunner,
145
keybindingProvider: this.options.getKeyBinding,
146
classNames: action.class,
147
anchorAlignmentProvider: this.options.anchorAlignmentProvider,
148
menuAsChild: !!this.options.renderDropdownAsChildElement,
149
skipTelemetry: this.options.skipTelemetry,
150
hoverDelegate: this.options.hoverDelegate
151
}
152
);
153
result.setActionContext(this.actionBar.context);
154
this.submenuActionViewItems.push(result);
155
this.disposables.add(this._onDidChangeDropdownVisibility.add(result.onDidChangeVisibility));
156
157
return result;
158
}
159
160
return undefined;
161
}
162
}));
163
164
// Store effective action min width
165
this.actionMinWidth = (options.responsiveBehavior?.actionMinWidth ?? ACTION_MIN_WIDTH) + ACTION_PADDING;
166
167
// Responsive support
168
if (this.options.responsiveBehavior?.enabled) {
169
this.element.classList.toggle('responsive', true);
170
this.element.classList.toggle('responsive-all', this.options.responsiveBehavior.kind === 'all');
171
this.element.classList.toggle('responsive-last', this.options.responsiveBehavior.kind === 'last');
172
this.element.style.setProperty(ACTION_MIN_WIDTH_VAR, `${this.actionMinWidth - ACTION_PADDING}px`);
173
174
const observer = new ResizeObserver(() => {
175
this.updateActions(this.element.getBoundingClientRect().width);
176
});
177
observer.observe(this.element);
178
this._store.add(toDisposable(() => observer.disconnect()));
179
}
180
}
181
182
set actionRunner(actionRunner: IActionRunner) {
183
this.actionBar.actionRunner = actionRunner;
184
}
185
186
get actionRunner(): IActionRunner {
187
return this.actionBar.actionRunner;
188
}
189
190
set context(context: unknown) {
191
this.actionBar.context = context;
192
this.toggleMenuActionViewItem?.setActionContext(context);
193
for (const actionViewItem of this.submenuActionViewItems) {
194
actionViewItem.setActionContext(context);
195
}
196
}
197
198
getElement(): HTMLElement {
199
return this.element;
200
}
201
202
focus(): void {
203
this.actionBar.focus();
204
}
205
206
getItemsWidth(): number {
207
let itemsWidth = 0;
208
for (let i = 0; i < this.actionBar.length(); i++) {
209
itemsWidth += this.actionBar.getWidth(i);
210
}
211
return itemsWidth;
212
}
213
214
getItemAction(indexOrElement: number | HTMLElement) {
215
return this.actionBar.getAction(indexOrElement);
216
}
217
218
getItemWidth(index: number): number {
219
return this.actionBar.getWidth(index);
220
}
221
222
getItemsLength(): number {
223
return this.actionBar.length();
224
}
225
226
setAriaLabel(label: string): void {
227
this.actionBar.setAriaLabel(label);
228
}
229
230
setActions(primaryActions: ReadonlyArray<IAction>, secondaryActions?: ReadonlyArray<IAction>): void {
231
this.clear();
232
233
// Store primary and secondary actions as rendered initially
234
this.originalPrimaryActions = primaryActions ? primaryActions.slice(0) : [];
235
this.originalSecondaryActions = secondaryActions ? secondaryActions.slice(0) : [];
236
237
const primaryActionsToSet = primaryActions ? primaryActions.slice(0) : [];
238
239
// Inject additional action to open secondary actions if present
240
this.hasSecondaryActions = !!(secondaryActions && secondaryActions.length > 0);
241
if (this.hasSecondaryActions && secondaryActions) {
242
this.toggleMenuAction.menuActions = secondaryActions.slice(0);
243
primaryActionsToSet.push(this.toggleMenuAction);
244
}
245
246
if (primaryActionsToSet.length > 0 && this.options.trailingSeparator) {
247
primaryActionsToSet.push(new Separator());
248
}
249
250
primaryActionsToSet.forEach(action => {
251
this.actionBar.push(action, { icon: this.options.icon ?? true, label: this.options.label ?? false, keybinding: this.getKeybindingLabel(action) });
252
});
253
254
this.actionBar.domNode.classList.toggle('has-overflow', this.actionBar.hasAction(this.toggleMenuAction));
255
256
if (this.options.responsiveBehavior?.enabled) {
257
// Reset hidden actions
258
this.hiddenActions.length = 0;
259
260
// Set the minimum width
261
if (this.options.responsiveBehavior?.minItems !== undefined) {
262
const itemCount = this.options.responsiveBehavior.minItems;
263
264
// Account for overflow menu
265
let overflowWidth = 0;
266
if (
267
this.originalSecondaryActions.length > 0 ||
268
itemCount < this.originalPrimaryActions.length
269
) {
270
overflowWidth = ACTION_MIN_WIDTH + ACTION_PADDING;
271
}
272
273
this.container.style.minWidth = `${itemCount * this.actionMinWidth + overflowWidth}px`;
274
this.element.style.minWidth = `${itemCount * this.actionMinWidth + overflowWidth}px`;
275
} else {
276
this.container.style.minWidth = `${ACTION_MIN_WIDTH + ACTION_PADDING}px`;
277
this.element.style.minWidth = `${ACTION_MIN_WIDTH + ACTION_PADDING}px`;
278
}
279
280
// Update toolbar actions to fit with container width
281
this.updateActions(this.element.getBoundingClientRect().width);
282
}
283
}
284
285
isEmpty(): boolean {
286
return this.actionBar.isEmpty();
287
}
288
289
private getKeybindingLabel(action: IAction): string | undefined {
290
const key = this.options.getKeyBinding?.(action);
291
292
return key?.getLabel() ?? undefined;
293
}
294
295
private updateActions(containerWidth: number) {
296
// Actions bar is empty
297
if (this.actionBar.isEmpty()) {
298
return;
299
}
300
301
// Ensure that the container width respects the minimum width of the
302
// element which is set based on the `responsiveBehavior.minItems` option
303
containerWidth = Math.max(containerWidth, parseInt(this.element.style.minWidth));
304
305
// Each action is assumed to have a minimum width so that actions with a label
306
// can shrink to the action's minimum width. We do this so that action visibility
307
// takes precedence over the action label.
308
const actionBarWidth = (actualWidth: boolean) => {
309
if (this.options.responsiveBehavior?.kind === 'last') {
310
const hasToggleMenuAction = this.actionBar.hasAction(this.toggleMenuAction);
311
const primaryActionsCount = hasToggleMenuAction
312
? this.actionBar.length() - 1
313
: this.actionBar.length();
314
315
let itemsWidth = 0;
316
for (let i = 0; i < primaryActionsCount - 1; i++) {
317
itemsWidth += this.actionBar.getWidth(i) + ACTION_PADDING;
318
}
319
320
itemsWidth += actualWidth ? this.actionBar.getWidth(primaryActionsCount - 1) : this.actionMinWidth; // item to shrink
321
itemsWidth += hasToggleMenuAction ? ACTION_MIN_WIDTH + ACTION_PADDING : 0; // toggle menu action
322
323
return itemsWidth;
324
} else {
325
return this.actionBar.length() * this.actionMinWidth;
326
}
327
};
328
329
// Action bar fits and there are no hidden actions to show
330
if (actionBarWidth(false) <= containerWidth && this.hiddenActions.length === 0) {
331
return;
332
}
333
334
if (actionBarWidth(false) > containerWidth) {
335
// Check for max items limit
336
if (this.options.responsiveBehavior?.minItems !== undefined) {
337
const primaryActionsCount = this.actionBar.hasAction(this.toggleMenuAction)
338
? this.actionBar.length() - 1
339
: this.actionBar.length();
340
341
if (primaryActionsCount <= this.options.responsiveBehavior.minItems) {
342
return;
343
}
344
}
345
346
// Hide actions from the right
347
while (actionBarWidth(true) > containerWidth && this.actionBar.length() > 0) {
348
const index = this.originalPrimaryActions.length - this.hiddenActions.length - 1;
349
if (index < 0) {
350
break;
351
}
352
353
// Store the action and its size
354
const size = Math.min(this.actionMinWidth, this.getItemWidth(index));
355
const action = this.originalPrimaryActions[index];
356
this.hiddenActions.unshift({ action, size });
357
358
// Remove the action
359
this.actionBar.pull(index);
360
361
// There are no secondary actions, but we have actions that we need to hide so we
362
// create the overflow menu. This will ensure that another primary action will be
363
// removed making space for the overflow menu.
364
if (this.originalSecondaryActions.length === 0 && this.hiddenActions.length === 1) {
365
this.actionBar.push(this.toggleMenuAction, {
366
icon: this.options.icon ?? true,
367
label: this.options.label ?? false,
368
keybinding: this.getKeybindingLabel(this.toggleMenuAction),
369
});
370
}
371
}
372
} else {
373
// Show actions from the top of the toggle menu
374
while (this.hiddenActions.length > 0) {
375
const entry = this.hiddenActions.shift()!;
376
if (actionBarWidth(true) + entry.size > containerWidth) {
377
// Not enough space to show the action
378
this.hiddenActions.unshift(entry);
379
break;
380
}
381
382
// Add the action
383
this.actionBar.push(entry.action, {
384
icon: this.options.icon ?? true,
385
label: this.options.label ?? false,
386
keybinding: this.getKeybindingLabel(entry.action),
387
index: this.originalPrimaryActions.length - this.hiddenActions.length - 1
388
});
389
390
// There are no secondary actions, and there is only one hidden item left so we
391
// remove the overflow menu making space for the last hidden action to be shown.
392
if (this.originalSecondaryActions.length === 0 && this.hiddenActions.length === 0) {
393
this.toggleMenuAction.menuActions = [];
394
this.actionBar.pull(this.actionBar.length() - 1);
395
}
396
}
397
}
398
399
// Update overflow menu
400
const hiddenActions = this.hiddenActions.map(entry => entry.action);
401
if (this.originalSecondaryActions.length > 0 || hiddenActions.length > 0) {
402
const secondaryActions = this.originalSecondaryActions.slice(0);
403
this.toggleMenuAction.menuActions = Separator.join(hiddenActions, secondaryActions);
404
}
405
406
this.actionBar.domNode.classList.toggle('has-overflow', this.actionBar.hasAction(this.toggleMenuAction));
407
}
408
409
private clear(): void {
410
this.submenuActionViewItems = [];
411
this.disposables.clear();
412
this.actionBar.clear();
413
}
414
415
override dispose(): void {
416
this.clear();
417
this.disposables.dispose();
418
this.element.remove();
419
super.dispose();
420
}
421
}
422
423
export class ToggleMenuAction extends Action {
424
425
static readonly ID = 'toolbar.toggle.more';
426
427
private _menuActions: ReadonlyArray<IAction>;
428
private toggleDropdownMenu: () => void;
429
430
constructor(toggleDropdownMenu: () => void, title?: string) {
431
title = title || nls.localize('moreActions', "More Actions...");
432
super(ToggleMenuAction.ID, title, undefined, true);
433
434
this._menuActions = [];
435
this.toggleDropdownMenu = toggleDropdownMenu;
436
}
437
438
override async run(): Promise<void> {
439
this.toggleDropdownMenu();
440
}
441
442
get menuActions(): ReadonlyArray<IAction> {
443
return this._menuActions;
444
}
445
446
set menuActions(actions: ReadonlyArray<IAction>) {
447
this._menuActions = actions;
448
}
449
}
450
451