From 9c75e4863f95124a91f6d643b094195d21223d5a Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 10 Aug 2026 05:21:05 -0500 Subject: [PATCH 1/3] =?UTF-8?q?feat(desktop):=20snap=20HUD=20to=20cursor?= =?UTF-8?q?=20with=20global=20=E2=8C=98=E2=87=A7G?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register CommandOrControl+Shift+G in main while HUD mode is open so the floating bar can jump under the pointer from any app. Tap-to-snap only — Electron globalShortcut has no keyup for hold-to-follow. --- .../electron/hud-snap-shortcut.test.ts | 55 ++++++++++++++++ apps/desktop/electron/hud-snap-shortcut.ts | 57 +++++++++++++++++ apps/desktop/electron/hud-snap.test.ts | 34 ++++++++++ apps/desktop/electron/hud-snap.ts | 64 +++++++++++++++++++ apps/desktop/electron/main.ts | 47 ++++++++++++++ 5 files changed, 257 insertions(+) create mode 100644 apps/desktop/electron/hud-snap-shortcut.test.ts create mode 100644 apps/desktop/electron/hud-snap-shortcut.ts create mode 100644 apps/desktop/electron/hud-snap.test.ts create mode 100644 apps/desktop/electron/hud-snap.ts diff --git a/apps/desktop/electron/hud-snap-shortcut.test.ts b/apps/desktop/electron/hud-snap-shortcut.test.ts new file mode 100644 index 0000000000000..abd290ba39ed7 --- /dev/null +++ b/apps/desktop/electron/hud-snap-shortcut.test.ts @@ -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) + }) +}) diff --git a/apps/desktop/electron/hud-snap-shortcut.ts b/apps/desktop/electron/hud-snap-shortcut.ts new file mode 100644 index 0000000000000..5da8bb1cb873f --- /dev/null +++ b/apps/desktop/electron/hud-snap-shortcut.ts @@ -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() + } + } +} diff --git a/apps/desktop/electron/hud-snap.test.ts b/apps/desktop/electron/hud-snap.test.ts new file mode 100644 index 0000000000000..160e392fc2d6a --- /dev/null +++ b/apps/desktop/electron/hud-snap.test.ts @@ -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) +}) diff --git a/apps/desktop/electron/hud-snap.ts b/apps/desktop/electron/hud-snap.ts new file mode 100644 index 0000000000000..56bdac13d40e2 --- /dev/null +++ b/apps/desktop/electron/hud-snap.ts @@ -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, 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, + zoomFactor: number, + workArea: Rect +): Point { + return clampHudOrigin(windowOriginForCursorAnchor(cursor, anchor, zoomFactor), windowSize, workArea) +} diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index ddcbb44b71054..62e93cce12fa1 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -137,6 +137,8 @@ import { TEXT_PREVIEW_SOURCE_MAX_BYTES } from './hardening' import { cursorPointInWindow } from './hud-cursor' +import { createHudSnapShortcut } from './hud-snap-shortcut' +import { snapHudBounds } from './hud-snap' 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() From 440e6fb6405ce79fae3c7ddbd82741507126e51f Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 10 Aug 2026 05:21:07 -0500 Subject: [PATCH 2/3] fix(desktop): list HUD snap chord in keyboard shortcuts panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document ⌘⇧G as a read-only global shortcut active while HUD mode is up. --- apps/desktop/src/i18n/en.ts | 1 + apps/desktop/src/lib/keybinds/actions.ts | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index b0dba1f0e9d51..da7386f185fdd 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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', diff --git a/apps/desktop/src/lib/keybinds/actions.ts b/apps/desktop/src/lib/keybinds/actions.ts index be2d9d1317674..b042cd4f6e2b6 100644 --- a/apps/desktop/src/lib/keybinds/actions.ts +++ b/apps/desktop/src/lib/keybinds/actions.ts @@ -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'] } ] From 422b25693974288adc33c207cdfa4c18a37fd71e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 10 Aug 2026 05:26:38 -0500 Subject: [PATCH 3/3] fix(desktop): sort hud snap imports for eslint --- apps/desktop/electron/main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 62e93cce12fa1..d743b1f065b0c 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -137,8 +137,8 @@ import { TEXT_PREVIEW_SOURCE_MAX_BYTES } from './hardening' import { cursorPointInWindow } from './hud-cursor' -import { createHudSnapShortcut } from './hud-snap-shortcut' 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'