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:
committed by
brooklyn!
parent
a73b14c438
commit
e60983a697
@@ -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)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Vendored
+3
-1
@@ -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 }>
|
||||
|
||||
Reference in New Issue
Block a user