Skip to content

Commit 04d00ce

Browse files
feat(desktop): add auto-zoom scaling for high-DPI displays
Auto-detect display resolution and scale the UI proportionally so the app is usable on 4K/1440p monitors without affecting 1080p users. - Compute zoom factor from the display's DIP short edge relative to 1080p, snapped to 0.25 increments (max 3.0x) - Apply zoom on window creation, display change, move/resize across monitors, unmaximize, and leave-fullscreen - Clamp window bounds to the display work area after zoom changes - Scale minimum window size with zoom, capped to the work area - Replace built-in viewMenu with custom View menu supporting Ctrl+=/Ctrl+Plus (zoom in), Ctrl+- (zoom out), Ctrl+0 (reset) - Guard against re-entrant setBounds->move loops and skip zoom adjustments while fullscreen or maximized
1 parent 59b0527 commit 04d00ce

1 file changed

Lines changed: 163 additions & 6 deletions

File tree

apps/desktop/src/main.ts

Lines changed: 163 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import * as FS from "node:fs";
44
import * as OS from "node:os";
55
import * as Path from "node:path";
66

7-
import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, protocol, shell } from "electron";
7+
import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, protocol, screen, shell } from "electron";
88
import type { MenuItemConstructorOptions } from "electron";
99
import * as Effect from "effect/Effect";
1010
import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/contracts";
@@ -60,6 +60,14 @@ const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000;
6060
const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000;
6161
const DESKTOP_UPDATE_CHANNEL = "latest";
6262
const DESKTOP_UPDATE_ALLOW_PRERELEASE = false;
63+
const BASE_DEFAULT_WIDTH = 1100;
64+
const BASE_DEFAULT_HEIGHT = 780;
65+
const BASE_MIN_WIDTH = 840;
66+
const BASE_MIN_HEIGHT = 620;
67+
const REFERENCE_SHORT_EDGE = 1080;
68+
const ZOOM_STEP = 0.25;
69+
const MIN_ZOOM_FACTOR = 0.5;
70+
const MAX_ZOOM_FACTOR = 3.0;
6371

6472
type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"];
6573

@@ -538,7 +546,41 @@ function configureApplicationMenu(): void {
538546
],
539547
},
540548
{ role: "editMenu" },
541-
{ role: "viewMenu" },
549+
{
550+
label: "View",
551+
submenu: [
552+
{ role: "reload" },
553+
{ role: "forceReload" },
554+
{ role: "toggleDevTools" },
555+
{ type: "separator" },
556+
{
557+
label: "Reset Zoom",
558+
accelerator: "CmdOrCtrl+0",
559+
click: () => {
560+
const win = getFocusedBrowserWindow();
561+
if (win) applyAutoZoom(win);
562+
},
563+
},
564+
{
565+
label: "Zoom In",
566+
accelerator: "CmdOrCtrl+=",
567+
click: () => adjustZoom(ZOOM_STEP),
568+
},
569+
{
570+
label: "Zoom In",
571+
accelerator: "CmdOrCtrl+Plus",
572+
visible: false,
573+
click: () => adjustZoom(ZOOM_STEP),
574+
},
575+
{
576+
label: "Zoom Out",
577+
accelerator: "CmdOrCtrl+-",
578+
click: () => adjustZoom(-ZOOM_STEP),
579+
},
580+
{ type: "separator" },
581+
{ role: "togglefullscreen" },
582+
],
583+
},
542584
{ role: "windowMenu" },
543585
{
544586
role: "help",
@@ -1094,12 +1136,68 @@ function getIconOption(): { icon: string } | Record<string, never> {
10941136
return iconPath ? { icon: iconPath } : {};
10951137
}
10961138

1139+
function getFocusedBrowserWindow(): BrowserWindow | null {
1140+
return BrowserWindow.getFocusedWindow() ?? mainWindow ?? BrowserWindow.getAllWindows()[0] ?? null;
1141+
}
1142+
1143+
function adjustZoom(delta: number): void {
1144+
const win = getFocusedBrowserWindow();
1145+
if (!win || win.isDestroyed()) return;
1146+
const current = win.webContents.getZoomFactor();
1147+
win.webContents.setZoomFactor(Math.min(Math.max(current + delta, MIN_ZOOM_FACTOR), MAX_ZOOM_FACTOR));
1148+
}
1149+
1150+
function computeAutoZoomFactor(display: Electron.Display): number {
1151+
const shortEdge = Math.min(display.size.width, display.size.height);
1152+
if (shortEdge <= REFERENCE_SHORT_EDGE) return 1.0;
1153+
const ratio = shortEdge / REFERENCE_SHORT_EDGE;
1154+
return Math.min(Math.round(ratio / ZOOM_STEP) * ZOOM_STEP, MAX_ZOOM_FACTOR);
1155+
}
1156+
1157+
function applyAutoZoom(window: BrowserWindow): void {
1158+
if (window.isDestroyed()) return;
1159+
if (window.isFullScreen() || window.isMaximized()) return;
1160+
1161+
const display = screen.getDisplayMatching(window.getBounds());
1162+
const zoomFactor = computeAutoZoomFactor(display);
1163+
window.webContents.setZoomFactor(zoomFactor);
1164+
1165+
const workArea = display.workArea;
1166+
window.setMinimumSize(
1167+
Math.min(Math.round(BASE_MIN_WIDTH * zoomFactor), workArea.width),
1168+
Math.min(Math.round(BASE_MIN_HEIGHT * zoomFactor), workArea.height),
1169+
);
1170+
1171+
const bounds = window.getBounds();
1172+
const clamped = { ...bounds };
1173+
if (clamped.width > workArea.width) clamped.width = workArea.width;
1174+
if (clamped.height > workArea.height) clamped.height = workArea.height;
1175+
if (clamped.x < workArea.x) clamped.x = workArea.x;
1176+
if (clamped.y < workArea.y) clamped.y = workArea.y;
1177+
if (clamped.x + clamped.width > workArea.x + workArea.width)
1178+
clamped.x = workArea.x + workArea.width - clamped.width;
1179+
if (clamped.y + clamped.height > workArea.y + workArea.height)
1180+
clamped.y = workArea.y + workArea.height - clamped.height;
1181+
1182+
if (
1183+
clamped.x !== bounds.x ||
1184+
clamped.y !== bounds.y ||
1185+
clamped.width !== bounds.width ||
1186+
clamped.height !== bounds.height
1187+
) {
1188+
window.setBounds(clamped);
1189+
}
1190+
}
1191+
10971192
function createWindow(): BrowserWindow {
1193+
const targetDisplay = screen.getDisplayNearestPoint(screen.getCursorScreenPoint());
1194+
const zoomFactor = computeAutoZoomFactor(targetDisplay);
1195+
10981196
const window = new BrowserWindow({
1099-
width: 1100,
1100-
height: 780,
1101-
minWidth: 840,
1102-
minHeight: 620,
1197+
width: Math.round(BASE_DEFAULT_WIDTH * zoomFactor),
1198+
height: Math.round(BASE_DEFAULT_HEIGHT * zoomFactor),
1199+
minWidth: Math.round(BASE_MIN_WIDTH * zoomFactor),
1200+
minHeight: Math.round(BASE_MIN_HEIGHT * zoomFactor),
11031201
show: false,
11041202
autoHideMenuBar: true,
11051203
...getIconOption(),
@@ -1121,9 +1219,62 @@ function createWindow(): BrowserWindow {
11211219
});
11221220
window.webContents.on("did-finish-load", () => {
11231221
window.setTitle(APP_DISPLAY_NAME);
1222+
applyAutoZoom(window);
11241223
emitUpdateState();
11251224
});
1225+
1226+
let lastDisplayId = screen.getDisplayMatching(window.getBounds()).id;
1227+
let applyingZoom = false;
1228+
window.on("move", () => {
1229+
if (window.isDestroyed() || applyingZoom) return;
1230+
const currentDisplay = screen.getDisplayMatching(window.getBounds());
1231+
if (currentDisplay.id !== lastDisplayId) {
1232+
lastDisplayId = currentDisplay.id;
1233+
applyingZoom = true;
1234+
try {
1235+
applyAutoZoom(window);
1236+
} finally {
1237+
applyingZoom = false;
1238+
}
1239+
}
1240+
});
1241+
1242+
window.on("unmaximize", () => {
1243+
if (window.isDestroyed() || applyingZoom) return;
1244+
applyingZoom = true;
1245+
try {
1246+
applyAutoZoom(window);
1247+
lastDisplayId = screen.getDisplayMatching(window.getBounds()).id;
1248+
} finally {
1249+
applyingZoom = false;
1250+
}
1251+
});
1252+
window.on("leave-full-screen", () => {
1253+
if (window.isDestroyed() || applyingZoom) return;
1254+
applyingZoom = true;
1255+
try {
1256+
applyAutoZoom(window);
1257+
lastDisplayId = screen.getDisplayMatching(window.getBounds()).id;
1258+
} finally {
1259+
applyingZoom = false;
1260+
}
1261+
});
1262+
window.on("resize", () => {
1263+
if (window.isDestroyed() || applyingZoom) return;
1264+
const currentDisplay = screen.getDisplayMatching(window.getBounds());
1265+
if (currentDisplay.id !== lastDisplayId) {
1266+
lastDisplayId = currentDisplay.id;
1267+
applyingZoom = true;
1268+
try {
1269+
applyAutoZoom(window);
1270+
} finally {
1271+
applyingZoom = false;
1272+
}
1273+
}
1274+
});
1275+
11261276
window.once("ready-to-show", () => {
1277+
applyAutoZoom(window);
11271278
window.show();
11281279
});
11291280

@@ -1182,6 +1333,12 @@ app
11821333
configureApplicationMenu();
11831334
registerDesktopProtocol();
11841335
configureAutoUpdater();
1336+
1337+
screen.on("display-metrics-changed", () => {
1338+
for (const win of BrowserWindow.getAllWindows()) {
1339+
applyAutoZoom(win);
1340+
}
1341+
});
11851342
void bootstrap().catch((error) => {
11861343
handleFatalStartupError("bootstrap", error);
11871344
});

0 commit comments

Comments
 (0)