From d1d70c69cf10d55f9cf8228394544e9f0153caf1 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Tue, 6 Oct 2026 18:13:05 +0200 Subject: [PATCH 1/2] fix(hud): keep the HUD's size while it is dragged at fractional scaling (#1004) Electron's setPosition() re-applies the size read back from getBounds(), and on Windows both conversions between pixels and DIP round the size outwards. At 125 % every drag step grew the window by up to 2px, which slid the bar off the pointer and past the work-area clamp, under the taskbar. Move the HUD with setBounds() and its own DIP size, the one it was created or last resized with, never the size read back from the window. --- electron/windows.test.ts | 132 +++++++++++++++++++++++++++++++++++++++ electron/windows.ts | 37 ++++++++--- 2 files changed, 160 insertions(+), 9 deletions(-) create mode 100644 electron/windows.test.ts diff --git a/electron/windows.test.ts b/electron/windows.test.ts new file mode 100644 index 000000000..de7e2d8e6 --- /dev/null +++ b/electron/windows.test.ts @@ -0,0 +1,132 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +// Windows at 125 %, as in #1004: a 1920x1080 panel whose taskbar leaves a 1032px work area. +const WORK_AREA_BOTTOM_PX = 1032; +// The bar as LaunchWindow lays it out: centred, 20 CSS px above the window's bottom edge. +const BAR = { width: 560, height: 64, bottomInset: 20 }; + +const electron = vi.hoisted(() => { + type Rect = { x: number; y: number; width: number; height: number }; + const scale = 1.25; + // Chromium's DIP <-> pixel conversion on Windows (ui/display/win/screen_win.cc): the + // origin is rounded, the size is ScaleToEnclosingRect's, i.e. the span rounded outwards. + const enclosing = (start: number, size: number, factor: number) => + Math.ceil((start + size) * factor) - Math.floor(start * factor); + const convert = (rect: Rect, factor: number): Rect => ({ + x: Math.round(rect.x * factor), + y: Math.round(rect.y * factor), + width: enclosing(rect.x, rect.width, factor), + height: enclosing(rect.y, rect.height, factor), + }); + + /** A BrowserWindow as Electron drives one on Windows: only the pixel rect is real. */ + class FakeWindow { + pixels: Rect; + webContents = { on: vi.fn(), send: vi.fn() }; + setContentProtection = vi.fn(); + once = vi.fn(); + on = vi.fn(); + loadURL = vi.fn(); + loadFile = vi.fn(); + constructor(options: Rect) { + this.pixels = convert(options, scale); + } + getBounds() { + return convert(this.pixels, 1 / scale); + } + getPosition() { + const { x, y } = this.getBounds(); + return [x, y]; + } + setBounds(bounds: Rect) { + this.pixels = convert(bounds, scale); + } + // Electron's own implementation (shell/browser/native_window.cc). + setPosition(x: number, y: number) { + const { width, height } = this.getBounds(); + this.setBounds({ x, y, width, height }); + } + isDestroyed() { + return false; + } + isMinimized() { + return false; + } + } + + // 1920x1032 px of work area, enclosed in DIP the way Chromium reports it. + const display = { workArea: { x: 0, y: 0, width: 1536, height: 826 } }; + const handlers = new Map void>(); + return { scale, FakeWindow, display, handlers }; +}); + +vi.mock("electron", () => ({ + app: {}, + BrowserWindow: electron.FakeWindow, + ipcMain: { + on: (channel: string, handler: (event: unknown, ...args: unknown[]) => void) => + electron.handlers.set(channel, handler), + }, + screen: { + getPrimaryDisplay: () => electron.display, + getDisplayMatching: () => electron.display, + on: vi.fn(), + }, +})); + +const REAL_PLATFORM = process.platform; +let createHudOverlayWindow: typeof import("./windows").createHudOverlayWindow; + +beforeAll(async () => { + // windows.ts decides at import whether to clamp the bar or, on Linux, the whole window. + Object.defineProperty(process, "platform", { value: "win32", configurable: true }); + ({ createHudOverlayWindow } = await import("./windows")); +}); + +afterAll(() => { + Object.defineProperty(process, "platform", { value: REAL_PLATFORM, configurable: true }); +}); + +const send = (channel: string, ...args: unknown[]) => electron.handlers.get(channel)?.({}, ...args); + +/** A new HUD, sized by the renderer's first measurement: 944x705 DIP, 1180x882 px. */ +function openHud() { + const win = createHudOverlayWindow() as unknown as InstanceType; + const size = { width: 944, height: 705 }; + send("hud-overlay-set-size", size.width, size.height, { + x: (size.width - BAR.width) / 2, + y: size.height - BAR.bottomInset - BAR.height, + width: BAR.width, + height: BAR.height, + }); + return win; +} + +/** A drag by the handle in small steps; the renderer sends the total travel each time. */ +function drag(steps: number, stepX: number, stepY: number) { + send("hud-overlay-drag-start"); + for (let i = 1; i <= steps; i++) { + send("hud-overlay-drag-to", i * stepX, i * stepY); + } + send("hud-overlay-drag-end"); +} + +describe("HUD drag at 125 % scaling (#1004)", () => { + it("keeps the window's size", () => { + const win = openHud(); + const { width, height } = win.pixels; + drag(40, 1.6, -2.4); + // One DIP size lands on 1180 or 1181 px depending on where it starts: a pixel of + // wobble, never growth. + expect(Math.abs(win.pixels.width - width)).toBeLessThanOrEqual(1); + expect(Math.abs(win.pixels.height - height)).toBeLessThanOrEqual(1); + }); + + it("keeps the bar above the taskbar after a long drag down", () => { + const win = openHud(); + drag(40, 0.8, 40); + const barBottom = win.pixels.y + win.pixels.height - BAR.bottomInset * electron.scale; + // One row of slack: the work area Electron reports is enclosed in DIP (825.6 -> 826). + expect(barBottom).toBeLessThanOrEqual(WORK_AREA_BOTTOM_PX + 1); + }); +}); diff --git a/electron/windows.ts b/electron/windows.ts index 72f2fe938..fafac9a46 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -108,6 +108,22 @@ export const ASSET_BASE_URL_ARG = `--asset-base-url=${pathToFileURL(`${ASSET_BAS let hudOverlayWindow: BrowserWindow | null = null; +// The HUD's size in DIP, as last given: at creation, then by "hud-overlay-set-size". +// Every move re-applies this size, never one read back from the window, because the +// read-back drifts: on Windows, getBounds() converts the physical rect to DIP by +// enclosing it (rounding outwards) and setBounds() converts back the same way, so under +// fractional scaling each read-modify-write grows the window by a pixel or two. At 125 % +// a drag grew it on every step, sliding the bar off the pointer and under the taskbar +// (#1004). setPosition() is no way out: Electron implements it as setBounds() with the +// size from getBounds(). +let hudWindowSize = { width: 0, height: 0 }; + +/** The HUD's position, with its own size instead of the drifting read-back. */ +function hudBounds(win: BrowserWindow): Electron.Rectangle { + const [x, y] = win.getPosition(); + return { x, y, ...hudWindowSize }; +} + // Origin the current drag gesture started from. The renderer sends the pointer's // *total* travel since pointerdown rather than per-frame deltas, so every move is // an absolute `origin + delta` — no rounding to accumulate, and a dropped message @@ -139,7 +155,7 @@ function reclampHud() { const win = hudOverlayWindow; if (!win || win.isDestroyed() || win.isMinimized() || hudDragOrigin) return; - const bounds = win.getBounds(); + const bounds = hudBounds(win); const next = clampHudBoundsToWorkArea(bounds, hudContentRect, hudWorkAreaFor(bounds)); if (!sameRect(next, bounds)) { win.setBounds(next, false); @@ -247,9 +263,10 @@ ipcMain.on("hud-overlay-drag-start", () => { // Under Wayland this origin is a lie: Electron documents getPosition() as returning // [0, 0] there, because the protocol prohibits a client from introspecting or // setting its own global coordinates. The origin+delta scheme below therefore - // resolves against 0 rather than the window's real position, and setPosition() is - // itself a no-op — so dragging cannot work on Wayland by this route at all. The - // finiteness check only keeps a garbage origin from reaching a native setter. + // resolves against 0 rather than the window's real position, and the position + // setBounds() asks for is ignored — so dragging cannot work on Wayland by this + // route at all. The finiteness check only keeps a garbage origin from reaching a + // native setter. const [x, y] = hudOverlayWindow.getPosition(); hudDragOrigin = Number.isFinite(x) && Number.isFinite(y) ? { x, y } : null; }); @@ -272,14 +289,14 @@ ipcMain.on("hud-overlay-drag-to", (_event, deltaX: number, deltaY: number) => { // the bar is transparent reserve (see hudWindowBounds.ts), and clamping the // window instead would hand the bar the reserve's width and ~600px of height as // a margin it can never cross — the "stuck at the bottom of the screen" trap. - const bounds = hudOverlayWindow.getBounds(); + const bounds = hudBounds(hudOverlayWindow); const destination = hudDragDestination({ bounds, origin: hudDragOrigin, deltaX, deltaY }); const next = clampHudBoundsToWorkArea(destination, hudContentRect, hudWorkAreaFor(destination)); - // Position only: a per-frame setBounds round-trips the size through DIP rounding, - // which can creep it a pixel at a time under fractional scaling. - hudOverlayWindow.setPosition(next.x, next.y, false); + // setBounds with the HUD's own size, not setPosition, which re-applies the size read + // back from the window and grows it every frame under fractional scaling. + hudOverlayWindow.setBounds(next, false); }); ipcMain.on("hud-overlay-drag-end", () => { @@ -316,7 +333,7 @@ ipcMain.on("hud-overlay-set-size", (_event, width: number, height: number, conte return; } - const bounds = hudOverlayWindow.getBounds(); + const bounds = hudBounds(hudOverlayWindow); const nextContent = HUD_CLAMPS_CONTENT ? parseHudContentRect(content) : null; const next = hudResizeBounds({ bounds, @@ -331,6 +348,7 @@ ipcMain.on("hud-overlay-set-size", (_event, width: number, height: number, conte if (nextContent) { hudContentRect = nextContent; } + hudWindowSize = { width: next.width, height: next.height }; if (!sameRect(next, bounds)) { hudOverlayWindow.setBounds(next, false); @@ -426,6 +444,7 @@ export function createHudOverlayWindow(): BrowserWindow { }); hudOverlayWindow = win; + hudWindowSize = { width: windowWidth, height: windowHeight }; watchHudWorkAreaChanges(); // Display changes while minimized were skipped; catch up on the way back. win.on("restore", reclampHud); From 8f019affdf0c7c0bda8205db8914fc500852c2c4 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Tue, 6 Oct 2026 21:55:14 +0200 Subject: [PATCH 2/2] fix(hud): refuse an empty size from the renderer --- electron/windows.test.ts | 9 +++++++++ electron/windows.ts | 6 +++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/electron/windows.test.ts b/electron/windows.test.ts index de7e2d8e6..685195e19 100644 --- a/electron/windows.test.ts +++ b/electron/windows.test.ts @@ -129,4 +129,13 @@ describe("HUD drag at 125 % scaling (#1004)", () => { // One row of slack: the work area Electron reports is enclosed in DIP (825.6 -> 826). expect(barBottom).toBeLessThanOrEqual(WORK_AREA_BOTTOM_PX + 1); }); + + it("ignores an empty size instead of carrying it into the next move", () => { + const win = openHud(); + const { width, height } = win.pixels; + send("hud-overlay-set-size", 0, 0, null); + drag(4, 2, 0); + expect(Math.abs(win.pixels.width - width)).toBeLessThanOrEqual(1); + expect(Math.abs(win.pixels.height - height)).toBeLessThanOrEqual(1); + }); }); diff --git a/electron/windows.ts b/electron/windows.ts index fafac9a46..8273b5b9b 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -317,11 +317,15 @@ ipcMain.on("hud-overlay-drag-end", () => { // snaps anyway — tweening the window across 10 frames just meant 10 frames of the // bar sitting at an offset that didn't match the content it was drawn with. ipcMain.on("hud-overlay-set-size", (_event, width: number, height: number, content: unknown) => { + // An empty size is refused too: it would be stored as the HUD's own size and re-applied by + // every later move. if ( !hudOverlayWindow || hudOverlayWindow.isDestroyed() || !Number.isFinite(width) || - !Number.isFinite(height) + !Number.isFinite(height) || + width <= 0 || + height <= 0 ) { return; }