@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);
}
}
}