Skip to content
Open
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
33 changes: 26 additions & 7 deletions electron/windows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -217,7 +233,7 @@ function reclampHud() {
})
: clampHudBoundsToWorkArea(bounds, hudContentRect, workArea);
if (!sameRect(next, bounds)) {
win.setBounds(next, false);
setHudWindowBounds(win, next);
}
}

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

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

Expand Down Expand Up @@ -390,9 +391,12 @@ export function LaunchWindow() {
// made the HUD flicker and jump the first few times each popover was opened.
//
// Two rules break it, and both live here:
// 1. Only the bar is measured. Everything floating above it has a fixed
// width and a capped height, so its space is *reserved* from the first
// frame — opening a popover costs zero native resizes.
// 1. On macOS and Windows, only the bar is measured. Everything floating
// above it has a fixed width and a capped height, so its space is
// *reserved* from the first frame — opening a popover costs zero native
// resizes. On Linux, where the growth reserve is dropped to avoid
// intercepting clicks, the anchor height drives the window size and
// content height changes can resize it.
// 2. No measured box may be sized against the viewport. Caps come from
// screen.availHeight (which a window resize can't change) and are pushed
// down as CSS custom properties.
Expand Down Expand Up @@ -435,43 +439,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,
Comment thread
lehkost16 marked this conversation as resolved.
Resolved
toastsHeight,
growthReserve,
Comment thread
lehkost16 marked this conversation as resolved.
Resolved
});

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
Expand All @@ -481,15 +506,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,
Expand Down Expand Up @@ -528,9 +550,10 @@ export function LaunchWindow() {
}, [trayLayout]);

// One persistent observer; elements wire themselves up via callback refs as
// they mount/unmount. The bar and the notice column size the window; the anchor
// (the whole stack) only feeds the content rect, so a popover opening costs a
// re-clamp but never a native resize.
// they mount/unmount. On Windows and macOS, the bar and the notice column size
// the window; the anchor only feeds the content rect, so a popover opening
// costs zero native resizes. On Linux, the anchor height drives window size
// and content height changes can resize it.
const hudResizeObserverRef = useRef<ResizeObserver | null>(null);
useEffect(() => {
const observer = new ResizeObserver(() => measureHudSize());
Expand Down
Loading
Loading