Merge pull request #83130 from NousResearch/bb/hud-snap-pointer
feat(desktop): snap HUD to cursor with global ⌘⇧G
This commit is contained in:
commit
338bca7968
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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'] }
|
||||
]
|
||||
|
|
|
|||
Loading…
Reference in New Issue