Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add zoom config on UI and fix zoom-related issues #853

Merged
merged 6 commits into from
Oct 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 44 additions & 5 deletions src/app/AppConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import { join } from 'node:path'
import { readFile, writeFile } from 'node:fs/promises'
import { app } from 'electron'
import { app, webContents } from 'electron'
import { isLinux, isMac } from '../shared/os.utils.js'

const APP_CONFIG_FILE_NAME = 'config.json'
Expand Down Expand Up @@ -45,6 +45,11 @@ export type AppConfig = {
* Default: true on macOS, false otherwise.
*/
monochromeTrayIcon: boolean
/**
* Zoom factor of the application.
* Default: 1.
*/
zoomFactor: number

// ----------------
// Privacy settings
Expand All @@ -59,19 +64,26 @@ export type AppConfig = {
// Nothing yet...
}

export type AppConfigKey = keyof AppConfig
Antreesy marked this conversation as resolved.
Show resolved Hide resolved

/**
* Get the default config
*/
const defaultAppConfig: AppConfig = {
theme: 'default',
systemTitleBar: isLinux(),
monochromeTrayIcon: isMac(),
zoomFactor: 1,
}

/** Local cache of the config file mixed with the default values */
const appConfig: Partial<AppConfig> = {}
/** Whether the application config has been read from the config file and ready to use */
let initialized = false
/**
* Listeners for application config changes
*/
const appConfigChangeListeners: { [K in AppConfigKey]?: Set<(value: AppConfig[K]) => void> } = {}

/**
* Read the application config from the file
Expand Down Expand Up @@ -114,13 +126,13 @@ export async function loadAppConfig() {
}

export function getAppConfig(): AppConfig
export function getAppConfig<T extends keyof AppConfig>(key?: T): AppConfig[T]
export function getAppConfig<T extends AppConfigKey>(key?: T): AppConfig[T]
/**
* Get an application config value
* @param key - The config key to get
* @return - If key is provided, the value of the key. Otherwise, the full config
*/
export function getAppConfig<T extends keyof AppConfig>(key?: T): AppConfig | AppConfig[T] {
export function getAppConfig<T extends AppConfigKey>(key?: T): AppConfig | AppConfig[T] {
if (!initialized) {
throw new Error('The application config is not initialized yet')
}
Expand All @@ -140,11 +152,38 @@ export function getAppConfig<T extends keyof AppConfig>(key?: T): AppConfig | Ap
* @param value - Value to set or undefined to reset to the default value
* @return Promise<AppConfig> - The full settings after the change
*/
export async function setAppConfig<K extends keyof AppConfig>(key: K, value?: AppConfig[K]) {
export function setAppConfig<K extends AppConfigKey>(key: K, value?: AppConfig[K]) {
// Ignore if no change
if (appConfig[key] === value) {
return
}

if (value !== undefined) {
appConfig[key] = value
} else {
delete appConfig[key]
value = defaultAppConfig[key]
}

for (const contents of webContents.getAllWebContents()) {
contents.send('app:config:change', { key, value, appConfig })
}

for (const listener of appConfigChangeListeners[key] ?? []) {
listener(value)
}

writeAppConfigFile(appConfig)
}

/**
* Listen to application config changes
* @param key - The config key to listen to
* @param callback - The callback to call when the config changes
*/
export function onAppConfigChange<K extends AppConfigKey>(key: K, callback: (value: AppConfig[K]) => void) {
if (!appConfigChangeListeners[key]) {
appConfigChangeListeners[key] = new Set([])
}
await writeAppConfigFile(appConfig)
appConfigChangeListeners[key].add(callback)
}
20 changes: 5 additions & 15 deletions src/app/app.menu.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,11 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

const {
app,
shell,
Menu,
} = require('electron')
const { app, shell, Menu } = require('electron')
const { isMac } = require('../shared/os.utils.js')
const packageJson = require('../../package.json')
const { createHelpWindow } = require('../help/help.window.js')
const { increaseZoom, decreaseZoom, setZoom } = require('./zoom.service.ts')

/**
* Setup application menu
Expand Down Expand Up @@ -75,16 +72,9 @@ function setupMenu() {
{ role: 'forceReload' },
{ role: 'toggleDevTools' },
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
// By default zoomIn works by "CommandOrControl + +" ("CommandOrControl + SHIFT + =")
// Hidden menu item adds zoomIn without SHIFT
{
role: 'zoomIn',
accelerator: 'CommandOrControl+=',
visible: false,
},
{ role: 'zoomOut' },
{ label: 'Reset Zoom', accelerator: 'CommandOrControl+0', click: (event, browserWindow) => setZoom(browserWindow, 1) },
{ label: 'Zoom In', accelerator: 'CommandOrControl+=', click: (event, browserWindow) => increaseZoom(browserWindow) },
{ label: 'Zoom Out', accelerator: 'CommandOrControl+-', click: (event, browserWindow) => decreaseZoom(browserWindow) },
{ type: 'separator' },
{ role: 'togglefullscreen' },
],
Expand Down
22 changes: 0 additions & 22 deletions src/app/applyWheelZoom.js

This file was deleted.

49 changes: 49 additions & 0 deletions src/app/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/*
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { screen } from 'electron'
import { getAppConfig } from './AppConfig.ts'

/**
* Get the scaled window size based on the current zoom factor
* @param width - Base window width
* @param height - Base window height
* @param limit - Limit the window size to the screen size
*/
export function getScaledWindowSize({ width, height }: { width: number, height: number }, limit = true) {
const zoomFactor = getAppConfig('zoomFactor')

const windowWidth = Math.round(width * zoomFactor)
const windowHeight = Math.round(height * zoomFactor)

if (limit) {
const primaryDisplay = screen.getPrimaryDisplay()
const { width: screenWidth, height: screenHeight } = primaryDisplay.workAreaSize

return {
width: Math.min(windowWidth, screenWidth),
height: Math.min(windowHeight, screenHeight),
}
}

return {
width: windowWidth,
height: windowHeight,
}
}

/**
* Get the scaled window minimum size based on the current zoom factor
* @param minWidth - Base window minimum width
* @param minWeight - Base window minimum height
* @param limit - Limit the window size to the screen size
*/
export function getScaledWindowMinSize({ minWidth, minHeight }: { minWidth: number, minHeight: number }, limit = true) {
const { width, height } = getScaledWindowSize({ width: minWidth, height: minHeight }, limit)
return {
minWidth: width,
minHeight: height,
}
}
83 changes: 83 additions & 0 deletions src/app/zoom.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { BrowserWindow } from 'electron'
import { onAppConfigChange, setAppConfig } from './AppConfig.ts'
import { ZOOM_MAX, ZOOM_MIN, TITLE_BAR_HEIGHT } from '../constants.js'

/**
* Electron zoom level factor. scale factor is 1.2 ** level
*/
const ELECTRON_ZOOM_LEVEL_FACTOR = 1.2

/**
* Default Electron zoom level change on hotkey - 0.5 level
*/
const ZOOM_STEP_FACTOR = Math.pow(ELECTRON_ZOOM_LEVEL_FACTOR, 0.5)

/**
* Set zoom factor for a browser window
* @param browserWindow - Browser window
* @param zoom - Zoom factor
*/
export function setZoom(browserWindow: BrowserWindow, zoom: number) {
if (browserWindow.webContents.getZoomFactor() === zoom) {
return
}

// Limit zoom level to custom title bar limits
if (zoom < ZOOM_MIN || zoom > ZOOM_MAX) {
return
}

browserWindow.webContents.setZoomFactor(zoom)
// Resize the title bar to match the new zoom level
try {
browserWindow.setTitleBarOverlay({
height: Math.round(TITLE_BAR_HEIGHT * zoom),
})
} catch {
// This is fine and expected
// when the browser window does not have a title bar overlay
}

setAppConfig('zoomFactor', zoom)
}

/**
* Increase zoom level
* @param browserWindow - Browser window
*/
export function increaseZoom(browserWindow: BrowserWindow) {
setZoom(browserWindow, browserWindow.webContents.getZoomFactor() * ZOOM_STEP_FACTOR)
}

/**
* Decrease zoom level
* @param browserWindow - Browser window
*/
export function decreaseZoom(browserWindow: BrowserWindow) {
setZoom(browserWindow, browserWindow.webContents.getZoomFactor() / ZOOM_STEP_FACTOR)
}

/**
* Enable zooming a window by mouse wheel
* @param browserWindow - Browser window
*/
export function applyWheelZoom(browserWindow: BrowserWindow) {
browserWindow.webContents.on('zoom-changed', (event, zoomDirection) => {
if (zoomDirection === 'in') {
increaseZoom(browserWindow)
} else {
decreaseZoom(browserWindow)
}
})
}

onAppConfigChange('zoomFactor', (value) => {
for (const browserWindow of BrowserWindow.getAllWindows()) {
setZoom(browserWindow, value)
}
})
14 changes: 8 additions & 6 deletions src/authentication/authentication.window.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,20 @@ const { BASE_TITLE, TITLE_BAR_HEIGHT } = require('../constants.js')
const { applyContextMenu } = require('../app/applyContextMenu.js')
const { getBrowserWindowIcon } = require('../shared/icons.utils.js')
const { getAppConfig } = require('../app/AppConfig.ts')
const { getScaledWindowSize } = require('../app/utils.ts')

/**
* @return {import('electron').BrowserWindow}
*/
function createAuthenticationWindow() {
const WIDTH = 450
const HEIGHT = 500
const zoomFactor = getAppConfig('zoomFactor')
const TITLE = `Authentication - ${BASE_TITLE}`
const window = new BrowserWindow({
title: TITLE,
width: WIDTH,
height: HEIGHT,
...getScaledWindowSize({
width: 450,
height: 500,
}),
show: false,
maximizable: false,
resizable: false,
Expand All @@ -33,12 +35,12 @@ function createAuthenticationWindow() {
titleBarOverlay: {
color: '#00679E00', // Transparent
symbolColor: '#FFFFFF', // White
height: TITLE_BAR_HEIGHT,
height: Math.round(TITLE_BAR_HEIGHT * zoomFactor),
},
// Position of the top left corner of the traffic light on Mac
trafficLightPosition: {
x: 12, // Same as on Talk Window
y: (TITLE_BAR_HEIGHT - 16) / 2, // 16 is the default traffic light button diameter
y: Math.round((TITLE_BAR_HEIGHT * zoomFactor - 16) / 2), // 16 is the default traffic light button diameter
},
})

Expand Down
17 changes: 13 additions & 4 deletions src/authentication/login.window.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ const { parseLoginRedirectUrl } = require('./login.service.js')
const { getOsTitle } = require('../shared/os.utils.js')
const { applyContextMenu } = require('../app/applyContextMenu.js')
const { getBrowserWindowIcon } = require('../shared/icons.utils.js')
const { getScaledWindowMinSize, getScaledWindowSize } = require('../app/utils.ts')
const { getAppConfig } = require('../app/AppConfig.ts')

const genId = () => Math.random().toString(36).slice(2, 9)

Expand All @@ -26,12 +28,18 @@ function openLoginWebView(parentWindow, serverUrl) {
const HEIGHT = 750
const TITLE = `Login - ${BASE_TITLE}`

const zoomFactor = getAppConfig('zoomFactor')

const window = new BrowserWindow({
title: TITLE,
width: WIDTH,
height: HEIGHT,
minWidth: WIDTH,
minHeight: HEIGHT,
...getScaledWindowSize({
width: WIDTH,
height: HEIGHT,
}),
...getScaledWindowMinSize({
width: WIDTH,
height: HEIGHT,
}),
useContentSize: true,
resizable: true,
center: true,
Expand All @@ -42,6 +50,7 @@ function openLoginWebView(parentWindow, serverUrl) {
webPreferences: {
partition: `non-persist:login-web-view-${genId()}`,
nodeIntegration: false,
zoomFactor,
},
icon: getBrowserWindowIcon(),
})
Expand Down
4 changes: 4 additions & 0 deletions src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ const MIN_REQUIRED_NEXTCLOUD_VERSION = 27
const MIN_REQUIRED_TALK_VERSION = 17
const MIN_REQUIRED_BUILT_IN_TALK_VERSION = '18.0.0'
const TITLE_BAR_HEIGHT = 34 + 2 * 4 + 2 * 2 // default-clickable-area + 2 * default-grid-baseline + 2 * focus outline-width
const ZOOM_MIN = 0.55
const ZOOM_MAX = 5

module.exports = {
BASE_TITLE,
Expand All @@ -27,4 +29,6 @@ module.exports = {
MIN_REQUIRED_TALK_VERSION,
MIN_REQUIRED_BUILT_IN_TALK_VERSION,
TITLE_BAR_HEIGHT,
ZOOM_MIN,
ZOOM_MAX,
}
Loading