From d3df12ede0d83af6f26d036f60945127d0638e8e Mon Sep 17 00:00:00 2001 From: MinhOmega <49482201+MinhOmega@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:58:39 +0700 Subject: [PATCH 1/2] fix(linux): stop HUD menus being clipped by the fallback window Without mouse passthrough the HUD is an ordinary 860x160 window, and the popovers render inside it, so any menu taller than the window loses its top rows. The More menu is the obvious victim: with ten locales it needs roughly 510px and only about 150px were available. An expand-on-interaction path already existed, but the call sat behind `process.platform !== "linux"` inside a branch that is only reachable when `isHudOverlayMousePassthroughSupported()` is false, which is exactly `platform === "linux"`. The condition could never be true, so the window never grew and the expanded height went unused. Simply removing that guard is not enough. Growing the window on hover moves the bar out from under the pointer, which fires mouseleave, which shrinks it back under the pointer again, and the HUD oscillates (#891). Wayland makes it worse: compositors ignore client-side moves, so a window that grows keeps its top-left pinned and drags the bar down with it instead of extending upward. That was confirmed on GNOME 49 under mutter, where getBounds() reports the requested bottom-anchored position while the bar visibly moves down instead. Reserve the headroom once, at creation, and never resize for menus. The bar renders at the bottom edge of the window, so it stays where it was and the transparent space above it is already free for menus to open into. Nothing resizes, so nothing can move, on X11 or Wayland. Compact height goes from 160 to 560 (a 400px menu card, 16px of offsets and about 96px of bar and padding), with a test that fails if it ever drops below what a full-height menu needs. The expanded height used by the floating webcam preview moves to 680 so it still adds room on top of that. The hover path now documents that it deliberately does not resize, and setHudOverlayFallbackExpanded goes away with its last caller. --- electron/hudOverlayBounds.test.ts | 36 ++++++++++++++++----------- electron/hudOverlayBounds.ts | 14 +++++++++-- electron/windows.ts | 41 ++++--------------------------- 3 files changed, 39 insertions(+), 52 deletions(-) diff --git a/electron/hudOverlayBounds.test.ts b/electron/hudOverlayBounds.test.ts index db21e92bd..7840ee2e8 100644 --- a/electron/hudOverlayBounds.test.ts +++ b/electron/hudOverlayBounds.test.ts @@ -21,18 +21,26 @@ describe("getHudOverlayWindowBounds", () => { it("uses a bottom-centered compact fallback when mouse passthrough is unavailable", () => { expect(getHudOverlayWindowBounds(workArea, false)).toEqual({ x: 650, - y: 920, + y: 520, width: 860, - height: 160, + height: 560, }); }); + it("reserves enough compact height for a full-height HUD menu", () => { + // The menu card is capped at 400px and sits above roughly 96px of bar and + // padding plus 16px of offsets. If the compact window is shorter than that + // the menu is clipped by the window bounds, which is the bug this guards. + const compact = getHudOverlayWindowBounds(workArea, false); + expect(compact.height).toBeGreaterThanOrEqual(400 + 16 + 96); + }); + 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, }); }); @@ -49,9 +57,9 @@ describe("getHudOverlayWindowBounds", () => { ), ).toEqual({ x: -100, - y: 280, + y: 20, width: 640, - height: 160, + height: 420, }); }); @@ -98,9 +106,9 @@ describe("resizeHudOverlayFallbackBounds", () => { ), ).toEqual({ x: 420, - y: 320, + y: 180, width: 860, - height: 540, + height: 680, }); }); @@ -110,17 +118,17 @@ describe("resizeHudOverlayFallbackBounds", () => { workArea, { x: 420, - y: 320, + y: 180, width: 860, - height: 540, + height: 680, }, false, ), ).toEqual({ x: 420, - y: 700, + y: 300, width: 860, - height: 160, + height: 560, }); }); @@ -138,9 +146,9 @@ describe("resizeHudOverlayFallbackBounds", () => { ), ).toEqual({ x: 1060, - y: 520, + y: 380, width: 860, - height: 540, + height: 680, }); }); }); diff --git a/electron/hudOverlayBounds.ts b/electron/hudOverlayBounds.ts index 8c51b88c7..defb6b3e8 100644 --- a/electron/hudOverlayBounds.ts +++ b/electron/hudOverlayBounds.ts @@ -6,8 +6,18 @@ 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; +// Without mouse passthrough the HUD is an ordinary window, so anything the +// renderer draws outside it is clipped: a menu taller than the window loses its +// top rows. Resizing on demand is not an option either, because Wayland +// compositors ignore client-side moves, so a window that grows keeps its +// top-left pinned and drags the bar down with it. +// +// So reserve the menu headroom once, at creation. The bar renders at the bottom +// edge of the window, which leaves the space above it transparent and free for +// menus to open into without the window ever changing size. +// 400px menu card + 16px offsets + ~96px of bar and padding, rounded up. +const NON_PASSTHROUGH_HUD_COMPACT_HEIGHT_DIP = 560; +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); diff --git a/electron/windows.ts b/electron/windows.ts index 23e874fa1..1c7a67549 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -5,11 +5,7 @@ import { fileURLToPath } from "node:url"; import { app, BrowserWindow, ipcMain } from "electron"; import { supportsHudCaptureProtection } from "../src/lib/hudCaptureProtection"; import { USER_DATA_PATH } from "./appPaths"; -import { - getHudOverlayWindowBounds, - resizeHudOverlayFallbackBounds, - shouldExpandHudOverlayFallback, -} from "./hudOverlayBounds"; +import { getHudOverlayWindowBounds, shouldExpandHudOverlayFallback } from "./hudOverlayBounds"; import { getHudOverlayTaskbarOptions } from "./hudOverlayWindowOptions"; import { getPackagedRendererBaseUrl } from "./rendererServer"; @@ -265,34 +261,6 @@ function positionUpdateToastWindow() { updateToastWindow.moveTop(); } -function setHudOverlayFallbackExpanded(expanded: boolean) { - if (hudOverlayRecordingActive) { - hudOverlayFallbackExpanded = false; - return; - } - - 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(); - } -} - function setHudOverlayMousePassthrough(ignore: boolean) { hudOverlayIgnoringMouse = hudOverlaySourceSelectionActive && !hudOverlayRecordingActive ? true : ignore; @@ -312,9 +280,10 @@ function setHudOverlayMousePassthrough(ignore: boolean) { } if (!isHudOverlayMousePassthroughSupported()) { - if (process.platform !== "linux") { - setHudOverlayFallbackExpanded(!ignore); - } + // Deliberately no resize here. This branch is Linux-only, and growing the + // window on hover moves the bar out from under the pointer, which fires + // mouseleave and shrinks it back, oscillating. The fallback window instead + // reserves menu headroom up front, so menus need no resize at all. hudOverlayWindow.setIgnoreMouseEvents(false); return; } From 685b83bf8fd599135e5fbd63d09a88bad99477df Mon Sep 17 00:00:00 2001 From: MinhOmega <49482201+MinhOmega@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:58:39 +0700 Subject: [PATCH 2/2] fix(i18n): show Deutsch instead of the raw "de" locale code The HUD language menu listed German as "de". MorePopover kept its own `Record` label map that had no `de` entry, so the lookup fell through to the raw locale code. SettingsPanel had a second copy of the same map which did include German, typed as `Record`. Keep one map, in `src/i18n/config.ts` next to `SUPPORTED_LOCALES`, and have both menus read from it. Typing it as `Record` means adding a locale without a label is a compile error rather than a menu row that renders its own locale code, so the two cannot drift apart again. --- src/components/launch/popovers/MorePopover.tsx | 16 ++-------------- src/components/video-editor/SettingsPanel.tsx | 17 ++--------------- src/i18n/config.ts | 18 ++++++++++++++++++ 3 files changed, 22 insertions(+), 29 deletions(-) diff --git a/src/components/launch/popovers/MorePopover.tsx b/src/components/launch/popovers/MorePopover.tsx index 9a5a52905..f3e71300c 100644 --- a/src/components/launch/popovers/MorePopover.tsx +++ b/src/components/launch/popovers/MorePopover.tsx @@ -14,25 +14,13 @@ import { useI18n } from "@/contexts/I18nContext"; import { useScopedT } from "@/contexts/I18nContext"; import { useTheme } from "@/contexts/ThemeContext"; import type { AppLocale } from "@/i18n/config"; -import { SUPPORTED_LOCALES } from "@/i18n/config"; +import { LOCALE_LABELS, SUPPORTED_LOCALES } from "@/i18n/config"; import styles from "../LaunchWindow.module.css"; import { useLaunchPopoverCoordinator } from "./LaunchPopoverCoordinator"; import { DropdownItem, HudPopover } from "./PopoverScaffold"; const POPOVER_ID = "more"; -const LOCALE_LABELS: Record = { - en: "English", - es: "Español", - fr: "Français", - it: "Italiano", - nl: "Nederlands", - ko: "한국어", - "pt-BR": "Português", - "zh-CN": "簡體中文", - "zh-TW": "繁體中文", -}; - export function MorePopover({ trigger, supportsHudCaptureProtection, @@ -170,7 +158,7 @@ export function MorePopover({ requestClose(POPOVER_ID); }} > - {LOCALE_LABELS[code] ?? code} + {LOCALE_LABELS[code]} ))} {appVersion && ( diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 586e09d2a..e1f3aea58 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -32,7 +32,7 @@ import { import { type AspectRatio } from "@/utils/aspectRatioUtils"; import { useI18n, useScopedT } from "../../contexts/I18nContext"; import type { AppLocale } from "../../i18n/config"; -import { SUPPORTED_LOCALES } from "../../i18n/config"; +import { LOCALE_LABELS, SUPPORTED_LOCALES } from "../../i18n/config"; import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel"; import CaptionListPanel from "./CaptionListPanel"; import type { CaptionRetimeSpan } from "./captionOps"; @@ -707,19 +707,6 @@ const CAPTION_LANGUAGE_OPTIONS = [ { value: "ko", label: "Korean" }, ] as const; -const APP_LANGUAGE_LABELS: Record = { - en: "English", - es: "Español", - fr: "Français", - de: "Deutsch", - it: "Italiano", - nl: "Nederlands", - ko: "한국어", - "pt-BR": "Português", - "zh-CN": "簡體中文", - "zh-TW": "繁體中文", -}; - function loadPreviewImage(url: string) { return new Promise((resolve, reject) => { const image = new Image(); @@ -2575,7 +2562,7 @@ export function SettingsPanel({ {SUPPORTED_LOCALES.map((candidateLocale) => ( - {APP_LANGUAGE_LABELS[candidateLocale]} + {LOCALE_LABELS[candidateLocale]} ))} diff --git a/src/i18n/config.ts b/src/i18n/config.ts index 567d72efe..6c7258afb 100644 --- a/src/i18n/config.ts +++ b/src/i18n/config.ts @@ -25,3 +25,21 @@ export const I18N_NAMESPACES = [ export type AppLocale = (typeof SUPPORTED_LOCALES)[number]; export type I18nNamespace = (typeof I18N_NAMESPACES)[number]; + +/** + * Native display name per locale. Typed against AppLocale so adding a locale to + * SUPPORTED_LOCALES without a label is a compile error rather than a menu row + * that renders the raw locale code. + */ +export const LOCALE_LABELS: Record = { + en: "English", + es: "Español", + fr: "Français", + de: "Deutsch", + it: "Italiano", + nl: "Nederlands", + ko: "한국어", + "pt-BR": "Português", + "zh-CN": "簡體中文", + "zh-TW": "繁體中文", +};