From 23afa4c41817bef4a5d20e9b93eab56ddf3e94d6 Mon Sep 17 00:00:00 2001 From: Rover <110387638+rover-001@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:36:31 +0530 Subject: [PATCH 1/3] Fix Linux Wayland HUD oscillation on hover and popover menu clipping - Expand HUD window only while popover menus are active, preventing hover oscillation loops under Wayland compositors (e.g. Hyprland) that re-center resizing floating windows - Calculate dynamic bottom padding when resize anchor is centered so the HUD bar remains stationary on screen during window expansion - Increase NON_PASSTHROUGH_HUD_EXPANDED_HEIGHT_DIP from 540 to 680 to prevent tall popover dropdowns (More, Mic, Cam) from clipping at the top - Update unit tests for 680px bounds and getHudOverlayResizeAnchor - Document Hyprland/Omarchy window rules in README.md --- README.md | 31 +++++++++++++++ electron/electron-env.d.ts | 2 + electron/hudOverlayBounds.test.ts | 39 +++++++++++++++---- electron/hudOverlayBounds.ts | 17 +++++++- electron/preload.ts | 3 ++ electron/windows.ts | 10 +++++ src/components/launch/LaunchWindow.tsx | 9 ++++- .../hooks/useLaunchHudInteractionState.ts | 1 + .../hooks/useLaunchWindowSystemState.ts | 5 +++ 9 files changed, 106 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 121ef9343..946419fca 100644 --- a/README.md +++ b/README.md @@ -225,6 +225,37 @@ Target-specific build commands are also available: --- +## Linux: Hyprland / Omarchy + +Recordly's recording HUD, countdown, and source picker are transparent floating windows. +Hyprland decorates them like any other window, so the compositor's blur, shadow, dim, and +opacity rules show up as a grey box around the HUD. Wayland also ignores `alwaysOnTop`, so +the HUD can end up behind other windows or stuck on one workspace. + +Add these rules to `~/.config/hypr/hyprland.lua` (Omarchy) and run `hyprctl reload`: + +```lua +o.window("^[Rr]ecordly$", { tag = "-default-opacity", opacity = "1 1" }) +o.window({ class = "^[Rr]ecordly$", float = true }, { + pin = true, + no_blur = true, + no_shadow = true, + border_size = 0, + no_dim = true, +}) +``` + +Plain `hyprland.conf` equivalent: + +```ini +windowrule = opacity 1 1, class:^[Rr]ecordly$ +windowrule = pin, class:^[Rr]ecordly$, floating:1 +windowrule = noblur, class:^[Rr]ecordly$, floating:1 +windowrule = noshadow, class:^[Rr]ecordly$, floating:1 +windowrule = nodim, class:^[Rr]ecordly$, floating:1 +windowrule = bordersize 0, class:^[Rr]ecordly$, floating:1 +``` + ## macOS: "App cannot be opened" Locally built apps may be quarantined by macOS. diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index c00ef3226..87e708bda 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -198,6 +198,7 @@ interface RendererNativeExportCapabilities { interface Window { electronAPI: { hudOverlaySetIgnoreMouse: (ignore: boolean) => void; + hudOverlaySetMenuOpen: (open: boolean) => void; hudOverlaySetSourceSelectionActive: (active: boolean) => void; hudOverlayDrag: (phase: "start" | "move" | "end", screenX: number, screenY: number) => void; hudOverlayHide: () => void; @@ -208,6 +209,7 @@ interface Window { getHudOverlayMousePassthroughSupported: () => Promise<{ success: boolean; supported: boolean; + resizeAnchor?: "bottom" | "center"; }>; setHudOverlayCaptureProtection: ( enabled: boolean, diff --git a/electron/hudOverlayBounds.test.ts b/electron/hudOverlayBounds.test.ts index db21e92bd..d08b59b0a 100644 --- a/electron/hudOverlayBounds.test.ts +++ b/electron/hudOverlayBounds.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { + getHudOverlayResizeAnchor, getHudOverlayWindowBounds, resizeHudOverlayFallbackBounds, shouldExpandHudOverlayFallback, @@ -30,9 +31,9 @@ describe("getHudOverlayWindowBounds", () => { it("expands the non-passthrough fallback for HUD menus and hover interaction", () => { expect(getHudOverlayWindowBounds(workArea, false, true)).toEqual({ x: 650, - y: 540, + y: 400, width: 860, - height: 540, + height: 680, }); }); @@ -98,9 +99,9 @@ describe("resizeHudOverlayFallbackBounds", () => { ), ).toEqual({ x: 420, - y: 320, + y: 180, width: 860, - height: 540, + height: 680, }); }); @@ -110,9 +111,9 @@ describe("resizeHudOverlayFallbackBounds", () => { workArea, { x: 420, - y: 320, + y: 180, width: 860, - height: 540, + height: 680, }, false, ), @@ -138,13 +139,35 @@ describe("resizeHudOverlayFallbackBounds", () => { ), ).toEqual({ x: 1060, - y: 520, + y: 380, width: 860, - height: 540, + height: 680, }); }); }); +describe("getHudOverlayResizeAnchor", () => { + it("returns bottom on non-linux platforms", () => { + expect(getHudOverlayResizeAnchor("darwin", {})).toBe("bottom"); + expect(getHudOverlayResizeAnchor("win32", {})).toBe("bottom"); + }); + + it("returns center on linux under wayland", () => { + expect( + getHudOverlayResizeAnchor("linux", { XDG_SESSION_TYPE: "wayland" }), + ).toBe("center"); + expect( + getHudOverlayResizeAnchor("linux", { WAYLAND_DISPLAY: "wayland-1" }), + ).toBe("center"); + }); + + it("returns bottom on linux under x11", () => { + expect( + getHudOverlayResizeAnchor("linux", { XDG_SESSION_TYPE: "x11" }), + ).toBe("bottom"); + }); +}); + describe("shouldExpandHudOverlayFallback", () => { it("expands while recording only when the floating webcam preview is visible", () => { expect( diff --git a/electron/hudOverlayBounds.ts b/electron/hudOverlayBounds.ts index 8c51b88c7..0f24ca8ea 100644 --- a/electron/hudOverlayBounds.ts +++ b/electron/hudOverlayBounds.ts @@ -7,12 +7,27 @@ export interface HudOverlayWorkArea { const NON_PASSTHROUGH_HUD_WIDTH_DIP = 860; const NON_PASSTHROUGH_HUD_COMPACT_HEIGHT_DIP = 160; -const NON_PASSTHROUGH_HUD_EXPANDED_HEIGHT_DIP = 540; +const NON_PASSTHROUGH_HUD_EXPANDED_HEIGHT_DIP = 680; function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } +export type HudOverlayResizeAnchor = "bottom" | "center"; + +// Wayland refuses client-side window placement, so the compositor decides where +// a resized HUD lands. Hyprland keeps floating windows centered while X11 honors +// the bottom-anchored bounds this module computes. +export function getHudOverlayResizeAnchor( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, +): HudOverlayResizeAnchor { + if (platform !== "linux") { + return "bottom"; + } + return env.XDG_SESSION_TYPE === "wayland" || env.WAYLAND_DISPLAY ? "center" : "bottom"; +} + export function getHudOverlayWindowBounds( workArea: HudOverlayWorkArea, mousePassthroughSupported: boolean, diff --git a/electron/preload.ts b/electron/preload.ts index a30372314..fced9567d 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -167,6 +167,9 @@ contextBridge.exposeInMainWorld("electronAPI", { hudOverlaySetIgnoreMouse: (ignore: boolean) => { ipcRenderer.send("hud-overlay-set-ignore-mouse", ignore); }, + hudOverlaySetMenuOpen: (open: boolean) => { + ipcRenderer.send("hud-overlay-set-menu-open", open); + }, hudOverlaySetSourceSelectionActive: (active: boolean) => { ipcRenderer.send("hud-overlay-set-source-selection-active", active); }, diff --git a/electron/windows.ts b/electron/windows.ts index 47e90b8fc..0083275f5 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -6,6 +6,7 @@ import { app, BrowserWindow, ipcMain } from "electron"; import { supportsHudCaptureProtection } from "../src/lib/hudCaptureProtection"; import { USER_DATA_PATH } from "./appPaths"; import { + getHudOverlayResizeAnchor, getHudOverlayWindowBounds, resizeHudOverlayFallbackBounds, shouldExpandHudOverlayFallback, @@ -331,6 +332,14 @@ ipcMain.on("hud-overlay-set-ignore-mouse", (_event, ignore: boolean) => { setHudOverlayMousePassthrough(Boolean(ignore)); }); +// Linux has no hover-driven passthrough, so the compact HUD window only grows +// while a menu is open; growing on hover moves the bar out from under the pointer. +ipcMain.on("hud-overlay-set-menu-open", (_event, open: boolean) => { + if (process.platform === "linux") { + setHudOverlayFallbackExpanded(Boolean(open)); + } +}); + ipcMain.on("hud-overlay-set-source-selection-active", (_event, active: boolean) => { hudOverlaySourceSelectionActive = Boolean(active); if (hudOverlaySourceSelectionActive) { @@ -418,6 +427,7 @@ ipcMain.handle("get-hud-overlay-mouse-passthrough-supported", () => { return { success: true, supported: isHudOverlayMousePassthroughSupported(), + resizeAnchor: getHudOverlayResizeAnchor(process.platform, process.env), }; }); diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index f7583b28e..add1cddd2 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -109,6 +109,7 @@ function LaunchWindowContent() { const { hudOverlayMousePassthroughSupported, + hudOverlayResizeAnchor, platform, appVersion, hideHudFromCapture, @@ -446,8 +447,12 @@ function LaunchWindowContent() { value={{ onMouseEnter: handleHudMouseEnter, onMouseLeave: handleHudMouseLeave }} >
(null); useEffect(() => { + window.electronAPI?.hudOverlaySetMenuOpen?.(openId !== null); if (openId !== null) { if (timeoutRef.current) clearTimeout(timeoutRef.current); window.electronAPI?.hudOverlaySetIgnoreMouse?.(false); diff --git a/src/components/launch/hooks/useLaunchWindowSystemState.ts b/src/components/launch/hooks/useLaunchWindowSystemState.ts index 56793fa36..f3199967a 100644 --- a/src/components/launch/hooks/useLaunchWindowSystemState.ts +++ b/src/components/launch/hooks/useLaunchWindowSystemState.ts @@ -7,6 +7,9 @@ export function useLaunchWindowSystemState( const [hudOverlayMousePassthroughSupported, setHudOverlayMousePassthroughSupported] = useState< boolean | null >(null); + const [hudOverlayResizeAnchor, setHudOverlayResizeAnchor] = useState<"bottom" | "center">( + "bottom", + ); const [platform, setPlatform] = useState(null); const [appVersion, setAppVersion] = useState(null); const [hideHudFromCapture, setHideHudFromCapture] = useState(true); @@ -54,6 +57,7 @@ export function useLaunchWindowSystemState( const result = await window.electronAPI.getHudOverlayMousePassthroughSupported(); if (!cancelled && result.success) { setHudOverlayMousePassthroughSupported(result.supported); + setHudOverlayResizeAnchor(result.resizeAnchor ?? "bottom"); } } catch (error) { console.error("Failed to load HUD overlay mouse passthrough support:", error); @@ -132,6 +136,7 @@ export function useLaunchWindowSystemState( return { recordingsDirectory, hudOverlayMousePassthroughSupported, + hudOverlayResizeAnchor, platform, appVersion, hideHudFromCapture, From fd53b7dbd4777f3bf4e902b311f5f520ae028be1 Mon Sep 17 00:00:00 2001 From: Rover <110387638+rover-001@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:47:40 +0530 Subject: [PATCH 2/3] Address review feedback: track popover close timeout and document Wayland layout constants - Store popover close setTimeout handle in ref, cancel on openId change/unmount, and verify openId remains null before ignoring mouse - Derive Wayland center offset constant from compact HUD height (160px) and standard bottom padding (20px) with explanatory documentation --- src/components/launch/LaunchWindow.tsx | 17 ++++++++++++++++- .../hooks/useLaunchHudInteractionState.ts | 19 +++++++++++++++++-- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index add1cddd2..81c892e92 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -45,6 +45,19 @@ import { MarqueeText } from "./SourceSelector"; const SHOW_DEV_UPDATE_PREVIEW = import.meta.env.DEV; +// Wayland compositors (e.g. Hyprland) re-center resizing floating windows around their +// midpoint rather than honoring bottom bounds. +// In compact mode, the window is 160px tall with 20px (1.25rem / pb-5) bottom padding. +// The distance from the window center (50vh = 80px) to the HUD bottom edge is: +// 80px - 20px = 60px. +// Setting paddingBottom to calc(50vh - 60px) maintains this constant 60px offset from +// the window center, ensuring the HUD bar remains visually stationary on screen when +// the window expands vertically to accommodate popover menus. +const COMPACT_HUD_HEIGHT_DIP = 160; +const STANDARD_HUD_BOTTOM_PADDING_PX = 20; // 1.25rem (pb-5) +const WAYLAND_CENTER_OFFSET_PX = + COMPACT_HUD_HEIGHT_DIP / 2 - STANDARD_HUD_BOTTOM_PADDING_PX; + export function LaunchWindow() { return ( @@ -451,7 +464,9 @@ function LaunchWindowContent() { style={{ height: "100vh", paddingBottom: - hudOverlayResizeAnchor === "center" ? "calc(50vh - 60px)" : "1.25rem", + hudOverlayResizeAnchor === "center" + ? `calc(50vh - ${WAYLAND_CENTER_OFFSET_PX}px)` + : "1.25rem", }} >
(null); + const popoverCloseTimeoutRef = useRef(null); + const openIdRef = useRef(openId); + openIdRef.current = openId; useEffect(() => { + if (popoverCloseTimeoutRef.current) { + clearTimeout(popoverCloseTimeoutRef.current); + popoverCloseTimeoutRef.current = null; + } + window.electronAPI?.hudOverlaySetMenuOpen?.(openId !== null); 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) { + popoverCloseTimeoutRef.current = setTimeout(() => { + if (openIdRef.current === null && !isMouseOverHudRef.current) { window.electronAPI?.hudOverlaySetIgnoreMouse?.(true); } }, 150); } + + return () => { + if (popoverCloseTimeoutRef.current) { + clearTimeout(popoverCloseTimeoutRef.current); + popoverCloseTimeoutRef.current = null; + } + }; }, [openId]); useEffect(() => { From 84aca58c4bfbb79c4e4d474cc4639b59c30e7fcc Mon Sep 17 00:00:00 2001 From: Rover <110387638+rover-001@users.noreply.github.com> Date: Fri, 4 Sep 2026 12:00:22 +0530 Subject: [PATCH 3/3] Address CodeRabbit review feedback: move ref mutation to useLayoutEffect and version Hyprland window rules - Move openIdRef update from render body to useLayoutEffect to comply with React purity rules and prevent leaking uncommitted state - Add version-specific windowrule syntax for Hyprland 0.53.0+ (match:class) alongside legacy windowrulev2 syntax (<0.53.0) in README.md --- README.md | 27 ++++++++++++++----- .../hooks/useLaunchHudInteractionState.ts | 7 +++-- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 946419fca..51a51e7cf 100644 --- a/README.md +++ b/README.md @@ -245,15 +245,28 @@ o.window({ class = "^[Rr]ecordly$", float = true }, { }) ``` -Plain `hyprland.conf` equivalent: +Plain `hyprland.conf` equivalents: + +**Hyprland 0.53.0+ (new windowrule syntax):** + +```ini +windowrule = opacity 1 1, match:class ^[Rr]ecordly$ +windowrule = pin, match:class ^[Rr]ecordly$, match:float 1 +windowrule = noblur, match:class ^[Rr]ecordly$, match:float 1 +windowrule = noshadow, match:class ^[Rr]ecordly$, match:float 1 +windowrule = nodim, match:class ^[Rr]ecordly$, match:float 1 +windowrule = bordersize 0, match:class ^[Rr]ecordly$, match:float 1 +``` + +**Hyprland < 0.53.0 (legacy windowrulev2 syntax):** ```ini -windowrule = opacity 1 1, class:^[Rr]ecordly$ -windowrule = pin, class:^[Rr]ecordly$, floating:1 -windowrule = noblur, class:^[Rr]ecordly$, floating:1 -windowrule = noshadow, class:^[Rr]ecordly$, floating:1 -windowrule = nodim, class:^[Rr]ecordly$, floating:1 -windowrule = bordersize 0, class:^[Rr]ecordly$, floating:1 +windowrulev2 = opacity 1 1, class:^[Rr]ecordly$ +windowrulev2 = pin, class:^[Rr]ecordly$, floating:1 +windowrulev2 = noblur, class:^[Rr]ecordly$, floating:1 +windowrulev2 = noshadow, class:^[Rr]ecordly$, floating:1 +windowrulev2 = nodim, class:^[Rr]ecordly$, floating:1 +windowrulev2 = bordersize 0, class:^[Rr]ecordly$, floating:1 ``` ## macOS: "App cannot be opened" diff --git a/src/components/launch/hooks/useLaunchHudInteractionState.ts b/src/components/launch/hooks/useLaunchHudInteractionState.ts index 8760bc955..ad3e6528d 100644 --- a/src/components/launch/hooks/useLaunchHudInteractionState.ts +++ b/src/components/launch/hooks/useLaunchHudInteractionState.ts @@ -1,4 +1,4 @@ -import { type MouseEvent, type RefObject, useCallback, useEffect, useRef } from "react"; +import { type MouseEvent, type RefObject, useCallback, useEffect, useLayoutEffect, useRef } from "react"; export function useLaunchHudInteractionState({ openId, @@ -15,7 +15,10 @@ export function useLaunchHudInteractionState({ const timeoutRef = useRef(null); const popoverCloseTimeoutRef = useRef(null); const openIdRef = useRef(openId); - openIdRef.current = openId; + + useLayoutEffect(() => { + openIdRef.current = openId; + }, [openId]); useEffect(() => { if (popoverCloseTimeoutRef.current) {