diff --git a/apps/desktop/electron/hud-cursor.test.ts b/apps/desktop/electron/hud-cursor.test.ts new file mode 100644 index 0000000000000..9eb0b5fdd92d0 --- /dev/null +++ b/apps/desktop/electron/hud-cursor.test.ts @@ -0,0 +1,50 @@ +/** + * Unit tests for the HUD's Linux cursor conversion. These cover the two things + * that decide whether the bar is clickable: landing on the right point, and + * admitting when the cursor has left. + */ + +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { cursorPointInWindow } from './hud-cursor' + +// A HUD parked 300px right and 200px down, 620×320 as it is created. +const BOUNDS = { x: 300, y: 200, width: 620, height: 320 } + +test('screen cursor becomes a point relative to the window', () => { + assert.deepEqual(cursorPointInWindow({ x: 300, y: 200 }, BOUNDS, 1), { x: 0, y: 0 }) + assert.deepEqual(cursorPointInWindow({ x: 460, y: 240 }, BOUNDS, 1), { x: 160, y: 40 }) +}) + +test('a zoomed page reports CSS pixels, not device-independent ones', () => { + // At 0.9 the page is wider than the window in CSS pixels, so a point half way + // across the window is further than half way across the document. + assert.deepEqual(cursorPointInWindow({ x: 610, y: 380 }, BOUNDS, 0.9), { + x: 310 / 0.9, + y: 180 / 0.9 + }) +}) + +test('a cursor outside the window is reported as lost', () => { + for (const outside of [ + { x: 299, y: 300 }, + { x: 500, y: 199 }, + { x: 920, y: 300 }, + { x: 500, y: 520 } + ]) { + assert.equal(cursorPointInWindow(outside, BOUNDS, 1), null) + } +}) + +test('the far edges belong to the window on entry, not on exit', () => { + assert.deepEqual(cursorPointInWindow({ x: 919, y: 519 }, BOUNDS, 1), { x: 619, y: 319 }) + assert.equal(cursorPointInWindow({ x: 920, y: 520 }, BOUNDS, 1), null) +}) + +test('a nonsense zoom factor is treated as unzoomed rather than poisoning the point', () => { + for (const bad of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) { + assert.deepEqual(cursorPointInWindow({ x: 460, y: 240 }, BOUNDS, bad), { x: 160, y: 40 }) + } +}) diff --git a/apps/desktop/electron/hud-cursor.ts b/apps/desktop/electron/hud-cursor.ts new file mode 100644 index 0000000000000..a37c7d5a1cab0 --- /dev/null +++ b/apps/desktop/electron/hud-cursor.ts @@ -0,0 +1,65 @@ +/** + * Cursor→page coordinate math for the HUD's Linux click-through fallback. + * + * The HUD decides whether to swallow the mouse by hit-testing the document + * under the cursor, which needs a cursor position in CSS pixels. On macOS and + * Windows that arrives for free: `setIgnoreMouseEvents(true, { forward: true })` + * keeps mousemove flowing to the page even while the window is ignoring, so the + * renderer can always see where the pointer is and re-arm when it returns to + * the bar. `forward` is `@platform darwin,win32`. On Linux the moves stop the + * instant the window starts ignoring, the renderer's last known point freezes, + * and the HUD can never decide to be solid again — the bar goes permanently + * untouchable. + * + * Main can still see the cursor, so on Linux it polls and pushes the position + * in. This is the conversion that push needs, kept pure and separate because + * the two unit mismatches in it are exactly what silently makes a hit test miss + * by a hand's width. + */ + +interface Point { + x: number + y: number +} + +interface Bounds { + x: number + y: number + width: number + height: number +} + +/** + * Screen-space cursor → the window's CSS pixel coordinates, or null when the + * cursor is outside the window. + * + * Two conversions, both load-bearing: + * + * - Screen to window: Electron reports the cursor in screen space and the + * window has its own origin, so the window's position has to come off + * first. + * - DIP to CSS: `getCursorScreenPoint()` and `getBounds()` both speak + * device-independent pixels, and `elementFromPoint` speaks CSS pixels. Those + * are the same number only at zoom 1. Anyone who has pressed ⌘− is reading + * a smaller page than the window is wide, and an unscaled point lands + * progressively further from the cursor the further it is from the origin. + * + * Returning null outside the window matters as much as the arithmetic: it is + * the renderer's signal that it has lost the cursor, which is what hands the + * window back to the app underneath instead of leaving it solid on a stale + * point. + */ +export function cursorPointInWindow(cursor: Point, bounds: Bounds, zoomFactor: number): Point | null { + const dx = cursor.x - bounds.x + const dy = cursor.y - bounds.y + + if (dx < 0 || dy < 0 || dx >= bounds.width || dy >= bounds.height) { + return null + } + + // A zero or bogus factor would divide the point into infinity; treat anything + // non-positive as unzoomed rather than poisoning the hit test. + const scale = Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1 + + return { x: dx / scale, y: dy / scale } +} diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index a1d3b705d2e4d..e45788cadb261 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -134,6 +134,7 @@ import { resolveTimeoutMs, TEXT_PREVIEW_SOURCE_MAX_BYTES } from './hardening' +import { cursorPointInWindow } from './hud-cursor' import { createLinkTitleWindow, guardLinkTitleSession, readLinkTitleWindowTitle } from './link-title-window' import { ensureMainWindow } from './main-window-lifecycle' import { @@ -9101,6 +9102,60 @@ function persistHudState() { const schedulePersistHudState = debounce(persistHudState, 250) +// How often Linux gets told where the cursor is. Fast enough that the bar is +// solid before a click lands after the pointer arrives, cheap enough to leave +// running for as long as the HUD is open — it is one `getCursorScreenPoint()` +// and, when the answer has not changed, nothing else. +const HUD_CURSOR_POLL_MS = 60 + +/** + * Feed the HUD renderer the cursor position on Linux. + * + * Everywhere else the renderer learns this from mousemove, which keeps arriving + * while the window ignores the mouse because we pass `{ forward: true }`. That + * option is macOS/Windows only. Without it a Linux HUD stops hearing the + * pointer the moment it turns click-through, so it can never notice the pointer + * coming back and stays transparent — the bar is there, and clicking it hits + * whatever is behind. Main can still see the cursor, so it says so. + * + * Deliberately the same decision, just a different source for one input: the + * renderer runs its usual hit test on the point it is handed. Re-deciding + * anything here would put a second, drifting copy of the click-through rules in + * the main process. + */ +function startHudCursorFeed(win: BrowserWindow) { + if (process.platform !== 'linux') { + return + } + + let last: string | null = null + + const timer = setInterval(() => { + if (win.isDestroyed() || !win.isVisible()) { + return + } + + const point = cursorPointInWindow( + screen.getCursorScreenPoint(), + win.getBounds(), + win.webContents.getZoomFactor() + ) + // Off-window is a real answer (it is what hands the mouse back), so it is + // sent — once. Only an unchanged answer is dropped, to keep an idle cursor + // from waking the renderer 16 times a second. + const key = point ? `${Math.round(point.x)},${Math.round(point.y)}` : 'out' + + if (key === last) { + return + } + + last = key + win.webContents.send('hermes:hud:cursor', point) + }, HUD_CURSOR_POLL_MS) + + win.on('closed', () => clearInterval(timer)) +} + function hudBounds() { // Remembered spot first — validated against the LIVE displays so a HUD // parked on an unplugged monitor comes back on-screen instead of lost. @@ -9223,6 +9278,8 @@ function spawnHudWindow(sessionId) { // times mid-drag). bindGeometryPersistence(win, schedulePersistHudState) + startHudCursorFeed(win) + wireWindowReveal(win, { show: () => { win.show() diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 792ee07ba05f3..0f18998117172 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -69,6 +69,16 @@ contextBridge.exposeInMainWorld('hermesDesktop', { ipcRenderer.on('hermes:hud:changed', listener) return () => ipcRenderer.removeListener('hermes:hud:changed', listener) + }, + // Linux only, and silent elsewhere: where the cursor is, in page + // coordinates, or null when it has left the window. Stands in for the + // mousemove that `setIgnoreMouseEvents(true, { forward: true })` delivers on + // macOS and Windows but not here. + onCursor: callback => { + const listener = (_event, point) => callback(point) + ipcRenderer.on('hermes:hud:cursor', listener) + + return () => ipcRenderer.removeListener('hermes:hud:cursor', listener) } }, // Quick Entry: the global-hotkey mini composer window. Main owns the OS diff --git a/apps/desktop/src/app/hud/click-through.ts b/apps/desktop/src/app/hud/click-through.ts index 9990658e2b55c..9ab21e5c3eeaa 100644 --- a/apps/desktop/src/app/hud/click-through.ts +++ b/apps/desktop/src/app/hud/click-through.ts @@ -58,7 +58,9 @@ export function hudIgnoresMouse( * interactive: wherever the cursor is over something the HUD paints, and * whenever a portalled overlay holds focus. `forward: true` keeps mousemove * flowing while ignoring, which is what lets it re-arm when the cursor comes - * back to the bar. + * back to the bar. That option is macOS/Windows only, so on Linux main polls + * the cursor and pushes it in through `onCursor` — the same point, the same + * decision, a different courier. * * It follows that nothing in HUD mode may declare `-webkit-app-region: drag` * at all: a draggable region swallows the page's mouse events, so the moves @@ -102,6 +104,17 @@ export function useHudClickThrough(rootRef: RefObject): void apply() } + // Linux's stand-in for mousemove, pushed from main because `forward` is not + // supported there and the moves stop the moment the window starts ignoring + // — leaving the bar permanently click-through. Same decision, same hit + // test; only where the point came from differs, and on macOS and Windows + // this never fires. `null` is the cursor leaving the window, which is the + // `onLost` answer. + const offCursor = window.hermesDesktop?.hud?.onCursor?.(next => { + point = next + apply() + }) + // Whenever we stop knowing where the cursor is, hand the window back. Solid // is only ever right under a cursor we can still see: the last point we saw // is usually the bar, and holding that answer means the whole rectangle @@ -123,6 +136,7 @@ export function useHudClickThrough(rootRef: RefObject): void return () => { setIgnoreMouse(false) + offCursor?.() window.removeEventListener('mousemove', onMove) window.removeEventListener('blur', onLost) window.removeEventListener('focus', apply) diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 7a7ba89b84e2f..c03c37b1df0e4 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -75,6 +75,7 @@ declare global { setSession: (sessionId: null | string) => void onGoto: (callback: (sessionId: string) => void) => () => void onChanged: (callback: (state: { open: boolean; sessionId: null | string }) => void) => () => void + onCursor: (callback: (point: { x: number; y: number } | null) => void) => () => void } // Quick Entry: a global-hotkey mini composer window. Main owns the OS // shortcut registration + the persisted preference (it must restore the