From d6b63e7cedbb27fc5e1df2e1ae828e070d17ac33 Mon Sep 17 00:00:00 2001 From: Tobias Ortmayr Date: Sun, 4 Jun 2023 05:04:06 +0200 Subject: [PATCH] Rework sidepanel behavior on tab overflow Rework behavior of the sidepanels if there more tabs than they can fit. Introduce behavior similar to VS Code: Overflowing tabs will simply be hidden or 'merged' into one menu button at the end of the tabbar. Clicking the button allows revealing of a currently hidden view. Special handling is implemented to ensure that the currently selected view never gets hidden away. Fixes #12416 --- .../browser/frontend-application-module.ts | 7 + .../shell/additional-views-menu-widget.tsx | 71 ++++++++++ .../src/browser/shell/side-panel-handler.ts | 24 ++++ packages/core/src/browser/shell/tab-bars.ts | 130 +++++++++++++++++- packages/core/src/browser/style/sidepanel.css | 1 + packages/core/src/browser/style/tabs.css | 8 ++ 6 files changed, 234 insertions(+), 7 deletions(-) create mode 100644 packages/core/src/browser/shell/additional-views-menu-widget.tsx diff --git a/packages/core/src/browser/frontend-application-module.ts b/packages/core/src/browser/frontend-application-module.ts index 595738d4803ee..871f03815d654 100644 --- a/packages/core/src/browser/frontend-application-module.ts +++ b/packages/core/src/browser/frontend-application-module.ts @@ -136,6 +136,7 @@ import { MarkdownRenderer, MarkdownRendererFactory, MarkdownRendererImpl } from import { StylingParticipant, StylingService } from './styling-service'; import { bindCommonStylingParticipants } from './common-styling-participants'; import { HoverService } from './hover-service'; +import { AdditionalViewsMenuWidget, AdditionalViewsMenuWidgetFactory } from './shell/additional-views-menu-widget'; export { bindResourceProvider, bindMessageService, bindPreferenceService }; @@ -167,6 +168,12 @@ export const frontendApplicationModule = new ContainerModule((bind, _unbind, _is bind(SidebarMenuWidget).toSelf(); bind(SidebarBottomMenuWidget).toSelf(); bind(SidebarBottomMenuWidgetFactory).toAutoFactory(SidebarBottomMenuWidget); + bind(AdditionalViewsMenuWidget).toSelf(); + bind(AdditionalViewsMenuWidgetFactory).toFactory(ctx => (side: 'left' | 'right') => { + const widget = ctx.container.resolve(AdditionalViewsMenuWidget); + widget.side = side; + return widget; + }); bind(SplitPositionHandler).toSelf().inSingletonScope(); bindContributionProvider(bind, TabBarToolbarContribution); diff --git a/packages/core/src/browser/shell/additional-views-menu-widget.tsx b/packages/core/src/browser/shell/additional-views-menu-widget.tsx new file mode 100644 index 0000000000000..335982a4eb525 --- /dev/null +++ b/packages/core/src/browser/shell/additional-views-menu-widget.tsx @@ -0,0 +1,71 @@ +// ***************************************************************************** +// Copyright (C) 2023 STMicroelectronics and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { inject, injectable } from '../../../shared/inversify'; +import { Command, CommandRegistry, Disposable, MenuModelRegistry, MenuPath, nls } from '../../common'; +import { Title, Widget, codicon } from '../widgets'; +import { SidebarMenuWidget } from './sidebar-menu-widget'; +import { SideTabBar } from './tab-bars'; + +export const AdditionalViewsMenuWidgetFactory = Symbol('AdditionalViewsMenuWidgetFactory'); +export type AdditionalViewsMenuWidgetFactory = (side: 'left' | 'right') => AdditionalViewsMenuWidget; + +export const ADDITIONAL_VIEWS_MENU_PATH: MenuPath = ['additional_views_menu']; + +@injectable() +export class AdditionalViewsMenuWidget extends SidebarMenuWidget { + static readonly ID = 'sidebar.additional.views'; + + side: 'left' | 'right'; + + @inject(CommandRegistry) + protected readonly commandRegistry: CommandRegistry; + + @inject(MenuModelRegistry) + protected readonly menuModelRegistry: MenuModelRegistry; + + protected menuDisposables: Disposable[] = []; + + updateAdditionalViews(sender: SideTabBar, event: { titles: Title[], startIndex: number }): void { + if (event.startIndex === -1) { + this.removeMenu(AdditionalViewsMenuWidget.ID); + } else { + this.addMenu({ + title: nls.localizeByDefault('Additional Views'), + iconClass: codicon('ellipsis'), + id: AdditionalViewsMenuWidget.ID, + menuPath: ADDITIONAL_VIEWS_MENU_PATH, + order: 0 + }); + } + + this.menuDisposables.forEach(disposable => disposable.dispose()); + this.menuDisposables = []; + event.titles.forEach((title, i) => this.registerMenuAction(sender, title, i)); + } + + protected registerMenuAction(sender: SideTabBar, title: Title, index: number): void { + const command: Command = { id: `reveal.${title.label}.${index}`, label: title.label }; + this.menuDisposables.push(this.commandRegistry.registerCommand(command, { + execute: () => { + window.requestAnimationFrame(() => { + sender.currentIndex = sender.titles.indexOf(title); + }); + } + })); + this.menuDisposables.push(this.menuModelRegistry.registerMenuAction(ADDITIONAL_VIEWS_MENU_PATH, { commandId: command.id, order: index.toString() })); + } +} diff --git a/packages/core/src/browser/shell/side-panel-handler.ts b/packages/core/src/browser/shell/side-panel-handler.ts index e83644c82a431..6bfdb5e066486 100644 --- a/packages/core/src/browser/shell/side-panel-handler.ts +++ b/packages/core/src/browser/shell/side-panel-handler.ts @@ -34,6 +34,7 @@ import { MenuPath } from '../../common/menu'; import { SidebarBottomMenuWidget } from './sidebar-bottom-menu-widget'; import { SidebarTopMenuWidget } from './sidebar-top-menu-widget'; import { PINNED_CLASS } from '../widgets'; +import { AdditionalViewsMenuWidget, AdditionalViewsMenuWidgetFactory } from './additional-views-menu-widget'; /** The class name added to the left and right area panels. */ export const LEFT_RIGHT_AREA_CLASS = 'theia-app-sides'; @@ -68,6 +69,11 @@ export class SidePanelHandler { * tab bar itself remains visible as long as there is at least one widget. */ tabBar: SideTabBar; + /** + * Conditional menu placed below the tabBar. Manages overflowing/hidden tabs. + * Is only visible if there are overflowing tabs. + */ + additionalViewsMenu: AdditionalViewsMenuWidget; /** * The menu placed on the sidebar top. * Displayed as icons. @@ -118,6 +124,7 @@ export class SidePanelHandler { @inject(TabBarRendererFactory) protected tabBarRendererFactory: () => TabBarRenderer; @inject(SidebarTopMenuWidgetFactory) protected sidebarTopWidgetFactory: () => SidebarTopMenuWidget; @inject(SidebarBottomMenuWidgetFactory) protected sidebarBottomWidgetFactory: () => SidebarBottomMenuWidget; + @inject(AdditionalViewsMenuWidgetFactory) protected additionalViewsMenuFactory: AdditionalViewsMenuWidgetFactory; @inject(SplitPositionHandler) protected splitPositionHandler: SplitPositionHandler; @inject(FrontendApplicationStateService) protected readonly applicationStateService: FrontendApplicationStateService; @inject(TheiaDockPanel.Factory) protected readonly dockPanelFactory: TheiaDockPanel.Factory; @@ -133,6 +140,7 @@ export class SidePanelHandler { this.options = options; this.topMenu = this.createSidebarTopMenu(); this.tabBar = this.createSideBar(); + this.additionalViewsMenu = this.createAdditionalViewsWidget(); this.bottomMenu = this.createSidebarBottomMenu(); this.toolBar = this.createToolbar(); this.dockPanel = this.createSidePanel(); @@ -175,6 +183,7 @@ export class SidePanelHandler { sideBar.collapseRequested.connect(() => this.collapse(), this); sideBar.currentChanged.connect(this.onCurrentTabChanged, this); sideBar.tabDetachRequested.connect(this.onTabDetachRequested, this); + sideBar.tabsOverflowChanged.connect(this.onTabsOverflowChanged, this); return sideBar; } @@ -199,6 +208,12 @@ export class SidePanelHandler { return toolbar; } + protected createAdditionalViewsWidget(): AdditionalViewsMenuWidget { + const widget = this.additionalViewsMenuFactory(this.side); + widget.addClass('theia-sidebar-menu'); + return widget; + } + protected createSidebarTopMenu(): SidebarTopMenuWidget { return this.createSidebarMenu(this.sidebarTopWidgetFactory); } @@ -254,6 +269,7 @@ export class SidePanelHandler { sidebarContainer.addClass('theia-app-sidebar-container'); sidebarContainerLayout.addWidget(this.topMenu); sidebarContainerLayout.addWidget(this.tabBar); + sidebarContainerLayout.addWidget(this.additionalViewsMenu); sidebarContainerLayout.addWidget(this.bottomMenu); BoxPanel.setStretch(sidebarContainer, 0); @@ -636,6 +652,14 @@ export class SidePanelHandler { }); } + protected onTabsOverflowChanged(sender: SideTabBar, event: { titles: Title[], startIndex: number }): void { + if (event.startIndex >= 0 && event.startIndex <= sender.currentIndex) { + sender.revealTab(sender.currentIndex); + } else { + this.additionalViewsMenu.updateAdditionalViews(sender, event); + } + } + /* * Handle the `widgetAdded` signal from the dock panel. The widget's title is inserted into the * tab bar according to the `rankProperty` value that may be attached to the widget. diff --git a/packages/core/src/browser/shell/tab-bars.ts b/packages/core/src/browser/shell/tab-bars.ts index 918384027076f..d243c7fc4b19e 100644 --- a/packages/core/src/browser/shell/tab-bars.ts +++ b/packages/core/src/browser/shell/tab-bars.ts @@ -68,6 +68,7 @@ export interface SideBarRenderData extends TabBar.IRenderData { iconSize?: SizeData; paddingTop?: number; paddingBottom?: number; + visible?: boolean } export interface ScrollableRenderData extends TabBar.IRenderData { @@ -194,6 +195,14 @@ export class TabBarRenderer extends TabBar.Renderer { ); } + override createTabClass(data: SideBarRenderData): string { + let tabClass = super.createTabClass(data); + if (!(data.visible ?? true)) { + tabClass += ' p-mod-invisible'; + } + return tabClass; + } + /** * Generate ID for an entry in the tab bar * @param {Title} title Title of the widget controlled by this tab bar @@ -586,7 +595,7 @@ export class ScrollableTabBar extends TabBar { protected scrollBar?: PerfectScrollbar; private scrollBarFactory: () => PerfectScrollbar; - private pendingReveal?: Promise; + protected pendingReveal?: Promise; private isMouseOver = false; protected needsRecompute = false; protected tabSize = 0; @@ -960,12 +969,24 @@ export class SideTabBar extends ScrollableTabBar { */ readonly collapseRequested = new Signal>(this); + /** + * Emitted when the set of overflowing/hidden tabs changes. + */ + readonly tabsOverflowChanged = new Signal[], startIndex: number }>(this); + private mouseData?: { pressX: number, pressY: number, mouseDownTabIndex: number }; + private tabsOverflowData?: { + titles: Title[], + startIndex: number + }; + + private _rowGap: number; + constructor(options?: TabBar.IOptions & PerfectScrollbar.Options) { super(options); @@ -996,7 +1017,6 @@ export class SideTabBar extends ScrollableTabBar { } protected override onAfterAttach(msg: Message): void { - super.onAfterAttach(msg); this.updateTabs(); this.node.addEventListener('p-dragenter', this); this.node.addEventListener('p-dragover', this); @@ -1014,9 +1034,65 @@ export class SideTabBar extends ScrollableTabBar { protected override onUpdateRequest(msg: Message): void { this.updateTabs(); - if (this.scrollBar) { - this.scrollBar.update(); + } + + protected override onResize(msg: Widget.ResizeMessage): void { + // Tabs need to be updated if there are already overflowing tabs or the current tabs don't fit + if (this.tabsOverflowData || this.node.clientHeight < this.contentNode.clientHeight) { + this.updateTabs(); + } + } + + // Queries the tabRowGap value of the content node. Needed to properly compute overflowing + // tabs that should be hidden + protected get tabRowGap(): number { + // We assume that the tab row gap is static i.e. we compute it once an then cache it + if (!this._rowGap) { + this._rowGap = this.computeTabRowGap(); + } + return this._rowGap; + + } + + protected computeTabRowGap(): number { + const style = window.getComputedStyle(this.contentNode); + const rowGapStyle = style.getPropertyValue('row-gap'); + const numericValue = parseFloat(rowGapStyle); + const unit = rowGapStyle.match(/[a-zA-Z]+/)?.[0]; + + const tempDiv = document.createElement('div'); + tempDiv.style.height = '1' + unit; + document.body.appendChild(tempDiv); + const rowGapValue = numericValue * tempDiv.offsetHeight; + document.body.removeChild(tempDiv); + return rowGapValue; + } + + /** + * Reveal the tab with the given index by moving it into the non-overflowing tabBar section + * if necessary. + */ + override revealTab(index: number): Promise { + if (this.pendingReveal) { + // A reveal has already been scheduled + return this.pendingReveal; } + const result = new Promise(resolve => { + // The tab might not have been created yet, so wait until the next frame + window.requestAnimationFrame(() => { + if (this.tabsOverflowData && index >= this.tabsOverflowData.startIndex) { + const title = this.titles[index]; + this.insertTab(this.tabsOverflowData.startIndex - 1, title); + } + + if (this.pendingReveal === result) { + this.pendingReveal = undefined; + } + resolve(); + }); + }); + this.pendingReveal = result; + return result; } /** @@ -1032,13 +1108,18 @@ export class SideTabBar extends ScrollableTabBar { const hiddenContent = this.hiddenContentNode; const n = hiddenContent.children.length; const renderData = new Array>(n); + const availableWidth = this.node.clientHeight; + let actualWidth = 0; + let overflowStartIndex = -1; for (let i = 0; i < n; i++) { const hiddenTab = hiddenContent.children[i]; // Extract tab padding from the computed style const tabStyle = window.getComputedStyle(hiddenTab); + const paddingTop = parseFloat(tabStyle.paddingTop!); + const paddingBottom = parseFloat(tabStyle.paddingBottom!); const rd: Partial = { - paddingTop: parseFloat(tabStyle.paddingTop!), - paddingBottom: parseFloat(tabStyle.paddingBottom!) + paddingTop, + paddingBottom }; // Extract label size from the DOM const labelElements = hiddenTab.getElementsByClassName('p-TabBar-tabLabel'); @@ -1051,15 +1132,50 @@ export class SideTabBar extends ScrollableTabBar { if (iconElements.length === 1) { const icon = iconElements[0]; rd.iconSize = { width: icon.clientWidth, height: icon.clientHeight }; + actualWidth += icon.clientHeight + paddingTop + paddingBottom + this.tabRowGap; + + if (actualWidth > availableWidth && i !== 0) { + rd.visible = false; + if (overflowStartIndex === -1) { + overflowStartIndex = i; + } + } + renderData[i] = rd; } - renderData[i] = rd; } // Render into the visible node this.renderTabs(this.contentNode, renderData); + this.computeOverflowingTabsData(overflowStartIndex); }); } } + private computeOverflowingTabsData(startIndex: number): void { + // ensure that render tabs has completed + window.requestAnimationFrame(() => { + if (startIndex === -1) { + if (this.tabsOverflowData) { + this.tabsOverflowData = undefined; + this.tabsOverflowChanged.emit({ titles: [], startIndex }); + } + return; + } + const newOverflowingTabs = this.titles.slice(startIndex); + + if (!this.tabsOverflowData) { + this.tabsOverflowData = { titles: newOverflowingTabs, startIndex }; + this.tabsOverflowChanged.emit(this.tabsOverflowData); + return; + } + + if ((newOverflowingTabs.length !== this.tabsOverflowData?.titles.length ?? 0) || + newOverflowingTabs.find((newTitle, i) => newTitle !== this.tabsOverflowData?.titles[i]) !== undefined) { + this.tabsOverflowData = { titles: newOverflowingTabs, startIndex }; + this.tabsOverflowChanged.emit(this.tabsOverflowData); + } + }); + } + /** * Render the tab bar using the given DOM element as host. The optional `renderData` is forwarded * to the TabBarRenderer. diff --git a/packages/core/src/browser/style/sidepanel.css b/packages/core/src/browser/style/sidepanel.css index 161d327aa081c..374dd47d9e932 100644 --- a/packages/core/src/browser/style/sidepanel.css +++ b/packages/core/src/browser/style/sidepanel.css @@ -302,6 +302,7 @@ display: flex; position: absolute; visibility: hidden; + padding-inline-start: 0px; } .p-TabBar.theia-app-sides > .theia-TabBar-hidden-content .p-TabBar-tab { diff --git a/packages/core/src/browser/style/tabs.css b/packages/core/src/browser/style/tabs.css index 21b9b9885a5b2..047a623eb5346 100644 --- a/packages/core/src/browser/style/tabs.css +++ b/packages/core/src/browser/style/tabs.css @@ -195,6 +195,10 @@ height: inherit; } +.p-TabBar[data-orientation='vertical'] .p-TabBar-tab.p-mod-invisible { + visibility: hidden; +} + .p-TabBar.theia-app-centers .p-TabBar-tab.p-mod-closable > .p-TabBar-tabCloseIcon, .p-TabBar.theia-app-centers .p-TabBar-tab.theia-mod-pinned > .p-TabBar-tabCloseIcon { padding: 2px; @@ -324,6 +328,10 @@ right: calc((var(--theia-private-horizontal-tab-scrollbar-rail-height) - var(--theia-private-horizontal-tab-scrollbar-height)) / 2); } +.p-TabBar[data-orientation='vertical'] .p-TabBar-content-container { + display: block; +} + /*----------------------------------------------------------------------------- | Dragged tabs |----------------------------------------------------------------------------*/