diff --git a/electron/windows.ts b/electron/windows.ts index 8db7d9cd4..f0ff7c89d 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 fffc58d53..5da604fa7 100644 --- a/src/components/launch/LaunchWindow.test.tsx +++ b/src/components/launch/LaunchWindow.test.tsx @@ -10,10 +10,12 @@ 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, HUD_TOAST_WIDTH, + HUD_TOOLTIP_STACK_HEIGHT, HUD_UNREPORTED_PANELS_ALLOWANCE, } from "./hudGeometry"; import { LaunchWindow } from "./LaunchWindow"; @@ -1286,6 +1288,108 @@ 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, 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 () => { renderLaunchWindow(); diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 50df3e645..22d36751a 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -52,9 +52,10 @@ import { computeHudToastsHeight, computeHudWindowSize, HUD_BAR_BOTTOM, - HUD_GROWTH_RESERVE, HUD_POPOVER_GAP, HUD_STACK_GAP, + HUD_TOOLTIP_STACK_HEIGHT, + hudGrowthReserve, } from "./hudGeometry"; import styles from "./LaunchWindow.module.css"; @@ -435,43 +436,64 @@ 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`); + // 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, }); const allocated = hudAllocatedSizeRef.current; // 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 || 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 @@ -481,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 a5044b6c5..2511244cc 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,84 @@ 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("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 5887343f6..a9ed87c51 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. */ @@ -60,6 +78,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 +141,18 @@ 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; } /** Room left above the bar for whatever is floating there. */ @@ -159,25 +203,34 @@ 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 // 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), }, }; }