Path: blob/main/src/vs/base/browser/ui/toolbar/toolbar.ts
5250 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 { IContextMenuProvider } from '../../contextmenu.js';6import { ActionBar, ActionsOrientation, IActionViewItemProvider } from '../actionbar/actionbar.js';7import { AnchorAlignment } from '../contextview/contextview.js';8import { DropdownMenuActionViewItem } from '../dropdown/dropdownActionViewItem.js';9import { Action, IAction, IActionRunner, Separator, SubmenuAction } from '../../../common/actions.js';10import { Codicon } from '../../../common/codicons.js';11import { ThemeIcon } from '../../../common/themables.js';12import { EventMultiplexer } from '../../../common/event.js';13import { ResolvedKeybinding } from '../../../common/keybindings.js';14import { Disposable, DisposableStore, toDisposable } from '../../../common/lifecycle.js';15import './toolbar.css';16import * as nls from '../../../../nls.js';17import { IHoverDelegate } from '../hover/hoverDelegate.js';18import { createInstantHoverDelegate } from '../hover/hoverDelegateFactory.js';1920const ACTION_MIN_WIDTH = 20; /* 20px codicon */21const ACTION_PADDING = 4; /* 4px padding */2223const ACTION_MIN_WIDTH_VAR = '--vscode-toolbar-action-min-width';2425export interface IToolBarOptions {26orientation?: ActionsOrientation;27actionViewItemProvider?: IActionViewItemProvider;28ariaLabel?: string;29getKeyBinding?: (action: IAction) => ResolvedKeybinding | undefined;30actionRunner?: IActionRunner;31toggleMenuTitle?: string;32anchorAlignmentProvider?: () => AnchorAlignment;33renderDropdownAsChildElement?: boolean;34moreIcon?: ThemeIcon;35allowContextMenu?: boolean;36skipTelemetry?: boolean;37hoverDelegate?: IHoverDelegate;38trailingSeparator?: boolean;3940/**41* If true, toggled primary items are highlighted with a background color.42*/43highlightToggledItems?: boolean;4445/**46* Render action with icons (default: `true`)47*/48icon?: boolean;4950/**51* Render action with label (default: `false`)52*/53label?: boolean;5455/**56* Controls the responsive behavior of the primary group of the toolbar.57* - `enabled`: Whether the responsive behavior is enabled.58* - `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.59* - `minItems`: The minimum number of items that should always be visible.60* - `actionMinWidth`: The minimum width of each action item. Defaults to `ACTION_MIN_WIDTH` (24px).61*/62responsiveBehavior?: { enabled: boolean; kind: 'last' | 'all'; minItems?: number; actionMinWidth?: number };63}6465/**66* A widget that combines an action bar for primary actions and a dropdown for secondary actions.67*/68export class ToolBar extends Disposable {69private options: IToolBarOptions;70protected readonly actionBar: ActionBar;71private toggleMenuAction: ToggleMenuAction;72private toggleMenuActionViewItem: DropdownMenuActionViewItem | undefined;73private submenuActionViewItems: DropdownMenuActionViewItem[] = [];74private hasSecondaryActions: boolean = false;75private readonly element: HTMLElement;7677private _onDidChangeDropdownVisibility = this._register(new EventMultiplexer<boolean>());78get onDidChangeDropdownVisibility() { return this._onDidChangeDropdownVisibility.event; }79private originalPrimaryActions: ReadonlyArray<IAction> = [];80private originalSecondaryActions: ReadonlyArray<IAction> = [];81private hiddenActions: { action: IAction; size: number }[] = [];82private readonly disposables = this._register(new DisposableStore());83private readonly actionMinWidth: number;8485constructor(private readonly container: HTMLElement, contextMenuProvider: IContextMenuProvider, options: IToolBarOptions = { orientation: ActionsOrientation.HORIZONTAL }) {86super();8788options.hoverDelegate = options.hoverDelegate ?? this._register(createInstantHoverDelegate());89this.options = options;9091this.toggleMenuAction = this._register(new ToggleMenuAction(() => this.toggleMenuActionViewItem?.show(), options.toggleMenuTitle));9293this.element = document.createElement('div');94this.element.className = 'monaco-toolbar';95container.appendChild(this.element);9697this.actionBar = this._register(new ActionBar(this.element, {98orientation: options.orientation,99ariaLabel: options.ariaLabel,100actionRunner: options.actionRunner,101allowContextMenu: options.allowContextMenu,102highlightToggledItems: options.highlightToggledItems,103hoverDelegate: options.hoverDelegate,104actionViewItemProvider: (action, viewItemOptions) => {105if (action.id === ToggleMenuAction.ID) {106this.toggleMenuActionViewItem = new DropdownMenuActionViewItem(107action,108{ getActions: () => this.toggleMenuAction.menuActions },109contextMenuProvider,110{111actionViewItemProvider: this.options.actionViewItemProvider,112actionRunner: this.actionRunner,113keybindingProvider: this.options.getKeyBinding,114classNames: ThemeIcon.asClassNameArray(options.moreIcon ?? Codicon.toolBarMore),115anchorAlignmentProvider: this.options.anchorAlignmentProvider,116menuAsChild: !!this.options.renderDropdownAsChildElement,117skipTelemetry: this.options.skipTelemetry,118isMenu: true,119hoverDelegate: this.options.hoverDelegate120}121);122this.toggleMenuActionViewItem.setActionContext(this.actionBar.context);123this.disposables.add(this._onDidChangeDropdownVisibility.add(this.toggleMenuActionViewItem.onDidChangeVisibility));124125return this.toggleMenuActionViewItem;126}127128if (options.actionViewItemProvider) {129const result = options.actionViewItemProvider(action, viewItemOptions);130131if (result) {132return result;133}134}135136if (action instanceof SubmenuAction) {137const result = new DropdownMenuActionViewItem(138action,139action.actions,140contextMenuProvider,141{142actionViewItemProvider: this.options.actionViewItemProvider,143actionRunner: this.actionRunner,144keybindingProvider: this.options.getKeyBinding,145classNames: action.class,146anchorAlignmentProvider: this.options.anchorAlignmentProvider,147menuAsChild: !!this.options.renderDropdownAsChildElement,148skipTelemetry: this.options.skipTelemetry,149hoverDelegate: this.options.hoverDelegate150}151);152result.setActionContext(this.actionBar.context);153this.submenuActionViewItems.push(result);154this.disposables.add(this._onDidChangeDropdownVisibility.add(result.onDidChangeVisibility));155156return result;157}158159return undefined;160}161}));162163// Store effective action min width164this.actionMinWidth = (options.responsiveBehavior?.actionMinWidth ?? ACTION_MIN_WIDTH) + ACTION_PADDING;165166// Responsive support167if (this.options.responsiveBehavior?.enabled) {168this.element.classList.toggle('responsive', true);169this.element.classList.toggle('responsive-all', this.options.responsiveBehavior.kind === 'all');170this.element.classList.toggle('responsive-last', this.options.responsiveBehavior.kind === 'last');171this.element.style.setProperty(ACTION_MIN_WIDTH_VAR, `${this.actionMinWidth - ACTION_PADDING}px`);172173const observer = new ResizeObserver(() => {174this.updateActions(this.element.getBoundingClientRect().width);175});176observer.observe(this.element);177this._store.add(toDisposable(() => observer.disconnect()));178}179}180181set actionRunner(actionRunner: IActionRunner) {182this.actionBar.actionRunner = actionRunner;183}184185get actionRunner(): IActionRunner {186return this.actionBar.actionRunner;187}188189set context(context: unknown) {190this.actionBar.context = context;191this.toggleMenuActionViewItem?.setActionContext(context);192for (const actionViewItem of this.submenuActionViewItems) {193actionViewItem.setActionContext(context);194}195}196197getElement(): HTMLElement {198return this.element;199}200201focus(): void {202this.actionBar.focus();203}204205getItemsWidth(): number {206let itemsWidth = 0;207for (let i = 0; i < this.actionBar.length(); i++) {208itemsWidth += this.actionBar.getWidth(i);209}210return itemsWidth;211}212213getItemAction(indexOrElement: number | HTMLElement) {214return this.actionBar.getAction(indexOrElement);215}216217getItemWidth(index: number): number {218return this.actionBar.getWidth(index);219}220221getItemsLength(): number {222return this.actionBar.length();223}224225setAriaLabel(label: string): void {226this.actionBar.setAriaLabel(label);227}228229setActions(primaryActions: ReadonlyArray<IAction>, secondaryActions?: ReadonlyArray<IAction>): void {230this.clear();231232// Store primary and secondary actions as rendered initially233this.originalPrimaryActions = primaryActions ? primaryActions.slice(0) : [];234this.originalSecondaryActions = secondaryActions ? secondaryActions.slice(0) : [];235236const primaryActionsToSet = primaryActions ? primaryActions.slice(0) : [];237238// Inject additional action to open secondary actions if present239this.hasSecondaryActions = !!(secondaryActions && secondaryActions.length > 0);240if (this.hasSecondaryActions && secondaryActions) {241this.toggleMenuAction.menuActions = secondaryActions.slice(0);242primaryActionsToSet.push(this.toggleMenuAction);243}244245if (primaryActionsToSet.length > 0 && this.options.trailingSeparator) {246primaryActionsToSet.push(new Separator());247}248249primaryActionsToSet.forEach(action => {250this.actionBar.push(action, { icon: this.options.icon ?? true, label: this.options.label ?? false, keybinding: this.getKeybindingLabel(action) });251});252253this.actionBar.domNode.classList.toggle('has-overflow', this.actionBar.hasAction(this.toggleMenuAction));254255if (this.options.responsiveBehavior?.enabled) {256// Reset hidden actions257this.hiddenActions.length = 0;258259// Set the minimum width260if (this.options.responsiveBehavior?.minItems !== undefined) {261const itemCount = this.options.responsiveBehavior.minItems;262263// Account for overflow menu264let overflowWidth = 0;265if (266this.originalSecondaryActions.length > 0 ||267itemCount < this.originalPrimaryActions.length268) {269overflowWidth = ACTION_MIN_WIDTH + ACTION_PADDING;270}271272this.container.style.minWidth = `${itemCount * this.actionMinWidth + overflowWidth}px`;273this.element.style.minWidth = `${itemCount * this.actionMinWidth + overflowWidth}px`;274} else {275this.container.style.minWidth = `${ACTION_MIN_WIDTH + ACTION_PADDING}px`;276this.element.style.minWidth = `${ACTION_MIN_WIDTH + ACTION_PADDING}px`;277}278279// Update toolbar actions to fit with container width280this.updateActions(this.element.getBoundingClientRect().width);281}282}283284isEmpty(): boolean {285return this.actionBar.isEmpty();286}287288private getKeybindingLabel(action: IAction): string | undefined {289const key = this.options.getKeyBinding?.(action);290291return key?.getLabel() ?? undefined;292}293294private updateActions(containerWidth: number) {295// Actions bar is empty296if (this.actionBar.isEmpty()) {297return;298}299300// Ensure that the container width respects the minimum width of the301// element which is set based on the `responsiveBehavior.minItems` option302containerWidth = Math.max(containerWidth, parseInt(this.element.style.minWidth));303304// Each action is assumed to have a minimum width so that actions with a label305// can shrink to the action's minimum width. We do this so that action visibility306// takes precedence over the action label.307const actionBarWidth = (actualWidth: boolean) => {308if (this.options.responsiveBehavior?.kind === 'last') {309const hasToggleMenuAction = this.actionBar.hasAction(this.toggleMenuAction);310const primaryActionsCount = hasToggleMenuAction311? this.actionBar.length() - 1312: this.actionBar.length();313314let itemsWidth = 0;315for (let i = 0; i < primaryActionsCount - 1; i++) {316itemsWidth += this.actionBar.getWidth(i) + ACTION_PADDING;317}318319itemsWidth += actualWidth ? this.actionBar.getWidth(primaryActionsCount - 1) : this.actionMinWidth; // item to shrink320itemsWidth += hasToggleMenuAction ? ACTION_MIN_WIDTH + ACTION_PADDING : 0; // toggle menu action321322return itemsWidth;323} else {324return this.actionBar.length() * this.actionMinWidth;325}326};327328// Action bar fits and there are no hidden actions to show329if (actionBarWidth(false) <= containerWidth && this.hiddenActions.length === 0) {330return;331}332333if (actionBarWidth(false) > containerWidth) {334// Check for max items limit335if (this.options.responsiveBehavior?.minItems !== undefined) {336const primaryActionsCount = this.actionBar.hasAction(this.toggleMenuAction)337? this.actionBar.length() - 1338: this.actionBar.length();339340if (primaryActionsCount <= this.options.responsiveBehavior.minItems) {341return;342}343}344345// Hide actions from the right346while (actionBarWidth(true) > containerWidth && this.actionBar.length() > 0) {347const index = this.originalPrimaryActions.length - this.hiddenActions.length - 1;348if (index < 0) {349break;350}351352// Store the action and its size353const size = Math.min(this.actionMinWidth, this.getItemWidth(index));354const action = this.originalPrimaryActions[index];355this.hiddenActions.unshift({ action, size });356357// Remove the action358this.actionBar.pull(index);359360// There are no secondary actions, but we have actions that we need to hide so we361// create the overflow menu. This will ensure that another primary action will be362// removed making space for the overflow menu.363if (this.originalSecondaryActions.length === 0 && this.hiddenActions.length === 1) {364this.actionBar.push(this.toggleMenuAction, {365icon: this.options.icon ?? true,366label: this.options.label ?? false,367keybinding: this.getKeybindingLabel(this.toggleMenuAction),368});369}370}371} else {372// Show actions from the top of the toggle menu373while (this.hiddenActions.length > 0) {374const entry = this.hiddenActions.shift()!;375if (actionBarWidth(true) + entry.size > containerWidth) {376// Not enough space to show the action377this.hiddenActions.unshift(entry);378break;379}380381// Add the action382this.actionBar.push(entry.action, {383icon: this.options.icon ?? true,384label: this.options.label ?? false,385keybinding: this.getKeybindingLabel(entry.action),386index: this.originalPrimaryActions.length - this.hiddenActions.length - 1387});388389// There are no secondary actions, and there is only one hidden item left so we390// remove the overflow menu making space for the last hidden action to be shown.391if (this.originalSecondaryActions.length === 0 && this.hiddenActions.length === 0) {392this.toggleMenuAction.menuActions = [];393this.actionBar.pull(this.actionBar.length() - 1);394}395}396}397398// Update overflow menu399const hiddenActions = this.hiddenActions.map(entry => entry.action);400if (this.originalSecondaryActions.length > 0 || hiddenActions.length > 0) {401const secondaryActions = this.originalSecondaryActions.slice(0);402this.toggleMenuAction.menuActions = Separator.join(hiddenActions, secondaryActions);403}404405this.actionBar.domNode.classList.toggle('has-overflow', this.actionBar.hasAction(this.toggleMenuAction));406}407408private clear(): void {409this.submenuActionViewItems = [];410this.disposables.clear();411this.actionBar.clear();412}413414override dispose(): void {415this.clear();416this.disposables.dispose();417this.element.remove();418super.dispose();419}420}421422export class ToggleMenuAction extends Action {423424static readonly ID = 'toolbar.toggle.more';425426private _menuActions: ReadonlyArray<IAction>;427private toggleDropdownMenu: () => void;428429constructor(toggleDropdownMenu: () => void, title?: string) {430title = title || nls.localize('moreActions', "More Actions...");431super(ToggleMenuAction.ID, title, undefined, true);432433this._menuActions = [];434this.toggleDropdownMenu = toggleDropdownMenu;435}436437override async run(): Promise<void> {438this.toggleDropdownMenu();439}440441get menuActions(): ReadonlyArray<IAction> {442return this._menuActions;443}444445set menuActions(actions: ReadonlyArray<IAction>) {446this._menuActions = actions;447}448}449450451