diff --git a/src/app/AppConfig.ts b/src/app/AppConfig.ts index 8621012f..447c9841 100644 --- a/src/app/AppConfig.ts +++ b/src/app/AppConfig.ts @@ -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' @@ -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 @@ -59,6 +64,8 @@ export type AppConfig = { // Nothing yet... } +export type AppConfigKey = keyof AppConfig + /** * Get the default config */ @@ -66,12 +73,17 @@ 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 = {} /** 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 @@ -114,13 +126,13 @@ export async function loadAppConfig() { } export function getAppConfig(): AppConfig -export function getAppConfig(key?: T): AppConfig[T] +export function getAppConfig(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(key?: T): AppConfig | AppConfig[T] { +export function getAppConfig(key?: T): AppConfig | AppConfig[T] { if (!initialized) { throw new Error('The application config is not initialized yet') } @@ -140,11 +152,38 @@ export function getAppConfig(key?: T): AppConfig | Ap * @param value - Value to set or undefined to reset to the default value * @return Promise - The full settings after the change */ -export async function setAppConfig(key: K, value?: AppConfig[K]) { +export function setAppConfig(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(key: K, callback: (value: AppConfig[K]) => void) { + if (!appConfigChangeListeners[key]) { + appConfigChangeListeners[key] = new Set([]) } - await writeAppConfigFile(appConfig) + appConfigChangeListeners[key].add(callback) } diff --git a/src/app/app.menu.js b/src/app/app.menu.js index 7f631fcb..128ab603 100644 --- a/src/app/app.menu.js +++ b/src/app/app.menu.js @@ -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 @@ -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' }, ], diff --git a/src/app/applyWheelZoom.js b/src/app/applyWheelZoom.js deleted file mode 100644 index 13bef1a2..00000000 --- a/src/app/applyWheelZoom.js +++ /dev/null @@ -1,22 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -/** - * Enable zooming window by mouse wheel - * - * @param {import('electron').BrowserWindow} browserWindow - Browser window - */ -export function applyWheelZoom(browserWindow) { - browserWindow.webContents.on('zoom-changed', (event, zoomDirection) => { - const zoom = browserWindow.webContents.getZoomLevel() - // 0.5 level delta seems to be equivalent of "CTRL+" (zoomIn) and CTRL- (zoomOut) commands - const zoomDelta = 0.5 * (zoomDirection === 'in' ? 1 : -1) - const newZoom = zoom + zoomDelta - // Undocumented default limits for zoom level is [-8, 9] or [~23.25%, 515.97%] - if (newZoom >= -8 && newZoom <= 9) { - browserWindow.webContents.setZoomLevel(zoom + zoomDelta) - } - }) -} diff --git a/src/app/utils.ts b/src/app/utils.ts new file mode 100644 index 00000000..2a871cdc --- /dev/null +++ b/src/app/utils.ts @@ -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, + } +} diff --git a/src/app/zoom.service.ts b/src/app/zoom.service.ts new file mode 100644 index 00000000..909861f2 --- /dev/null +++ b/src/app/zoom.service.ts @@ -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) + } +}) diff --git a/src/authentication/authentication.window.js b/src/authentication/authentication.window.js index 50e1fb2a..55b06697 100644 --- a/src/authentication/authentication.window.js +++ b/src/authentication/authentication.window.js @@ -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, @@ -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 }, }) diff --git a/src/authentication/login.window.js b/src/authentication/login.window.js index 95fcd74f..b456b9a0 100644 --- a/src/authentication/login.window.js +++ b/src/authentication/login.window.js @@ -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) @@ -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, @@ -42,6 +50,7 @@ function openLoginWebView(parentWindow, serverUrl) { webPreferences: { partition: `non-persist:login-web-view-${genId()}`, nodeIntegration: false, + zoomFactor, }, icon: getBrowserWindowIcon(), }) diff --git a/src/constants.js b/src/constants.js index 346a9092..141879fd 100644 --- a/src/constants.js +++ b/src/constants.js @@ -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, @@ -27,4 +29,6 @@ module.exports = { MIN_REQUIRED_TALK_VERSION, MIN_REQUIRED_BUILT_IN_TALK_VERSION, TITLE_BAR_HEIGHT, + ZOOM_MIN, + ZOOM_MAX, } diff --git a/src/help/help.window.js b/src/help/help.window.js index cc070b5c..c56eeeab 100644 --- a/src/help/help.window.js +++ b/src/help/help.window.js @@ -7,6 +7,7 @@ const { BASE_TITLE } = require('../constants.js') const { BrowserWindow } = require('electron') const { applyExternalLinkHandler } = require('../app/externalLinkHandlers.js') const { getBrowserWindowIcon } = require('../shared/icons.utils.js') +const { getScaledWindowSize } = require('../app/utils.ts') /** * @@ -14,13 +15,13 @@ const { getBrowserWindowIcon } = require('../shared/icons.utils.js') * @return {import('electron').BrowserWindow} */ function createHelpWindow(parentWindow) { - const WIDTH = 720 - const HEIGHT = 525 const TITLE = `About - ${BASE_TITLE}` const window = new BrowserWindow({ title: TITLE, - width: WIDTH, - height: HEIGHT, + ...getScaledWindowSize({ + width: 720, + height: 525, + }), show: false, maximizable: false, resizable: false, diff --git a/src/preload.js b/src/preload.js index e7d6b037..7af4647d 100644 --- a/src/preload.js +++ b/src/preload.js @@ -106,6 +106,11 @@ const TALK_DESKTOP = { * @return {Promise} */ setAppConfig: (key, value) => ipcRenderer.invoke('app:config:set', key, value), + /** + * Listen for changes in the application config + * @param {(event: import('electron').IpcRedererEvent, payload: { key: string, value: unknown, appConfig: import('./app/AppConfig.ts').AppConfig}) => void} callback - Callback + */ + onAppConfigChange: (callback) => ipcRenderer.on('app:config:change', callback), /** * Trigger download of a URL * @param {string} url - URL to download diff --git a/src/shared/appConfig.service.ts b/src/shared/appConfig.service.ts index b571472b..78b00a3d 100644 --- a/src/shared/appConfig.service.ts +++ b/src/shared/appConfig.service.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { AppConfig } from '../app/AppConfig.ts' +import type { AppConfig, AppConfigKey } from '../app/AppConfig.ts' let appConfig: AppConfig | null = null @@ -35,7 +35,7 @@ export function getAppConfig() { * @param key - The key of the config value * @return - The config value */ -export function getAppConfigValue(key: K) { +export function getAppConfigValue(key: K) { if (!appConfig) { throw new Error('AppConfig is not initialized') } diff --git a/src/talk/renderer/Settings/DesktopSettingsSection.vue b/src/talk/renderer/Settings/DesktopSettingsSection.vue index c1526902..64be2c8f 100644 --- a/src/talk/renderer/Settings/DesktopSettingsSection.vue +++ b/src/talk/renderer/Settings/DesktopSettingsSection.vue @@ -4,17 +4,25 @@ --> + + + + diff --git a/src/talk/renderer/Settings/components/SettingsSelect.vue b/src/talk/renderer/Settings/components/SettingsSelect.vue index 320810d8..a695a511 100644 --- a/src/talk/renderer/Settings/components/SettingsSelect.vue +++ b/src/talk/renderer/Settings/components/SettingsSelect.vue @@ -6,9 +6,11 @@