@theia/core
Version: 
Theia is a cloud & desktop IDE framework implemented in TypeScript.
506 lines (451 loc) • 20.4 kB
text/typescript
// *****************************************************************************
// Copyright (C) 2017 TypeFox 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-only WITH Classpath-exception-2.0
// *****************************************************************************
import { inject, injectable, postConstruct } from 'inversify';
import { Command, CommandContribution, CommandRegistry, isOSX, isWindows, MenuModelRegistry, MenuContribution, Disposable, nls } from '../../common';
import {
    codicon, ConfirmDialog, KeybindingContribution, KeybindingRegistry, PreferenceScope, Widget,
    FrontendApplication, FrontendApplicationContribution, CommonMenus, CommonCommands, Dialog, Message, ApplicationShell, PreferenceService, animationFrame,
} from '../../browser';
import { ElectronMainMenuFactory } from './electron-main-menu-factory';
import { FrontendApplicationStateService, FrontendApplicationState } from '../../browser/frontend-application-state';
import { FrontendApplicationConfigProvider } from '../../browser/frontend-application-config-provider';
import { ZoomLevel } from '../window/electron-window-preferences';
import { BrowserMenuBarContribution } from '../../browser/menu/browser-menu-plugin';
import { WindowService } from '../../browser/window/window-service';
import { WindowTitleService } from '../../browser/window/window-title-service';
import '../../../src/electron-browser/menu/electron-menu-style.css';
import { ThemeService } from '../../browser/theming';
import { ThemeChangeEvent } from '../../common/theme';
export namespace ElectronCommands {
    export const TOGGLE_DEVELOPER_TOOLS = Command.toDefaultLocalizedCommand({
        id: 'theia.toggleDevTools',
        label: 'Toggle Developer Tools'
    });
    export const RELOAD = Command.toDefaultLocalizedCommand({
        id: 'view.reload',
        label: 'Reload Window'
    });
    export const ZOOM_IN = Command.toDefaultLocalizedCommand({
        id: 'view.zoomIn',
        label: 'Zoom In'
    });
    export const ZOOM_OUT = Command.toDefaultLocalizedCommand({
        id: 'view.zoomOut',
        label: 'Zoom Out'
    });
    export const RESET_ZOOM = Command.toDefaultLocalizedCommand({
        id: 'view.resetZoom',
        label: 'Reset Zoom'
    });
    export const CLOSE_WINDOW = Command.toDefaultLocalizedCommand({
        id: 'close.window',
        label: 'Close Window'
    });
    export const TOGGLE_FULL_SCREEN = Command.toDefaultLocalizedCommand({
        id: 'workbench.action.toggleFullScreen',
        category: CommonCommands.VIEW_CATEGORY,
        label: 'Toggle Full Screen'
    });
}
export namespace ElectronMenus {
    export const VIEW_WINDOW = [...CommonMenus.VIEW, 'window'];
    export const VIEW_ZOOM = [...CommonMenus.VIEW_APPEARANCE_SUBMENU, '4_appearance_submenu_zoom'];
}
export namespace ElectronMenus {
    export const HELP_TOGGLE = [...CommonMenus.HELP, 'z_toggle'];
}
export namespace ElectronMenus {
    export const FILE_CLOSE = [...CommonMenus.FILE_CLOSE, 'window-close'];
}
export const CustomTitleWidgetFactory = Symbol('CustomTitleWidgetFactory');
export type CustomTitleWidgetFactory = () => Widget | undefined;
export class ElectronMenuContribution extends BrowserMenuBarContribution implements FrontendApplicationContribution, CommandContribution, MenuContribution, KeybindingContribution {
    
    protected readonly stateService: FrontendApplicationStateService;
    
    protected readonly windowService: WindowService;
    
    protected readonly themeService: ThemeService;
    
    protected readonly customTitleWidgetFactory: CustomTitleWidgetFactory;
    protected titleBarStyleChangeFlag = false;
    protected titleBarStyle?: string;
    constructor(
         protected override readonly factory: ElectronMainMenuFactory
    ) {
        super(factory);
    }
    override onStart(app: FrontendApplication): void {
        this.handleTitleBarStyling(app);
        if (isOSX) {
            this.attachWindowFocusListener(app);
        }
        // Make sure the application menu is complete, once the frontend application is ready.
        // https://github.com/theia-ide/theia/issues/5100
        let onStateChange: Disposable | undefined = undefined;
        const stateServiceListener = (state: FrontendApplicationState) => {
            if (state === 'closing_window') {
                if (!!onStateChange) {
                    onStateChange.dispose();
                }
            }
        };
        onStateChange = this.stateService.onStateChanged(stateServiceListener);
        this.shell.mainPanel.onDidToggleMaximized(() => {
            this.handleToggleMaximized();
        });
        this.shell.bottomPanel.onDidToggleMaximized(() => {
            this.handleToggleMaximized();
        });
        this.attachMenuBarVisibilityListener();
        this.themeService.onDidColorThemeChange(e => {
            this.handleThemeChange(e);
        });
    }
    protected attachWindowFocusListener(app: FrontendApplication): void {
        // OSX: Recreate the menus when changing windows.
        // OSX only has one menu bar for all windows, so we need to swap
        // between them as the user switches windows.
        const disposeHandler = window.electronTheiaCore.onWindowEvent('focus', () => {
            this.setMenu(app);
        });
        window.addEventListener('unload', () => disposeHandler.dispose());
    }
    protected attachMenuBarVisibilityListener(): void {
        this.preferenceService.onPreferenceChanged(e => {
            if (e.preferenceName === 'window.menuBarVisibility') {
                this.handleFullScreen(e.newValue);
            }
        });
    }
    handleTitleBarStyling(app: FrontendApplication): void {
        this.hideTopPanel(app);
        window.electronTheiaCore.getTitleBarStyleAtStartup().then(style => {
            this.titleBarStyle = style;
            this.setMenu(app);
            this.preferenceService.ready.then(() => {
                this.preferenceService.set('window.titleBarStyle', this.titleBarStyle, PreferenceScope.User);
            });
        });
        this.preferenceService.ready.then(() => {
            window.electronTheiaCore.setMenuBarVisible(['classic', 'visible'].includes(this.preferenceService.get('window.menuBarVisibility', 'classic')));
        });
        this.preferenceService.onPreferenceChanged(change => {
            if (change.preferenceName === 'window.titleBarStyle') {
                if (this.titleBarStyleChangeFlag && this.titleBarStyle !== change.newValue) {
                    window.electronTheiaCore.setTitleBarStyle(change.newValue);
                    this.handleRequiredRestart();
                }
                this.titleBarStyleChangeFlag = true;
            }
        });
    }
    handleToggleMaximized(): void {
        const preference = this.preferenceService.get('window.menuBarVisibility');
        if (preference === 'classic') {
            this.factory.setMenuBar();
        }
    }
    /**
     * Hides the `theia-top-panel` depending on the selected `titleBarStyle`.
     * The `theia-top-panel` is used as the container of the main, application menu-bar for the
     * browser. Native Electron has it's own.
     * By default, this method is called on application `onStart`.
     */
    protected hideTopPanel(app: FrontendApplication): void {
        const itr = app.shell.children();
        let child = itr.next();
        while (child) {
            // Top panel for the menu contribution is not required for native Electron title bar.
            if (child.id === 'theia-top-panel') {
                child.setHidden(this.titleBarStyle !== 'custom');
                break;
            } else {
                child = itr.next();
            }
        }
    }
    protected setMenu(app: FrontendApplication): void {
        if (!isOSX) {
            this.hideTopPanel(app);
            if (this.titleBarStyle === 'custom' && !this.menuBar) {
                this.createCustomTitleBar(app);
                return;
            }
        }
        this.factory.setMenuBar();
    }
    protected createCustomTitleBar(app: FrontendApplication): void {
        const dragPanel = new Widget();
        dragPanel.id = 'theia-drag-panel';
        app.shell.addWidget(dragPanel, { area: 'top' });
        this.appendMenu(app.shell);
        this.createCustomTitleWidget(app);
        const controls = document.createElement('div');
        controls.id = 'window-controls';
        controls.append(
            this.createControlButton('minimize', () => window.electronTheiaCore.minimize()),
            this.createControlButton('maximize', () => window.electronTheiaCore.maximize()),
            this.createControlButton('restore', () => window.electronTheiaCore.unMaximize()),
            this.createControlButton('close', () => window.electronTheiaCore.close())
        );
        app.shell.topPanel.node.append(controls);
        this.handleWindowControls();
    }
    protected createCustomTitleWidget(app: FrontendApplication): void {
        const titleWidget = this.customTitleWidgetFactory();
        if (titleWidget) {
            app.shell.addWidget(titleWidget, { area: 'top' });
        }
    }
    protected handleWindowControls(): void {
        toggleControlButtons();
        window.electronTheiaCore.onWindowEvent('maximize', toggleControlButtons);
        window.electronTheiaCore.onWindowEvent('unmaximize', toggleControlButtons);
        function toggleControlButtons(): void {
            if (window.electronTheiaCore.isMaximized()) {
                document.body.classList.add('maximized');
            } else {
                document.body.classList.remove('maximized');
            }
        }
    }
    protected createControlButton(id: string, handler: () => void): HTMLElement {
        const button = document.createElement('div');
        button.id = `${id}-button`;
        button.className = `control-button ${codicon(`chrome-${id}`)}`;
        button.addEventListener('click', handler);
        return button;
    }
    protected async handleRequiredRestart(): Promise<void> {
        const msgNode = document.createElement('div');
        const message = document.createElement('p');
        message.textContent = nls.localizeByDefault('A setting has changed that requires a restart to take effect.');
        const detail = document.createElement('p');
        detail.textContent = nls.localizeByDefault(
            'Press the restart button to restart {0} and enable the setting.', FrontendApplicationConfigProvider.get().applicationName);
        msgNode.append(message, detail);
        const restart = nls.localizeByDefault('Restart');
        const dialog = new ConfirmDialog({
            title: restart,
            msg: msgNode,
            ok: restart,
            cancel: Dialog.CANCEL
        });
        if (await dialog.open()) {
            this.windowService.setSafeToShutDown();
            window.electronTheiaCore.restart();
        }
    }
    registerCommands(registry: CommandRegistry): void {
        registry.registerCommand(ElectronCommands.TOGGLE_DEVELOPER_TOOLS, {
            execute: () => {
                window.electronTheiaCore.toggleDevTools();
            }
        });
        registry.registerCommand(ElectronCommands.RELOAD, {
            execute: () => this.windowService.reload()
        });
        registry.registerCommand(ElectronCommands.CLOSE_WINDOW, {
            execute: () => window.electronTheiaCore.close()
        });
        registry.registerCommand(ElectronCommands.ZOOM_IN, {
            execute: async () => {
                const currentLevel = await window.electronTheiaCore.getZoomLevel();
                // When starting at a level that is not a multiple of 0.5, increment by at most 0.5 to reach the next highest multiple of 0.5.
                let zoomLevel = (Math.floor(currentLevel / ZoomLevel.VARIATION) * ZoomLevel.VARIATION) + ZoomLevel.VARIATION;
                if (zoomLevel > ZoomLevel.MAX) {
                    zoomLevel = ZoomLevel.MAX;
                    return;
                };
                this.preferenceService.set('window.zoomLevel', zoomLevel, PreferenceScope.User);
            }
        });
        registry.registerCommand(ElectronCommands.ZOOM_OUT, {
            execute: async () => {
                const currentLevel = await window.electronTheiaCore.getZoomLevel();
                // When starting at a level that is not a multiple of 0.5, decrement by at most 0.5 to reach the next lowest multiple of 0.5.
                let zoomLevel = (Math.ceil(currentLevel / ZoomLevel.VARIATION) * ZoomLevel.VARIATION) - ZoomLevel.VARIATION;
                if (zoomLevel < ZoomLevel.MIN) {
                    zoomLevel = ZoomLevel.MIN;
                    return;
                };
                this.preferenceService.set('window.zoomLevel', zoomLevel, PreferenceScope.User);
            }
        });
        registry.registerCommand(ElectronCommands.RESET_ZOOM, {
            execute: () => this.preferenceService.set('window.zoomLevel', ZoomLevel.DEFAULT, PreferenceScope.User)
        });
        registry.registerCommand(ElectronCommands.TOGGLE_FULL_SCREEN, {
            isEnabled: () => window.electronTheiaCore.isFullScreenable(),
            isVisible: () => window.electronTheiaCore.isFullScreenable(),
            execute: () => this.toggleFullScreen()
        });
    }
    registerKeybindings(registry: KeybindingRegistry): void {
        registry.registerKeybindings(
            {
                command: ElectronCommands.TOGGLE_DEVELOPER_TOOLS.id,
                keybinding: 'ctrlcmd+alt+i'
            },
            {
                command: ElectronCommands.RELOAD.id,
                keybinding: 'ctrlcmd+r'
            },
            {
                command: ElectronCommands.ZOOM_IN.id,
                keybinding: 'ctrlcmd+='
            },
            {
                command: ElectronCommands.ZOOM_IN.id,
                keybinding: 'ctrlcmd+add'
            },
            {
                command: ElectronCommands.ZOOM_OUT.id,
                keybinding: 'ctrlcmd+subtract'
            },
            {
                command: ElectronCommands.ZOOM_OUT.id,
                keybinding: 'ctrlcmd+-'
            },
            {
                command: ElectronCommands.RESET_ZOOM.id,
                keybinding: 'ctrlcmd+0'
            },
            {
                command: ElectronCommands.CLOSE_WINDOW.id,
                keybinding: (isOSX ? 'cmd+shift+w' : (isWindows ? 'ctrl+w' : /* Linux */ 'ctrl+q'))
            },
            {
                command: ElectronCommands.TOGGLE_FULL_SCREEN.id,
                keybinding: isOSX ? 'ctrl+ctrlcmd+f' : 'f11'
            }
        );
    }
    registerMenus(registry: MenuModelRegistry): void {
        registry.registerMenuAction(ElectronMenus.HELP_TOGGLE, {
            commandId: ElectronCommands.TOGGLE_DEVELOPER_TOOLS.id
        });
        registry.registerMenuAction(ElectronMenus.VIEW_WINDOW, {
            commandId: ElectronCommands.RELOAD.id,
            order: 'z0'
        });
        registry.registerMenuAction(ElectronMenus.VIEW_ZOOM, {
            commandId: ElectronCommands.ZOOM_IN.id,
            order: 'z1'
        });
        registry.registerMenuAction(ElectronMenus.VIEW_ZOOM, {
            commandId: ElectronCommands.ZOOM_OUT.id,
            order: 'z2'
        });
        registry.registerMenuAction(ElectronMenus.VIEW_ZOOM, {
            commandId: ElectronCommands.RESET_ZOOM.id,
            order: 'z3'
        });
        registry.registerMenuAction(ElectronMenus.FILE_CLOSE, {
            commandId: ElectronCommands.CLOSE_WINDOW.id,
        });
        registry.registerMenuAction(CommonMenus.VIEW_APPEARANCE_SUBMENU_SCREEN, {
            commandId: ElectronCommands.TOGGLE_FULL_SCREEN.id,
            label: nls.localizeByDefault('Full Screen'),
            order: '0'
        });
    }
    protected toggleFullScreen(): void {
        window.electronTheiaCore.toggleFullScreen();
        const menuBarVisibility = this.preferenceService.get('window.menuBarVisibility', 'classic');
        this.handleFullScreen(menuBarVisibility);
    }
    protected handleFullScreen(menuBarVisibility: string): void {
        const shouldShowTop = !window.electronTheiaCore.isFullScreen() || menuBarVisibility === 'visible';
        if (this.titleBarStyle === 'native') {
            window.electronTheiaCore.setMenuBarVisible(shouldShowTop);
        } else if (shouldShowTop) {
            this.shell.topPanel.show();
        } else {
            this.shell.topPanel.hide();
        }
    }
    protected handleThemeChange(e: ThemeChangeEvent): void {
        const backgroundColor = window.getComputedStyle(document.body).backgroundColor;
        window.electronTheiaCore.setBackgroundColor(backgroundColor);
    }
}
export class CustomTitleWidget extends Widget {
    
    protected readonly electronMenuContribution: ElectronMenuContribution;
    
    protected readonly windowTitleService: WindowTitleService;
    
    protected readonly applicationShell: ApplicationShell;
    
    protected readonly preferenceService: PreferenceService;
    constructor() {
        super();
        this.id = 'theia-custom-title';
    }
    
    protected init(): void {
        this.updateTitle(this.windowTitleService.title);
        this.windowTitleService.onDidChangeTitle(title => {
            this.updateTitle(title);
        });
        this.preferenceService.onPreferenceChanged(e => {
            if (e.preferenceName === 'window.menuBarVisibility') {
                animationFrame().then(() => this.adjustTitleToCenter());
            }
        });
    }
    protected override onResize(msg: Widget.ResizeMessage): void {
        this.adjustTitleToCenter();
        super.onResize(msg);
    }
    protected override onAfterShow(msg: Message): void {
        this.adjustTitleToCenter();
        super.onAfterShow(msg);
    }
    protected updateTitle(title: string): void {
        this.node.textContent = title;
        this.adjustTitleToCenter();
    }
    protected adjustTitleToCenter(): void {
        const menubar = this.electronMenuContribution.menuBar;
        if (menubar) {
            const titleWidth = this.node.clientWidth;
            const margin = 16;
            const leftMarker = menubar.node.offsetLeft + menubar.node.clientWidth + margin;
            const panelWidth = this.applicationShell.topPanel.node.clientWidth;
            const controlsWidth = 48 * 3; // Each window button has a width of 48px
            const rightMarker = panelWidth - controlsWidth - margin;
            let hidden = false;
            let relative = false;
            this.node.style.left = '50%';
            // The title has not enough space between the menu and the window controls
            // So we simply hide it
            if (rightMarker - leftMarker < titleWidth) {
                hidden = true;
            } else if ((panelWidth - titleWidth) / 2 < leftMarker || (panelWidth + titleWidth) / 2 > rightMarker) {
                // This indicates that the title has either hit the left (menu) or right (window controls) marker
                relative = true;
                this.node.style.left = `${leftMarker + (rightMarker - leftMarker - titleWidth) / 2}px`;
            }
            this.node.classList.toggle('hidden', hidden);
            this.node.classList.toggle('relative', relative);
        }
    }
}