Merge pull request #83130 from NousResearch/bb/hud-snap-pointer

feat(desktop): snap HUD to cursor with global ⌘⇧G
This commit is contained in:
brooklyn! 2026-08-10 05:33:55 -05:00 committed by GitHub
commit 338bca7968
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 261 additions and 1 deletions

View File

@ -0,0 +1,55 @@
import { describe, expect, it, vi } from 'vitest'
import { createHudSnapShortcut, DEFAULT_HUD_SNAP_SHORTCUT } from './hud-snap-shortcut'
import type { GlobalShortcutLike } from './quick-entry'
function fakeGlobalShortcut(options: { register?: boolean; taken?: string[] } = {}) {
const held = new Set(options.taken ?? [])
const globalShortcut: GlobalShortcutLike = {
isRegistered: vi.fn((accelerator: string) => held.has(accelerator)),
register: vi.fn((accelerator: string, _callback: () => void) => {
if (options.register === false || held.has(accelerator)) {
return false
}
held.add(accelerator)
return true
}),
unregister: vi.fn((accelerator: string) => void held.delete(accelerator))
}
return { globalShortcut, held }
}
describe('createHudSnapShortcut', () => {
it('registers CommandOrControl+Shift+G while the HUD is up', () => {
const snap = vi.fn<() => void>()
const { globalShortcut } = fakeGlobalShortcut()
const controller = createHudSnapShortcut(globalShortcut, snap)
expect(controller.register()).toBe(true)
expect(globalShortcut.isRegistered(DEFAULT_HUD_SNAP_SHORTCUT)).toBe(true)
})
it('dispose releases the accelerator', () => {
const snap = vi.fn<() => void>()
const { globalShortcut } = fakeGlobalShortcut()
const controller = createHudSnapShortcut(globalShortcut, snap)
controller.register()
controller.dispose()
expect(globalShortcut.isRegistered(DEFAULT_HUD_SNAP_SHORTCUT)).toBe(false)
})
it('register fails when the chord is already taken', () => {
const snap = vi.fn<() => void>()
const { globalShortcut } = fakeGlobalShortcut({ taken: [DEFAULT_HUD_SNAP_SHORTCUT] })
const controller = createHudSnapShortcut(globalShortcut, snap)
expect(controller.register()).toBe(false)
})
})

View File

@ -0,0 +1,57 @@
/**
* HUD snap-to-pointer global OS shortcut while the HUD is open.
*
* Tap G (CommandOrControl+Shift+G) from any app to park the HUD bar under
* the cursor. Main owns registration same authority split as Quick Entry.
* Electron's globalShortcut is press-only (no keyup), so this is tap-to-snap,
* not hold-to-follow.
*/
import type { GlobalShortcutLike } from './quick-entry'
export const DEFAULT_HUD_SNAP_SHORTCUT = 'CommandOrControl+Shift+G'
export interface HudSnapShortcutController {
/** Register the global chord. Returns false when another app owns it. */
register(): boolean
/** Release the chord (HUD closed / quit). Idempotent. */
dispose(): void
}
export function createHudSnapShortcut(globalShortcut: GlobalShortcutLike, onSnap: () => void): HudSnapShortcutController {
let active: null | string = null
const release = () => {
if (active) {
try {
globalShortcut.unregister(active)
} catch {
// Best effort — a dead accelerator must not block re-register.
}
active = null
}
}
return {
register() {
release()
const accelerator = DEFAULT_HUD_SNAP_SHORTCUT
let ok = false
try {
ok = globalShortcut.isRegistered(accelerator) ? false : globalShortcut.register(accelerator, onSnap)
} catch {
ok = false
}
active = ok ? accelerator : null
return ok
},
dispose() {
release()
}
}
}

View File

@ -0,0 +1,34 @@
import assert from 'node:assert/strict'
import { test } from 'vitest'
import { clampHudOrigin, snapHudBounds, windowOriginForCursorAnchor } from './hud-snap'
const WORK = { x: 0, y: 25, width: 1440, height: 875 }
const SIZE = { width: 620, height: 320 }
test('anchor under cursor at zoom 1', () => {
assert.deepEqual(windowOriginForCursorAnchor({ x: 500, y: 400 }, { x: 310, y: 48 }, 1), { x: 190, y: 352 })
})
test('anchor scales with page zoom', () => {
assert.deepEqual(windowOriginForCursorAnchor({ x: 500, y: 400 }, { x: 100, y: 50 }, 0.9), {
x: Math.round(500 - 100 * 0.9),
y: Math.round(400 - 50 * 0.9)
})
})
test('clamp keeps a sliver visible when the anchor would park off-screen', () => {
const origin = windowOriginForCursorAnchor({ x: 10, y: 30 }, { x: 310, y: 48 }, 1)
const clamped = clampHudOrigin(origin, SIZE, WORK)
assert.ok(clamped.x >= WORK.x + 40 - SIZE.width)
assert.ok(clamped.y >= WORK.y + 40 - SIZE.height)
})
test('snapHudBounds composes origin + clamp', () => {
const point = snapHudBounds({ x: 720, y: 450 }, { x: 310, y: 48 }, SIZE, 1, WORK)
assert.equal(point.x, 410)
assert.equal(point.y, 402)
})

View File

@ -0,0 +1,64 @@
/**
* HUD snap-to-pointer math where to park the window so a chosen anchor on
* the bar (usually the composer center) sits under the OS cursor.
*
* Kept pure so the two unit mismatches (screen vs window origin, DIP vs CSS)
* cannot drift from the Linux cursor feed in hud-cursor.ts.
*/
interface Point {
x: number
y: number
}
interface Rect {
x: number
y: number
width: number
height: number
}
/**
* Window top-left in screen space (DIP) that places `anchor` a point in the
* window's CSS pixels under `cursor` (screen DIP).
*/
export function windowOriginForCursorAnchor(
cursor: Point,
anchor: Point,
zoomFactor: number
): Point {
const scale = Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1
return {
x: Math.round(cursor.x - anchor.x * scale),
y: Math.round(cursor.y - anchor.y * scale)
}
}
/**
* Keep as much of the window on-screen as possible while preserving the anchor
* under the cursor when there is room; when there is not, clamp the origin so
* at least a sliver of the window stays in the work area.
*/
export function clampHudOrigin(origin: Point, windowSize: Pick<Rect, 'width' | 'height'>, workArea: Rect): Point {
const minVisible = 40
const maxX = workArea.x + workArea.width - minVisible
const minX = workArea.x + minVisible - windowSize.width
const maxY = workArea.y + workArea.height - minVisible
const minY = workArea.y + minVisible - windowSize.height
return {
x: Math.min(Math.max(origin.x, minX), maxX),
y: Math.min(Math.max(origin.y, minY), maxY)
}
}
export function snapHudBounds(
cursor: Point,
anchor: Point,
windowSize: Pick<Rect, 'width' | 'height'>,
zoomFactor: number,
workArea: Rect
): Point {
return clampHudOrigin(windowOriginForCursorAnchor(cursor, anchor, zoomFactor), windowSize, workArea)
}

View File

@ -137,6 +137,8 @@ import {
TEXT_PREVIEW_SOURCE_MAX_BYTES
} from './hardening'
import { cursorPointInWindow } from './hud-cursor'
import { snapHudBounds } from './hud-snap'
import { createHudSnapShortcut } from './hud-snap-shortcut'
import { buildHudWindowUrl } from './hud-url'
import { createLinkTitleWindow, guardLinkTitleSession, readLinkTitleWindowTitle } from './link-title-window'
import { ensureMainWindow } from './main-window-lifecycle'
@ -9211,6 +9213,45 @@ const schedulePersistHudState = debounce(persistHudState, 250)
// and, when the answer has not changed, nothing else.
const HUD_CURSOR_POLL_MS = 60
// Snap-to-pointer — global ⌘⇧G while the HUD is open (tap, not hold).
const HUD_SNAP_ANCHOR_Y = 48
function applyHudSnapToPointer() {
if (!hudWindow || hudWindow.isDestroyed()) {
return
}
const cursor = screen.getCursorScreenPoint()
const bounds = hudWindow.getBounds()
const display = screen.getDisplayNearestPoint(cursor)
const workArea = display?.workArea ?? bounds
const anchor = { x: Math.round(bounds.width / 2), y: HUD_SNAP_ANCHOR_Y }
const origin = snapHudBounds(
cursor,
anchor,
{ width: bounds.width, height: bounds.height },
hudWindow.webContents.getZoomFactor(),
workArea
)
// setBounds — NOT setPosition alone: on Windows, a transparent frameless
// window silently grows ~1px per setPosition call (see move-by handler).
hudWindow.setBounds({
x: origin.x,
y: origin.y,
width: bounds.width,
height: bounds.height
})
}
const hudSnapShortcut = createHudSnapShortcut(globalShortcut, applyHudSnapToPointer)
function registerHudSnapShortcut() {
if (!hudSnapShortcut.register()) {
rememberLog('[hud] snap shortcut unavailable — CommandOrControl+Shift+G may be owned by another app')
}
}
/**
* Feed the HUD renderer the cursor position on Linux.
*
@ -9450,6 +9491,7 @@ function openHudWindow(sessionId, profile) {
hudProfile = profileKey
hudWindow = spawnHudWindow(sessionId, profileKey)
broadcastHudState(true)
registerHudSnapShortcut()
return hudWindow
}
@ -9475,11 +9517,14 @@ function openHudWindow(sessionId, profile) {
hudProfile = profileKey
hudWindow = spawnHudWindow(sessionId, profileKey)
broadcastHudState(true)
registerHudSnapShortcut()
return hudWindow
}
function closeHudWindow() {
hudSnapShortcut.dispose()
const win = hudWindow
hudWindow = null
@ -12595,6 +12640,8 @@ app.on('before-quit', event => {
// floating composer with nothing behind it. Close it directly rather than via
// closeHudWindow(): that also re-shows the main window, which is wrong on the
// way out (and `hudRestoreMainWindow` may still be armed from entering HUD).
hudSnapShortcut.dispose()
if (hudWindow && !hudWindow.isDestroyed()) {
hudWindow.removeAllListeners('closed')
hudWindow.destroy()

View File

@ -263,6 +263,7 @@ export const en: Translations = {
'view.toggleStatusbar': 'Toggle status bar',
'view.showFiles': 'Show file browser',
'view.toggleHud': 'Toggle HUD mode',
'hud.snapToPointer': 'Move HUD to pointer (global, while HUD is open)',
'view.showTerminal': 'Toggle terminal',
'view.newTerminal': 'New terminal',
'view.nextTerminal': 'Next terminal',

View File

@ -244,5 +244,7 @@ export const KEYBIND_READONLY: readonly KeybindReadonly[] = [
// Code. Plain Ctrl+C also copies when text is selected (Windows Terminal /
// Tabby behavior); with no selection it stays SIGINT, so it isn't listed.
{ id: 'view.terminalCopy', category: 'view', keys: IS_MAC ? ['mod+c'] : ['mod+shift+c'] },
{ id: 'view.terminalPaste', category: 'view', keys: IS_MAC ? ['mod+v'] : ['mod+shift+v'] }
{ id: 'view.terminalPaste', category: 'view', keys: IS_MAC ? ['mod+v'] : ['mod+shift+v'] },
// Global OS chord registered in main while HUD mode is up.
{ id: 'hud.snapToPointer', category: 'view', keys: ['mod+shift+g'] }
]