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/electron-browser/menu/electron-context-menu-renderer.ts b/packages/core/src/electron-browser/menu/electron-context-menu-renderer.ts index c73b72d4565d2..9d59df03340f1 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,14 @@ /* 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'; export class ElectronContextMenuAccess extends ContextMenuAccess { constructor(readonly menu: electron.Menu) { @@ -73,27 +76,43 @@ 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 customTitleBarStyle: boolean = false; + + constructor(@inject(ElectronMainMenuFactory) private electronMenuFactory: ElectronMainMenuFactory) { + super(electronMenuFactory); } - 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()); + @postConstruct() + protected async init(): Promise { + await this.preferenceService.ready; + this.customTitleBarStyle = this.preferenceService.get('window.titleBarStyle') === 'custom'; + } + + protected doRender(options: RenderContextMenuOptions): ContextMenuAccess { + if (this.customTitleBarStyle) { + return super.doRender(options); + } else { + 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); } - 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..cccb005e31c78 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,14 @@ import { Command, CommandContribution, CommandRegistry, isOSX, isWindows, MenuModelRegistry, MenuContribution, Disposable } from '../../common'; -import { ApplicationShell, KeybindingContribution, KeybindingRegistry, PreferenceScope, PreferenceService } from '../../browser'; +import { ApplicationShell, ConfirmDialog, KeybindingContribution, KeybindingRegistry, PreferenceScope, PreferenceService, 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 { 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,7 +75,7 @@ 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; @@ -80,30 +83,40 @@ export class ElectronMenuContribution implements FrontendApplicationContribution @inject(PreferenceService) protected readonly preferenceService: PreferenceService; + protected titleBarStyleChangeFlag = false; + 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(); + this.setMenu(app); electron.remote.getCurrentWindow().setMenuBarVisibility(true); }); + this.preferenceService.onPreferenceChanged(change => { + if (change.preferenceName === 'window.titleBarStyle') { + if (this.titleBarStyleChangeFlag) { + electron.ipcRenderer.send('titleBarStyle-changed', change.newValue); + this.handleRequiredRestart(); + } + this.titleBarStyleChangeFlag = true; + } + }); 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(); @@ -129,16 +142,16 @@ export class ElectronMenuContribution implements FrontendApplicationContribution /** * Makes the `theia-top-panel` hidden as it is unused for the electron-based application. * 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.setHidden(this.preferenceService.get('window.titleBarStyle') !== 'custom'); child = undefined; } else { child = itr.next(); @@ -146,12 +159,74 @@ export class ElectronMenuContribution implements FrontendApplicationContribution } } - 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); + const preferredTitleBarStyle = this.preferenceService.get('window.titleBarStyle'); + if (preferredTitleBarStyle === 'custom' && !this.menuBar) { + const dragPanel = new Widget(); + dragPanel.id = 'theia-drag-panel'; + app.shell.addWidget(dragPanel, { area: 'top' }); + const logo = this.createLogo(); + app.shell.addWidget(logo, { area: 'top' }); + const menu = this.factory.createMenuBar(); + app.shell.addWidget(menu, { area: 'top' }); + 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)); + } + }); + const controls = document.createElement('div'); + controls.id = 'window-controls'; + controls.append( + this.createControlButton('min', 'chrome-minimize', () => electronWindow.minimize()), + this.createControlButton('max', 'chrome-maximize', () => electronWindow.maximize()), + this.createControlButton('restore', 'chrome-restore', () => electronWindow.unmaximize()), + this.createControlButton('close', 'chrome-close', () => electronWindow.close()) + ); + app.shell.topPanel.node.append(controls); + this.handleWindowControls(electronWindow); + } // Unix/Windows: Set the per-window menus - electronWindow.setMenu(menu); + electronWindow.setMenu(electronMenu); + } + } + + 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, codicon: string, handler: () => void): HTMLElement { + const button = document.createElement('div'); + button.id = `${id}-button`; + button.className = `control-button codicon codicon-${codicon}`; + 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..b219994fdd69b --- /dev/null +++ b/packages/core/src/electron-browser/menu/electron-menu-style.css @@ -0,0 +1,87 @@ +/******************************************************************************** + * 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 { + grid-row: 1 / span 1; + display: flex; + line-height: 30px; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; +} + +#min-button { + grid-column: 1; +} +#max-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); +} + +#close-button:hover { + background: #E81123 !important; +} + +#close-button:hover:before { + color: white; +} + +#restore-button { + display: none !important; +} + +.maximized #restore-button { + display: flex !important; +} + +.maximized #max-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..89c3225351b8a 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,24 @@ 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', + markdownDescription: 'Adjust the appearance of the window title bar. 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-main/electron-main-application.ts b/packages/core/src/electron-main/electron-main-application.ts index 67ddc09dbb1f2..0545ceb14b992 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'; @@ -31,6 +31,7 @@ import { ElectronSecurityTokenService } from './electron-security-token-service' import { ElectronSecurityToken } from '../electron-common/electron-token'; import Storage = require('electron-store'); import { DEFAULT_WINDOW_HASH } from '../browser/window/window-service'; +import { isWindows } from '../common'; const createYargs: (argv?: string[], cwd?: string) => Argv = require('yargs/yargs'); /** @@ -180,6 +181,8 @@ export class ElectronMainApplication { readonly backendPort = this._backendPort.promise; protected _config: FrontendApplicationConfig | undefined; + protected frame: boolean = isWindows; + get config(): FrontendApplicationConfig { if (!this._config) { throw new Error('You have to start the application first.'); @@ -219,6 +222,7 @@ export class ElectronMainApplication { async createWindow(asyncOptions: MaybePromise = this.getDefaultTheiaWindowOptions()): Promise { let options = await asyncOptions; options = this.avoidOverlap(options); + this.frame = !!options.frame; const electronWindow = new BrowserWindow(options); electronWindow.setMenuBarVisibility(false); this.attachReadyToShow(electronWindow); @@ -231,6 +235,7 @@ export class ElectronMainApplication { async getLastWindowOptions(): Promise { const windowState: TheiaBrowserWindowOptions | undefined = this.electronStore.get('windowstate') || this.getDefaultTheiaWindowOptions(); return { + frame: !isWindows, ...windowState, ...this.getDefaultOptions() }; @@ -355,7 +360,8 @@ export class ElectronMainApplication { width: bounds.width, height: bounds.height, x: bounds.x, - y: bounds.y + y: bounds.y, + frame: this.frame }); } catch (e) { console.error('Error while saving window state:', e); @@ -371,6 +377,15 @@ export class ElectronMainApplication { electronWindow.on('close', saveWindowState); electronWindow.on('resize', saveWindowStateDelayed); electronWindow.on('move', saveWindowStateDelayed); + + ipcMain.on('titleBarStyle-changed', (_event, data) => { + this.frame = data === 'native'; + saveWindowState(); + }); + + ipcMain.on('restart', () => { + this.restart(); + }); } /** @@ -496,6 +511,15 @@ export class ElectronMainApplication { this.requestStop(); } + protected restart(): void { + BrowserWindow.getAllWindows().forEach(e => e.close()); + this.launch({ + secondInstance: false, + argv: this.processArgv.getProcessArgvWithoutBin(process.argv), + cwd: process.cwd() + }); + } + protected async startContributions(): Promise { const promises = []; for (const contribution of this.contributions.getContributions()) {