Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ declare namespace NodeJS {
// Used in Renderer process, expose in `preload.ts`
interface Window {
electronAPI: {
platform: string;
invokeNativeBridge: <TData = unknown>(
request: import("../src/native/contracts").NativeBridgeRequest,
) => Promise<import("../src/native/contracts").NativeBridgeResponse<TData>>;
Expand Down Expand Up @@ -300,8 +301,10 @@ interface Window {
hudOverlayHide: () => void;
hudOverlayClose: () => void;
setHudOverlayIgnoreMouseEvents: (ignore: boolean) => void;
moveHudOverlayBy: (deltaX: number, deltaY: number) => void;
setHudOverlaySize: (width: number, height: number) => void;
beginHudOverlayDrag: (screenX: number, screenY: number) => void;
updateHudOverlayDrag: (screenX: number, screenY: number) => void;
endHudOverlayDrag: (screenX?: number, screenY?: number) => void;
showCountdownOverlay: (value: number, runId: number) => Promise<void>;
setCountdownOverlayValue: (value: number, runId: number) => Promise<void>;
hideCountdownOverlay: (runId: number) => Promise<void>;
Expand Down
48 changes: 48 additions & 0 deletions electron/hudOverlayBounds.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { getHudOverlayResizedBounds } from "./hudOverlayBounds";

describe("HUD overlay content resizing", () => {
it("preserves the bottom-centre anchor away from monitor edges", () => {
expect(
getHudOverlayResizedBounds(
{ x: 1244, y: 1634, width: 587, height: 92 },
{ x: 0, y: 0, width: 3072, height: 1728 },
594,
99,
),
).toEqual({ x: 1241, y: 1627, width: 594, height: 99 });
});

it("keeps an expanding popup inside the right edge of its display", () => {
expect(
getHudOverlayResizedBounds(
{ x: 2800, y: 1600, width: 220, height: 100 },
{ x: 0, y: 0, width: 3072, height: 1728 },
600,
200,
),
).toEqual({ x: 2472, y: 1500, width: 600, height: 200 });
});

it("supports work areas with negative mixed-monitor origins", () => {
expect(
getHudOverlayResizedBounds(
{ x: -1910, y: -180, width: 220, height: 100 },
{ x: -1920, y: -1040, width: 1920, height: 1040 },
600,
200,
),
).toEqual({ x: -1920, y: -280, width: 600, height: 200 });
});

it("clamps an oversized HUD to the display work area", () => {
expect(
getHudOverlayResizedBounds(
{ x: 3200, y: -1500, width: 400, height: 120 },
{ x: 3072, y: -1920, width: 1080, height: 1920 },
1400,
2200,
),
).toEqual({ x: 3072, y: -1920, width: 1080, height: 1920 });
});
});
40 changes: 40 additions & 0 deletions electron/hudOverlayBounds.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
export type HudOverlayBounds = {
x: number;
y: number;
width: number;
height: number;
};

function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}

/**
* Resize around the HUD's bottom-centre anchor, then keep the full transparent
* window inside the selected display's work area. The second step matters when
* a popup opens after the user parked the compact bar near a monitor edge.
*/
export function getHudOverlayResizedBounds(
currentBounds: HudOverlayBounds,
workArea: HudOverlayBounds,
requestedWidth: number,
requestedHeight: number,
): HudOverlayBounds {
const workWidth = Math.max(1, Math.round(workArea.width));
const workHeight = Math.max(1, Math.round(workArea.height));
const width = Math.min(workWidth, Math.max(1, Math.round(requestedWidth)));
const height = Math.min(workHeight, Math.max(1, Math.round(requestedHeight)));
const centerX = currentBounds.x + currentBounds.width / 2;
const bottomY = currentBounds.y + currentBounds.height;
const minX = Math.round(workArea.x);
const minY = Math.round(workArea.y);
const maxX = minX + workWidth - width;
const maxY = minY + workHeight - height;

return {
x: clamp(Math.round(centerX - width / 2), minX, maxX),
y: clamp(Math.round(bottomY - height), minY, maxY),
width,
height,
};
}
56 changes: 56 additions & 0 deletions electron/hudOverlayDrag.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import {
getHudOverlayDragBounds,
getHudOverlayDragPosition,
parseHudOverlayDragPoint,
} from "./hudOverlayDrag";

describe("HUD overlay anchored dragging", () => {
it("moves by the OS cursor delta without a scale multiplier", () => {
expect(
getHudOverlayDragPosition({ x: 1244, y: 1634 }, { x: 1272, y: 1671 }, { x: 1372, y: 1671 }),
).toEqual({ x: 1344, y: 1634 });
});

it("supports negative mixed-monitor origins", () => {
expect(
getHudOverlayDragPosition({ x: -1800, y: -560 }, { x: -1760, y: -520 }, { x: -1300, y: 80 }),
).toEqual({ x: -1340, y: 40 });
});

it("always resolves from the start instead of accumulating prior frames", () => {
const startWindow = { x: 1244, y: 1634 };
const startCursor = { x: 1272, y: 1671 };
expect(getHudOverlayDragPosition(startWindow, startCursor, { x: 1273, y: 1672 })).toEqual({
x: 1245,
y: 1635,
});
expect(getHudOverlayDragPosition(startWindow, startCursor, { x: 1274, y: 1673 })).toEqual({
x: 1246,
y: 1636,
});
});

it("rounds only the final DIP position", () => {
expect(
getHudOverlayDragPosition({ x: 10.25, y: 20.25 }, { x: 5.5, y: 8.5 }, { x: 7, y: 10 }),
).toEqual({ x: 12, y: 22 });
});

it("keeps the full BrowserWindow size immutable while moving", () => {
expect(
getHudOverlayDragBounds(
{ x: 1244, y: 1202, width: 220, height: 526 },
{ x: 1272, y: 1220 },
{ x: 1372, y: 1320 },
),
).toEqual({ x: 1344, y: 1302, width: 220, height: 526 });
});

it("accepts only finite renderer screen coordinates", () => {
expect(parseHudOverlayDragPoint(1278.7, 1681.2)).toEqual({ x: 1278.7, y: 1681.2 });
expect(parseHudOverlayDragPoint(Number.NaN, 1)).toBeNull();
expect(parseHudOverlayDragPoint(1, Number.POSITIVE_INFINITY)).toBeNull();
expect(parseHudOverlayDragPoint("1278.7", 1681.2)).toBeNull();
});
});
49 changes: 49 additions & 0 deletions electron/hudOverlayDrag.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
export type HudOverlayDragPoint = {
x: number;
y: number;
};

export type HudOverlayDragBounds = HudOverlayDragPoint & {
width: number;
height: number;
};

export function parseHudOverlayDragPoint(x: unknown, y: unknown): HudOverlayDragPoint | null {
return typeof x === "number" && Number.isFinite(x) && typeof y === "number" && Number.isFinite(y)
? { x, y }
: null;
}

/**
* Resolve each drag frame from the immutable start state. Electron exposes both
* BrowserWindow bounds and cursor screen points in DIP, so no display scale
* conversion is needed at 125%, arbitrary custom scaling, or across monitors.
* Re-anchoring to the start also prevents rounding error from accumulating.
*/
export function getHudOverlayDragPosition(
startWindow: HudOverlayDragPoint,
startCursor: HudOverlayDragPoint,
currentCursor: HudOverlayDragPoint,
): HudOverlayDragPoint {
return {
x: Math.round(startWindow.x + currentCursor.x - startCursor.x),
y: Math.round(startWindow.y + currentCursor.y - startCursor.y),
};
}

/**
* Keep the BrowserWindow's logical size immutable for the complete drag. On
* fractional-DPI Windows displays, repeatedly moving only the HWND position can
* otherwise let Chromium publish slightly different viewport dimensions.
*/
export function getHudOverlayDragBounds(
startWindow: HudOverlayDragBounds,
startCursor: HudOverlayDragPoint,
currentCursor: HudOverlayDragPoint,
): HudOverlayDragBounds {
return {
...getHudOverlayDragPosition(startWindow, startCursor, currentCursor),
width: startWindow.width,
height: startWindow.height,
};
}
46 changes: 46 additions & 0 deletions electron/hudOverlayMousePolicy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import {
isPointInsideHudOverlayBounds,
shouldIgnoreHudOverlayMouseEvents,
supportsHudOverlayHoverClickThrough,
} from "./hudOverlayMousePolicy";

describe("HUD overlay mouse policy", () => {
const bounds = { x: 1244, y: 1634, width: 587, height: 92 };

it("turns click-through off while the cursor is inside the HUD window", () => {
expect(shouldIgnoreHudOverlayMouseEvents(true, { x: 1278, y: 1680 }, bounds)).toBe(false);
});

it("restores click-through outside the HUD window", () => {
expect(shouldIgnoreHudOverlayMouseEvents(true, { x: 3370, y: -1057 }, bounds)).toBe(true);
});

it("keeps the HUD interactive when the renderer has an open control", () => {
expect(shouldIgnoreHudOverlayMouseEvents(false, { x: 3370, y: -1057 }, bounds)).toBe(false);
});

it("supports negative monitor origins without scale conversion", () => {
const negativeBounds = { x: -1920, y: -600, width: 640, height: 100 };
expect(isPointInsideHudOverlayBounds({ x: -1600, y: -550 }, negativeBounds)).toBe(true);
expect(isPointInsideHudOverlayBounds({ x: -1279, y: -550 }, negativeBounds)).toBe(false);
});

it("uses an exclusive right and bottom edge", () => {
expect(isPointInsideHudOverlayBounds({ x: 1244, y: 1634 }, bounds)).toBe(true);
expect(isPointInsideHudOverlayBounds({ x: 1831, y: 1725 }, bounds)).toBe(false);
expect(isPointInsideHudOverlayBounds({ x: 1830, y: 1726 }, bounds)).toBe(false);
});

it("rejects empty bounds", () => {
expect(
isPointInsideHudOverlayBounds({ x: 0, y: 0 }, { x: 0, y: 0, width: 0, height: 92 }),
).toBe(false);
});

it("keeps the Windows HUD interactive before the first mouse-down", () => {
expect(supportsHudOverlayHoverClickThrough("win32")).toBe(false);
expect(supportsHudOverlayHoverClickThrough("darwin")).toBe(true);
expect(supportsHudOverlayHoverClickThrough("linux")).toBe(true);
});
});
51 changes: 51 additions & 0 deletions electron/hudOverlayMousePolicy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
export type HudOverlayPoint = {
x: number;
y: number;
};

export type HudOverlayBounds = HudOverlayPoint & {
width: number;
height: number;
};

/**
* Windows native drag regions only become draggable when the BrowserWindow is
* already accepting mouse input before the initial button-down. Toggling a
* whole-window click-through flag on hover therefore has an unavoidable race
* at the first contact with the HUD. The Windows HUD is content-sized, so keep
* it interactive and reserve hover-based click-through for other platforms.
*/
export function supportsHudOverlayHoverClickThrough(platform: string): boolean {
return platform !== "win32";
}

export function isPointInsideHudOverlayBounds(
point: HudOverlayPoint,
bounds: HudOverlayBounds,
): boolean {
return (
bounds.width > 0 &&
bounds.height > 0 &&
point.x >= bounds.x &&
point.x < bounds.x + bounds.width &&
point.y >= bounds.y &&
point.y < bounds.y + bounds.height
);
}

/**
* Renderer hover events are only a fast path. A native Electron drag region
* consumes pointer events, so the main process must also make the HUD interactive
* whenever the OS cursor is inside the BrowserWindow bounds.
*
* Electron reports both cursor points and BrowserWindow bounds in DIP, so this
* comparison stays valid across arbitrary Windows scaling and negative monitor
* origins without multiplying by a scale factor.
*/
export function shouldIgnoreHudOverlayMouseEvents(
rendererRequestedIgnore: boolean,
cursor: HudOverlayPoint,
bounds: HudOverlayBounds,
): boolean {
return rendererRequestedIgnore && !isPointInsideHudOverlayBounds(cursor, bounds);
}
Loading
Loading