Skip to content

Commit

Permalink
[UI] Frameless window option (with theme support) (#3066)
Browse files Browse the repository at this point in the history
* Add frameless window app setting

* Extract titlebar options from custom theme

* Set titlebar overlay options from theme selector

* Make sidebar draggable on frameless windows

* Fix dead code error

* Add preliminary support for Linux

* Add proper safety checks

* Remove default titlebar height to let platform decide

* Custom WindowControls component for frameless windows

* Fix broken tests

* Replace isFramless IPC method with injected property

* Prevent overlay controls from covering up content

* Deal with macOS overlay controls being on the left

* Fix for non-native overlay controls

* Set window.isFrameless regardless of method used

* Use correct theme variables for window controls

* Change default window controls height to 39px

* Remove line checked in by mistake

* Use native overlay controls only on platforms that support it

* Add 'fullscreen' class to App element when window is in fullscreen mode

* Don't apply frameless fix if window is fullscreen

* Fix window controls not appearing on Linux

* Use more traditional icons for maximize/restore

* Remove camel-case dependecy

* Replace `getWindowProps()` by `isFrameless()`

* Add experimental feature notification

* Change tooltip to "Restore window" when maximized

* Don't show frameless window option in Steam Deck Game Mode

* Fix window controls scrolling with content

* Make WindowControls close button respect exitToTray setting

---------

Co-authored-by: Etaash Mathamsetty <[email protected]>
  • Loading branch information
0xCmdrKeen and Etaash-mathamsetty authored Nov 3, 2023
1 parent ada9cdb commit d02a705
Show file tree
Hide file tree
Showing 26 changed files with 414 additions and 19 deletions.
13 changes: 13 additions & 0 deletions public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,13 @@
"experimental_features": {
"enableNewDesign": "New design"
},
"frameless-window": {
"confirmation": {
"message": "This feature is still experimental. Please report any issues you encounter with it on GitHub.",
"title": "Experimental feature ahead"
},
"description": "Use frameless window (requires restart)"
},
"FsrSharpnessStrenght": "FSR Sharpness Strength",
"fsync": "Enable Fsync",
"gamemode": "Use GameMode (Feral Game Mode needs to be installed)",
Expand Down Expand Up @@ -748,6 +755,12 @@
"reload": "Reload page"
}
},
"window": {
"close": "Close",
"maximize": "Maximize window",
"minimize": "Minimize window",
"restore": "Restore window"
},
"wine": {
"actions": "Action",
"manager": {
Expand Down
22 changes: 11 additions & 11 deletions src/backend/__tests__/constants.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
import { fixAsarPath } from '../constants'

export function overrideProcessPlatform(os: string): string {
const original_os = process.platform

// override process.platform
Object.defineProperty(process, 'platform', {
value: os
})

return original_os
}

jest.mock('../logger/logfile')

describe('Constants - fixAsarPath', () => {
Expand All @@ -15,17 +26,6 @@ describe('Constants - fixAsarPath', () => {
})

describe('Constants - getShell', () => {
function overrideProcessPlatform(os: string): string {
const original_os = process.platform

// override process.platform
Object.defineProperty(process, 'platform', {
value: os
})

return original_os
}

async function getShell(): Promise<string> {
jest.resetModules()
return import('../constants').then((module) => {
Expand Down
52 changes: 52 additions & 0 deletions src/backend/__tests__/main_window.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createMainWindow, sendFrontendMessage } from '../main_window'
import { BrowserWindow, Display, screen } from 'electron'
import { configStore } from '../constants'
import { overrideProcessPlatform } from './constants.test'

jest.mock('../logger/logfile')

Expand Down Expand Up @@ -106,5 +107,56 @@ describe('main_window', () => {
expect(options.y).toBe(0)
})
})

describe('with frameless window enabled', () => {
beforeEach(() => {
jest.spyOn(configStore, 'has').mockReturnValue(false)
jest.spyOn(configStore, 'get').mockReturnValue({
framelessWindow: true
})
})

it('creates a simple frameless window on Linux', () => {
const originalPlatform = overrideProcessPlatform('linux')
const window = createMainWindow()
const options = window['options']
overrideProcessPlatform(originalPlatform)

expect(options.frame).toBe(false)
expect(options.titleBarStyle).toBeUndefined()
expect(options.titleBarOverlay).toBeUndefined()
})

it('creates a frameless window with overlay controls on macOS and Windows', () => {
;['darwin', 'win32'].forEach((platform) => {
const originalPlatform = overrideProcessPlatform(platform)
const window = createMainWindow()
const options = window['options']
overrideProcessPlatform(originalPlatform)

expect(options.frame).toBeUndefined()
expect(options.titleBarStyle).toBe('hidden')
expect(options.titleBarOverlay).toBe(true)
})
})
})

describe('with frameless window disabled', () => {
beforeAll(() => {
jest.spyOn(configStore, 'has').mockReturnValue(false)
jest.spyOn(configStore, 'get').mockReturnValue({
framelessWindow: false
})
})

it('creates the new window with default titlebar', () => {
const window = createMainWindow()
const options = window['options']

expect(options.frame).toBeUndefined()
expect(options.titleBarStyle).toBeUndefined()
expect(options.titleBarOverlay).toBeUndefined()
})
})
})
})
5 changes: 5 additions & 0 deletions src/backend/__tests__/test_data/custom_titlebar.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
:root {
--titlebar-height: 40px;
--titlebar-color: #1a1b1c;
--titlebar-symbol-color: #fafbfc;
}
5 changes: 4 additions & 1 deletion src/backend/api/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ipcRenderer } from 'electron'
import { ipcRenderer, TitleBarOverlayOptions } from 'electron'
import {
Runner,
InstallPlatform,
Expand Down Expand Up @@ -112,6 +112,9 @@ export const getThemeCSS = async (theme: string) =>

export const getCustomThemes = async () => ipcRenderer.invoke('getCustomThemes')

export const setTitleBarOverlay = (options: TitleBarOverlayOptions) =>
ipcRenderer.send('setTitleBarOverlay', options)

export const isGameAvailable = async (args: {
appName: string
runner: Runner
Expand Down
31 changes: 31 additions & 0 deletions src/backend/api/misc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,37 @@ export const getCurrentChangelog = async () =>
export const openPatreonPage = () => ipcRenderer.send('openPatreonPage')
export const openKofiPage = () => ipcRenderer.send('openKofiPage')
export const isFullscreen = async () => ipcRenderer.invoke('isFullscreen')
export const isFrameless = async () => ipcRenderer.invoke('isFrameless')
export const isMinimized = async () => ipcRenderer.invoke('isMinimized')
export const isMaximized = async () => ipcRenderer.invoke('isMaximized')
export const minimizeWindow = () => ipcRenderer.send('minimizeWindow')
export const maximizeWindow = () => ipcRenderer.send('maximizeWindow')
export const unmaximizeWindow = () => ipcRenderer.send('unmaximizeWindow')
export const closeWindow = () => ipcRenderer.send('closeWindow')
export const handleMaximized = (
callback: (e: Electron.IpcRendererEvent) => void
) => {
ipcRenderer.on('maximized', callback)
return () => {
ipcRenderer.removeListener('maximized', callback)
}
}
export const handleUnmaximized = (
callback: (e: Electron.IpcRendererEvent) => void
) => {
ipcRenderer.on('unmaximized', callback)
return () => {
ipcRenderer.removeListener('unmaximized', callback)
}
}
export const handleFullscreen = (
callback: (e: Electron.IpcRendererEvent, status: boolean) => void
) => {
ipcRenderer.on('fullscreen', callback)
return () => {
ipcRenderer.removeListener('fullscreen', callback)
}
}

export const openWebviewPage = (url: string) =>
ipcRenderer.send('openWebviewPage', url)
Expand Down
24 changes: 24 additions & 0 deletions src/backend/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ import { initTrayIcon } from './tray_icon/tray_icon'
import {
createMainWindow,
getMainWindow,
isFrameless,
sendFrontendMessage
} from './main_window'

Expand Down Expand Up @@ -198,6 +199,14 @@ async function initializeWindow(): Promise<BrowserWindow> {
mainWindow.setIcon(icon)
app.commandLine.appendSwitch('enable-spatial-navigation')

mainWindow.on('maximize', () => sendFrontendMessage('maximized'))
mainWindow.on('unmaximize', () => sendFrontendMessage('unmaximized'))
mainWindow.on('enter-full-screen', () =>
sendFrontendMessage('fullscreen', true)
)
mainWindow.on('leave-full-screen', () =>
sendFrontendMessage('fullscreen', false)
)
mainWindow.on('close', async (e) => {
e.preventDefault()

Expand Down Expand Up @@ -578,6 +587,13 @@ ipcMain.handle('checkDiskSpace', async (event, folder) => {
})
})

ipcMain.handle('isFrameless', () => isFrameless())
ipcMain.handle('isMinimized', () => !!getMainWindow()?.isMinimized())
ipcMain.handle('isMaximized', () => !!getMainWindow()?.isMaximized())
ipcMain.on('minimizeWindow', () => getMainWindow()?.minimize())
ipcMain.on('maximizeWindow', () => getMainWindow()?.maximize())
ipcMain.on('unmaximizeWindow', () => getMainWindow()?.unmaximize())
ipcMain.on('closeWindow', () => getMainWindow()?.close())
ipcMain.on('quit', async () => handleExit())

// Quit when all windows are closed, except on macOS. There, it's common
Expand Down Expand Up @@ -1597,6 +1613,14 @@ ipcMain.handle('getThemeCSS', async (event, theme) => {
return readFileSync(cssPath, 'utf-8')
})

ipcMain.on('setTitleBarOverlay', (e, args) => {
const mainWindow = getMainWindow()
if (typeof mainWindow?.['setTitleBarOverlay'] === 'function') {
logDebug(`Setting titlebar overlay options ${JSON.stringify(args)}`)
mainWindow?.setTitleBarOverlay(args)
}
})

ipcMain.on('addNewApp', (e, args) => addNewApp(args))

ipcMain.handle('removeApp', async (e, args) => {
Expand Down
23 changes: 20 additions & 3 deletions src/backend/main_window.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { WindowProps } from 'common/types'
import { AppSettings, WindowProps } from 'common/types'
import { BrowserWindow, screen } from 'electron'
import path from 'path'
import { configStore } from './constants'
Expand All @@ -9,6 +9,12 @@ export const getMainWindow = () => {
return mainWindow
}

let windowProps: WindowProps | null = null

export const isFrameless = () => {
return windowProps?.frame === false || windowProps?.titleBarStyle === 'hidden'
}

// send a message to the main window's webContents if available
// returns `false` if no mainWindow or no webContents
// returns `true` if the message was sent to the webContents
Expand All @@ -29,13 +35,13 @@ export const sendFrontendMessage = (message: string, ...payload: unknown[]) => {

// creates the mainWindow based on the configuration
export const createMainWindow = () => {
let windowProps: WindowProps = {
windowProps = {
height: 690,
width: 1200,
x: 0,
y: 0,
maximized: false
}
} as WindowProps

if (configStore.has('window-props')) {
windowProps = configStore.get('window-props', windowProps)
Expand All @@ -51,6 +57,17 @@ export const createMainWindow = () => {
windowProps.width = screenInfo.workAreaSize.width * 0.8
}
}
// Set up frameless window if enabled in settings
const settings = configStore.get('settings', <AppSettings>{})
if (settings.framelessWindow) {
// use native overlay controls where supported
if (['darwin', 'win32'].includes(process.platform)) {
windowProps.titleBarStyle = 'hidden'
windowProps.titleBarOverlay = true
} else {
windowProps.frame = false
}
}
const { maximized, ...props } = windowProps
// Create the browser window.
mainWindow = new BrowserWindow({
Expand Down
4 changes: 4 additions & 0 deletions src/backend/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,7 @@ import { contextBridge } from 'electron'
import api from './api'

contextBridge.exposeInMainWorld('api', api)
contextBridge.exposeInMainWorld(
'isSteamDeckGameMode',
process.env.XDG_CURRENT_DESKTOP === 'gamescope'
)
14 changes: 13 additions & 1 deletion src/common/typedefs/ipcBridge.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { DownloadManagerState } from './../types'
import { EventEmitter } from 'node:events'
import { IpcMainEvent, OpenDialogOptions } from 'electron'
import {
IpcMainEvent,
OpenDialogOptions,
TitleBarOverlayOptions
} from 'electron'

import {
Runner,
Expand Down Expand Up @@ -101,6 +105,11 @@ interface SyncIPCFunctions {
pauseCurrentDownload: () => void
cancelDownload: (removeDownloaded: boolean) => void
copySystemInfoToClipboard: () => void
minimizeWindow: () => void
maximizeWindow: () => void
unmaximizeWindow: () => void
closeWindow: () => void
setTitleBarOverlay: (options: TitleBarOverlayOptions) => void
winetricksInstall: ({
runner: Runner,
appName: string,
Expand Down Expand Up @@ -134,6 +143,9 @@ interface AsyncIPCFunctions {
getGogdlVersion: () => Promise<string>
getNileVersion: () => Promise<string>
isFullscreen: () => boolean
isFrameless: () => boolean
isMaximized: () => boolean
isMinimized: () => boolean
isFlatpak: () => boolean
getPlatform: () => NodeJS.Platform
showUpdateSetting: () => boolean
Expand Down
6 changes: 5 additions & 1 deletion src/common/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { GOGCloudSavesLocation, GogInstallPlatform } from './types/gog'
import { LegendaryInstallPlatform, GameMetadataInner } from './types/legendary'
import { IpcRendererEvent } from 'electron'
import { IpcRendererEvent, TitleBarOverlay } from 'electron'
import { ChildProcess } from 'child_process'
import type { HowLongToBeatEntry } from 'backend/wiki_game_info/howlongtobeat/utils'
import { NileInstallPlatform } from './types/nile'
Expand Down Expand Up @@ -72,6 +72,7 @@ export interface AppSettings extends GameSettings {
enableUpdates: boolean
exitToTray: boolean
experimentalFeatures: ExperimentalFeatures
framelessWindow: boolean
hideChangelogsOnStartup: boolean
libraryTopSection: LibraryTopSectionOptions
maxRecentGames: number
Expand Down Expand Up @@ -709,6 +710,9 @@ export type DownloadManagerState = 'idle' | 'running' | 'paused' | 'stopped'

export interface WindowProps extends Electron.Rectangle {
maximized: boolean
frame?: boolean
titleBarStyle?: 'default' | 'hidden' | 'hiddenInset'
titleBarOverlay?: TitleBarOverlay | boolean
}

interface GameScopeSettings {
Expand Down
16 changes: 14 additions & 2 deletions src/frontend/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,28 @@ import DownloadManager from './screens/DownloadManager'
import DialogHandler from './components/UI/DialogHandler'
import SettingsModal from './screens/Settings/components/SettingsModal'
import ExternalLinkDialog from './components/UI/ExternalLinkDialog'
import WindowControls from './components/UI/WindowControls'
import classNames from 'classnames'

function App() {
const { isSettingsModalOpen, isRTL, experimentalFeatures } =
useContext(ContextProvider)
const {
isSettingsModalOpen,
isRTL,
isFullscreen,
isFrameless,
experimentalFeatures
} = useContext(ContextProvider)

const hasNativeOverlayControls = navigator['windowControlsOverlay']?.visible
const showOverlayControls = isFrameless && !hasNativeOverlayControls

return (
<div
id="app"
className={classNames('App', {
isRTL,
frameless: isFrameless,
fullscreen: isFullscreen,
oldDesign: !experimentalFeatures.enableNewDesign
})}
>
Expand Down Expand Up @@ -76,6 +87,7 @@ function App() {
<ControllerHints />
<div className="simple-keyboard"></div>
</div>
{showOverlayControls && <WindowControls />}
</HashRouter>
</div>
)
Expand Down
Loading

0 comments on commit d02a705

Please sign in to comment.