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
70 changes: 70 additions & 0 deletions electron/windows.hudHide.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, () => 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<typeof electron.FakeWindow>;
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();
});
});
17 changes: 16 additions & 1 deletion electron/windows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
});

Expand Down
27 changes: 27 additions & 0 deletions src/components/launch/LaunchWindow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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";

Expand Down
15 changes: 9 additions & 6 deletions src/components/launch/LaunchWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -42,6 +42,7 @@ import {
import { HudDeviceSettings, type HudDeviceSettingsLabels } from "./HudDeviceSettings";
import {
addHudToastsToStack,
computeHudAvailableHeight,
computeHudBarMaxHeight,
computeHudModalMaxHeight,
computeHudPopoverMaxHeight,
Expand All @@ -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 {
Expand Down
48 changes: 48 additions & 0 deletions src/components/launch/hudGeometry.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
addHudToastsToStack,
computeHudAvailableHeight,
computeHudBarMaxHeight,
computeHudModalMaxHeight,
computeHudPopoverMaxHeight,
Expand All @@ -19,6 +20,7 @@ import {
HUD_TOAST_LIFT,
HUD_TOAST_VISIBLE,
HUD_TOAST_WIDTH,
HUD_UNREPORTED_PANELS_ALLOWANCE,
} from "./hudGeometry";

const SCREEN = 1080;
Expand Down Expand Up @@ -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);
Expand Down
32 changes: 32 additions & 0 deletions src/components/launch/hudGeometry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading