Skip to content

Commit

Permalink
Rework sidepanel behavior on tab overflow
Browse files Browse the repository at this point in the history
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
  • Loading branch information
tortmayr committed Jun 27, 2023
1 parent 8043260 commit d6b63e7
Show file tree
Hide file tree
Showing 6 changed files with 234 additions and 7 deletions.
7 changes: 7 additions & 0 deletions packages/core/src/browser/frontend-application-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down Expand Up @@ -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);
Expand Down
71 changes: 71 additions & 0 deletions packages/core/src/browser/shell/additional-views-menu-widget.tsx
Original file line number Diff line number Diff line change
@@ -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<Widget>[], 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<Widget>, 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() }));
}
}
24 changes: 24 additions & 0 deletions packages/core/src/browser/shell/side-panel-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand All @@ -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();
Expand Down Expand Up @@ -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;
}

Expand All @@ -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);
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -636,6 +652,14 @@ export class SidePanelHandler {
});
}

protected onTabsOverflowChanged(sender: SideTabBar, event: { titles: Title<Widget>[], 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.
Expand Down
130 changes: 123 additions & 7 deletions packages/core/src/browser/shell/tab-bars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export interface SideBarRenderData extends TabBar.IRenderData<Widget> {
iconSize?: SizeData;
paddingTop?: number;
paddingBottom?: number;
visible?: boolean
}

export interface ScrollableRenderData extends TabBar.IRenderData<Widget> {
Expand Down Expand Up @@ -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<Widget>} title Title of the widget controlled by this tab bar
Expand Down Expand Up @@ -586,7 +595,7 @@ export class ScrollableTabBar extends TabBar<Widget> {
protected scrollBar?: PerfectScrollbar;

private scrollBarFactory: () => PerfectScrollbar;
private pendingReveal?: Promise<void>;
protected pendingReveal?: Promise<void>;
private isMouseOver = false;
protected needsRecompute = false;
protected tabSize = 0;
Expand Down Expand Up @@ -960,12 +969,24 @@ export class SideTabBar extends ScrollableTabBar {
*/
readonly collapseRequested = new Signal<this, Title<Widget>>(this);

/**
* Emitted when the set of overflowing/hidden tabs changes.
*/
readonly tabsOverflowChanged = new Signal<this, { titles: Title<Widget>[], startIndex: number }>(this);

private mouseData?: {
pressX: number,
pressY: number,
mouseDownTabIndex: number
};

private tabsOverflowData?: {
titles: Title<Widget>[],
startIndex: number
};

private _rowGap: number;

constructor(options?: TabBar.IOptions<Widget> & PerfectScrollbar.Options) {
super(options);

Expand Down Expand Up @@ -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);
Expand All @@ -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<void> {
if (this.pendingReveal) {
// A reveal has already been scheduled
return this.pendingReveal;
}
const result = new Promise<void>(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;
}

/**
Expand All @@ -1032,13 +1108,18 @@ export class SideTabBar extends ScrollableTabBar {
const hiddenContent = this.hiddenContentNode;
const n = hiddenContent.children.length;
const renderData = new Array<Partial<SideBarRenderData>>(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<SideBarRenderData> = {
paddingTop: parseFloat(tabStyle.paddingTop!),
paddingBottom: parseFloat(tabStyle.paddingBottom!)
paddingTop,
paddingBottom
};
// Extract label size from the DOM
const labelElements = hiddenTab.getElementsByClassName('p-TabBar-tabLabel');
Expand All @@ -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.
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/browser/style/sidepanel.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading

0 comments on commit d6b63e7

Please sign in to comment.