diff --git a/electron/windows.hudHide.test.ts b/electron/windows.hudHide.test.ts new file mode 100644 index 0000000000..3671c247d2 --- /dev/null +++ b/electron/windows.hudHide.test.ts @@ -0,0 +1,70 @@ +import { afterAll, describe, expect, it, vi } from "vitest"; + +const electron = vi.hoisted(() => { + class FakeWindow { + webContents = { on: vi.fn(), send: vi.fn() }; + setContentProtection = vi.fn(); + setVisibleOnAllWorkspaces = vi.fn(); + setAlwaysOnTop = vi.fn(); + isAlwaysOnTop = () => true; + hide = vi.fn(); + minimize = vi.fn(); + once = vi.fn(); + on = vi.fn(); + loadURL = vi.fn(); + loadFile = vi.fn(); + isDestroyed() { + return false; + } + } + const handlers = new Map void>(); + return { FakeWindow, handlers }; +}); + +vi.mock("electron", () => ({ + app: {}, + BrowserWindow: electron.FakeWindow, + ipcMain: { + on: (channel: string, handler: () => void) => electron.handlers.set(channel, handler), + }, + screen: { + getPrimaryDisplay: () => ({ workArea: { x: 0, y: 0, width: 1920, height: 1080 } }), + on: vi.fn(), + }, +})); + +const REAL_PLATFORM = process.platform; + +afterAll(() => { + Object.defineProperty(process, "platform", { value: REAL_PLATFORM, configurable: true }); + Reflect.deleteProperty(process, "getSystemVersion"); +}); + +/** Opens a HUD on `platform` (windows.ts decides at import) and presses "Hide recording bar". */ +async function hideHudOn(platform: NodeJS.Platform) { + Object.defineProperty(process, "platform", { value: platform, configurable: true }); + Object.defineProperty(process, "getSystemVersion", { + value: () => (platform === "darwin" ? "15.5.0" : "10.0.26100"), + configurable: true, + }); + vi.resetModules(); + electron.handlers.clear(); + const { createHudOverlayWindow } = await import("./windows"); + const win = createHudOverlayWindow() as unknown as InstanceType; + electron.handlers.get("hud-overlay-hide")?.(); + return win; +} + +describe("Hide recording bar", () => { + it("hides the HUD on Linux, where a Wayland client cannot un-minimize itself", async () => { + const win = await hideHudOn("linux"); + expect(win.hide).toHaveBeenCalledOnce(); + expect(win.minimize).not.toHaveBeenCalled(); + }); + + it.each(["win32", "darwin"] as const)("still minimizes the HUD on %s", async (platform) => { + const win = await hideHudOn(platform); + expect(win.minimize).toHaveBeenCalledOnce(); + expect(win.hide).not.toHaveBeenCalled(); + }); +}); diff --git a/electron/windows.ts b/electron/windows.ts index e972caf2ad..013a1f32d5 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -234,9 +234,24 @@ function watchHudWorkAreaChanges() { screen.on("display-removed", reclampHud); } +// Linux hides the bar instead of minimizing it, because a Wayland client cannot bring a +// minimized window back. xdg-shell has set_minimized and no request to undo it, and +// Chromium's Wayland Show() returns early on a toplevel that still exists, so the tray's +// restore() and show() did nothing; focus() then asked for activation without a fresh token, +// which GNOME turns into an "is ready" notification while the bar stays minimized. hide() +// destroys the toplevel and show() maps a new one, which every compositor displays. The cost +// is the position: the compositor places the new toplevel itself. X11 takes the same path: +// there `skipTaskbar` already left a minimized HUD no taskbar entry, so the tray was its only +// way back anyway. +const HUD_HIDES_INSTEAD_OF_MINIMIZING = process.platform === "linux"; + ipcMain.on("hud-overlay-hide", () => { if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) { - hudOverlayWindow.minimize(); + if (HUD_HIDES_INSTEAD_OF_MINIMIZING) { + hudOverlayWindow.hide(); + } else { + hudOverlayWindow.minimize(); + } } }); diff --git a/src/components/launch/LaunchWindow.test.tsx b/src/components/launch/LaunchWindow.test.tsx index f1b9d26295..8cd92de957 100644 --- a/src/components/launch/LaunchWindow.test.tsx +++ b/src/components/launch/LaunchWindow.test.tsx @@ -14,6 +14,7 @@ import { HUD_POPOVER_MAX_HEIGHT, HUD_STACK_GAP, HUD_TOAST_WIDTH, + HUD_UNREPORTED_PANELS_ALLOWANCE, } from "./hudGeometry"; import { LaunchWindow } from "./LaunchWindow"; @@ -1393,6 +1394,32 @@ describe("LaunchWindow overlay sizing", () => { }); }); + it("keeps a tall tray below a Linux display whose whole height is reported free (Wayland)", async () => { + // What Chromium reports on GNOME Wayland: no panels at all, top bar and dock included. + platformState.value = "linux"; + Object.defineProperty(window.screen, "height", { value: 1080, configurable: true }); + Object.defineProperty(window.screen, "availHeight", { value: 1080, configurable: true }); + try { + renderLaunchWindow(); + + const bar = (await screen.findByTestId("hud-drag-handle")).closest( + "[data-tray-layout]", + ) as HTMLElement; + // The vertical tray from the GNOME pass, which used to be granted all 1080px. + stubBox(bar, 54, 499); + await flushResizeObservers(); + + await waitFor(() => { + expect(window.electronAPI.setHudOverlaySize).toHaveBeenCalled(); + }); + const [, height] = lastRequestedHudSize(); + expect(height).toBeLessThanOrEqual(1080 - HUD_UNREPORTED_PANELS_ALLOWANCE); + } finally { + Reflect.deleteProperty(window.screen, "height"); + Reflect.deleteProperty(window.screen, "availHeight"); + } + }); + it("grows the HUD overlay tall enough to fit the system language prompt", async () => { i18nState.value.systemLocaleSuggestion = "zh-CN"; diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 0aa3127b52..d4b402b841 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -5,7 +5,7 @@ import { offersEditableCursor } from "@/lib/editableCursorAvailability"; import { canTurnMicrophoneOn } from "@/lib/microphoneAvailability"; import { loadUserPreferences, saveUserPreferences } from "@/lib/userPreferences"; import { nativeBridgeClient } from "@/native"; -import { canRecordMicrophone } from "@/utils/platformUtils"; +import { canRecordMicrophone, getPlatform } from "@/utils/platformUtils"; import { type CameraDevice } from "../../hooks/useCameraDevices"; import { useCameraHudSync } from "../../hooks/useCameraHudSync"; import { @@ -42,6 +42,7 @@ import { import { HudDeviceSettings, type HudDeviceSettingsLabels } from "./HudDeviceSettings"; import { addHudToastsToStack, + computeHudAvailableHeight, computeHudBarMaxHeight, computeHudModalMaxHeight, computeHudPopoverMaxHeight, @@ -64,13 +65,15 @@ const FALLBACK_SCREEN_HEIGHT = 1080; /** * Work-area height of the display, which is what the HUD's vertical budget is - * really bounded by. Deliberately NOT `window.innerHeight`: the overlay window's - * own height is the value this measurement feeds back into, and reading it here - * is exactly what used to close the resize feedback loop. + * really bounded by (less what Wayland never reports: see computeHudAvailableHeight). + * Deliberately NOT `window.innerHeight`: the overlay window's own height is the + * value this measurement feeds back into, and reading it here is exactly what used + * to close the resize feedback loop. */ function getAvailableScreenHeight(): number { - const available = typeof window === "undefined" ? 0 : window.screen?.availHeight; - return available && available > 0 ? available : FALLBACK_SCREEN_HEIGHT; + const screen = typeof window === "undefined" ? undefined : window.screen; + const available = screen ? computeHudAvailableHeight(screen, getPlatform()) : 0; + return available > 0 ? available : FALLBACK_SCREEN_HEIGHT; } function hudBarMaxHeightCss(): string { diff --git a/src/components/launch/hudGeometry.test.ts b/src/components/launch/hudGeometry.test.ts index beca204da3..a5044b6c5a 100644 --- a/src/components/launch/hudGeometry.test.ts +++ b/src/components/launch/hudGeometry.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { addHudToastsToStack, + computeHudAvailableHeight, computeHudBarMaxHeight, computeHudModalMaxHeight, computeHudPopoverMaxHeight, @@ -19,6 +20,7 @@ import { HUD_TOAST_LIFT, HUD_TOAST_VISIBLE, HUD_TOAST_WIDTH, + HUD_UNREPORTED_PANELS_ALLOWANCE, } from "./hudGeometry"; const SCREEN = 1080; @@ -87,6 +89,52 @@ describe("computeHudWindowSize", () => { }); }); +describe("computeHudAvailableHeight", () => { + // GNOME 46 Wayland, 1920x1080 at 100 %: the vertical tray's window came out 1080 tall, the + // budget's ceiling, so Chromium's availHeight was the whole display, top bar and dock included. + const WAYLAND_1080P = { height: 1080, availHeight: 1080 }; + const GNOME_TOP_BAR = 32; + // Ubuntu's dock at its largest icon size (64px), along the bottom edge. + const LARGEST_UBUNTU_DOCK = 80; + + it("leaves room for the panels a Wayland session never reports", () => { + expect(computeHudAvailableHeight(WAYLAND_1080P, "linux")).toBe( + 1080 - HUD_UNREPORTED_PANELS_ALLOWANCE, + ); + }); + + it("trusts a work area the platform did report", () => { + // X11 publishes the panels (_NET_WORKAREA), so availHeight already leaves them out. + expect(computeHudAvailableHeight({ height: 1080, availHeight: 1048 }, "linux")).toBe(1048); + // A full-height work area on Windows is an auto-hidden taskbar, and macOS reports the + // menu bar and the Dock: neither is touched. + expect(computeHudAvailableHeight({ height: 1080, availHeight: 1080 }, "win32")).toBe(1080); + expect(computeHudAvailableHeight({ height: 1080, availHeight: 1055 }, "darwin")).toBe(1055); + }); + + it("budgets a vertical tray on Wayland that fits between GNOME's top bar and its dock", () => { + // The vertical bar from the GNOME pass, 54x499. Budgeted on the reported 1080 it was + // granted the full 1080: taller than the room under the top bar, so its bottom + // controls stayed off screen wherever it was dragged. + expect(size(54, 499, 0, WAYLAND_1080P.availHeight).granted.height).toBe(1080); + + const budget = computeHudAvailableHeight(WAYLAND_1080P, "linux"); + const { required, granted } = size(54, 499, 0, budget); + expect(granted.height).toBeLessThanOrEqual(1080 - GNOME_TOP_BAR - LARGEST_UBUNTU_DOCK); + expect(required.height).toBeLessThanOrEqual(granted.height); + // Still room for the bar itself and a usable popover above it, and no internal scroll. + expect(granted.height).toBeGreaterThanOrEqual( + HUD_BAR_BOTTOM + 499 + HUD_POPOVER_GAP + HUD_POPOVER_MAX_HEIGHT + HUD_EDGE_SLACK, + ); + expect(computeHudBarMaxHeight(budget)).toBeGreaterThanOrEqual(499); + }); + + it("leaves the horizontal tray's window as it was", () => { + const budget = computeHudAvailableHeight(WAYLAND_1080P, "linux"); + expect(size(528, 54, 0, budget)).toEqual(size(528, 54, 0, WAYLAND_1080P.availHeight)); + }); +}); + describe("computeHudPopoverMaxHeight", () => { it("caps at the design height when there is plenty of room", () => { expect(computeHudPopoverMaxHeight(48, SCREEN)).toBe(HUD_POPOVER_MAX_HEIGHT); diff --git a/src/components/launch/hudGeometry.ts b/src/components/launch/hudGeometry.ts index da93d456b9..5887343f6e 100644 --- a/src/components/launch/hudGeometry.ts +++ b/src/components/launch/hudGeometry.ts @@ -62,6 +62,38 @@ export const HUD_MODAL_MIN_HEIGHT = 200; export const HUD_GROWTH_RESERVE = 120; /** Floor for the window itself, in case the bar measures as ~nothing. */ export const HUD_MIN_WINDOW_WIDTH = 220; +/** + * Room left for desktop panels the platform never reported: GNOME's 32px top bar plus a + * bottom dock at Ubuntu's largest icon size, with some to spare. + */ +export const HUD_UNREPORTED_PANELS_ALLOWANCE = 128; + +/** + * The height the HUD budgets its window against: the work area, as the platform reports it. + * + * Wayland reports none. No protocol tells a client where the shell's panels are, so + * Chromium's `screen.availHeight` there is the display's full height, even on a GNOME + * desktop whose top bar and dock take part of it. The vertical tray asks for more than it + * can have, so on a 1080p display it was granted all 1080px: a window that cannot fit below + * GNOME's top bar, so the bottom of the bar (Open Studio, Hide, Quit) stayed off screen + * wherever it was dragged. The compositor also resizes a Wayland window from its top-left + * corner, so every flip to vertical pushed the bar down under the dock, and mutter moves a + * resized window back into the work area only when it fits there. Budgeted below the + * display's height, the window fits: it can be dragged fully on screen, and mutter puts a + * flipped tray back on screen by itself when the window was inside the work area before. + * + * Linux only, and only when nothing was reported: on X11 the panels are in `availHeight` + * already, and on Windows a full-height work area is an auto-hidden taskbar. + */ +export function computeHudAvailableHeight( + screen: { availHeight: number; height: number }, + platform: string, +): number { + if (platform === "linux" && screen.availHeight >= screen.height) { + return Math.max(0, screen.availHeight - HUD_UNREPORTED_PANELS_ALLOWANCE); + } + return screen.availHeight; +} export interface HudSize { width: number;