From 2bd3d459d6b76b5f36fe0418380cdbb279770616 Mon Sep 17 00:00:00 2001 From: shiyuqi Date: Sat, 7 Nov 2026 10:06:10 +0800 Subject: [PATCH 1/2] fix(hud): drop the growth reserve on Linux so the HUD hugs its content The HUD grants HUD_GROWTH_RESERVE beyond its required size on each axis (+240px width, +120px height) as hysteresis against native resizes. On Windows and macOS that overhang is click-through. On Linux the HUD is not click-through, and a Wayland compositor treats the whole toplevel as the window's allocation, so the reserve is a transparent rectangle around the bar that swallows desktop clicks. hudGrowthReserve(platform) returns 0 on Linux, and the renderer passes it into computeHudWindowSize and uses the same value for the shrink threshold, so granted equals required there while other platforms keep the reserve. Co-Authored-By: Claude Code --- src/components/launch/LaunchWindow.test.tsx | 21 +++++++++++++ src/components/launch/LaunchWindow.tsx | 10 +++++-- src/components/launch/hudGeometry.test.ts | 33 +++++++++++++++++++++ src/components/launch/hudGeometry.ts | 21 +++++++++++-- 4 files changed, 80 insertions(+), 5 deletions(-) diff --git a/src/components/launch/LaunchWindow.test.tsx b/src/components/launch/LaunchWindow.test.tsx index fffc58d538..270197fe53 100644 --- a/src/components/launch/LaunchWindow.test.tsx +++ b/src/components/launch/LaunchWindow.test.tsx @@ -10,6 +10,7 @@ import type { NativePlatform } from "@/native/contracts"; import { TooltipProvider } from "../ui/tooltip"; import { HUD_BAR_BOTTOM, + HUD_EDGE_SLACK, HUD_POPOVER_GAP, HUD_POPOVER_MAX_HEIGHT, HUD_STACK_GAP, @@ -1286,6 +1287,26 @@ describe("LaunchWindow overlay sizing", () => { ); }); + it("grants the bar no growth reserve on Linux", async () => { + platformState.value = "linux"; + renderLaunchWindow(); + + const bar = (await screen.findByTestId("hud-drag-handle")).closest( + "[data-tray-layout]", + ) as HTMLElement; + stubBox(bar, 400, 56); + await flushResizeObservers(); + + await waitFor(() => { + expect(window.electronAPI.setHudOverlaySize).toHaveBeenCalled(); + }); + + const [width] = lastRequestedHudSize(); + // The bar plus the edge slack, and nothing more: on Linux the HUD is not + // click-through, so extra width is a transparent dead zone around the bar. + expect(width).toBe(400 + HUD_EDGE_SLACK * 2); + }); + it("reclaims the overlay once the content drops well below what was granted", async () => { renderLaunchWindow(); diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 50df3e6450..c0d0677aab 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -52,9 +52,9 @@ import { computeHudToastsHeight, computeHudWindowSize, HUD_BAR_BOTTOM, - HUD_GROWTH_RESERVE, HUD_POPOVER_GAP, HUD_STACK_GAP, + hudGrowthReserve, } from "./hudGeometry"; import styles from "./LaunchWindow.module.css"; @@ -448,12 +448,16 @@ export function LaunchWindow() { anchorEl?.style.setProperty("--hud-popover-max-h", `${popoverMaxHeight}px`); anchorEl?.style.setProperty("--hud-modal-max-h", `${modalMaxHeight}px`); + // Zero on Linux: the HUD is not click-through there, so any reserve beyond what + // the bar and its popovers need is a transparent dead zone that eats clicks. + const growthReserve = hudGrowthReserve(getPlatform()); const { required, granted } = computeHudWindowSize({ barWidth, barHeight, noticeHeight, availableHeight, stackMaxHeight: Math.max(popoverMaxHeight, modalMaxHeight), + growthReserve, }); const allocated = hudAllocatedSizeRef.current; @@ -470,8 +474,8 @@ export function LaunchWindow() { orientationChanged || required.width > allocated.width || required.height > allocated.height || - granted.width + HUD_GROWTH_RESERVE < allocated.width || - granted.height + HUD_GROWTH_RESERVE < allocated.height; + granted.width + growthReserve < allocated.width || + granted.height + growthReserve < allocated.height; // The main process keeps this rect on screen, not the window: the window is // mostly transparent reserve, and clamping it would strand the bar at the diff --git a/src/components/launch/hudGeometry.test.ts b/src/components/launch/hudGeometry.test.ts index a5044b6c5a..a9b4d2404c 100644 --- a/src/components/launch/hudGeometry.test.ts +++ b/src/components/launch/hudGeometry.test.ts @@ -11,6 +11,7 @@ import { computeHudWindowSize, HUD_BAR_BOTTOM, HUD_EDGE_SLACK, + HUD_GROWTH_RESERVE, HUD_MODAL_MAX_HEIGHT, HUD_POPOVER_GAP, HUD_POPOVER_MAX_HEIGHT, @@ -21,6 +22,7 @@ import { HUD_TOAST_VISIBLE, HUD_TOAST_WIDTH, HUD_UNREPORTED_PANELS_ALLOWANCE, + hudGrowthReserve, } from "./hudGeometry"; const SCREEN = 1080; @@ -40,7 +42,38 @@ function size(barWidth: number, barHeight: number, noticeHeight = 0, availableHe }); } +describe("hudGrowthReserve", () => { + it("keeps the hysteresis on the platforms whose HUD is click-through", () => { + expect(hudGrowthReserve("darwin")).toBe(HUD_GROWTH_RESERVE); + expect(hudGrowthReserve("win32")).toBe(HUD_GROWTH_RESERVE); + }); + + it("is zero on Linux, where reserve outside the content is a dead zone", () => { + // The Linux HUD is not click-through, so a transparent margin larger than the + // bar and its popover eats desktop clicks around the bar. + expect(hudGrowthReserve("linux")).toBe(0); + }); +}); + describe("computeHudWindowSize", () => { + it("grants the growth reserve on each axis by default", () => { + const { required, granted } = size(520, 48); + expect(granted.width).toBe(required.width + HUD_GROWTH_RESERVE * 2); + expect(granted.height).toBe(required.height + HUD_GROWTH_RESERVE); + }); + + it("hugs the required size when the reserve is zero", () => { + const { required, granted } = computeHudWindowSize({ + barWidth: 520, + barHeight: 48, + noticeHeight: 0, + availableHeight: SCREEN, + stackMaxHeight: HUD_MODAL_MAX_HEIGHT, + growthReserve: 0, + }); + expect(granted).toEqual(required); + }); + it("reserves the popover's full height whether or not one is open", () => { // The whole point of reserving: the window is already big enough for a // popover before the first one is ever opened, so opening one can't resize diff --git a/src/components/launch/hudGeometry.ts b/src/components/launch/hudGeometry.ts index 5887343f6e..17903c0368 100644 --- a/src/components/launch/hudGeometry.ts +++ b/src/components/launch/hudGeometry.ts @@ -60,6 +60,20 @@ export const HUD_MODAL_MIN_HEIGHT = 200; * keeps the common interactions completely free of native window churn. */ export const HUD_GROWTH_RESERVE = 120; +/** + * Growth reserve for a platform. Zero on Linux: there the HUD is not click-through + * (see `setHudMouseEventsEnabled`), and a Wayland compositor treats the whole + * toplevel as the window's allocation, so reserve the bar does not need is a large + * transparent rectangle that still takes input — a dead zone around the bar rather + * than the Windows/macOS overhang, which passes clicks through. + * + * The cost is that a bar growing past its current size resizes the window on + * Linux; on a 3-pixel label change that is a discrete resize, not the per-frame + * churn the reserve exists to absorb. + */ +export function hudGrowthReserve(platform: string): number { + return platform === "linux" ? 0 : HUD_GROWTH_RESERVE; +} /** Floor for the window itself, in case the bar measures as ~nothing. */ export const HUD_MIN_WINDOW_WIDTH = 220; /** @@ -109,6 +123,8 @@ export interface HudSizeInput { availableHeight: number; /** Room reserved above the bar: the tallest surface that can float there. */ stackMaxHeight: number; + /** Slack to grant on each axis; defaults to `HUD_GROWTH_RESERVE`. */ + growthReserve?: number; } /** Room left above the bar for whatever is floating there. */ @@ -172,12 +188,13 @@ export function computeHudWindowSize(input: HudSizeInput): { // got, then skip a resize it genuinely needs later on. const ceiling = input.availableHeight > 0 ? input.availableHeight : Number.POSITIVE_INFINITY; const requiredHeight = Math.min(rawHeight, ceiling); + const reserve = input.growthReserve ?? HUD_GROWTH_RESERVE; return { required: { width: requiredWidth, height: requiredHeight }, granted: { - width: requiredWidth + HUD_GROWTH_RESERVE * 2, - height: Math.min(rawHeight + HUD_GROWTH_RESERVE, ceiling), + width: requiredWidth + reserve * 2, + height: Math.min(rawHeight + reserve, ceiling), }, }; } From 0f075828f3c9e6a8d1327de04032379ea0a57baa Mon Sep 17 00:00:00 2001 From: shiyuqi Date: Sat, 7 Nov 2026 10:32:58 +0800 Subject: [PATCH 2/2] fix(hud): size the Linux HUD to its visible stack The HUD cannot be click-through on Linux, so every pixel of transparent window reserve is a dead zone over the desktop. Size the window to the measured stack instead: the bar alone (plus a floor for portalled tooltips) while collapsed, and the open panel's measured height while one is open, shrinking back when it closes. The window height now also counts the visible toast stack, which the growth reserve used to cover. Without it, a Linux window that hugs the bar cuts the toast off. The same height feeds needsResize, so a toast appearing or leaving costs one resize. Linux also lifts the fixed min/max size hints (resizable: false) around setBounds, since Mutter otherwise ignores the resize, and starts at the compact bar size instead of the old 820x560 reserve. Co-Authored-By: Claude Code --- electron/windows.ts | 33 ++++++-- src/components/launch/LaunchWindow.test.tsx | 85 ++++++++++++++++++++- src/components/launch/LaunchWindow.tsx | 55 ++++++++----- src/components/launch/hudGeometry.test.ts | 46 +++++++++++ src/components/launch/hudGeometry.ts | 48 ++++++++++-- 5 files changed, 233 insertions(+), 34 deletions(-) diff --git a/electron/windows.ts b/electron/windows.ts index 8db7d9cd4a..f0ff7c89dc 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -165,6 +165,22 @@ function hudBounds(win: BrowserWindow): Electron.Rectangle { return { x, y, ...hudWindowSize }; } +/** + * Applies size and position to the HUD. On Linux, `resizable: false` is published to + * the window manager as fixed min/max hints, and Mutter then ignores the programmatic + * size change — the Linux HUD grows and shrinks as panels open and close (see + * LaunchWindow.tsx), so the hints are lifted around the call. + */ +function setHudWindowBounds(win: BrowserWindow, next: Electron.Rectangle) { + if (process.platform === "linux") { + win.setResizable(true); + win.setBounds(next, false); + win.setResizable(false); + } else { + win.setBounds(next, false); + } +} + // Origin the current drag gesture started from. The renderer sends the pointer's // *total* travel since pointerdown rather than per-frame deltas, so every move is // an absolute `origin + delta` — no rounding to accumulate, and a dropped message @@ -217,7 +233,7 @@ function reclampHud() { }) : clampHudBoundsToWorkArea(bounds, hudContentRect, workArea); if (!sameRect(next, bounds)) { - win.setBounds(next, false); + setHudWindowBounds(win, next); } } @@ -435,7 +451,7 @@ ipcMain.on("hud-overlay-set-size", (_event, width: number, height: number, conte hudWindowSize = { width: next.width, height: next.height }; if (!sameRect(next, bounds)) { - hudOverlayWindow.setBounds(next, false); + setHudWindowBounds(hudOverlayWindow, next); } reassertAlwaysOnTop(hudOverlayWindow); }); @@ -458,11 +474,14 @@ export function createHudOverlayWindow(): BrowserWindow { const primaryDisplay = screen.getPrimaryDisplay(); const { workArea } = primaryDisplay; - // Close to what the renderer asks for on its very first measurement (bar plus - // the reserved space above it), so the HUD doesn't visibly resize itself the - // instant it becomes visible. See src/components/launch/hudGeometry.ts. - const windowWidth = 820; - const windowHeight = 560; + // Close to what the renderer asks for on its very first measurement, so the HUD + // doesn't visibly resize itself the instant it becomes visible. Windows and macOS + // ask for the bar plus the reserved space above it; Linux hugs the bar (the HUD + // is not click-through there, so a reserve would swallow desktop clicks until the + // first measurement). See src/components/launch/hudGeometry.ts. + const isLinux = process.platform === "linux"; + const windowWidth = isLinux ? 600 : 820; + const windowHeight = isLinux ? 190 : 560; const x = Math.floor(workArea.x + (workArea.width - windowWidth) / 2); const y = Math.floor(workArea.y + workArea.height - windowHeight - 5); diff --git a/src/components/launch/LaunchWindow.test.tsx b/src/components/launch/LaunchWindow.test.tsx index 270197fe53..5da604fa7e 100644 --- a/src/components/launch/LaunchWindow.test.tsx +++ b/src/components/launch/LaunchWindow.test.tsx @@ -15,6 +15,7 @@ import { HUD_POPOVER_MAX_HEIGHT, HUD_STACK_GAP, HUD_TOAST_WIDTH, + HUD_TOOLTIP_STACK_HEIGHT, HUD_UNREPORTED_PANELS_ALLOWANCE, } from "./hudGeometry"; import { LaunchWindow } from "./LaunchWindow"; @@ -1301,10 +1302,92 @@ describe("LaunchWindow overlay sizing", () => { expect(window.electronAPI.setHudOverlaySize).toHaveBeenCalled(); }); - const [width] = lastRequestedHudSize(); + const [width, height] = lastRequestedHudSize(); // The bar plus the edge slack, and nothing more: on Linux the HUD is not // click-through, so extra width is a transparent dead zone around the bar. expect(width).toBe(400 + HUD_EDGE_SLACK * 2); + // The height keeps room for the tallest tooltip (portalled, so never measured) + // and nothing for the panels that are not open. + expect(height).toBe(HUD_BAR_BOTTOM + 56 + HUD_TOOLTIP_STACK_HEIGHT + HUD_EDGE_SLACK); + }); + + it("shrinks back to the bar on Linux once a wider bar narrows", async () => { + platformState.value = "linux"; + renderLaunchWindow(); + + const bar = (await screen.findByTestId("hud-drag-handle")).closest( + "[data-tray-layout]", + ) as HTMLElement; + stubBox(bar, 520, 56); + await flushResizeObservers(); + await waitFor(() => { + expect(lastRequestedHudSize()[0]).toBe(520 + HUD_EDGE_SLACK * 2); + }); + + stubBox(bar, 400, 56); + await flushResizeObservers(); + // No shrink threshold without a reserve: the window follows the bar down + // instead of keeping a click-blocking margin. + expect(lastRequestedHudSize()[0]).toBe(400 + HUD_EDGE_SLACK * 2); + }); + + it("grows for an open panel on Linux and shrinks back once it closes", async () => { + platformState.value = "linux"; + renderLaunchWindow(); + + const bar = (await screen.findByTestId("hud-drag-handle")).closest( + "[data-tray-layout]", + ) as HTMLElement; + const anchor = bar.parentElement as HTMLElement; + const collapsed = HUD_BAR_BOTTOM + 56 + HUD_TOOLTIP_STACK_HEIGHT + HUD_EDGE_SLACK; + stubBox(bar, 400, 56); + await flushResizeObservers(); + expect(lastRequestedHudSize()[1]).toBe(collapsed); + + // A 300px panel open above the bar: the window follows its measured height, + // so the space is there while the panel is and gone when it is not. + const stack = 56 + HUD_POPOVER_GAP + 300; + stubBox(anchor, 400, stack); + await flushResizeObservers(); + expect(lastRequestedHudSize()[1]).toBe(HUD_BAR_BOTTOM + stack + HUD_EDGE_SLACK); + + stubBox(anchor, 400, 56); + await flushResizeObservers(); + expect(lastRequestedHudSize()[1]).toBe(collapsed); + }); + + it("counts a toast in the Linux window height and gives the room back", async () => { + platformState.value = "linux"; + renderLaunchWindow(); + + const bar = (await screen.findByTestId("hud-drag-handle")).closest( + "[data-tray-layout]", + ) as HTMLElement; + stubBox(bar, 400, 56); + await flushResizeObservers(); + const collapsed = HUD_BAR_BOTTOM + 56 + HUD_TOOLTIP_STACK_HEIGHT + HUD_EDGE_SLACK; + expect(lastRequestedHudSize()[1]).toBe(collapsed); + + act(() => { + toast.error("Not enough disk space to start recording (only 298 MB free)."); + }); + const text = await screen.findByText(/Not enough disk space/); + const list = text.closest("[data-sonner-toaster]") as HTMLElement; + // Sonner writes the front toast's height on its list once it has measured it; + // jsdom has no layout, so the measurement is written here. + act(() => { + list.style.setProperty("--front-toast-height", "64px"); + }); + await waitFor(() => { + expect(lastRequestedHudSize()[1]).toBe(collapsed + HUD_STACK_GAP + 64); + }); + + act(() => { + toast.dismiss(); + }); + await waitFor(() => { + expect(lastRequestedHudSize()[1]).toBe(collapsed); + }); }); it("reclaims the overlay once the content drops well below what was granted", async () => { diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index c0d0677aab..22d36751aa 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -54,6 +54,7 @@ import { HUD_BAR_BOTTOM, HUD_POPOVER_GAP, HUD_STACK_GAP, + HUD_TOOLTIP_STACK_HEIGHT, hudGrowthReserve, } from "./hudGeometry"; import styles from "./LaunchWindow.module.css"; @@ -435,28 +436,44 @@ export function LaunchWindow() { : 0; // The two floating surfaces get their own CSS caps (a 470px-tall language - // list would look absurd), but the *window* always reserves room for the - // taller of them. Sizing the reserve to whichever happens to be open would - // mean the window grows when the panel opens — and since the stack is - // bottom-anchored, growing upward moves every bit of content down in window + // list would look absurd). On Windows and macOS the *window* always reserves + // room for the taller of them: sizing the reserve to whichever happens to be + // open would mean the window grows when the panel opens — and since the stack + // is bottom-anchored, growing upward moves every bit of content down in window // coordinates, which the renderer repaints a frame or two after the native - // resize lands. That gap is visible as exactly the position judder this - // whole architecture exists to remove. So: reserve the maximum, always. + // resize lands. That gap is visible as exactly the position judder this whole + // architecture exists to remove. Linux cannot afford that reserve: its HUD is + // not click-through, so the empty space intercepts desktop clicks whenever no + // panel is open. There the window follows the measured stack instead. const popoverMaxHeight = computeHudPopoverMaxHeight(barHeight, availableHeight); const modalMaxHeight = computeHudModalMaxHeight(barHeight, availableHeight); const anchorEl = hudAnchorRef.current; anchorEl?.style.setProperty("--hud-popover-max-h", `${popoverMaxHeight}px`); anchorEl?.style.setProperty("--hud-modal-max-h", `${modalMaxHeight}px`); - // Zero on Linux: the HUD is not click-through there, so any reserve beyond what - // the bar and its popovers need is a transparent dead zone that eats clicks. - const growthReserve = hudGrowthReserve(getPlatform()); + // Measured after the caps above are applied: the anchor is the whole stack, + // bar plus whatever is open above it, so its height is exactly the content + // that has to be on screen right now. + const anchorRect = anchorEl?.getBoundingClientRect(); + const stackRect = anchorRect?.width && anchorRect.height ? anchorRect : barRect; + const stackHeight = stackRect.height || barHeight; + + const platform = getPlatform(); + const isLinux = platform === "linux"; + const growthReserve = hudGrowthReserve(platform); + const toastsHeight = hudToastsHeightRef.current; const { required, granted } = computeHudWindowSize({ barWidth, barHeight, - noticeHeight, + // On Linux the measured stack already contains any notice, so counting the + // notice again would reserve its height twice. + noticeHeight: isLinux ? 0 : noticeHeight, availableHeight, stackMaxHeight: Math.max(popoverMaxHeight, modalMaxHeight), + openStackHeight: isLinux + ? Math.max(HUD_TOOLTIP_STACK_HEIGHT, stackHeight - barHeight) + : undefined, + toastsHeight, growthReserve, }); @@ -464,12 +481,13 @@ export function LaunchWindow() { // A different orientation is a different shape entirely (wide-short vs // narrow-tall), so the previous allocation says nothing useful. const orientationChanged = allocated.orientation !== trayLayout; - // Grow the moment the content stops fitting. Shrink only once the content - // has fallen a whole reserve below what was granted — that asymmetry is the - // hysteresis: the bar can grow into its reserve (recording controls, a - // longer source name) and back out again without a single native resize, - // while a one-off bad reading (an unstyled first paint in dev, say) can't - // leave the overlay permanently oversized. + // Grow the moment the content stops fitting. On Windows and macOS shrink only + // once the content has fallen a whole reserve below what was granted — that + // asymmetry is the hysteresis: the bar can grow into its reserve (recording + // controls, a longer source name) and back out again without a single native + // resize, while a one-off bad reading (an unstyled first paint in dev, say) + // can't leave the overlay permanently oversized. Linux has no reserve to fall + // back into, so it resizes symmetrically and never keeps dead space. const needsResize = orientationChanged || required.width > allocated.width || @@ -485,15 +503,12 @@ export function LaunchWindow() { // and since the stack is centred and pinned HUD_BAR_BOTTOM above the window's // bottom edge (LaunchWindow.module.css), the rect the granted size will // produce is computed exactly rather than measured a frame late. - const anchorRect = anchorEl?.getBoundingClientRect(); - const stackRect = anchorRect?.width && anchorRect.height ? anchorRect : barRect; - const stackHeight = stackRect.height || barHeight; // Toasts sit above the stack, and they are just as visible: a toast pushed off the top // of the screen is an error nobody reads. setHudToastBottom(computeHudToastBottom(stackHeight)); const visible = addHudToastsToStack( { width: stackRect.width || barWidth, height: stackHeight }, - hudToastsHeightRef.current, + toastsHeight, ); const contentFor = (size: { width: number; height: number }) => ({ x: (size.width - visible.width) / 2, diff --git a/src/components/launch/hudGeometry.test.ts b/src/components/launch/hudGeometry.test.ts index a9b4d2404c..2511244cc6 100644 --- a/src/components/launch/hudGeometry.test.ts +++ b/src/components/launch/hudGeometry.test.ts @@ -74,6 +74,52 @@ describe("computeHudWindowSize", () => { expect(granted).toEqual(required); }); + it("counts a visible toast in the window height", () => { + const base = size(520, 48); + const withToast = computeHudWindowSize({ + barWidth: 520, + barHeight: 48, + noticeHeight: 0, + availableHeight: SCREEN, + stackMaxHeight: HUD_MODAL_MAX_HEIGHT, + toastsHeight: 64, + }); + // A toast floats above the whole stack, so it needs its own room above the + // reserve — on Linux, with no growth reserve, nothing else would cover it. + expect(withToast.required.height - base.required.height).toBe(HUD_STACK_GAP + 64); + expect(withToast.granted.height - base.granted.height).toBe(HUD_STACK_GAP + 64); + }); + + it("hugs the measured open stack instead of the reserved maximum", () => { + const { required } = computeHudWindowSize({ + barWidth: 400, + barHeight: 56, + noticeHeight: 0, + availableHeight: SCREEN, + stackMaxHeight: HUD_MODAL_MAX_HEIGHT, + openStackHeight: 308, + growthReserve: 0, + }); + // No popover gap or panel reserve of its own: the measured height already + // includes the gap to the bar. + expect(required.height).toBe(HUD_BAR_BOTTOM + 56 + 308 + HUD_EDGE_SLACK); + }); + + it("does not count a notice twice when the open stack is measured", () => { + const input = { + barWidth: 400, + barHeight: 56, + availableHeight: SCREEN, + stackMaxHeight: HUD_MODAL_MAX_HEIGHT, + openStackHeight: 56, + }; + const withoutNotice = computeHudWindowSize({ ...input, noticeHeight: 0 }); + const withNotice = computeHudWindowSize({ ...input, noticeHeight: 200 }); + // The measured stack already contains the notice; adding noticeHeight as well + // would reserve it a second time. + expect(withNotice.required.height).toBe(withoutNotice.required.height); + }); + it("reserves the popover's full height whether or not one is open", () => { // The whole point of reserving: the window is already big enough for a // popover before the first one is ever opened, so opening one can't resize diff --git a/src/components/launch/hudGeometry.ts b/src/components/launch/hudGeometry.ts index 17903c0368..a9ed87c515 100644 --- a/src/components/launch/hudGeometry.ts +++ b/src/components/launch/hudGeometry.ts @@ -16,6 +16,13 @@ * Opening or closing a popover, or a device list changing length, then costs * exactly zero native resizes. * + * Linux is the exception, and has to be: the HUD there cannot be made + * click-through (see LaunchWindow.tsx), so reserve the bar does not need is a + * transparent rectangle that intercepts desktop clicks. Its window follows the + * measured stack instead — `openStackHeight` below — and resizes as a panel opens + * or closes. Tooltips are the one floating thing no measurement sees, so they get + * a fixed floor (`HUD_TOOLTIP_STACK_HEIGHT`). + * * Nothing here may depend on `window.innerWidth/innerHeight`: that is the edge * the loop used to close on. Screen-derived values (`screen.availHeight`) are * fine — they don't change when the window does. @@ -27,6 +34,17 @@ export const HUD_BAR_BOTTOM = 20; export const HUD_EDGE_SLACK = 24; /** Vertical gap between the bar and the popover stack floating above it. */ export const HUD_POPOVER_GAP = 8; +/** + * The least room the window keeps above the bar on Linux while nothing is open. + * + * Tooltips are portalled and never part of the stack the renderer measures, so the + * window cannot discover their height the way it discovers a popover's. Sized for + * the tallest: four lines at the tooltip's 16px leading, plus its 6px padding on + * each side, a 1px border on each side and the 8px gap to the bar + * (4 × 16 + 12 + 2 + 8 = 86). A taller tooltip is moved back over the bar by + * Radix's collision handling rather than resizing the window on every hover. + */ +export const HUD_TOOLTIP_STACK_HEIGHT = 86; /** Gap between stacked floating elements (popover, notices). */ export const HUD_STACK_GAP = 10; /** Width of the device / language popovers. */ @@ -123,6 +141,16 @@ export interface HudSizeInput { availableHeight: number; /** Room reserved above the bar: the tallest surface that can float there. */ stackMaxHeight: number; + /** + * Measured height of the stack open above the bar right now, the gap to the bar + * included. When set it replaces `stackMaxHeight` and `noticeHeight`: the window + * hugs what is visible instead of reserving the tallest panel. Linux does this + * because its HUD is not click-through, so an empty reserve intercepts desktop + * clicks; the window has to shrink again when a panel closes. + */ + openStackHeight?: number; + /** Height of the toast stack on screen, 0 when none. */ + toastsHeight?: number; /** Slack to grant on each axis; defaults to `HUD_GROWTH_RESERVE`. */ growthReserve?: number; } @@ -175,13 +203,21 @@ export function computeHudWindowSize(input: HudSizeInput): { const requiredWidth = Math.max(HUD_MIN_WINDOW_WIDTH, stackWidth + HUD_EDGE_SLACK * 2); const noticeBlock = input.noticeHeight > 0 ? HUD_STACK_GAP + Math.ceil(input.noticeHeight) : 0; + // Toasts float above the whole stack, so they need room whenever one is on + // screen — on Linux with no growth reserve that room has to be asked for + // explicitly or the window cuts the toast off. + const toastBlock = + input.toastsHeight && input.toastsHeight > 0 + ? HUD_STACK_GAP + Math.ceil(input.toastsHeight) + : 0; + // `openStackHeight` already contains the gap to the bar and any notice, so it + // stands in for both the reserve and the notice block on its own. + const stackBlock = + input.openStackHeight !== undefined + ? Math.max(0, Math.ceil(input.openStackHeight)) + : HUD_POPOVER_GAP + Math.ceil(input.stackMaxHeight) + noticeBlock; const rawHeight = - HUD_BAR_BOTTOM + - Math.ceil(input.barHeight) + - HUD_POPOVER_GAP + - Math.ceil(input.stackMaxHeight) + - noticeBlock + - HUD_EDGE_SLACK; + HUD_BAR_BOTTOM + Math.ceil(input.barHeight) + stackBlock + toastBlock + HUD_EDGE_SLACK; // The main process clamps the whole window to the work area on Linux, so clamp here too: // otherwise the caller would record an allocation the window never actually