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 <biotrioo@protonmail.com>
This commit is contained in:
Brooklyn Nicholson
2026-08-28 14:28:26 -05:00
committed by brooklyn!
parent a73b14c438
commit e60983a697
8 changed files with 243 additions and 56 deletions
+43
View File
@@ -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)
})
+37
View File
@@ -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
}
}
}
+40 -12
View File
@@ -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)
})
+5
View File
@@ -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,
+2
View File
@@ -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),
+52 -4
View File
@@ -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)
})
})
+61 -39
View File
@@ -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)
}
+3 -1
View File
@@ -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 }>