From 1bcdf4b0664b8474c2638a360828beb062554493 Mon Sep 17 00:00:00 2001 From: Mark Sujew Date: Fri, 3 Sep 2021 02:18:41 +0200 Subject: [PATCH] Support vscode's `titleBarStyle` --- CHANGELOG.md | 4 + .../menu/sample-electron-menu-module.ts | 2 +- .../sample-updater-frontend-contribution.ts | 2 +- .../menu/browser-context-menu-renderer.ts | 2 +- .../src/browser/menu/browser-menu-plugin.ts | 30 ++++- .../menu/electron-context-menu-renderer.ts | 54 +++++--- .../menu/electron-main-menu-factory.ts | 25 ++-- .../menu/electron-menu-contribution.ts | 123 ++++++++++++++---- .../menu/electron-menu-style.css | 84 ++++++++++++ .../window/electron-window-preferences.ts | 15 +++ .../messaging/electron-messages.ts | 20 +++ .../electron-main-application.ts | 103 ++++++++++++--- 12 files changed, 384 insertions(+), 80 deletions(-) create mode 100644 packages/core/src/electron-browser/menu/electron-menu-style.css create mode 100644 packages/core/src/electron-common/messaging/electron-messages.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9eac42cfee5ce..6901259230332 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ [Breaking Changes:](#breaking_changes_1.19.0) - [view-container] `ViewContainerPart` constructor takes new 2 parameters: `originalContainerId` and `originalContainerTitle`. The existing `viewContainerId` parameter has been renamed to `currentContainerId` to enable drag & drop views. [#9644](https://github.com/eclipse-theia/theia/pull/9644) +- [electron] `ElectronMainMenuFactory` now inherits from `BrowserMainMenuFactory` and its methods have been renamed. [#10044](https://github.com/eclipse-theia/theia/pull/10044) + - renamed `handleDefault` to `handleElectronDefault` + - renamed `createContextMenu` to `createElectronContextMenu` + - renamed `createMenuBar` to `createElectronMenuBar` ## v1.18.0 - 9/30/2021 diff --git a/examples/api-samples/src/electron-browser/menu/sample-electron-menu-module.ts b/examples/api-samples/src/electron-browser/menu/sample-electron-menu-module.ts index 286a21484e134..7f40836b6288f 100644 --- a/examples/api-samples/src/electron-browser/menu/sample-electron-menu-module.ts +++ b/examples/api-samples/src/electron-browser/menu/sample-electron-menu-module.ts @@ -27,7 +27,7 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { class SampleElectronMainMenuFactory extends ElectronMainMenuFactory { // eslint-disable-next-line @typescript-eslint/no-explicit-any - protected handleDefault(menuNode: CompositeMenuNode, args: any[] = [], options?: ElectronMenuOptions): Electron.MenuItemConstructorOptions[] { + protected handleElectronDefault(menuNode: CompositeMenuNode, args: any[] = [], options?: ElectronMenuOptions): Electron.MenuItemConstructorOptions[] { if (menuNode instanceof PlaceholderMenuNode) { return [{ label: menuNode.label, diff --git a/examples/api-samples/src/electron-browser/updater/sample-updater-frontend-contribution.ts b/examples/api-samples/src/electron-browser/updater/sample-updater-frontend-contribution.ts index ed4baca3c1231..7fc6c1913a66e 100644 --- a/examples/api-samples/src/electron-browser/updater/sample-updater-frontend-contribution.ts +++ b/examples/api-samples/src/electron-browser/updater/sample-updater-frontend-contribution.ts @@ -90,7 +90,7 @@ export class ElectronMenuUpdater { this.setMenu(); } - private setMenu(menu: Menu | null = this.factory.createMenuBar(), electronWindow: BrowserWindow = remote.getCurrentWindow()): void { + private setMenu(menu: Menu | null = this.factory.createElectronMenuBar(), electronWindow: BrowserWindow = remote.getCurrentWindow()): void { if (isOSX) { remote.Menu.setApplicationMenu(menu); } else { diff --git a/packages/core/src/browser/menu/browser-context-menu-renderer.ts b/packages/core/src/browser/menu/browser-context-menu-renderer.ts index 2b664b75f0238..c549094700e78 100644 --- a/packages/core/src/browser/menu/browser-context-menu-renderer.ts +++ b/packages/core/src/browser/menu/browser-context-menu-renderer.ts @@ -36,7 +36,7 @@ export class BrowserContextMenuRenderer extends ContextMenuRenderer { super(); } - protected doRender({ menuPath, anchor, args, onHide }: RenderContextMenuOptions): BrowserContextMenuAccess { + protected doRender({ menuPath, anchor, args, onHide }: RenderContextMenuOptions): ContextMenuAccess { const contextMenu = this.menuFactory.createContextMenu(menuPath, args); const { x, y } = coordinateFromAnchor(anchor); if (onHide) { diff --git a/packages/core/src/browser/menu/browser-menu-plugin.ts b/packages/core/src/browser/menu/browser-menu-plugin.ts index 452da0acbf9ae..f393a301264c0 100644 --- a/packages/core/src/browser/menu/browser-menu-plugin.ts +++ b/packages/core/src/browser/menu/browser-menu-plugin.ts @@ -18,7 +18,7 @@ import { injectable, inject } from 'inversify'; import { MenuBar, Menu as MenuWidget, Widget } from '@phosphor/widgets'; import { CommandRegistry as PhosphorCommandRegistry } from '@phosphor/commands'; import { - CommandRegistry, ActionMenuNode, CompositeMenuNode, + CommandRegistry, ActionMenuNode, CompositeMenuNode, environment, MenuModelRegistry, MAIN_MENU_BAR, MenuPath, DisposableCollection, Disposable, MenuNode } from '../../common'; import { KeybindingRegistry } from '../keybinding'; @@ -28,6 +28,7 @@ import { ContextMenuContext } from './context-menu-context'; import { waitForRevealed } from '../widgets'; import { ApplicationShell } from '../shell'; import { CorePreferences } from '../core-preferences'; +import { PreferenceService } from '../preferences/preference-service'; export abstract class MenuBarWidget extends MenuBar { abstract activateMenu(label: string, ...labels: string[]): Promise; @@ -371,21 +372,40 @@ export class BrowserMenuBarContribution implements FrontendApplicationContributi @inject(ApplicationShell) protected readonly shell: ApplicationShell; + @inject(PreferenceService) + protected readonly preferenceService: PreferenceService; + constructor( @inject(BrowserMainMenuFactory) protected readonly factory: BrowserMainMenuFactory ) { } onStart(app: FrontendApplication): void { - const logo = this.createLogo(); - app.shell.addWidget(logo, { area: 'top' }); - const menu = this.factory.createMenuBar(); - app.shell.addWidget(menu, { area: 'top' }); + this.appendMenu(app.shell); } get menuBar(): MenuBarWidget | undefined { return this.shell.topPanel.widgets.find(w => w instanceof MenuBarWidget) as MenuBarWidget | undefined; } + protected appendMenu(shell: ApplicationShell): void { + const logo = this.createLogo(); + shell.addWidget(logo, { area: 'top' }); + const menu = this.factory.createMenuBar(); + shell.addWidget(menu, { area: 'top' }); + // Hiding the menu is only necessary in electron + // In the browser we hide the whole top panel + if (environment.electron.is()) { + this.preferenceService.ready.then(() => { + menu.setHidden(['compact', 'hidden'].includes(this.preferenceService.get('window.menuBarVisibility', ''))); + }); + this.preferenceService.onPreferenceChanged(change => { + if (change.preferenceName === 'window.menuBarVisibility') { + menu.setHidden(['compact', 'hidden'].includes(change.newValue)); + } + }); + } + } + protected createLogo(): Widget { const logo = new Widget(); logo.id = 'theia:icon'; diff --git a/packages/core/src/electron-browser/menu/electron-context-menu-renderer.ts b/packages/core/src/electron-browser/menu/electron-context-menu-renderer.ts index c73b72d4565d2..792b40386c997 100644 --- a/packages/core/src/electron-browser/menu/electron-context-menu-renderer.ts +++ b/packages/core/src/electron-browser/menu/electron-context-menu-renderer.ts @@ -17,11 +17,15 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as electron from '../../../shared/electron'; -import { inject, injectable } from 'inversify'; -import { ContextMenuRenderer, RenderContextMenuOptions, ContextMenuAccess, FrontendApplicationContribution, CommonCommands, coordinateFromAnchor } from '../../browser'; +import { inject, injectable, postConstruct } from 'inversify'; +import { + ContextMenuRenderer, RenderContextMenuOptions, ContextMenuAccess, FrontendApplicationContribution, CommonCommands, coordinateFromAnchor, PreferenceService +} from '../../browser'; import { ElectronMainMenuFactory } from './electron-main-menu-factory'; import { ContextMenuContext } from '../../browser/menu/context-menu-context'; import { MenuPath, MenuContribution, MenuModelRegistry } from '../../common'; +import { BrowserContextMenuRenderer } from '../../browser/menu/browser-context-menu-renderer'; +import { RequestTitleBarStyle, TitleBarStyleAtStartup } from '../../electron-common/messaging/electron-messages'; export class ElectronContextMenuAccess extends ContextMenuAccess { constructor(readonly menu: electron.Menu) { @@ -73,27 +77,45 @@ export class ElectronTextInputContextMenuContribution implements FrontendApplica } @injectable() -export class ElectronContextMenuRenderer extends ContextMenuRenderer { +export class ElectronContextMenuRenderer extends BrowserContextMenuRenderer { @inject(ContextMenuContext) protected readonly context: ContextMenuContext; - constructor(@inject(ElectronMainMenuFactory) private menuFactory: ElectronMainMenuFactory) { - super(); + @inject(PreferenceService) + protected readonly preferenceService: PreferenceService; + + protected useNativeStyle: boolean = true; + + constructor(@inject(ElectronMainMenuFactory) private electronMenuFactory: ElectronMainMenuFactory) { + super(electronMenuFactory); + } + + @postConstruct() + protected async init(): Promise { + electron.ipcRenderer.on(TitleBarStyleAtStartup, (_event, style: string) => { + this.useNativeStyle = style === 'native'; + }); + electron.ipcRenderer.send(RequestTitleBarStyle); } - protected doRender({ menuPath, anchor, args, onHide }: RenderContextMenuOptions): ElectronContextMenuAccess { - const menu = this.menuFactory.createContextMenu(menuPath, args); - const { x, y } = coordinateFromAnchor(anchor); - const zoom = electron.webFrame.getZoomFactor(); - // x and y values must be Ints or else there is a conversion error - menu.popup({ x: Math.round(x * zoom), y: Math.round(y * zoom) }); - // native context menu stops the event loop, so there is no keyboard events - this.context.resetAltPressed(); - if (onHide) { - menu.once('menu-will-close', () => onHide()); + protected doRender(options: RenderContextMenuOptions): ContextMenuAccess { + if (this.useNativeStyle) { + const { menuPath, anchor, args, onHide } = options; + const menu = this.electronMenuFactory.createElectronContextMenu(menuPath, args); + const { x, y } = coordinateFromAnchor(anchor); + const zoom = electron.webFrame.getZoomFactor(); + // x and y values must be Ints or else there is a conversion error + menu.popup({ x: Math.round(x * zoom), y: Math.round(y * zoom) }); + // native context menu stops the event loop, so there is no keyboard events + this.context.resetAltPressed(); + if (onHide) { + menu.once('menu-will-close', () => onHide()); + } + return new ElectronContextMenuAccess(menu); + } else { + return super.doRender(options); } - return new ElectronContextMenuAccess(menu); } } diff --git a/packages/core/src/electron-browser/menu/electron-main-menu-factory.ts b/packages/core/src/electron-browser/menu/electron-main-menu-factory.ts index 51fa1f305a107..536110a33aad0 100644 --- a/packages/core/src/electron-browser/menu/electron-main-menu-factory.ts +++ b/packages/core/src/electron-browser/menu/electron-main-menu-factory.ts @@ -24,10 +24,9 @@ import { } from '../../common'; import { Keybinding } from '../../common/keybinding'; import { PreferenceService, KeybindingRegistry, CommonCommands } from '../../browser'; -import { ContextKeyService } from '../../browser/context-key-service'; import debounce = require('lodash.debounce'); -import { ContextMenuContext } from '../../browser/menu/context-menu-context'; import { MAXIMIZED_CLASS } from '../../browser/shell/theia-dock-panel'; +import { BrowserMainMenuFactory } from '../../browser/menu/browser-menu-plugin'; /** * Representation of possible electron menu options. @@ -55,23 +54,18 @@ export type ElectronMenuItemRole = ('undo' | 'redo' | 'cut' | 'copy' | 'paste' | 'moveTabToNewWindow' | 'windowMenu'); @injectable() -export class ElectronMainMenuFactory { +export class ElectronMainMenuFactory extends BrowserMainMenuFactory { protected _menu: Electron.Menu | undefined; protected _toggledCommands: Set = new Set(); - @inject(ContextKeyService) - protected readonly contextKeyService: ContextKeyService; - - @inject(ContextMenuContext) - protected readonly context: ContextMenuContext; - constructor( @inject(CommandRegistry) protected readonly commandRegistry: CommandRegistry, @inject(PreferenceService) protected readonly preferencesService: PreferenceService, @inject(MenuModelRegistry) protected readonly menuProvider: MenuModelRegistry, @inject(KeybindingRegistry) protected readonly keybindingRegistry: KeybindingRegistry ) { + super(); preferencesService.onPreferenceChanged( debounce(e => { if (e.preferenceName === 'window.menuBarVisibility') { @@ -92,15 +86,16 @@ export class ElectronMainMenuFactory { async setMenuBar(): Promise { await this.preferencesService.ready; - const createdMenuBar = this.createMenuBar(); if (isOSX) { + const createdMenuBar = this.createElectronMenuBar(); electron.remote.Menu.setApplicationMenu(createdMenuBar); - } else { + } else if (this.preferencesService.get('window.titleBarStyle') === 'native') { + const createdMenuBar = this.createElectronMenuBar(); electron.remote.getCurrentWindow().setMenu(createdMenuBar); } } - createMenuBar(): Electron.Menu | null { + createElectronMenuBar(): Electron.Menu | null { const preference = this.preferencesService.get('window.menuBarVisibility') || 'classic'; const maxWidget = document.getElementsByClassName(MAXIMIZED_CLASS); if (preference === 'visible' || (preference === 'classic' && maxWidget.length === 0)) { @@ -118,7 +113,7 @@ export class ElectronMainMenuFactory { return null; } - createContextMenu(menuPath: MenuPath, args?: any[]): Electron.Menu { + createElectronContextMenu(menuPath: MenuPath, args?: any[]): Electron.Menu { const menuModel = this.menuProvider.getMenu(menuPath); const template = this.fillMenuTemplate([], menuModel, args, { showDisabled: false }); return electron.remote.Menu.buildFromTemplate(template); @@ -221,13 +216,13 @@ export class ElectronMainMenuFactory { this._toggledCommands.add(commandId); } } else { - items.push(...this.handleDefault(menu, args, options)); + items.push(...this.handleElectronDefault(menu, args, options)); } } return items; } - protected handleDefault(menuNode: MenuNode, args: any[] = [], options?: ElectronMenuOptions): Electron.MenuItemConstructorOptions[] { + protected handleElectronDefault(menuNode: MenuNode, args: any[] = [], options?: ElectronMenuOptions): Electron.MenuItemConstructorOptions[] { return []; } diff --git a/packages/core/src/electron-browser/menu/electron-menu-contribution.ts b/packages/core/src/electron-browser/menu/electron-menu-contribution.ts index aaf1926e0c99a..ec989a10ed2ca 100644 --- a/packages/core/src/electron-browser/menu/electron-menu-contribution.ts +++ b/packages/core/src/electron-browser/menu/electron-menu-contribution.ts @@ -20,11 +20,15 @@ import { Command, CommandContribution, CommandRegistry, isOSX, isWindows, MenuModelRegistry, MenuContribution, Disposable } from '../../common'; -import { ApplicationShell, KeybindingContribution, KeybindingRegistry, PreferenceScope, PreferenceService } from '../../browser'; +import { ApplicationShell, codicon, ConfirmDialog, KeybindingContribution, KeybindingRegistry, PreferenceScope, Widget } from '../../browser'; import { FrontendApplication, FrontendApplicationContribution, CommonMenus } from '../../browser'; import { ElectronMainMenuFactory } from './electron-main-menu-factory'; import { FrontendApplicationStateService, FrontendApplicationState } from '../../browser/frontend-application-state'; +import { RequestTitleBarStyle, Restart, TitleBarStyleAtStartup, TitleBarStyleChanged } from '../../electron-common/messaging/electron-messages'; import { ZoomLevel } from '../window/electron-window-preferences'; +import { BrowserMenuBarContribution } from '../../browser/menu/browser-menu-plugin'; + +import '../../../src/electron-browser/menu/electron-menu-style.css'; export namespace ElectronCommands { export const TOGGLE_DEVELOPER_TOOLS: Command = { @@ -72,38 +76,33 @@ export namespace ElectronMenus { } @injectable() -export class ElectronMenuContribution implements FrontendApplicationContribution, CommandContribution, MenuContribution, KeybindingContribution { +export class ElectronMenuContribution extends BrowserMenuBarContribution implements FrontendApplicationContribution, CommandContribution, MenuContribution, KeybindingContribution { @inject(FrontendApplicationStateService) protected readonly stateService: FrontendApplicationStateService; - @inject(PreferenceService) - protected readonly preferenceService: PreferenceService; + protected titleBarStyleChangeFlag = false; + protected titleBarStyle?: string; constructor( @inject(ElectronMainMenuFactory) protected readonly factory: ElectronMainMenuFactory, @inject(ApplicationShell) protected shell: ApplicationShell - ) { } + ) { + super(factory); + } onStart(app: FrontendApplication): void { - this.hideTopPanel(app); - this.preferenceService.ready.then(() => { - this.setMenu(); - electron.remote.getCurrentWindow().setMenuBarVisibility(true); - }); + this.handleTitleBarStyling(app); if (isOSX) { // 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. - electron.remote.getCurrentWindow().on('focus', () => this.setMenu()); + electron.remote.getCurrentWindow().on('focus', () => this.setMenu(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 === 'ready') { - this.setMenu(); - } if (state === 'closing_window') { if (!!onStateChange) { onStateChange.dispose(); @@ -119,6 +118,30 @@ export class ElectronMenuContribution implements FrontendApplicationContribution }); } + handleTitleBarStyling(app: FrontendApplication): void { + this.hideTopPanel(app); + electron.ipcRenderer.on(TitleBarStyleAtStartup, (_event, style: string) => { + this.titleBarStyle = style; + this.preferenceService.ready.then(() => { + this.preferenceService.set('window.titleBarStyle', this.titleBarStyle, PreferenceScope.User); + }); + }); + electron.ipcRenderer.send(RequestTitleBarStyle); + this.preferenceService.ready.then(() => { + this.setMenu(app); + electron.remote.getCurrentWindow().setMenuBarVisibility(['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 && electron.remote.getCurrentWindow().isFocused()) { + electron.ipcRenderer.send(TitleBarStyleChanged, change.newValue); + this.handleRequiredRestart(); + } + this.titleBarStyleChangeFlag = true; + } + }); + } + handleToggleMaximized(): void { const preference = this.preferenceService.get('window.menuBarVisibility'); if (preference === 'classic') { @@ -127,31 +150,87 @@ export class ElectronMenuContribution implements FrontendApplicationContribution } /** - * Makes the `theia-top-panel` hidden as it is unused for the electron-based application. + * 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. Electron has it's own. + * 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 Electron. + // Top panel for the menu contribution is not required for native Electron title bar. if (child.id === 'theia-top-panel') { - child.setHidden(true); - child = undefined; + child.setHidden(this.titleBarStyle !== 'custom'); + break; } else { child = itr.next(); } } } - private setMenu(menu: electron.Menu | null = this.factory.createMenuBar(), electronWindow: electron.BrowserWindow = electron.remote.getCurrentWindow()): void { + protected setMenu(app: FrontendApplication, electronMenu: electron.Menu | null = this.factory.createElectronMenuBar(), + electronWindow: electron.BrowserWindow = electron.remote.getCurrentWindow()): void { if (isOSX) { - electron.remote.Menu.setApplicationMenu(menu); + electron.remote.Menu.setApplicationMenu(electronMenu); } else { + this.hideTopPanel(app); + if (this.titleBarStyle === 'custom' && !this.menuBar) { + this.createCustomTitleBar(app, electronWindow); + } // Unix/Windows: Set the per-window menus - electronWindow.setMenu(menu); + electronWindow.setMenu(electronMenu); + } + } + + protected createCustomTitleBar(app: FrontendApplication, electronWindow: electron.BrowserWindow): void { + const dragPanel = new Widget(); + dragPanel.id = 'theia-drag-panel'; + app.shell.addWidget(dragPanel, { area: 'top' }); + this.appendMenu(app.shell); + const controls = document.createElement('div'); + controls.id = 'window-controls'; + controls.append( + this.createControlButton('minimize', () => electronWindow.minimize()), + this.createControlButton('maximize', () => electronWindow.maximize()), + this.createControlButton('restore', () => electronWindow.unmaximize()), + this.createControlButton('close', () => electronWindow.close()) + ); + app.shell.topPanel.node.append(controls); + this.handleWindowControls(electronWindow); + } + + protected handleWindowControls(electronWindow: electron.BrowserWindow): void { + toggleControlButtons(); + electronWindow.on('maximize', toggleControlButtons); + electronWindow.on('unmaximize', toggleControlButtons); + + function toggleControlButtons(): void { + if (electronWindow.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 { + const dialog = new ConfirmDialog({ + title: 'A setting has changed that requires a restart to take effect', + msg: 'Press the restart button to restart the application and enable the setting.', + ok: 'Restart', + cancel: 'Cancel' + }); + if (await dialog.open()) { + electron.ipcRenderer.send(Restart); } } diff --git a/packages/core/src/electron-browser/menu/electron-menu-style.css b/packages/core/src/electron-browser/menu/electron-menu-style.css new file mode 100644 index 0000000000000..61f137863276e --- /dev/null +++ b/packages/core/src/electron-browser/menu/electron-menu-style.css @@ -0,0 +1,84 @@ +/******************************************************************************** + * Copyright (C) 2021 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 WITH Classpath-exception-2.0 + ********************************************************************************/ + +#theia-drag-panel { + position: absolute; + display: block; + top: 0; + left: 0; + width: 100%; + height: calc(100% - 4px); + margin: 4px; + -webkit-app-region: drag !important; +} + +#theia-top-panel > * { + -webkit-app-region: no-drag; +} + +#window-controls { + display: grid; + grid-template-columns: repeat(3, 48px); + position: absolute; + top: 0; + right: 0; + height: 100%; +} + +#window-controls .control-button { + display: flex; + line-height: 30px; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; +} + +#minimize-button { + grid-column: 1; +} + +#maximize-button, #restore-button { + grid-column: 2; +} + +#close-button { + grid-column: 3; +} + +#window-controls .control-button { + user-select: none; +} + +#window-controls .control-button:hover { + background: rgba(50%, 50%, 50%, 0.2); +} + +#window-controls #close-button:hover { + background: #E81123; +} + +#window-controls #close-button:hover:before { + color: white; +} + +body:not(.maximized) #restore-button { + display: none; +} + +body.maximized #maximize-button { + display: none; +} diff --git a/packages/core/src/electron-browser/window/electron-window-preferences.ts b/packages/core/src/electron-browser/window/electron-window-preferences.ts index b7d97e543b84a..9c97bc732b5a5 100644 --- a/packages/core/src/electron-browser/window/electron-window-preferences.ts +++ b/packages/core/src/electron-browser/window/electron-window-preferences.ts @@ -16,6 +16,7 @@ import { interfaces } from 'inversify'; import { createPreferenceProxy, PreferenceContribution, PreferenceProxy, PreferenceSchema, PreferenceService } from '../../browser/preferences'; +import { isOSX, isWindows } from '../../common'; export namespace ZoomLevel { export const DEFAULT = 0; @@ -38,11 +39,25 @@ export const electronWindowPreferencesSchema: PreferenceSchema = { // eslint-disable-next-line max-len 'description': 'Adjust the zoom level of the window. The original size is 0 and each increment above (e.g. 1.0) or below (e.g. -1.0) represents zooming 20% larger or smaller. You can also enter decimals to adjust the zoom level with a finer granularity.' }, + 'window.titleBarStyle': { + type: 'string', + enum: ['native', 'custom'], + markdownEnumDescriptions: [ + 'Native title bar is displayed.', + 'Custom title bar is displayed.' + ], + default: isWindows ? 'custom' : 'native', + scope: 'application', + // eslint-disable-next-line max-len + markdownDescription: 'Adjust the appearance of the window title bar. On Linux and Windows, this setting also affects the application and context menu appearances. Changes require a full restart to apply.', + included: !isOSX + }, } }; export class ElectronWindowConfiguration { 'window.zoomLevel': number; + 'window.titleBarStyle': 'native' | 'custom'; } export const ElectronWindowPreferenceContribution = Symbol('ElectronWindowPreferenceContribution'); diff --git a/packages/core/src/electron-common/messaging/electron-messages.ts b/packages/core/src/electron-common/messaging/electron-messages.ts new file mode 100644 index 0000000000000..b64f3db3d9e92 --- /dev/null +++ b/packages/core/src/electron-common/messaging/electron-messages.ts @@ -0,0 +1,20 @@ +/******************************************************************************** + * Copyright (C) 2021 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 WITH Classpath-exception-2.0 + ********************************************************************************/ + +export const RequestTitleBarStyle = 'requestTitleBarStyle'; +export const TitleBarStyleChanged = 'titleBarStyleChanged'; +export const TitleBarStyleAtStartup = 'titleBarStyleAtStartup'; +export const Restart = 'restart'; diff --git a/packages/core/src/electron-main/electron-main-application.ts b/packages/core/src/electron-main/electron-main-application.ts index 5c4a3fa35c8bf..a9fca66448382 100644 --- a/packages/core/src/electron-main/electron-main-application.ts +++ b/packages/core/src/electron-main/electron-main-application.ts @@ -15,7 +15,7 @@ ********************************************************************************/ import { inject, injectable, named } from 'inversify'; -import { screen, globalShortcut, app, BrowserWindow, BrowserWindowConstructorOptions, Event as ElectronEvent } from '../../shared/electron'; +import { screen, globalShortcut, ipcMain, app, BrowserWindow, BrowserWindowConstructorOptions, Event as ElectronEvent } from '../../shared/electron'; import * as path from 'path'; import { Argv } from 'yargs'; import { AddressInfo } from 'net'; @@ -32,6 +32,8 @@ import { ElectronSecurityToken } from '../electron-common/electron-token'; import Storage = require('electron-store'); // eslint-disable-next-line @theia/runtime-import-check import { DEFAULT_WINDOW_HASH } from '../browser/window/window-service'; +import { isOSX, isWindows } from '../common'; +import { RequestTitleBarStyle, Restart, TitleBarStyleAtStartup, TitleBarStyleChanged } from '../electron-common/messaging/electron-messages'; const createYargs: (argv?: string[], cwd?: string) => Argv = require('yargs/yargs'); @@ -182,6 +184,10 @@ export class ElectronMainApplication { readonly backendPort = this._backendPort.promise; protected _config: FrontendApplicationConfig | undefined; + protected useNativeWindowFrame: boolean = true; + protected didUseNativeWindowFrameOnStart = new Map(); + protected restarting = false; + get config(): FrontendApplicationConfig { if (!this._config) { throw new Error('You have to start the application first.'); @@ -190,6 +196,7 @@ export class ElectronMainApplication { } async start(config: FrontendApplicationConfig): Promise { + this.useNativeWindowFrame = this.getTitleBarStyle(config) === 'native'; this._config = config; this.hookApplicationEvents(); const port = await this.startBackend(); @@ -204,6 +211,23 @@ export class ElectronMainApplication { }); } + protected getTitleBarStyle(config: FrontendApplicationConfig): 'native' | 'custom' { + if (isOSX) { + return 'native'; + } + const storedFrame = this.electronStore.get('windowstate')?.frame; + if (storedFrame !== undefined) { + return !!storedFrame ? 'native' : 'custom'; + } + if (config.preferences && config.preferences['window.titleBarStyle']) { + const titleBarStyle = config.preferences['window.titleBarStyle']; + if (titleBarStyle === 'native' || titleBarStyle === 'custom') { + return titleBarStyle; + } + } + return isWindows ? 'custom' : 'native'; + } + protected async launch(params: ElectronMainExecutionParams): Promise { createYargs(params.argv, params.cwd) .command('$0 [file]', false, @@ -233,6 +257,7 @@ export class ElectronMainApplication { async getLastWindowOptions(): Promise { const windowState: TheiaBrowserWindowOptions | undefined = this.electronStore.get('windowstate') || this.getDefaultTheiaWindowOptions(); return { + frame: this.useNativeWindowFrame, ...windowState, ...this.getDefaultOptions() }; @@ -327,6 +352,7 @@ export class ElectronMainApplication { const y = Math.round(bounds.y + (bounds.height - height) / 2); const x = Math.round(bounds.x + (bounds.width - width) / 2); return { + frame: this.useNativeWindowFrame, isFullScreen: false, isMaximized: false, width, @@ -348,31 +374,41 @@ export class ElectronMainApplication { * Save the window geometry state on every change. */ protected attachSaveWindowState(electronWindow: BrowserWindow): void { - const saveWindowState = () => { - try { - const bounds = electronWindow.getBounds(); - this.electronStore.set('windowstate', { - isFullScreen: electronWindow.isFullScreen(), - isMaximized: electronWindow.isMaximized(), - width: bounds.width, - height: bounds.height, - x: bounds.x, - y: bounds.y - }); - } catch (e) { - console.error('Error while saving window state:', e); - } - }; let delayedSaveTimeout: NodeJS.Timer | undefined; const saveWindowStateDelayed = () => { if (delayedSaveTimeout) { clearTimeout(delayedSaveTimeout); } - delayedSaveTimeout = setTimeout(saveWindowState, 1000); + delayedSaveTimeout = setTimeout(() => this.saveWindowState(electronWindow), 1000); }; - electronWindow.on('close', saveWindowState); + electronWindow.on('close', () => { + this.saveWindowState(electronWindow); + this.didUseNativeWindowFrameOnStart.delete(electronWindow.id); + }); electronWindow.on('resize', saveWindowStateDelayed); electronWindow.on('move', saveWindowStateDelayed); + this.didUseNativeWindowFrameOnStart.set(electronWindow.id, this.useNativeWindowFrame); + } + + protected saveWindowState(electronWindow: BrowserWindow): void { + // In some circumstances the `electronWindow` can be `null` + if (!electronWindow) { + return; + } + try { + const bounds = electronWindow.getBounds(); + this.electronStore.set('windowstate', { + isFullScreen: electronWindow.isFullScreen(), + isMaximized: electronWindow.isMaximized(), + width: bounds.width, + height: bounds.height, + x: bounds.x, + y: bounds.y, + frame: this.useNativeWindowFrame + }); + } catch (e) { + console.error('Error while saving window state:', e); + } } /** @@ -477,6 +513,19 @@ export class ElectronMainApplication { app.on('will-quit', this.onWillQuit.bind(this)); app.on('second-instance', this.onSecondInstance.bind(this)); app.on('window-all-closed', this.onWindowAllClosed.bind(this)); + + ipcMain.on(TitleBarStyleChanged, ({ sender }, titleBarStyle: string) => { + this.useNativeWindowFrame = titleBarStyle === 'native'; + this.saveWindowState(BrowserWindow.fromId(sender.id)); + }); + + ipcMain.on(Restart, ({ sender }) => { + this.restart(sender.id); + }); + + ipcMain.on(RequestTitleBarStyle, ({ sender }) => { + sender.send(TitleBarStyleAtStartup, this.didUseNativeWindowFrameOnStart.get(sender.id) ? 'native' : 'custom'); + }); } protected onWillQuit(event: ElectronEvent): void { @@ -495,7 +544,23 @@ export class ElectronMainApplication { } protected onWindowAllClosed(event: ElectronEvent): void { - this.requestStop(); + if (!this.restarting) { + this.requestStop(); + } + } + + protected restart(id: number): void { + this.restarting = true; + const window = BrowserWindow.fromId(id); + window.on('closed', async () => { + await this.launch({ + secondInstance: false, + argv: this.processArgv.getProcessArgvWithoutBin(process.argv), + cwd: process.cwd() + }); + this.restarting = false; + }); + window.close(); } protected async startContributions(): Promise {