Skip to content
Merged
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
145 changes: 145 additions & 0 deletions electron/hudOverlayBounds.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { describe, expect, it } from "vitest";

import {
getHudOverlayWindowBounds,
resizeHudOverlayFallbackBounds,
} from "./hudOverlayBounds";

describe("getHudOverlayWindowBounds", () => {
const workArea = {
x: 120,
y: 40,
width: 1920,
height: 1040,
};

it("uses the full work area when mouse passthrough is supported", () => {
expect(getHudOverlayWindowBounds(workArea, true)).toEqual(workArea);
});

it("uses a bottom-centered compact fallback when mouse passthrough is unavailable", () => {
expect(getHudOverlayWindowBounds(workArea, false)).toEqual({
x: 650,
y: 920,
width: 860,
height: 160,
});
});

it("expands the non-passthrough fallback for HUD menus and hover interaction", () => {
expect(getHudOverlayWindowBounds(workArea, false, true)).toEqual({
x: 650,
y: 540,
width: 860,
height: 540,
});
});

it("keeps the compact fallback inside small displays", () => {
expect(
getHudOverlayWindowBounds(
{
x: -100,
y: 20,
width: 640,
height: 420,
},
false,
),
).toEqual({
x: -100,
y: 280,
width: 640,
height: 160,
});
});

it("fits the expanded fallback inside small displays", () => {
expect(
getHudOverlayWindowBounds(
{
x: -100,
y: 20,
width: 640,
height: 420,
},
false,
true,
),
).toEqual({
x: -100,
y: 20,
width: 640,
height: 420,
});
});
});

describe("resizeHudOverlayFallbackBounds", () => {
const workArea = {
x: 0,
y: 0,
width: 1920,
height: 1080,
};

it("preserves the dragged bottom edge when expanding", () => {
expect(
resizeHudOverlayFallbackBounds(
workArea,
{
x: 420,
y: 700,
width: 860,
height: 160,
},
true,
),
).toEqual({
x: 420,
y: 320,
width: 860,
height: 540,
});
});

it("preserves the dragged bottom edge when compacting", () => {
expect(
resizeHudOverlayFallbackBounds(
workArea,
{
x: 420,
y: 320,
width: 860,
height: 540,
},
false,
),
).toEqual({
x: 420,
y: 700,
width: 860,
height: 160,
});
});

it("keeps resized fallback bounds inside the display work area", () => {
expect(
resizeHudOverlayFallbackBounds(
workArea,
{
x: 1500,
y: 900,
width: 860,
height: 160,
},
true,
),
).toEqual({
x: 1060,
y: 520,
width: 860,
height: 540,
});
});
});
55 changes: 55 additions & 0 deletions electron/hudOverlayBounds.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
export interface HudOverlayWorkArea {
x: number;
y: number;
width: number;
height: number;
}

const NON_PASSTHROUGH_HUD_WIDTH_DIP = 860;
const NON_PASSTHROUGH_HUD_COMPACT_HEIGHT_DIP = 160;
const NON_PASSTHROUGH_HUD_EXPANDED_HEIGHT_DIP = 540;

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

export function getHudOverlayWindowBounds(
workArea: HudOverlayWorkArea,
mousePassthroughSupported: boolean,
fallbackExpanded = false,
): HudOverlayWorkArea {
if (mousePassthroughSupported) {
return { ...workArea };
}

const width = Math.min(workArea.width, NON_PASSTHROUGH_HUD_WIDTH_DIP);
const height = Math.min(
workArea.height,
fallbackExpanded
? NON_PASSTHROUGH_HUD_EXPANDED_HEIGHT_DIP
: NON_PASSTHROUGH_HUD_COMPACT_HEIGHT_DIP,
);

return {
x: Math.round(workArea.x + (workArea.width - width) / 2),
y: Math.round(workArea.y + workArea.height - height),
width,
height,
};
}

export function resizeHudOverlayFallbackBounds(
workArea: HudOverlayWorkArea,
currentBounds: HudOverlayWorkArea,
fallbackExpanded: boolean,
): HudOverlayWorkArea {
const nextBounds = getHudOverlayWindowBounds(workArea, false, fallbackExpanded);
const maxX = workArea.x + workArea.width - nextBounds.width;
const maxY = workArea.y + workArea.height - nextBounds.height;

return {
...nextBounds,
x: clamp(currentBounds.x, workArea.x, maxX),
y: clamp(currentBounds.y + currentBounds.height - nextBounds.height, workArea.y, maxY),
};
}
41 changes: 35 additions & 6 deletions electron/windows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { app, BrowserWindow, ipcMain } from "electron";
import { USER_DATA_PATH } from "./appPaths";
import {
getHudOverlayWindowBounds,
resizeHudOverlayFallbackBounds,
} from "./hudOverlayBounds";
import { getPackagedRendererBaseUrl } from "./rendererServer";

const electronWindowsDir = path.dirname(fileURLToPath(import.meta.url));
Expand All @@ -23,6 +27,7 @@ const WINDOW_ICON_PATH = path.join(
let hudOverlayWindow: BrowserWindow | null = null;
let hudOverlayHiddenFromCapture = true;
let hudOverlayCaptureProtectionLoaded = false;
let hudOverlayFallbackExpanded = false;
let countdownWindow: BrowserWindow | null = null;
let updateToastWindow: BrowserWindow | null = null;

Expand Down Expand Up @@ -183,12 +188,11 @@ function getHudOverlayDisplay() {

function getHudOverlayBounds() {
const { workArea } = getHudOverlayDisplay();
return {
x: workArea.x,
y: workArea.y,
width: workArea.width,
height: workArea.height,
};
return getHudOverlayWindowBounds(
workArea,
isHudOverlayMousePassthroughSupported(),
hudOverlayFallbackExpanded,
);
}

function applyHudOverlayBounds() {
Expand Down Expand Up @@ -242,9 +246,33 @@ function positionUpdateToastWindow() {
updateToastWindow.moveTop();
}

function setHudOverlayFallbackExpanded(expanded: boolean) {
hudOverlayFallbackExpanded = expanded;
if (
!hudOverlayWindow ||
hudOverlayWindow.isDestroyed() ||
isHudOverlayMousePassthroughSupported()
) {
return;
}

const { workArea } = getHudOverlayDisplay();
const nextBounds = resizeHudOverlayFallbackBounds(
workArea,
hudOverlayWindow.getBounds(),
expanded,
);
hudOverlayWindow.setBounds(nextBounds, false);
positionUpdateToastWindow();
if (hudOverlayWindow.isVisible()) {
hudOverlayWindow.moveTop();
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

ipcMain.on("hud-overlay-set-ignore-mouse", (_event, ignore: boolean) => {
if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) {
if (!isHudOverlayMousePassthroughSupported()) {
setHudOverlayFallbackExpanded(!ignore);
hudOverlayWindow.setIgnoreMouseEvents(false);
return;
}
Expand Down Expand Up @@ -358,6 +386,7 @@ ipcMain.handle("set-hud-overlay-capture-protection", (_event, enabled: boolean)

export function createHudOverlayWindow(): BrowserWindow {
loadHudOverlayCaptureProtectionSetting();
hudOverlayFallbackExpanded = false;
const initialBounds = getHudOverlayBounds();
let hasShownHudWindow = false;

Expand Down
46 changes: 21 additions & 25 deletions src/components/launch/LaunchWindow.tsx
Original file line number Diff line number Diff line change
@@ -1,46 +1,45 @@
import {
ArrowClockwiseIcon,
CaretUpIcon,
DotsThreeVerticalIcon,
MicrophoneIcon,
MicrophoneSlashIcon,
MinusIcon,
MonitorIcon,
DotsThreeVerticalIcon,
TimerIcon,
VideoCameraIcon,
VideoCameraSlashIcon,
XIcon,
ArrowClockwiseIcon,
} from "@phosphor-icons/react";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useRef } from "react";
import { RxDragHandleDots2 } from "react-icons/rx";
import { Separator } from "@/components/ui/separator";
import { useScopedT } from "../../contexts/I18nContext";
import { useHudBarDrag } from "./hooks/useHudBarDrag";
import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices";
import { useLaunchWindowSystemState } from "./hooks/useLaunchWindowSystemState";
import { useLaunchHudInteractionState } from "./hooks/useLaunchHudInteractionState";
import { useLaunchWindowActions } from "./hooks/useLaunchWindowActions";
import { useRecordingTimer } from "./hooks/useRecordingTimer";
import { useScreenRecorder } from "../../hooks/useScreenRecorder";
import { useVideoDevices } from "../../hooks/useVideoDevices";
import { useWebcamPreviewOverlay } from "./hooks/useWebcamPreviewOverlay";
import { Button } from "../ui/button";
import { HudInteractionContext } from "./contexts/HudInteractionContext";
import {
canToggleFloatingWebcamPreview,
} from "./floatingWebcamPreview";
import { LaunchPopoverCoordinatorProvider, useLaunchPopoverCoordinator } from "./popovers/LaunchPopoverCoordinator";
import { useHudBarDrag } from "./hooks/useHudBarDrag";
import { useLaunchHudInteractionState } from "./hooks/useLaunchHudInteractionState";
import { useLaunchWindowActions } from "./hooks/useLaunchWindowActions";
import { useLaunchWindowSystemState } from "./hooks/useLaunchWindowSystemState";
import { useRecordingTimer } from "./hooks/useRecordingTimer";
import { useWebcamPreviewOverlay } from "./hooks/useWebcamPreviewOverlay";
import styles from "./LaunchWindow.module.css";
import { CountdownPopover } from "./popovers/CountdownPopover";
import { LaunchPopoverCoordinatorProvider, useLaunchPopoverCoordinator } from "./popovers/LaunchPopoverCoordinator";
import { MicPopover } from "./popovers/MicPopover";
import { MorePopover } from "./popovers/MorePopover";
import { ProjectPopover } from "./popovers/ProjectPopover";
import { SourcePopover } from "./popovers/SourcePopover";
import { WebcamPopover } from "./popovers/WebcamPopover";
import { HudInteractionContext } from "./contexts/HudInteractionContext";
import { MarqueeText } from "./SourceSelector";
import styles from "./LaunchWindow.module.css";

import { Separator } from "@/components/ui/separator";
import { Button } from "../ui/button";
import { RecordingControls } from "./RecordingControls";
import { useEffect, useRef } from "react";
import { MarqueeText } from "./SourceSelector";

const SHOW_DEV_UPDATE_PREVIEW = import.meta.env.DEV;

Expand Down Expand Up @@ -427,6 +426,8 @@ function LaunchWindowContent() {
);

const hudMode = finalizing ? "finalizing" : recording ? "recording" : "idle";
const useNativeHudBarDrag =
platform === "linux" || hudOverlayMousePassthroughSupported === false;

return (
<HudInteractionContext.Provider value={{ onMouseEnter: handleHudMouseEnter, onMouseLeave: handleHudMouseLeave }}>
Expand Down Expand Up @@ -456,16 +457,11 @@ function LaunchWindowContent() {
className={`${styles.bar} launch-theme mb-2`}
>
<div
// On Linux (especially Wayland) the compositor owns window
// placement, so BrowserWindow.setBounds() is silently ignored.
// Fall back to a native OS drag via -webkit-app-region on the
// handle. We still need JS pointer handlers in webcam-preview
// mode (which translates via CSS inside the window), so only
// mark the handle as a native drag region for the IPC path.
// Linux compositors and non-passthrough Windows fallback windows
// need native window dragging; the JS drag path only translates
// content inside the HUD window.
className={`flex items-center px-0.5 cursor-grab active:cursor-grabbing ${
platform === "linux" && !showRecordingWebcamPreview
? styles.electronDrag
: ""
useNativeHudBarDrag ? styles.electronDrag : ""
}`}
onPointerDown={handleHudBarPointerDown}
onPointerMove={handleHudBarPointerMove}
Expand Down