Skip to content

Commit

Permalink
Fixed #1278: implemented tabbar decorator & supported error marker in…
Browse files Browse the repository at this point in the history
… editor tabs

- Implemented `TabBarDecorator` that provides tabs with decorations, similar to what we already had for tree nodes.
- Supported diagnostic problem markers (error, warning, ...) in editor tabs in the main area. Tabs in side bars can be decorated as well in the future using the same code.

Signed-off-by: fangnx <naxin.fang@ericsson.com>
  • Loading branch information
fangnx committed Aug 8, 2019
1 parent f9ff237 commit dc8fa17
Show file tree
Hide file tree
Showing 5 changed files with 394 additions and 12 deletions.
7 changes: 6 additions & 1 deletion packages/core/src/browser/frontend-application-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ import { MimeService } from './mime-service';
import { ApplicationShellMouseTracker } from './shell/application-shell-mouse-tracker';
import { ViewContainer } from './view-container';
import { Widget } from './widgets';
import { TabBarDecoratorService, TabBarDecorator } from './shell/tab-bar-decorator';

export const frontendApplicationModule = new ContainerModule((bind, unbind, isBound, rebind) => {
const themeService = ThemeService.get();
Expand Down Expand Up @@ -112,9 +113,13 @@ export const frontendApplicationModule = new ContainerModule((bind, unbind, isBo
bind(DockPanelRenderer).toSelf();
bind(TabBarRendererFactory).toFactory(context => () => {
const contextMenuRenderer = context.container.get<ContextMenuRenderer>(ContextMenuRenderer);
return new TabBarRenderer(contextMenuRenderer);
const decoratorService = context.container.get<TabBarDecoratorService>(TabBarDecoratorService);
return new TabBarRenderer(contextMenuRenderer, decoratorService);
});

bindContributionProvider(bind, TabBarDecorator);
bind(TabBarDecoratorService).toSelf().inSingletonScope();

bindContributionProvider(bind, OpenHandler);
bind(DefaultOpenerService).toSelf().inSingletonScope();
bind(OpenerService).toService(DefaultOpenerService);
Expand Down
175 changes: 175 additions & 0 deletions packages/core/src/browser/shell/tab-bar-decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
/********************************************************************************
* Copyright (C) 2019 Ericsson 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, named, postConstruct } from 'inversify';
import { Event, Emitter, Disposable, DisposableCollection, ContributionProvider } from '../../common';
import { Title, Widget } from '@phosphor/widgets';

export const TabBarDecorator = Symbol('TabBarDecorator');

export interface TabBarDecorator {

/**
* The unique identifier of the tab bar decorator.
*/
readonly id: string;

readonly onDidChangeDecorations: Event<(titles: Title<Widget>[]) => void>;

/**
* Decorate tabs by the associated editor URI.
* @returns A map from URI of the tab to its decoration data.
*/
decorate(titles: Title<Widget>[]): Map<string, TabBarDecoration.Data>;
}

@injectable()
export class TabBarDecoratorService implements Disposable {

protected readonly onDidChangeDecorationsEmitter = new Emitter<void>();

readonly onDidChangeDecorations = this.onDidChangeDecorationsEmitter.event;

protected readonly toDispose = new DisposableCollection();

@inject(ContributionProvider) @named(TabBarDecorator)
protected readonly contributions: ContributionProvider<TabBarDecorator>;

protected decorators: ReadonlyArray<TabBarDecorator>;

@postConstruct()
protected init(): void {
this.decorators = this.contributions.getContributions();
this.toDispose.pushAll(this.decorators.map(decorator =>
decorator.onDidChangeDecorations(data =>
this.onDidChangeDecorationsEmitter.fire(undefined)
))
);

}

dispose(): void {
this.toDispose.dispose();
}

getDecorations(titles: Title<Widget>[]): Map<string, TabBarDecoration.Data[]> {
const changes: Map<string, TabBarDecoration.Data[]> = new Map();
for (const decorator of this.decorators) {
console.log(decorator);
for (const [id, data] of (decorator.decorate(titles)).entries()) {
if (changes.has(id)) {
changes.get(id)!.push(data);
} else {
changes.set(id, [data]);
}
}
}
return changes;
}
}

export namespace TabBarDecoration {

export namespace Styles {
export const CAPTION_HIGHLIGHT_CLASS = 'theia-caption-highlight';
export const CAPTION_PREFIX_CLASS = 'theia-caption-prefix';
export const CAPTION_SUFFIX_CLASS = 'theia-caption-suffix';
export const ICON_WRAPPER_CLASS = 'theia-icon-wrapper';
export const DECORATOR_SIZE_CLASS = 'theia-decorator-size';
export const TOP_RIGHT_CLASS = 'theia-top-right';
export const BOTTOM_RIGHT_CLASS = 'theia-bottom-right';
export const BOTTOM_LEFT_CLASS = 'theia-bottom-left';
export const TOP_LEFT_CLASS = 'theia-top-left';
}

/**
* Enumeration for the quadrant to overlay the icon on.
*/
export enum IconOverlayPosition {
TOP_RIGHT,
BOTTOM_RIGHT,
BOTTOM_LEFT,
TOP_LEFT
}

export type Color = string;

export namespace IconOverlayPosition {

export function getStyle(position: IconOverlayPosition): string {
switch (position) {
case IconOverlayPosition.TOP_RIGHT: return TabBarDecoration.Styles.TOP_RIGHT_CLASS;
case IconOverlayPosition.BOTTOM_RIGHT: return TabBarDecoration.Styles.BOTTOM_RIGHT_CLASS;
case IconOverlayPosition.BOTTOM_LEFT: return TabBarDecoration.Styles.BOTTOM_LEFT_CLASS;
case IconOverlayPosition.TOP_LEFT: return TabBarDecoration.Styles.TOP_LEFT_CLASS;
}
}
}

/**
* A shape that can be optionally rendered behind the overlay icon. Can be used to further refine colors.
*/
export interface IconOverlayBackground {

readonly shape: 'circle' | 'square';
readonly color?: Color;
}

/**
* Has no effect if the tree node being decorated has no associated icon.
*/
export interface BaseOverlay {

/**
* The position where the decoration will be placed on the top of the original icon.
*/
readonly position: IconOverlayPosition;

/**
* The color of the overlaying icon. If not specified, then the default icon color will be used.
*/
readonly color?: Color;

/**
* The optional background color of the overlay icon.
*/
readonly background?: IconOverlayBackground;

}

export interface IconOverlay extends BaseOverlay {

/**
* This should be the name of the Font Awesome icon with out the `fa fa-` prefix, just the name, for instance `paw`.
* For the existing icons, see here: https://fontawesome.com/v4.7.0/icons/.
*/
readonly icon: string;
}

export interface IconClassOverlay extends BaseOverlay {

/**
* This should be the entire Font Awesome class array, for instance ['fa', 'fa-paw']
* For the existing icons, see here: https://fontawesome.com/v4.7.0/icons/.
*/
readonly iconClass: string[];
}

export interface Data {

readonly iconOverlay?: IconOverlay | IconClassOverlay
}
}
85 changes: 77 additions & 8 deletions packages/core/src/browser/shell/tab-bars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,15 @@
import PerfectScrollbar from 'perfect-scrollbar';
import { TabBar, Title, Widget } from '@phosphor/widgets';
import { VirtualElement, h, VirtualDOM, ElementInlineStyle } from '@phosphor/virtualdom';
import { MenuPath } from '../../common';
import { DisposableCollection, MenuPath, notEmpty } from '../../common';
import { ContextMenuRenderer } from '../context-menu-renderer';
import { Signal } from '@phosphor/signaling';
import { Message } from '@phosphor/messaging';
import { ArrayExt } from '@phosphor/algorithm';
import { ElementExt } from '@phosphor/domutils';
import { TabBarToolbarRegistry, TabBarToolbar } from './tab-bar-toolbar';
import { TheiaDockPanel, MAIN_AREA_ID, BOTTOM_AREA_ID } from './theia-dock-panel';
import { TabBarDecoration, TabBarDecoratorService } from './tab-bar-decorator';

/** The class name added to hidden content nodes, which are required to render vertical side bars. */
const HIDDEN_CONTENT_CLASS = 'theia-TabBar-hidden-content';
Expand Down Expand Up @@ -71,18 +72,29 @@ export class TabBarRenderer extends TabBar.Renderer {
*/
contextMenuPath?: MenuPath;

protected readonly toDispose = new DisposableCollection();

// TODO refactor shell, rendered should only receive props with event handlers
// events should be handled by clients, like ApplicationShell
// right now it is mess: (1) client logic belong to renderer, (2) cyclic dependencies between renderes and clients
constructor(protected readonly contextMenuRenderer?: ContextMenuRenderer) {
constructor(
protected readonly contextMenuRenderer?: ContextMenuRenderer,
protected readonly decoratorService?: TabBarDecoratorService
) {
super();
if (this.decoratorService) {
this.toDispose.push(this.decoratorService);
this.toDispose.push(this.decoratorService.onDidChangeDecorations(() => this.tabBar && this.tabBar.update()));
}
}

/**
* Render tabs with the default DOM structure, but additionally register a context
* menu listener.
* @param {SideBarRenderData} data - data to render the tab.
* @param {boolean} decorateIcon - whether the tab icon should be decorated (e.g., with diagnostic markers).
*/
renderTab(data: SideBarRenderData): VirtualElement {
renderTab(data: SideBarRenderData, decorateIcon: boolean = true): VirtualElement {
const title = data.title;
const id = this.createTabId(data.title);
const key = this.createTabKey(data);
Expand All @@ -95,7 +107,7 @@ export class TabBarRenderer extends TabBar.Renderer {
oncontextmenu: this.handleContextMenuEvent,
ondblclick: this.handleDblClickEvent
},
this.renderIcon(data),
this.renderIcon(data, decorateIcon),
this.renderLabel(data),
this.renderCloseIcon(data)
);
Expand Down Expand Up @@ -155,18 +167,75 @@ export class TabBarRenderer extends TabBar.Renderer {
return h.div({ className: 'p-TabBar-tabLabel', style }, data.title.label);
}

protected getDecorations(tab: string): TabBarDecoration.Data[] {
const tabDecorations = [];
if (this.tabBar && this.decoratorService) {
const allDecorations = this.decoratorService.getDecorations([...this.tabBar.titles]);
if (allDecorations.has(tab)) {
tabDecorations.push(...allDecorations.get(tab));
}
}
return tabDecorations;
}

protected getDecorationData<K extends keyof TabBarDecoration.Data>(tab: string, key: K): TabBarDecoration.Data[K][] {
return this.getDecorations(tab).filter(data => data[key] !== undefined).map(data => data[key]);

}

private getIconClass(iconName: string | string[], additionalClasses: string[] = []): string {
const iconClass = (typeof iconName === 'string') ? ['a', 'fa', `fa-${iconName}`] : ['a'].concat(iconName);
return iconClass.concat(additionalClasses).join(' ');
}

/**
* If size information is available for the icon, set it as inline style. Tab padding
* is also considered in the `top` position.
* @param {boolean} decorate - whether the icon should be decorated.
*/
renderIcon(data: SideBarRenderData): VirtualElement {
renderIcon(data: SideBarRenderData, decorate?: boolean): VirtualElement {
let top: string | undefined;
if (data.paddingTop) {
top = `${data.paddingTop || 0}px`;
}
const className = this.createIconClass(data);
const style: ElementInlineStyle = { top };
return h.div({ className, style }, data.title.iconLabel);
const baseClassName = this.createIconClass(data);

const overlayIcons: VirtualElement[] = [];
const decorationData = this.getDecorationData(data.title.caption, 'iconOverlay');

// Check if the tab has decoration markers to be rendered on top.
if (decorate && decorationData.length > 0) {
const baseIcon: VirtualElement = h.div({ className: baseClassName, style }, data.title.iconLabel);
const wrapperClassName: string = TabBarDecoration.Styles.ICON_WRAPPER_CLASS;
decorationData
.filter(notEmpty)
.map(overlay => [overlay.position, overlay] as [TabBarDecoration.IconOverlayPosition, TabBarDecoration.IconOverlay | TabBarDecoration.IconClassOverlay])
.forEach(([position, overlay]) => {
const iconAdditionalClasses: string[] = [TabBarDecoration.Styles.DECORATOR_SIZE_CLASS, TabBarDecoration.IconOverlayPosition.getStyle(position)];
const overlayIconStyle = (color?: string) => {
if (color === undefined) {
return {};
}
return { color };
};
// Parse the optional background (if it exists) of the overlay icon.
if (overlay.background) {
const backgroundIconClassName = this.getIconClass(overlay.background.shape, iconAdditionalClasses);
overlayIcons.push(
h.div({ key: data.title.label + '-background', className: backgroundIconClassName, style: overlayIconStyle(overlay.background.color) })
);
}
// Parse the overlay icon.
const overlayIcon = (overlay as TabBarDecoration.IconOverlay).icon || (overlay as TabBarDecoration.IconClassOverlay).iconClass;
const overlayIconClassName = this.getIconClass(overlayIcon, iconAdditionalClasses);
overlayIcons.push(
h.span({ key: data.title.label, className: overlayIconClassName, style: overlayIconStyle(overlay.color) })
);
});
return h.div({ className: wrapperClassName, style }, [baseIcon, ...overlayIcons]);
}
return h.div({ className: baseClassName, style }, data.title.iconLabel);
}

protected handleContextMenuEvent = (event: MouseEvent) => {
Expand Down Expand Up @@ -550,7 +619,7 @@ export class SideTabBar extends ScrollableTabBar {
} else {
rd = { title, current, zIndex };
}
content[i] = renderer.renderTab(rd);
content[i] = renderer.renderTab(rd, false);
}
VirtualDOM.render(content, host);
}
Expand Down
Loading

0 comments on commit dc8fa17

Please sign in to comment.