From e60983a69730c058ce772829df3273aee6de3889 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 28 Aug 2026 14:28:26 -0500 Subject: [PATCH] fix(desktop): let the HUD drag onto another monitor Composer drag added renderer CSS-pixel deltas onto a window AppKit clamps to the current display, so the bar could not follow the cursor onto a second monitor (and drifted on mixed-DPI Windows). Track the OS cursor in main and lift that clamp. Co-authored-by: Biotrioo --- apps/desktop/electron/hud-drag.test.ts | 43 ++++++++ apps/desktop/electron/hud-drag.ts | 37 +++++++ apps/desktop/electron/hud-ipc.ts | 52 ++++++--- apps/desktop/electron/main.ts | 5 + apps/desktop/electron/preload.ts | 2 + .../desktop/src/app/hud/composer-drag.test.ts | 56 +++++++++- apps/desktop/src/app/hud/composer-drag.ts | 100 +++++++++++------- apps/desktop/src/global.d.ts | 4 +- 8 files changed, 243 insertions(+), 56 deletions(-) create mode 100644 apps/desktop/electron/hud-drag.test.ts create mode 100644 apps/desktop/electron/hud-drag.ts diff --git a/apps/desktop/electron/hud-drag.test.ts b/apps/desktop/electron/hud-drag.test.ts new file mode 100644 index 0000000000..52e0ece2b9 --- /dev/null +++ b/apps/desktop/electron/hud-drag.test.ts @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { createHudDragSession } from './hud-drag' + +test('origin is cursor minus the grab offset captured at press', () => { + const session = createHudDragSession() + + session.begin({ x: 500, y: 400 }, { x: 190, y: 80 }) + assert.deepEqual(session.origin({ x: 540, y: 410 }), { x: 230, y: 90 }) +}) + +test('a drag onto another display does not depend on the previous window origin', () => { + const session = createHudDragSession() + + // Primary display is 1920px wide. Grab while the bar sits near the right edge. + session.begin({ x: 1880, y: 400 }, { x: 1570, y: 80 }) + + // Cursor is now on the secondary display. Even if AppKit clamped the last + // setBounds so the window never left 1920, the next origin is still on the + // other monitor — not getPosition() plus a CSS-pixel delta. + assert.deepEqual(session.origin({ x: 2100, y: 400 }), { x: 1790, y: 80 }) + + session.end() + assert.equal(session.origin({ x: 2100, y: 400 }), null) +}) + +test('crossing a mixed-DPI display keeps 1:1 tracking in native DIP', () => { + const session = createHudDragSession() + + session.begin({ x: 1900, y: 400 }, { x: 1280, y: 200 }) + + // Electron samples the cursor after the scale-factor transition. No renderer + // CSS conversion is involved; the window origin moves by the same DIP delta. + assert.deepEqual(session.origin({ x: 1940, y: 400 }), { x: 1320, y: 200 }) +}) + +test('a move before begin is ignored so a stale pointer cannot jump the HUD', () => { + const session = createHudDragSession() + + assert.equal(session.origin({ x: 10, y: 20 }), null) +}) diff --git a/apps/desktop/electron/hud-drag.ts b/apps/desktop/electron/hud-drag.ts new file mode 100644 index 0000000000..eecfd1e985 --- /dev/null +++ b/apps/desktop/electron/hud-drag.ts @@ -0,0 +1,37 @@ +/** + * HUD composer-drag geometry — where to park the window so it stays under the + * OS cursor, including across monitors. + * + * Renderer PointerEvent screen coordinates are CSS pixels. Electron's + * `screen.getCursorScreenPoint()` and `BrowserWindow` bounds are DIP. An + * absolute grab offset sampled in main stays 1:1 across mixed-DPI displays, + * even if the last setBounds was clamped to the previous monitor. + */ + +export interface HudDragPoint { + x: number + y: number +} + +export function createHudDragSession() { + let offset: HudDragPoint | null = null + + return { + begin(cursor: HudDragPoint, windowOrigin: HudDragPoint) { + offset = { x: cursor.x - windowOrigin.x, y: cursor.y - windowOrigin.y } + }, + origin(cursor: HudDragPoint): HudDragPoint | null { + if (!offset) { + return null + } + + return { + x: Math.round(cursor.x - offset.x), + y: Math.round(cursor.y - offset.y) + } + }, + end() { + offset = null + } + } +} diff --git a/apps/desktop/electron/hud-ipc.ts b/apps/desktop/electron/hud-ipc.ts index dc9066b239..63f64ab6cd 100644 --- a/apps/desktop/electron/hud-ipc.ts +++ b/apps/desktop/electron/hud-ipc.ts @@ -2,8 +2,9 @@ // from main.ts; the HUD window handle and session-id latch stay injected // because main.ts owns the window lifecycle and the close broadcast reads the // latch when handing the session back to the app window. -import { type BrowserWindow, ipcMain } from 'electron' +import { type BrowserWindow, ipcMain, screen } from 'electron' +import { createHudDragSession } from './hud-drag' import { normalizeHudResizeBounds } from './hud-geometry' import { hudWindowingView, resolveHudWindowing } from './hud-windowing' import { hudFrostFor, type TranslucencyState } from './translucency' @@ -32,6 +33,8 @@ export function registerHudIpc({ resetHudLayout, setHudSessionId }: HudIpcDeps) { + const hudDrag = createHudDragSession() + // The renderer needs this before first paint so X11 never installs the // Chromium drag region that steals modifier-drag gestures from the WM. // Main answers because it owns the actual Ozone backend selection. @@ -170,6 +173,32 @@ export function registerHudIpc({ hudWindow.setIgnoreMouseEvents(Boolean(ignore), { forward: true }) }) + ipcMain.on('hermes:hud:begin-move', event => { + const hudWindow = getHudWindow() + + if ( + !hudWindow || + hudWindow.isDestroyed() || + event.sender !== hudWindow.webContents || + !hudWindowing().clientPlacement + ) { + return + } + + const [x, y] = hudWindow.getPosition() + hudDrag.begin(screen.getCursorScreenPoint(), { x, y }) + }) + + ipcMain.on('hermes:hud:end-move', event => { + const hudWindow = getHudWindow() + + if (hudWindow && !hudWindow.isDestroyed() && event.sender !== hudWindow.webContents) { + return + } + + hudDrag.end() + }) + ipcMain.on('hermes:hud:move-by', (event, delta) => { const hudWindow = getHudWindow() @@ -177,28 +206,27 @@ export function registerHudIpc({ return } - const dx = Number(delta?.x) - const dy = Number(delta?.y) const width = Number(delta?.width) const height = Number(delta?.height) - if (!Number.isFinite(dx) || !Number.isFinite(dy) || !Number.isFinite(width) || !Number.isFinite(height)) { + if (!Number.isFinite(width) || !Number.isFinite(height) || !hudWindowing().clientPlacement) { return } - const [x, y] = hudWindow.getPosition() + const origin = hudDrag.origin(screen.getCursorScreenPoint()) - if (!hudWindowing().clientPlacement) { + if (!origin) { return } - // setBounds — NOT setPosition: on Windows, a transparent frameless window - // silently grows ~1px per setPosition call (worse at >100% DPI). The renderer - // snapshots outerWidth/outerHeight when the composer drag arms and re-pins - // to that size on every moveBy (same pattern as the pet overlay drag). + // Cursor − grab offset in Electron DIP (see hud-drag.ts). setBounds — + // NOT setPosition: on Windows, a transparent frameless window silently + // grows ~1px per setPosition call (worse at >100% DPI). The renderer + // snapshots outerWidth/outerHeight when the composer drag arms and + // re-pins to that size on every move (same pattern as the pet overlay). hudWindow.setBounds({ - x: Math.round(x + dx), - y: Math.round(y + dy), + x: origin.x, + y: origin.y, width: Math.round(width), height: Math.round(height) }) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 4b339db9b2..fbf80f6bc9 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -13533,6 +13533,11 @@ function spawnHudWindow(sessionId, profile) { // `hermes:hud:set-bounds`, which flips resizable on for the call — the // same pattern the pet overlay uses for its wheel-scale. resizable: false, + // macOS AppKit's constrainFrameRect clamps setBounds to the current + // display unless this is on. The HUD is moved by renderer-driven + // setBounds (not a native titlebar drag), so without it the bar cannot + // be dragged onto another monitor. No-op on Windows/Linux. + enableLargerThanScreen: true, movable: true, minimizable: false, maximizable: false, diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index a6b48e1218..7e5526c00e 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -89,6 +89,8 @@ contextBridge.exposeInMainWorld('hermesDesktop', { open: request => ipcRenderer.invoke('hermes:hud:open', request), close: () => ipcRenderer.invoke('hermes:hud:close'), setIgnoreMouse: ignore => ipcRenderer.send('hermes:hud:ignore-mouse', ignore), + beginMove: () => ipcRenderer.send('hermes:hud:begin-move'), + endMove: () => ipcRenderer.send('hermes:hud:end-move'), moveBy: delta => ipcRenderer.send('hermes:hud:move-by', delta), setWorkspaceTransfer: transferring => ipcRenderer.send('hermes:hud:workspace-transfer', transferring), setBounds: bounds => ipcRenderer.send('hermes:hud:set-bounds', bounds), diff --git a/apps/desktop/src/app/hud/composer-drag.test.ts b/apps/desktop/src/app/hud/composer-drag.test.ts index f3f633bc95..aa58947e61 100644 --- a/apps/desktop/src/app/hud/composer-drag.test.ts +++ b/apps/desktop/src/app/hud/composer-drag.test.ts @@ -10,6 +10,8 @@ const LONG_PRESS_MS = 140 const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] } const initialHermesDesktop = desktopWindow.hermesDesktop +const beginMove = vi.fn() +const endMove = vi.fn() const moveBy = vi.fn() const setWorkspaceTransfer = vi.fn() @@ -31,10 +33,14 @@ function pressTarget() { beforeEach(() => { vi.useFakeTimers() + beginMove.mockClear() + endMove.mockClear() moveBy.mockClear() setWorkspaceTransfer.mockClear() setWindowSize(620, 320) - desktopWindow.hermesDesktop = { hud: { moveBy, setWorkspaceTransfer } } as unknown as Window['hermesDesktop'] + desktopWindow.hermesDesktop = { + hud: { beginMove, endMove, moveBy, setWorkspaceTransfer } + } as unknown as Window['hermesDesktop'] }) afterEach(() => { @@ -65,7 +71,8 @@ describe('useHudComposerDrag', () => { act(() => void vi.advanceTimersByTime(LONG_PRESS_MS)) act(() => void window.dispatchEvent(new PointerEvent('pointermove', { pointerId: 1, screenX: 110, screenY: 210 }))) - expect(moveBy).toHaveBeenCalledWith({ x: 10, y: 10, width: 620, height: 320 }) + expect(beginMove).toHaveBeenCalledTimes(1) + expect(moveBy).toHaveBeenCalledWith({ width: 620, height: 320 }) expect(setWorkspaceTransfer).not.toHaveBeenCalled() // A window that drifted wider mid-drag must not feed its new size back in — @@ -73,7 +80,11 @@ describe('useHudComposerDrag', () => { setWindowSize(900, 500) act(() => void window.dispatchEvent(new PointerEvent('pointermove', { pointerId: 1, screenX: 115, screenY: 215 }))) - expect(moveBy).toHaveBeenLastCalledWith({ x: 5, y: 5, width: 620, height: 320 }) + expect(moveBy).toHaveBeenLastCalledWith({ width: 620, height: 320 }) + + act(() => void window.dispatchEvent(new PointerEvent('pointerup', { pointerId: 1 }))) + + expect(endMove).toHaveBeenCalledTimes(1) }) it('does not move the window until the hold arms', () => { @@ -92,6 +103,7 @@ describe('useHudComposerDrag', () => { act(() => void window.dispatchEvent(new PointerEvent('pointermove', { pointerId: 1, screenX: 102, screenY: 201 }))) expect(moveBy).not.toHaveBeenCalled() + expect(beginMove).not.toHaveBeenCalled() expect(setWorkspaceTransfer).not.toHaveBeenCalled() }) @@ -113,10 +125,12 @@ describe('useHudComposerDrag', () => { ) expect(setWorkspaceTransfer).toHaveBeenLastCalledWith(true) + expect(beginMove).toHaveBeenCalledTimes(1) act(() => void window.dispatchEvent(new PointerEvent('pointerup', { pointerId: 9 }))) expect(setWorkspaceTransfer).toHaveBeenLastCalledWith(false) + expect(endMove).toHaveBeenCalledTimes(1) }) it('moves immediately with Ctrl over selected text without destroying the selection', () => { @@ -177,8 +191,42 @@ describe('useHudComposerDrag', () => { ) ) - expect(moveBy).toHaveBeenCalledWith({ x: 1, y: 2, width: 620, height: 320 }) + expect(moveBy).toHaveBeenCalledWith({ width: 620, height: 320 }) expect(document.activeElement).toBe(editor) expect(selection.toString()).toBe('selected text') }) + + it('keeps the grab alive when crossing a display cancels the pointer', () => { + const target = pressTarget() + const { result } = renderHook(() => useHudComposerDrag(true)) + + act(() => + result.current.onPointerDown({ + button: 0, + currentTarget: target, + pointerId: 3, + screenX: 1880, + screenY: 400 + } as never) + ) + act(() => void vi.advanceTimersByTime(LONG_PRESS_MS)) + act(() => void window.dispatchEvent(new PointerEvent('pointermove', { pointerId: 3, screenX: 1900, screenY: 400 }))) + + expect(beginMove).toHaveBeenCalledTimes(1) + expect(endMove).not.toHaveBeenCalled() + + act(() => + void window.dispatchEvent( + new PointerEvent('pointercancel', { cancelable: true, pointerId: 3, screenX: 2100, screenY: 400 }) + ) + ) + + expect(endMove).not.toHaveBeenCalled() + expect(moveBy).toHaveBeenLastCalledWith({ width: 620, height: 320 }) + expect(target.setPointerCapture).toHaveBeenCalled() + + act(() => void window.dispatchEvent(new MouseEvent('mouseup'))) + + expect(endMove).toHaveBeenCalledTimes(1) + }) }) diff --git a/apps/desktop/src/app/hud/composer-drag.ts b/apps/desktop/src/app/hud/composer-drag.ts index 603e920e45..8ea9f74932 100644 --- a/apps/desktop/src/app/hud/composer-drag.ts +++ b/apps/desktop/src/app/hud/composer-drag.ts @@ -10,8 +10,6 @@ const MOVE_TOLERANCE = 8 interface PressState { armed: boolean - lastX: number - lastY: number mode: 'control' | 'hold' originH: number originW: number @@ -55,6 +53,24 @@ function setWorkspaceTransfer(transferring: boolean): void { window.hermesDesktop?.hud?.setWorkspaceTransfer?.(transferring) } +function moveHud(state: PressState): void { + window.hermesDesktop?.hud?.moveBy?.({ + width: state.originW, + height: state.originH + }) +} + +function armGrab(state: PressState, workspaceTransfer: boolean): void { + state.armed = true + state.workspaceTransfer = workspaceTransfer + + if (workspaceTransfer) { + setWorkspaceTransfer(true) + } + + window.hermesDesktop?.hud?.beginMove?.() +} + /** * HUD-only: press and hold the composer, then drag to move the window. On X11, * Ctrl+primary-button is an immediate grab that also works over selected text. @@ -68,13 +84,15 @@ function setWorkspaceTransfer(transferring: boolean): void { * because apps cannot place their own top-level surfaces there. X11 stays on * this renderer path and additionally supports an immediate Ctrl-drag. * - * Deltas are read in SCREEN coordinates. Client coordinates are relative to the - * window we are moving, so a window that keeps up with the cursor reports the - * same clientX every frame — zero delta, and the drag dies one pixel in. + * The renderer only owns hold detection and the size snapshot. Once armed, + * main samples the native cursor and parks the window at cursor minus grab + * offset (see hud-drag.ts). Client coordinates are relative to the window we + * are moving, so a window that keeps up reports the same clientX every frame. * * The size is snapshotted at press and sent with every move, so main can pin it * (see hermes:hud:move-by — a transparent frameless window drifts wider on - * Windows otherwise). Same shape as the pet overlay's drag. + * Windows otherwise). Crossing a display can fire pointercancel; that must + * not end the grab, or the bar sticks on the first monitor. */ export function useHudComposerDrag( enabled: boolean, @@ -93,6 +111,10 @@ export function useHudComposerDrag( const state = stateRef.current if (state) { + if (state.armed) { + window.hermesDesktop?.hud?.endMove?.() + } + if (state.workspaceTransfer) { setWorkspaceTransfer(false) } @@ -122,9 +144,7 @@ export function useHudComposerDrag( } const state: PressState = { - armed: immediate, - lastX: event.screenX, - lastY: event.screenY, + armed: false, mode: immediate ? 'control' : 'hold', originH: window.outerHeight, originW: window.outerWidth, @@ -142,12 +162,7 @@ export function useHudComposerDrag( } if (immediate) { - state.workspaceTransfer = workspaceTransfer - - if (workspaceTransfer) { - setWorkspaceTransfer(true) - } - + armGrab(state, workspaceTransfer) setGrabbing(true) triggerHaptic('selection') capturePointer(state) @@ -162,13 +177,7 @@ export function useHudComposerDrag( return } - state.armed = true - state.workspaceTransfer = workspaceTransfer - - if (workspaceTransfer) { - setWorkspaceTransfer(true) - } - + armGrab(state, workspaceTransfer) setGrabbing(true) triggerHaptic('selection') @@ -208,25 +217,14 @@ export function useHudComposerDrag( } event.preventDefault() - - const dx = event.screenX - state.lastX - const dy = event.screenY - state.lastY - - state.lastX = event.screenX - state.lastY = event.screenY - - window.hermesDesktop?.hud?.moveBy?.({ - x: dx, - y: dy, - width: state.originW, - height: state.originH - }) + moveHud(state) } - const onUp = (event: PointerEvent) => { + const onUp = (event: PointerEvent | MouseEvent) => { const state = stateRef.current + const pointerId = 'pointerId' in event ? event.pointerId : state?.pointerId - if (!state || event.pointerId !== state.pointerId) { + if (!state || pointerId !== state.pointerId) { return } @@ -238,6 +236,28 @@ export function useHudComposerDrag( reset() } + // Crossing a display often cancels the pointer without a matching up. + // Ending the grab there is what parks the HUD on the first monitor; snap + // to the native cursor and keep the hold so the next move (or mouseup) + // can finish on the other display. + const onCancel = (event: PointerEvent) => { + const state = stateRef.current + + if (!state || event.pointerId !== state.pointerId) { + return + } + + if (!state.armed) { + reset() + + return + } + + event.preventDefault() + moveHud(state) + capturePointer(state) + } + const preventEditorGesture = (event: Event) => { if (stateRef.current?.mode === 'control') { event.preventDefault() @@ -249,14 +269,16 @@ export function useHudComposerDrag( // `selectstart` stops the same press from replacing that range. window.addEventListener('pointermove', onMove, true) window.addEventListener('pointerup', onUp, true) - window.addEventListener('pointercancel', onUp, true) + window.addEventListener('mouseup', onUp, true) + window.addEventListener('pointercancel', onCancel, true) window.addEventListener('dragstart', preventEditorGesture, true) window.addEventListener('selectstart', preventEditorGesture, true) return () => { window.removeEventListener('pointermove', onMove, true) window.removeEventListener('pointerup', onUp, true) - window.removeEventListener('pointercancel', onUp, true) + window.removeEventListener('mouseup', onUp, true) + window.removeEventListener('pointercancel', onCancel, true) window.removeEventListener('dragstart', preventEditorGesture, true) window.removeEventListener('selectstart', preventEditorGesture, true) } diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 795d517333..bb09b46c48 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -106,7 +106,9 @@ declare global { open: (request?: { sessionId?: null | string; profile?: null | string }) => Promise<{ ok: boolean }> close: () => Promise<{ ok: boolean }> setIgnoreMouse: (ignore: boolean) => void - moveBy: (delta: { x: number; y: number; width: number; height: number }) => void + beginMove: () => void + endMove: () => void + moveBy: (delta: { width: number; height: number }) => void setWorkspaceTransfer?: (transferring: boolean) => void setBounds: (bounds: { x: number; y: number; width: number; height: number }) => void resetLayout: () => Promise<{ ok: boolean }>