From 32363f44367a2a7368c14e57568a06f08cf911ad Mon Sep 17 00:00:00 2001 From: Matt McClaskey Date: Sat, 13 Jun 2026 21:22:39 -0700 Subject: [PATCH] VNC-259 Add per-display resolution zoom --- app/styles/base.css | 25 ++++++--- app/ui.js | 121 +++++++++++++++++++++++++++++++++++++++----- core/display.js | 87 +++++++++++++++++++++++++------ core/rfb.js | 39 ++++++++++++-- index.html | 5 +- 5 files changed, 235 insertions(+), 42 deletions(-) diff --git a/app/styles/base.css b/app/styles/base.css index 65e10324d..c78eb6bb2 100644 --- a/app/styles/base.css +++ b/app/styles/base.css @@ -1396,18 +1396,31 @@ a:visited { border: 1px solid #e5e5e5; line-height: 1; } -#noVNC_setting_enable_hidpi_option { - display: none!important; -} -#noVNC_setting_enable_hidpi_option.show { - display: flex!important; -} #noVNC_auto_placement_option { display: none!important; } #noVNC_auto_placement_option.show { display: flex!important; } +.monitor-resolution-scales { + display: flex; + flex-wrap: wrap; + gap: 5px; +} +.monitor-resolution-scales label { + align-items: center; + background-color: #f1f1f1; + border: 1px solid #e5e5e5; + border-radius: 5px; + display: flex; + font-size: 13px; + gap: 6px; + line-height: 1; + padding: 4px 7px; +} +.monitor-resolution-scales select { + font-size: 13px; +} #noVNC_refreshMonitors { position: absolute; top: 20px; diff --git a/app/ui.js b/app/ui.js index 1ba0b19f1..dddc2a956 100644 --- a/app/ui.js +++ b/app/ui.js @@ -56,6 +56,9 @@ import { perfLogger } from '../core/util/performance-logger.js'; // perfLogger.enable(5000); const PAGE_TITLE = "KasmVNC"; +const RESOLUTION_SCALE_MIN = 25; +const RESOLUTION_SCALE_MAX = 200; +const RESOLUTION_SCALE_STEP = 25; var currentEventCount = -1; var idleCounter = 0; @@ -86,6 +89,10 @@ const UI = { displayWindows: new Map([['primary', 'primary']]), registeredWindows: new Map([['primary', 'primary']]), fpsChartTicks: [], + resolutionScaleOptions: Array.from( + { length: ((RESOLUTION_SCALE_MAX - RESOLUTION_SCALE_MIN) / RESOLUTION_SCALE_STEP) + 1 }, + (_, i) => RESOLUTION_SCALE_MIN + (i * RESOLUTION_SCALE_STEP) + ), monitorDragOk: false, monitorStartX: 0, @@ -603,6 +610,7 @@ const UI = { // unless the optional parameter changeFunc is used instead. addSettingChangeHandler(name, changeFunc) { const settingElem = document.getElementById("noVNC_setting_" + name); + if (!settingElem) return; if (changeFunc === undefined) { changeFunc = () => UI.saveSetting(name); } @@ -2441,6 +2449,11 @@ const UI = { }, closeDisplays() { + UI.syncMonitorResolutionScaleControls(); + if (UI.rfb && UI.monitors.length > 0) { + UI.setScreenPlan(); + UI.draw(); + } document.getElementById('noVNC_displays').classList.remove("noVNC_open"); }, @@ -2513,19 +2526,25 @@ const UI = { initMonitors(screenPlan) { const { scale } = UI.multiMonitorSettings() let monitors = [] - let showNativeResolution = false let num = 1; screenPlan.screens.forEach(screen => { - if (parseFloat(screen.pixelRatio) != 1) { - showNativeResolution = true - } + const resolutionScale = UI.normalizeResolutionScale( + screen.resolutionScale || UI.defaultResolutionScale(screen.pixelRatio)); + const localWidth = screen.containerWidth || + Math.round(screen.serverWidth / UI.resolutionScaleRatio(resolutionScale, screen.pixelRatio)); + const localHeight = screen.containerHeight || + Math.round(screen.serverHeight / UI.resolutionScaleRatio(resolutionScale, screen.pixelRatio)); + const scaleRatio = UI.resolutionScaleRatio(resolutionScale, screen.pixelRatio); monitors.push({ id: screen.screenID, x: screen.x / scale, y: screen.y / scale, - w: screen.serverWidth / scale, - h: screen.serverHeight / scale, + w: (localWidth * scaleRatio) / scale, + h: (localHeight * scaleRatio) / scale, + localWidth, + localHeight, pixelRatio: screen.pixelRatio, + resolutionScale, scale: 1, fill: '#eeeeeecc', isDragging: false, @@ -2533,11 +2552,6 @@ const UI = { }) num++ }) - if (showNativeResolution) { - document.getElementById('noVNC_setting_enable_hidpi_option').classList.add("show"); - } else { - document.getElementById('noVNC_setting_enable_hidpi_option').classList.remove("show"); - } UI.monitors = monitors let deepCopyMonitors = JSON.parse(JSON.stringify(monitors)) UI.sortedMonitors = deepCopyMonitors.sort((a, b) => { @@ -2546,7 +2560,80 @@ const UI = { } return a.x - b.x }) + UI.renderResolutionScaleControls() + + }, + + normalizeResolutionScale(value) { + value = parseInt(value); + if (!Number.isFinite(value)) { + return 100; + } + value = Math.round(value / RESOLUTION_SCALE_STEP) * RESOLUTION_SCALE_STEP; + return Math.min(RESOLUTION_SCALE_MAX, Math.max(RESOLUTION_SCALE_MIN, value)); + }, + + defaultResolutionScale(pixelRatio) { + return UI.normalizeResolutionScale((parseFloat(pixelRatio) || 1) * 100); + }, + + resolutionScaleRatio(resolutionScale, pixelRatio) { + return (parseFloat(pixelRatio) || 1) / (UI.normalizeResolutionScale(resolutionScale) / 100); + }, + + renderResolutionScaleControls() { + const container = document.getElementById('noVNC_monitor_resolution_scales'); + if (!container) return; + container.replaceChildren(); + UI.sortedMonitors.forEach(monitor => { + const label = document.createElement('label'); + label.textContent = `Display ${monitor.num}`; + + const select = document.createElement('select'); + select.dataset.screenId = monitor.id; + UI.resolutionScaleOptions.forEach(value => { + const option = document.createElement('option'); + option.value = value; + option.textContent = `${value}%`; + select.appendChild(option); + }); + select.value = String(UI.normalizeResolutionScale(monitor.resolutionScale)); + select.addEventListener('change', UI.updateMonitorResolutionScale); + + label.appendChild(select); + container.appendChild(label); + }); + }, + + updateMonitorResolutionScale(event) { + const screenID = event.target.dataset.screenId; + const resolutionScale = UI.normalizeResolutionScale(event.target.value); + UI.updateMonitorResolutionScaleValue(screenID, resolutionScale); + UI.recenter(); + UI.draw(); + }, + + updateMonitorResolutionScaleValue(screenID, resolutionScale) { + const updateMonitor = monitor => { + if (String(monitor.id) !== String(screenID)) return; + + monitor.resolutionScale = resolutionScale; + const scaleRatio = UI.resolutionScaleRatio(monitor.resolutionScale, monitor.pixelRatio); + monitor.w = (monitor.localWidth * scaleRatio) / UI.multiMonitorSettings().scale; + monitor.h = (monitor.localHeight * scaleRatio) / UI.multiMonitorSettings().scale; + }; + + UI.monitors.forEach(updateMonitor); + UI.sortedMonitors.forEach(updateMonitor); + }, + + syncMonitorResolutionScaleControls() { + document.querySelectorAll('#noVNC_monitor_resolution_scales select').forEach(select => { + UI.updateMonitorResolutionScaleValue( + select.dataset.screenId, + UI.normalizeResolutionScale(select.value)); + }); }, updateMonitors(screenPlan) { @@ -2782,10 +2869,17 @@ const UI = { for (var i = 0; i < monitors.length; i++) { var monitor = monitors[i]; var a = sortedMonitors.find(el => el.id === monitor.id) + const serverWidth = Math.round(a.w * scale); + const serverHeight = Math.round(a.h * scale); + const displayScale = 1 / UI.resolutionScaleRatio(a.resolutionScale, a.pixelRatio); screens.push({ screenID: a.id, - serverHeight: Math.round(a.h * scale), - serverWidth: Math.round(a.w * scale), + width: Math.round(a.localWidth), + height: Math.round(a.localHeight), + serverHeight, + serverWidth, + scale: displayScale, + resolutionScale: a.resolutionScale, x: Math.round((a.x - left) * scale), y: Math.round((a.y - top) * scale) }) @@ -2797,6 +2891,7 @@ const UI = { } if (UI.rfb) { UI.rfb.applyScreenPlan(screenPlan); + window.setTimeout(() => UI.rfb.updateConnectionSettings(), 0); } }, diff --git a/core/display.js b/core/display.js index 93fef11be..abf5fd119 100644 --- a/core/display.js +++ b/core/display.js @@ -18,6 +18,27 @@ import {Canvas2DRenderer} from "./renderers/Canvas2DRenderer"; import {WebGLRenderer} from "./renderers/WebGLRenderer"; import { perfLogger } from './util/performance-logger.js'; +const MIN_RESOLUTION_SCALE = 25; +const MAX_RESOLUTION_SCALE = 200; +const RESOLUTION_SCALE_STEP = 25; + +function clampResolutionScale(value) { + value = Number(value); + if (!Number.isFinite(value)) { + return 100; + } + value = Math.round(value / RESOLUTION_SCALE_STEP) * RESOLUTION_SCALE_STEP; + return Math.min(MAX_RESOLUTION_SCALE, Math.max(MIN_RESOLUTION_SCALE, value)); +} + +function defaultResolutionScale(pixelRatio) { + return clampResolutionScale((Number(pixelRatio) || 1) * 100); +} + +function resolutionScaleRatio(resolutionScale, pixelRatio) { + return (Number(pixelRatio) || 1) / (clampResolutionScale(resolutionScale) / 100); +} + export default class Display { constructor(target, rfb, isPrimaryDisplay, videoRenderingMode = 'canvas2d') { Log.Debug(">> Display.constructor"); @@ -156,6 +177,8 @@ export default class Display { relativePositionX: 0, //offset relative to primary monitor, always 0 for primary relativePositionY: 0, //offset relative to primary monitor, always 0 for primary pixelRatio: window.devicePixelRatio, + resolutionScale: defaultResolutionScale(window.devicePixelRatio), + resolutionScaleCustom: false, containerHeight: this._target.parentNode.offsetHeight, containerWidth: this._target.parentNode.offsetWidth, channel: null, @@ -299,6 +322,9 @@ export default class Display { this._screens[i].containerHeight = Math.floor(parentNodeSize.height / 2) * 2; this._screens[i].containerWidth = Math.floor(parentNodeSize.width / 2) * 2; this._screens[i].pixelRatio = window.devicePixelRatio; + if (!this._screens[i].resolutionScaleCustom) { + this._screens[i].resolutionScale = defaultResolutionScale(this._screens[i].pixelRatio); + } this._screens[i].width = this._screens[i].containerWidth; this._screens[i].height = this._screens[i].containerHeight; @@ -310,15 +336,21 @@ export default class Display { //max the resolution of a single screen to 1280 if ( (this._screens[i].serverReportedWidth > 0 && this._screens[i].serverReportedHeight > 0) && - ( - disableScaling || - (this._screens[i].serverReportedWidth !== this._screens[i].serverWidth || this._screens[i].serverReportedHeight !== this._screens[i].serverHeight) - ) && + (disableScaling || + (!this._screens[i].resolutionScaleCustom && + (this._screens[i].serverReportedWidth !== this._screens[i].serverWidth || + this._screens[i].serverReportedHeight !== this._screens[i].serverHeight))) && (!max_width && !max_height) ) { height = this._screens[i].serverReportedHeight; width = this._screens[i].serverReportedWidth; } + else if (!max_width && !max_height) { + const scaleRatio = resolutionScaleRatio(this._screens[i].resolutionScale, this._screens[i].pixelRatio); + width = Math.floor(width * scaleRatio); + height = Math.floor(height * scaleRatio); + scale = 1 / scaleRatio; + } else if (width > 1280 && !disableLimit && resolutionQuality == 1 && streamMode == encodings.pseudoEncodingStreamingModeJpegWebp) { height = Math.floor(1280 * (height/width)); //keeping the aspect ratio of original resolution, shrink y to match x width = 1280; @@ -384,19 +416,39 @@ export default class Display { for (let i = 0; i < screenPlan.screens.length; i++) { for (let z = 0; z < this._screens.length; z++) { if (screenPlan.screens[i].screenID === this._screens[z].screenID) { - if (this._screens[z].x !== screenPlan.screens[i].x || this._screens[z].y !== screenPlan.screens[i].y) { - if (z == 0) { - this._screens[z].x = screenPlan.screens[i].x; - this._screens[z].y = screenPlan.screens[i].y; + if (typeof screenPlan.screens[i].resolutionScale !== 'undefined') { + const resolutionScale = clampResolutionScale(screenPlan.screens[i].resolutionScale); + if (this._screens[z].resolutionScale !== resolutionScale) { + this._screens[z].resolutionScale = resolutionScale; + this._screens[z].resolutionScaleCustom = true; + changes = true; } + } + if (typeof screenPlan.screens[i].serverWidth !== 'undefined' && + typeof screenPlan.screens[i].serverHeight !== 'undefined' && + (this._screens[z].serverWidth !== screenPlan.screens[i].serverWidth || + this._screens[z].serverHeight !== screenPlan.screens[i].serverHeight)) { + this._screens[z].serverWidth = screenPlan.screens[i].serverWidth; + this._screens[z].serverHeight = screenPlan.screens[i].serverHeight; + changes = true; + } + if (typeof screenPlan.screens[i].width !== 'undefined') { + this._screens[z].width = screenPlan.screens[i].width; + } + if (typeof screenPlan.screens[i].height !== 'undefined') { + this._screens[z].height = screenPlan.screens[i].height; + } + if (typeof screenPlan.screens[i].scale !== 'undefined') { + this._screens[z].scale = screenPlan.screens[i].scale; + } + if (this._screens[z].x !== screenPlan.screens[i].x || this._screens[z].y !== screenPlan.screens[i].y) { + this._screens[z].x = screenPlan.screens[i].x; + this._screens[z].y = screenPlan.screens[i].y; changes = true; } if (this._screens[z].x2 !== this._screens[z].x + this._screens[z].serverWidth || this._screens[z].y2 !== this._screens[z].y + this._screens[z].serverHeight) { - if (z == 0) { - this._screens[z].x2 = this._screens[z].x + this._screens[z].serverWidth - this._screens[z].y2 = this._screens[z].y + this._screens[z].serverHeight - - } + this._screens[z].x2 = this._screens[z].x + this._screens[z].serverWidth + this._screens[z].y2 = this._screens[z].y + this._screens[z].serverHeight changes = true; } } @@ -405,7 +457,7 @@ export default class Display { return changes; } - addScreen(screenID, width, height, pixelRatio, containerHeight, containerWidth, scale, serverWidth, serverHeight, x, y, windowId) { + addScreen(screenID, width, height, pixelRatio, containerHeight, containerWidth, scale, serverWidth, serverHeight, x, y, windowId, resolutionScale = null) { if (!this._isPrimaryDisplay) { throw new Error("Cannot add a screen to a secondary display."); } @@ -424,14 +476,17 @@ export default class Display { if (screenIdx > 0) { //existing screen, update const existing_screen = this._screens[screenIdx]; + const resolvedResolutionScale = resolutionScale || defaultResolutionScale(pixelRatio); if (existing_screen.serverHeight !== serverHeight || existing_screen.serverWidth !== serverWidth || existing_screen.width !== width || existing_screen.height !== height || existing_screen.containerHeight !== containerHeight || existing_screen.containerWidth !== containerWidth || existing_screen.scale !== scale || existing_screen.pixelRatio !== pixelRatio || - existing_screen.x !== x || existing_screen.y !== y) { + existing_screen.x !== x || existing_screen.y !== y || existing_screen.resolutionScale !== resolvedResolutionScale) { existing_screen.width = width; existing_screen.height = height; existing_screen.containerHeight = containerHeight; existing_screen.containerWidth = containerWidth; existing_screen.pixelRatio = pixelRatio; + existing_screen.resolutionScale = resolvedResolutionScale; + existing_screen.resolutionScaleCustom = resolutionScale !== null; existing_screen.scale = scale; existing_screen.serverWidth = serverWidth; existing_screen.serverHeight = serverHeight; @@ -459,6 +514,8 @@ export default class Display { x: x, y: 0, pixelRatio: pixelRatio, + resolutionScale: resolutionScale || defaultResolutionScale(pixelRatio), + resolutionScaleCustom: resolutionScale !== null, containerHeight: containerHeight, containerWidth: containerWidth, channel: new BroadcastChannel(`channel_${screenID}`), diff --git a/core/rfb.js b/core/rfb.js index db4d8e6ce..5b3025a11 100644 --- a/core/rfb.js +++ b/core/rfb.js @@ -873,7 +873,18 @@ export default class RFB extends EventTargetMixin { for (let i = 0; i < screenPlan.screens.length; i++) { for (let z = 1; z < fullPlan.screens.length; z++) { if (screenPlan.screens[i].screenID === fullPlan.screens[z].screenID) { - this._proxyRFBMessage('applyScreenPlan', [ fullPlan.screens[z].screenID, fullPlan.screens[z].screenIndex, screenPlan.screens[i].width, screenPlan.screens[i].height, screenPlan.screens[i].x, screenPlan.screens[i].y ]); + this._proxyRFBMessage('applyScreenPlan', [ + fullPlan.screens[z].screenID, + fullPlan.screens[z].screenIndex, + screenPlan.screens[i].width, + screenPlan.screens[i].height, + screenPlan.screens[i].x, + screenPlan.screens[i].y, + screenPlan.screens[i].serverWidth, + screenPlan.screens[i].serverHeight, + screenPlan.screens[i].scale, + screenPlan.screens[i].resolutionScale, + ]); } } } @@ -901,9 +912,15 @@ export default class RFB extends EventTargetMixin { screenID: fullPlan.screens[i].screenID, serverWidth: fullPlan.screens[i].serverWidth, serverHeight: fullPlan.screens[i].serverHeight, + containerWidth: fullPlan.screens[i].containerWidth, + containerHeight: fullPlan.screens[i].containerHeight, + width: fullPlan.screens[i].width, + height: fullPlan.screens[i].height, + scale: fullPlan.screens[i].scale, x: fullPlan.screens[i].x, y: fullPlan.screens[i].y, - pixelRatio: fullPlan.screens[i].pixelRatio + pixelRatio: fullPlan.screens[i].pixelRatio, + resolutionScale: fullPlan.screens[i].resolutionScale } ) } @@ -1936,7 +1953,7 @@ export default class RFB extends EventTargetMixin { ...event.data.details, screenID: event.data.screenID } - let screenIndex = this._display.addScreen(event.data.screenID, event.data.width, event.data.height, event.data.pixelRatio, event.data.containerHeight, event.data.containerWidth, event.data.scale, event.data.serverWidth, event.data.serverHeight, event.data.x, event.data.y, event.data.windowId); + let screenIndex = this._display.addScreen(event.data.screenID, event.data.width, event.data.height, event.data.pixelRatio, event.data.containerHeight, event.data.containerWidth, event.data.scale, event.data.serverWidth, event.data.serverHeight, event.data.x, event.data.y, event.data.windowId, event.data.resolutionScale); this._proxyRFBMessage('screenRegistrationConfirmed', [ this._display.screens[screenIndex].screenID, screenIndex ]); this._sendEncodings(); clearTimeout(this._resizeTimeout); @@ -1945,7 +1962,7 @@ export default class RFB extends EventTargetMixin { Log.Info(`Secondary monitor (${event.data.screenID}) has been registered.`); break; case 'reattach': - let changes = this._display.addScreen(event.data.screenID, event.data.width, event.data.height, event.data.pixelRatio, event.data.containerHeight, event.data.containerWidth, event.data.scale, event.data.serverWidth, event.data.serverHeight, event.data.x, event.data.y, event.data.windowId); + let changes = this._display.addScreen(event.data.screenID, event.data.width, event.data.height, event.data.pixelRatio, event.data.containerHeight, event.data.containerWidth, event.data.scale, event.data.serverWidth, event.data.serverHeight, event.data.x, event.data.y, event.data.windowId, event.data.resolutionScale); clearTimeout(this._resizeTimeout); this._resizeTimeout = setTimeout(this._requestRemoteResize.bind(this), 500); @@ -2065,6 +2082,19 @@ export default class RFB extends EventTargetMixin { this._display.screens[0].height = event.data.args[3]; this._display.screens[0].x = event.data.args[4]; this._display.screens[0].y = event.data.args[5]; + if (typeof event.data.args[6] !== 'undefined') { + this._display.screens[0].serverWidth = event.data.args[6]; + } + if (typeof event.data.args[7] !== 'undefined') { + this._display.screens[0].serverHeight = event.data.args[7]; + } + if (typeof event.data.args[8] !== 'undefined') { + this._display.screens[0].scale = event.data.args[8]; + } + if (typeof event.data.args[9] !== 'undefined') { + this._display.screens[0].resolutionScale = event.data.args[9]; + this._display.screens[0].resolutionScaleCustom = true; + } this.updateConnectionSettings(); } @@ -2109,6 +2139,7 @@ export default class RFB extends EventTargetMixin { y: currentScreen.y || 0, pixelRatio: screen.pixelRatio, scale: screen.scale, + resolutionScale: screen.resolutionScale, serverWidth: screen.serverWidth, serverHeight: screen.serverHeight, containerWidth: screen.containerWidth, diff --git a/index.html b/index.html index 53398e8a0..a9e27d05e 100644 --- a/index.html +++ b/index.html @@ -626,10 +626,7 @@

Auto placement - +