fix(desktop): keep the HUD clickable on Linux

Click-through decides whether to swallow the mouse by hit-testing the document
under the cursor, and it learns where the cursor is from mousemove. Those keep
arriving while the window ignores the mouse only because of
`setIgnoreMouseEvents(true, { forward: true })`, and `forward` is
`@platform darwin,win32`. On Linux the moves stop the instant the HUD turns
click-through, so it never sees the pointer return to the bar: the bar is
visible, and clicking it hits whatever is behind.

Main can still see the cursor, so on Linux it polls and pushes the position to
the renderer, which runs its usual hit test on it. The decision and its rules
stay in one place — only the courier for that one input changes — and off-window
is sent as null, which is already how the renderer hands the mouse back.
This commit is contained in:
Brooklyn Nicholson 2026-08-08 22:15:24 -05:00
parent d73bc7f17a
commit da933bf279
6 changed files with 198 additions and 1 deletions

View File

@ -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 })
}
})

View File

@ -0,0 +1,65 @@
/**
* Cursorpage 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 }
}

View File

@ -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()

View File

@ -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

View File

@ -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<HTMLElement | null>): 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<HTMLElement | null>): void
return () => {
setIgnoreMouse(false)
offCursor?.()
window.removeEventListener('mousemove', onMove)
window.removeEventListener('blur', onLost)
window.removeEventListener('focus', apply)

View File

@ -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