diff --git a/docs/superpowers/plans/2026-08-10-recording-hud-click-through.md b/docs/superpowers/plans/2026-08-10-recording-hud-click-through.md new file mode 100644 index 000000000..2bb6c3758 --- /dev/null +++ b/docs/superpowers/plans/2026-08-10-recording-hud-click-through.md @@ -0,0 +1,203 @@ +# Recording HUD Click-Through Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Keep the visible recording HUD interactive while allowing clicks through every transparent part of its Electron window. + +**Architecture:** Express the main-process decision as a pure mouse-policy function beside the existing HUD bounds policy. The Electron window manager will use that policy for both native window bounds and `setIgnoreMouseEvents`, preserving the renderer's existing hover-driven requests during recording instead of overriding them. + +**Tech Stack:** Electron 43, TypeScript, Vitest 4, Biome 2 + +## Global Constraints + +- Transparent HUD areas must pass clicks through while recording on platforms where Electron mouse forwarding is supported. +- The visible HUD bar, popovers, drag interactions, and webcam preview must remain interactive. +- Linux and Windows versions without forwarding support must retain the compact interactive fallback. +- Do not modify recording backends, cursor telemetry, project state, editor behavior, or export behavior. + +--- + +## File structure + +- `electron/hudOverlayBounds.ts`: owns pure HUD native-window geometry and mouse-policy decisions. +- `electron/hudOverlayBounds.test.ts`: covers geometry and recording-time mouse-policy behavior without constructing Electron windows. +- `electron/windows.ts`: applies the pure policy to the live `BrowserWindow` and preserves renderer-requested passthrough state across recording transitions. + +### Task 1: Add the recording-safe HUD mouse policy + +**Files:** +- Modify: `electron/hudOverlayBounds.ts` +- Test: `electron/hudOverlayBounds.test.ts` + +**Interfaces:** +- Consumes: `mousePassthroughSupported`, `requestedIgnore`, and `recordingActive` booleans. +- Produces: `resolveHudOverlayMousePolicy(options): { usePassthroughWindow: boolean; ignoreMouseEvents: boolean }`. + +- [x] **Step 1: Write the failing regression tests** + +Add the new export to the existing import and add this suite: + +```ts +describe("resolveHudOverlayMousePolicy", () => { + it("keeps transparent HUD pixels click-through while recording", () => { + expect( + resolveHudOverlayMousePolicy({ + mousePassthroughSupported: true, + requestedIgnore: true, + recordingActive: true, + }), + ).toEqual({ usePassthroughWindow: true, ignoreMouseEvents: true }); + }); + + it("keeps visible HUD controls interactive while recording", () => { + expect( + resolveHudOverlayMousePolicy({ + mousePassthroughSupported: true, + requestedIgnore: false, + recordingActive: true, + }), + ).toEqual({ usePassthroughWindow: true, ignoreMouseEvents: false }); + }); + + it("retains the interactive compact fallback when passthrough is unsupported", () => { + expect( + resolveHudOverlayMousePolicy({ + mousePassthroughSupported: false, + requestedIgnore: true, + recordingActive: true, + }), + ).toEqual({ usePassthroughWindow: false, ignoreMouseEvents: false }); + }); +}); +``` + +- [x] **Step 2: Run the focused test and verify RED** + +Run: `npx vitest --run electron/hudOverlayBounds.test.ts` + +Expected: FAIL because `resolveHudOverlayMousePolicy` is not exported. + +- [x] **Step 3: Implement the minimal pure policy** + +Add to `electron/hudOverlayBounds.ts`: + +```ts +export function resolveHudOverlayMousePolicy({ + mousePassthroughSupported, + requestedIgnore, +}: { + mousePassthroughSupported: boolean; + requestedIgnore: boolean; + recordingActive: boolean; +}) { + return { + usePassthroughWindow: mousePassthroughSupported, + ignoreMouseEvents: mousePassthroughSupported && requestedIgnore, + }; +} +``` + +Recording state remains part of the interface to make the invariant explicit: starting a recording does not disable a platform capability. + +- [x] **Step 4: Run the focused test and verify GREEN** + +Run: `npx vitest --run electron/hudOverlayBounds.test.ts` + +Expected: all tests in `electron/hudOverlayBounds.test.ts` pass. + +### Task 2: Apply the policy to the live Electron HUD + +**Files:** +- Modify: `electron/windows.ts:7-13` +- Modify: `electron/windows.ts:196-209` +- Modify: `electron/windows.ts:289-327` +- Modify: `electron/windows.ts:505-515` +- Modify: `electron/windows.ts:631-665` + +**Interfaces:** +- Consumes: `resolveHudOverlayMousePolicy` from Task 1 and the existing renderer-requested `hudOverlayIgnoringMouse` state. +- Produces: recording transitions that preserve click-through outside visible HUD controls. + +- [x] **Step 1: Import and use the pure policy for window geometry** + +Import `resolveHudOverlayMousePolicy` from `./hudOverlayBounds`. In `getHudOverlayBounds`, resolve the policy with the current support, request, and recording state, then pass `policy.usePassthroughWindow` to `getHudOverlayWindowBounds` instead of `isHudOverlayMousePassthroughSupported() && !hudOverlayRecordingActive`. + +```ts +const mousePolicy = resolveHudOverlayMousePolicy({ + mousePassthroughSupported: isHudOverlayMousePassthroughSupported(), + requestedIgnore: hudOverlayIgnoringMouse, + recordingActive: hudOverlayRecordingActive, +}); +``` + +- [x] **Step 2: Stop recording state from overriding renderer requests** + +In `setHudOverlayMousePassthrough`, keep the source-selection override but remove the `hudOverlayRecordingActive ? false` branch. Resolve the pure policy and: + +- keep fallback expansion and `setIgnoreMouseEvents(false)` when `usePassthroughWindow` is false; +- call `setIgnoreMouseEvents(true, { forward: true })` when `ignoreMouseEvents` is true; +- otherwise call `setIgnoreMouseEvents(false)`. + +- [x] **Step 3: Preserve the requested state across creation and recording transitions** + +Use the pure policy during initial window setup. Remove the recording-only `setIgnoreMouseEvents(false)` shortcut from `reassertHudOverlayMousePassthrough`. Update `setHudOverlayRecordingActive` to reapply `hudOverlayIgnoringMouse` rather than passing `!hudOverlayRecordingActive`. + +```ts +setHudOverlayMousePassthrough(hudOverlayIgnoringMouse); +``` + +- [x] **Step 4: Run focused HUD tests** + +Run: `npx vitest --run electron/hudOverlayBounds.test.ts src/components/launch/hudMousePassthrough.test.ts src/components/launch/floatingWebcamPreview.test.ts` + +Expected: all focused tests pass. + +- [x] **Step 5: Commit the behavioral change** + +```bash +git add electron/hudOverlayBounds.ts electron/hudOverlayBounds.test.ts electron/windows.ts +git commit -m "fix: keep recording HUD transparent to clicks" +``` + +### Task 3: Verify and publish + +**Files:** +- Verify: all changed files and repository checks +- Publish: `tanmayapex/Recordly` head branch to `webadderallorg/Recordly:main` + +**Interfaces:** +- Consumes: the complete branch diff from Tasks 1-2. +- Produces: a tested draft pull request following upstream conventions. + +- [x] **Step 1: Run repository validation** + +Run, in order: + +```bash +npx tsc --noEmit +npm run lint +npm test +git diff --check origin/main...HEAD +``` + +Expected: commands exit successfully. Any inherited advisory warnings must be reported accurately rather than described as clean output. + +- [ ] **Step 2: Inspect scope and history** + +Run: + +```bash +git status -sb +git diff --stat origin/main...HEAD +git log --oneline origin/main..HEAD +``` + +Expected: only the approved design/plan and HUD policy implementation are present. + +- [ ] **Step 3: Push through the authenticated fork** + +Confirm `gh api user --jq .login` returns `tanmayapex`, create or reuse `tanmayapex/Recordly`, configure a `fork` remote if necessary, and push `codex/fix-recording-hud-click-through` with tracking. + +- [ ] **Step 4: Open the draft PR** + +Target `webadderallorg/Recordly:main` with a concise conventional title such as `fix(hud): pass clicks through transparent recording overlay`. The body must include Description, Problem and root cause, Focused change, User impact, Testing, and Risk sections. diff --git a/docs/superpowers/specs/2026-08-10-recording-hud-click-through-design.md b/docs/superpowers/specs/2026-08-10-recording-hud-click-through-design.md new file mode 100644 index 000000000..84a42b29b --- /dev/null +++ b/docs/superpowers/specs/2026-08-10-recording-hud-click-through-design.md @@ -0,0 +1,34 @@ +# Recording HUD click-through design + +## Problem + +When recording starts, the Electron main process forces the transparent HUD window to accept mouse events. The native window then intercepts clicks across its entire rectangle, including visually transparent pixels. Controls in the recorded website or application underneath that rectangle therefore stop responding. + +## Intended behavior + +- Transparent HUD areas pass mouse input to the application underneath while recording. +- The visible HUD bar, its popovers, drag interactions, and the recording webcam preview remain interactive. +- Platforms where Electron mouse-event forwarding is unsupported retain the existing compact interactive fallback window. + +## Design + +Reuse the renderer-driven hover policy that already provides click-through behavior before recording. The renderer continues to request an interactive HUD while the pointer is over a visible HUD element and requests passthrough after the pointer leaves. + +The Electron main process will stop overriding that requested state merely because recording is active. On platforms with passthrough support, recording will keep the full-display transparent overlay and honor the renderer request with `setIgnoreMouseEvents(true, { forward: true })` outside interactive elements. On unsupported platforms, the native window remains interactive and compact. + +The policy deciding whether a request can use native passthrough will be expressed as a small pure function so recording behavior can be covered without constructing an Electron `BrowserWindow` in tests. + +## Compatibility and risk + +The change does not affect capture backends, recorded media, cursor telemetry, or editor behavior. The primary risk is making recording controls temporarily unclickable; retaining Electron's forwarded mouse movement and the existing renderer hover handlers prevents that on supported platforms. Unsupported platforms keep their current fallback rather than relying on unavailable forwarding behavior. + +## Testing + +- Add a regression test proving that a supported platform honors passthrough requests while recording. +- Prove that interactive requests still disable passthrough while recording. +- Prove that unsupported platforms never enable passthrough. +- Run the focused HUD tests, the complete test suite, TypeScript typechecking, and Biome lint. + +## Pull request + +The PR will be opened from the `tanmayapex` fork against `webadderallorg/Recordly:main`. Its description will follow the repository's recent convention: problem/root cause, focused change, user impact, verification, and risk. diff --git a/electron/hudOverlayBounds.test.ts b/electron/hudOverlayBounds.test.ts index db21e92bd..42a2037d4 100644 --- a/electron/hudOverlayBounds.test.ts +++ b/electron/hudOverlayBounds.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from "vitest"; import { + getHudOverlayMouseReassertCommands, getHudOverlayWindowBounds, resizeHudOverlayFallbackBounds, + resolveHudOverlayMousePolicy, shouldExpandHudOverlayFallback, } from "./hudOverlayBounds"; @@ -76,6 +78,77 @@ describe("getHudOverlayWindowBounds", () => { }); }); +describe("resolveHudOverlayMousePolicy", () => { + it("keeps transparent HUD pixels click-through while recording", () => { + expect( + resolveHudOverlayMousePolicy({ + mousePassthroughSupported: true, + requestedIgnore: true, + recordingActive: true, + }), + ).toEqual({ usePassthroughWindow: true, ignoreMouseEvents: true }); + }); + + it("preserves the renderer's requested mouse policy outside recording", () => { + expect( + resolveHudOverlayMousePolicy({ + mousePassthroughSupported: true, + requestedIgnore: true, + recordingActive: false, + }), + ).toEqual({ usePassthroughWindow: true, ignoreMouseEvents: true }); + }); + + it("keeps visible HUD controls interactive while recording", () => { + expect( + resolveHudOverlayMousePolicy({ + mousePassthroughSupported: true, + requestedIgnore: false, + recordingActive: true, + }), + ).toEqual({ usePassthroughWindow: true, ignoreMouseEvents: false }); + }); + + it("retains the interactive compact fallback when passthrough is unsupported", () => { + expect( + resolveHudOverlayMousePolicy({ + mousePassthroughSupported: false, + requestedIgnore: true, + recordingActive: true, + }), + ).toEqual({ usePassthroughWindow: false, ignoreMouseEvents: false }); + }); +}); + +describe("getHudOverlayMouseReassertCommands", () => { + it("restores passthrough immediately after resetting the Windows native flag", () => { + expect( + getHudOverlayMouseReassertCommands({ + usePassthroughWindow: true, + ignoreMouseEvents: true, + }), + ).toEqual([{ ignoreMouseEvents: false }, { ignoreMouseEvents: true, forward: true }]); + }); + + it("leaves an intentionally interactive HUD interactive after the reset", () => { + expect( + getHudOverlayMouseReassertCommands({ + usePassthroughWindow: true, + ignoreMouseEvents: false, + }), + ).toEqual([{ ignoreMouseEvents: false }]); + }); + + it("does not issue passthrough commands for compact fallback windows", () => { + expect( + getHudOverlayMouseReassertCommands({ + usePassthroughWindow: false, + ignoreMouseEvents: false, + }), + ).toEqual([]); + }); +}); + describe("resizeHudOverlayFallbackBounds", () => { const workArea = { x: 0, diff --git a/electron/hudOverlayBounds.ts b/electron/hudOverlayBounds.ts index 8c51b88c7..1be559a0b 100644 --- a/electron/hudOverlayBounds.ts +++ b/electron/hudOverlayBounds.ts @@ -13,6 +13,41 @@ function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } +export function resolveHudOverlayMousePolicy({ + mousePassthroughSupported, + requestedIgnore, +}: { + mousePassthroughSupported: boolean; + requestedIgnore: boolean; + recordingActive: boolean; +}) { + return { + usePassthroughWindow: mousePassthroughSupported, + ignoreMouseEvents: mousePassthroughSupported && requestedIgnore, + }; +} + +export interface HudOverlayMouseReassertCommand { + ignoreMouseEvents: boolean; + forward?: true; +} + +export function getHudOverlayMouseReassertCommands({ + usePassthroughWindow, + ignoreMouseEvents, +}: { + usePassthroughWindow: boolean; + ignoreMouseEvents: boolean; +}): HudOverlayMouseReassertCommand[] { + if (!usePassthroughWindow) { + return []; + } + + return ignoreMouseEvents + ? [{ ignoreMouseEvents: false }, { ignoreMouseEvents: true, forward: true }] + : [{ ignoreMouseEvents: false }]; +} + export function getHudOverlayWindowBounds( workArea: HudOverlayWorkArea, mousePassthroughSupported: boolean, diff --git a/electron/windows.ts b/electron/windows.ts index 55f6314cd..9a15e953d 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -6,8 +6,10 @@ import { fileURLToPath } from "node:url"; import { app, BrowserWindow, ipcMain } from "electron"; import { USER_DATA_PATH } from "./appPaths"; import { + getHudOverlayMouseReassertCommands, getHudOverlayWindowBounds, resizeHudOverlayFallbackBounds, + resolveHudOverlayMousePolicy, shouldExpandHudOverlayFallback, } from "./hudOverlayBounds"; import { getPackagedRendererBaseUrl } from "./rendererServer"; @@ -32,7 +34,6 @@ let hudOverlayCaptureProtectionLoaded = false; let hudOverlayFallbackExpanded = false; let hudOverlayIgnoringMouse = true; let hudOverlaySourceSelectionActive = false; -let hudOverlayMouseReassertTimer: NodeJS.Timeout | null = null; let hudOverlayRecordingActive = false; let hudOverlayWebcamPreviewVisible = false; let countdownWindow: BrowserWindow | null = null; @@ -195,16 +196,17 @@ function getHudOverlayDisplay() { function getHudOverlayBounds() { const { workArea } = getHudOverlayDisplay(); + const mousePolicy = resolveHudOverlayMousePolicy({ + mousePassthroughSupported: isHudOverlayMousePassthroughSupported(), + requestedIgnore: hudOverlayIgnoringMouse, + recordingActive: hudOverlayRecordingActive, + }); const fallbackExpanded = shouldExpandHudOverlayFallback({ fallbackExpanded: hudOverlayFallbackExpanded, recordingActive: hudOverlayRecordingActive, webcamPreviewVisible: hudOverlayWebcamPreviewVisible, }); - return getHudOverlayWindowBounds( - workArea, - isHudOverlayMousePassthroughSupported() && !hudOverlayRecordingActive, - fallbackExpanded, - ); + return getHudOverlayWindowBounds(workArea, mousePolicy.usePassthroughWindow, fallbackExpanded); } function applyHudOverlayBounds() { @@ -288,29 +290,19 @@ function setHudOverlayFallbackExpanded(expanded: boolean) { function setHudOverlayMousePassthrough(ignore: boolean) { hudOverlayIgnoringMouse = - hudOverlaySourceSelectionActive && !hudOverlayRecordingActive - ? true - : hudOverlayRecordingActive - ? false - : ignore; - - if (hudOverlayMouseReassertTimer) { - clearTimeout(hudOverlayMouseReassertTimer); - hudOverlayMouseReassertTimer = null; - } + hudOverlaySourceSelectionActive && !hudOverlayRecordingActive ? true : ignore; if (!hudOverlayWindow || hudOverlayWindow.isDestroyed()) { return; } - if (hudOverlayRecordingActive) { - hudOverlayFallbackExpanded = false; - applyHudOverlayBounds(); - hudOverlayWindow.setIgnoreMouseEvents(false); - return; - } + const mousePolicy = resolveHudOverlayMousePolicy({ + mousePassthroughSupported: isHudOverlayMousePassthroughSupported(), + requestedIgnore: hudOverlayIgnoringMouse, + recordingActive: hudOverlayRecordingActive, + }); - if (!isHudOverlayMousePassthroughSupported()) { + if (!mousePolicy.usePassthroughWindow) { if (process.platform !== "linux") { setHudOverlayFallbackExpanded(!ignore); } @@ -318,7 +310,7 @@ function setHudOverlayMousePassthrough(ignore: boolean) { return; } - if (ignore) { + if (mousePolicy.ignoreMouseEvents) { hudOverlayWindow.setIgnoreMouseEvents(true, { forward: true }); return; } @@ -326,6 +318,22 @@ function setHudOverlayMousePassthrough(ignore: boolean) { hudOverlayWindow.setIgnoreMouseEvents(false); } +function reassertHudOverlayMousePassthroughForWindow(hud: BrowserWindow): void { + const mousePolicy = resolveHudOverlayMousePolicy({ + mousePassthroughSupported: isHudOverlayMousePassthroughSupported(), + requestedIgnore: hudOverlayIgnoringMouse, + recordingActive: hudOverlayRecordingActive, + }); + + for (const command of getHudOverlayMouseReassertCommands(mousePolicy)) { + if (command.forward) { + hud.setIgnoreMouseEvents(command.ignoreMouseEvents, { forward: true }); + } else { + hud.setIgnoreMouseEvents(command.ignoreMouseEvents); + } + } +} + ipcMain.on("hud-overlay-set-ignore-mouse", (_event, ignore: boolean) => { setHudOverlayMousePassthrough(Boolean(ignore)); }); @@ -489,12 +497,7 @@ export function createHudOverlayWindow(): BrowserWindow { win.show(); win.moveTop(); if (process.platform === "win32" && isHudOverlayMousePassthroughSupported()) { - win.setIgnoreMouseEvents(false); - setTimeout(() => { - if (!win.isDestroyed()) { - setHudOverlayMousePassthrough(hudOverlayIgnoringMouse); - } - }, 50); + reassertHudOverlayMousePassthroughForWindow(win); } }; @@ -502,13 +505,16 @@ export function createHudOverlayWindow(): BrowserWindow { win.setContentProtection(hudOverlayHiddenFromCapture); } - if (isHudOverlayMousePassthroughSupported()) { - if (hudOverlayRecordingActive) { - hudOverlayIgnoringMouse = false; - win.setIgnoreMouseEvents(false); - } else { - hudOverlayIgnoringMouse = true; + const initialMousePolicy = resolveHudOverlayMousePolicy({ + mousePassthroughSupported: isHudOverlayMousePassthroughSupported(), + requestedIgnore: hudOverlayIgnoringMouse, + recordingActive: hudOverlayRecordingActive, + }); + if (initialMousePolicy.usePassthroughWindow) { + if (initialMousePolicy.ignoreMouseEvents) { win.setIgnoreMouseEvents(true, { forward: true }); + } else { + win.setIgnoreMouseEvents(false); } } @@ -521,12 +527,7 @@ export function createHudOverlayWindow(): BrowserWindow { if (process.platform === "win32" && isHudOverlayMousePassthroughSupported()) { win.on("focus", () => { if (!win.isDestroyed()) { - win.setIgnoreMouseEvents(false); - setTimeout(() => { - if (!win.isDestroyed()) { - setHudOverlayMousePassthrough(hudOverlayIgnoringMouse); - } - }, 50); + reassertHudOverlayMousePassthroughForWindow(win); } }); } @@ -638,30 +639,17 @@ export function reassertHudOverlayMousePassthrough(): void { return; } - if (hudOverlayRecordingActive) { - hud.setIgnoreMouseEvents(false); - return; - } - - // Toggle off then back on so the native WS_EX_TRANSPARENT flag is fully - // re-initialised rather than merely re-asserted in a potentially broken state. - hud.setIgnoreMouseEvents(false); - if (hudOverlayMouseReassertTimer) { - clearTimeout(hudOverlayMouseReassertTimer); - } - hudOverlayMouseReassertTimer = setTimeout(() => { - hudOverlayMouseReassertTimer = null; - if (!hud.isDestroyed()) { - setHudOverlayMousePassthrough(hudOverlayIgnoringMouse); - } - }, 50); + // Toggle off then restore the renderer-requested policy synchronously. This + // resets WS_EX_TRANSPARENT without exposing a full-screen interactive HUD + // during an arbitrary timer window. + reassertHudOverlayMousePassthroughForWindow(hud); } export function setHudOverlayRecordingActive(recording: boolean): void { hudOverlayRecordingActive = Boolean(recording); hudOverlayFallbackExpanded = false; applyHudOverlayBounds(); - setHudOverlayMousePassthrough(!hudOverlayRecordingActive); + setHudOverlayMousePassthrough(hudOverlayIgnoringMouse); } export function createUpdateToastWindow(): BrowserWindow { diff --git a/src/components/launch/hooks/useLaunchHudInteractionState.ts b/src/components/launch/hooks/useLaunchHudInteractionState.ts index 42f60b72a..f10daba04 100644 --- a/src/components/launch/hooks/useLaunchHudInteractionState.ts +++ b/src/components/launch/hooks/useLaunchHudInteractionState.ts @@ -1,4 +1,5 @@ import { type MouseEvent, type RefObject, useCallback, useEffect, useRef } from "react"; +import { shouldRestoreHudMousePassthrough } from "../hudMousePassthrough"; export function useLaunchHudInteractionState({ openId, @@ -12,21 +13,27 @@ export function useLaunchHudInteractionState({ webcamPreviewDragStartRef: RefObject; }) { const isMouseOverHudRef = useRef(false); - const timeoutRef = useRef(null); + const restoreMousePassthroughIfIdle = useCallback(() => { + if ( + shouldRestoreHudMousePassthrough({ + isMouseOverHud: isMouseOverHudRef.current, + popoverOpen: openId !== null, + isHudDragging: Boolean(isHudDraggingRef.current), + isWebcamPreviewDragging: Boolean(isWebcamPreviewDraggingRef.current), + webcamPreviewPointerDown: Boolean(webcamPreviewDragStartRef.current), + }) + ) { + window.electronAPI?.hudOverlaySetIgnoreMouse?.(true); + } + }, [openId, isHudDraggingRef, isWebcamPreviewDraggingRef, webcamPreviewDragStartRef]); useEffect(() => { if (openId !== null) { - if (timeoutRef.current) clearTimeout(timeoutRef.current); window.electronAPI?.hudOverlaySetIgnoreMouse?.(false); } else { - // Proactively check if we should ignore mouse when popover closes - setTimeout(() => { - if (!isMouseOverHudRef.current) { - window.electronAPI?.hudOverlaySetIgnoreMouse?.(true); - } - }, 150); + restoreMousePassthroughIfIdle(); } - }, [openId]); + }, [openId, restoreMousePassthroughIfIdle]); useEffect(() => { const handleMouseOver = (e: globalThis.MouseEvent) => { @@ -38,28 +45,16 @@ export function useLaunchHudInteractionState({ if (isInteractive) { isMouseOverHudRef.current = true; - if (timeoutRef.current) clearTimeout(timeoutRef.current); window.electronAPI?.hudOverlaySetIgnoreMouse?.(false); } else if (openId === null) { isMouseOverHudRef.current = false; - if (timeoutRef.current) clearTimeout(timeoutRef.current); - timeoutRef.current = setTimeout(() => { - if ( - openId === null && - !isHudDraggingRef.current && - !isWebcamPreviewDraggingRef.current && - !webcamPreviewDragStartRef.current && - !isMouseOverHudRef.current - ) { - window.electronAPI?.hudOverlaySetIgnoreMouse?.(true); - } - }, 300); + restoreMousePassthroughIfIdle(); } }; window.addEventListener("mouseover", handleMouseOver); return () => window.removeEventListener("mouseover", handleMouseOver); - }, [openId, isHudDraggingRef, isWebcamPreviewDraggingRef, webcamPreviewDragStartRef]); + }, [openId, restoreMousePassthroughIfIdle]); const beginInteractiveHudAction = useCallback(() => { isMouseOverHudRef.current = true; @@ -68,7 +63,6 @@ export function useLaunchHudInteractionState({ const handleHudMouseEnter = useCallback(() => { isMouseOverHudRef.current = true; - if (timeoutRef.current) clearTimeout(timeoutRef.current); window.electronAPI?.hudOverlaySetIgnoreMouse?.(false); }, []); @@ -80,22 +74,9 @@ export function useLaunchHudInteractionState({ } isMouseOverHudRef.current = false; - - if (timeoutRef.current) clearTimeout(timeoutRef.current); - - timeoutRef.current = setTimeout(() => { - if ( - openId === null && - !isHudDraggingRef.current && - !isWebcamPreviewDraggingRef.current && - !webcamPreviewDragStartRef.current && - !isMouseOverHudRef.current - ) { - window.electronAPI?.hudOverlaySetIgnoreMouse?.(true); - } - }, 300); + restoreMousePassthroughIfIdle(); }, - [openId, isHudDraggingRef, isWebcamPreviewDraggingRef, webcamPreviewDragStartRef], + [restoreMousePassthroughIfIdle], ); return { diff --git a/src/components/launch/hudMousePassthrough.test.ts b/src/components/launch/hudMousePassthrough.test.ts index 9490b0547..fc087a119 100644 --- a/src/components/launch/hudMousePassthrough.test.ts +++ b/src/components/launch/hudMousePassthrough.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { mergeHudInteractiveBounds, + shouldRestoreHudMousePassthrough, shouldRestoreHudMousePassthroughAfterDrag, } from "./hudMousePassthrough"; @@ -44,3 +45,37 @@ describe("shouldRestoreHudMousePassthroughAfterDrag", () => { expect(shouldRestoreHudMousePassthroughAfterDrag(null, 180, 230)).toBe(true); }); }); + +describe("shouldRestoreHudMousePassthrough", () => { + it("restores passthrough as soon as the pointer leaves idle HUD content", () => { + expect( + shouldRestoreHudMousePassthrough({ + isMouseOverHud: false, + popoverOpen: false, + isHudDragging: false, + isWebcamPreviewDragging: false, + webcamPreviewPointerDown: false, + }), + ).toBe(true); + }); + + it("keeps the HUD interactive while the pointer or another HUD interaction is active", () => { + const idleState = { + isMouseOverHud: false, + popoverOpen: false, + isHudDragging: false, + isWebcamPreviewDragging: false, + webcamPreviewPointerDown: false, + }; + + for (const activeState of [ + { isMouseOverHud: true }, + { popoverOpen: true }, + { isHudDragging: true }, + { isWebcamPreviewDragging: true }, + { webcamPreviewPointerDown: true }, + ]) { + expect(shouldRestoreHudMousePassthrough({ ...idleState, ...activeState })).toBe(false); + } + }); +}); diff --git a/src/components/launch/hudMousePassthrough.ts b/src/components/launch/hudMousePassthrough.ts index 5d972f559..377be395e 100644 --- a/src/components/launch/hudMousePassthrough.ts +++ b/src/components/launch/hudMousePassthrough.ts @@ -37,3 +37,25 @@ export function shouldRestoreHudMousePassthroughAfterDrag( clientY > bounds.bottom ); } + +export function shouldRestoreHudMousePassthrough({ + isMouseOverHud, + popoverOpen, + isHudDragging, + isWebcamPreviewDragging, + webcamPreviewPointerDown, +}: { + isMouseOverHud: boolean; + popoverOpen: boolean; + isHudDragging: boolean; + isWebcamPreviewDragging: boolean; + webcamPreviewPointerDown: boolean; +}): boolean { + return ( + !isMouseOverHud && + !popoverOpen && + !isHudDragging && + !isWebcamPreviewDragging && + !webcamPreviewPointerDown + ); +}