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
3 changes: 2 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,8 @@ export default function App() {
return (
<TooltipProvider>
{showNotes ? <NotesWindow /> : content}
<Toaster />
{/* The HUD mounts its own, placed clear of the bar (LaunchWindow). */}
{windowType !== "hud-overlay" && <Toaster />}
</TooltipProvider>
);
}
143 changes: 142 additions & 1 deletion src/components/launch/LaunchWindow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,18 @@ import "@testing-library/jest-dom";
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { StrictMode } from "react";
import { toast } from "sonner";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { nativeBridgeClient } from "@/native";
import type { NativePlatform } from "@/native/contracts";
import { TooltipProvider } from "../ui/tooltip";
import { HUD_BAR_BOTTOM, HUD_POPOVER_GAP, HUD_POPOVER_MAX_HEIGHT } from "./hudGeometry";
import {
HUD_BAR_BOTTOM,
HUD_POPOVER_GAP,
HUD_POPOVER_MAX_HEIGHT,
HUD_STACK_GAP,
HUD_TOAST_WIDTH,
} from "./hudGeometry";
import { LaunchWindow } from "./LaunchWindow";

type SelectedSourceChangedListener = Parameters<
Expand Down Expand Up @@ -2206,3 +2213,137 @@ describe("LaunchWindow software encoder fallback notice", () => {
expect(recorderState.value.dismissSoftwareEncoderFallbackNotice).toHaveBeenCalledWith(true);
});
});

// #1149: sonner's default corner of the HUD window is where the bar is, so a toast covered
// Record, Pause and Stop, and the window being click-through there passed a click on its
// close button to the app behind.
describe("LaunchWindow toasts", () => {
const DISK_SPACE = "Not enough disk space to start recording (only 298 MB free).";

beforeEach(() => {
platformState.value = "darwin";
resetLaunchMocks();
resizeCallbacks.length = 0;
vi.stubGlobal("ResizeObserver", CapturingResizeObserver);
});

afterEach(() => {
act(() => {
toast.dismiss();
});
cleanup();
vi.unstubAllGlobals();
});

async function showToast() {
act(() => {
toast.error(DISK_SPACE);
});
const text = await screen.findByText(DISK_SPACE);
const item = text.closest("[data-sonner-toast]") as HTMLElement;
const list = text.closest("[data-sonner-toaster]") as HTMLElement;
return { item, list };
}

async function stubBar(width: number, height: number) {
const bar = (await screen.findByTestId("hud-drag-handle")).closest(
"[data-tray-layout]",
) as HTMLElement;
stubBox(bar, width, height);
return bar;
}

it("centres the toasts above the bar instead of in the window's corner", async () => {
renderLaunchWindow();
await stubBar(400, 56);
await flushResizeObservers();

const { list } = await showToast();

expect(list).toHaveAttribute("data-y-position", "bottom");
expect(list).toHaveAttribute("data-x-position", "center");
expect(list.style.getPropertyValue("--offset-bottom")).toBe(
`${HUD_BAR_BOTTOM + 56 + HUD_STACK_GAP}px`,
);
});

it("moves the toasts above a popover opened over the bar", async () => {
renderLaunchWindow();
const bar = await stubBar(400, 56);
await flushResizeObservers();
const { list } = await showToast();

fireEvent.click(screen.getByRole("button", { name: "Language: English" }));
await screen.findByTestId("hud-language-menu");
const stackHeight = 56 + HUD_POPOVER_GAP + 300;
stubBox(bar.parentElement as HTMLElement, 400, stackHeight);
await flushResizeObservers();

expect(list.style.getPropertyValue("--offset-bottom")).toBe(
`${HUD_BAR_BOTTOM + stackHeight + HUD_STACK_GAP}px`,
);
});

it("marks a toast interactive, so the pushed cursor over it lifts click-through", async () => {
renderLaunchWindow();
await waitFor(() => {
expect(window.electronAPI.setHudOverlayIgnoreMouseEvents).toHaveBeenLastCalledWith(true);
});
const { item } = await showToast();

expect(item.closest("[data-hud-interactive='true']")).toBe(screen.getByTestId("hud-toaster"));

// jsdom has no hit test: the point resolves to the toast's close button, as it
// would in the browser.
const closeButton = item.querySelector("[data-close-button]");
expect(closeButton).not.toBeNull();
Object.defineProperty(document, "elementFromPoint", {
value: vi.fn(() => closeButton),
configurable: true,
});
const setIgnore = vi.mocked(window.electronAPI.setHudOverlayIgnoreMouseEvents);
try {
setIgnore.mockClear();
for (const listener of hudCursorListeners) listener(450, 300);
expect(setIgnore).toHaveBeenCalledWith(false);
} finally {
Reflect.deleteProperty(document, "elementFromPoint");
}
});

it("keeps the toasts in the rect the main process holds on screen", async () => {
renderLaunchWindow();
// A vertical bar, narrower than a toast.
await stubBar(48, 480);
await flushResizeObservers();
const { list } = await showToast();
// 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.
list.style.setProperty("--front-toast-height", "64px");

const sizeCalls = vi.mocked(window.electronAPI.setHudOverlaySize).mock.calls;
const [allocatedWidth, allocatedHeight] = sizeCalls[sizeCalls.length - 1];
const height = 480 + HUD_STACK_GAP + 64;
await waitFor(() => {
expect(window.electronAPI.setHudOverlayContent).toHaveBeenLastCalledWith({
x: (allocatedWidth - HUD_TOAST_WIDTH) / 2,
y: allocatedHeight - HUD_BAR_BOTTOM - height,
width: HUD_TOAST_WIDTH,
height,
});
});

// And out of it once the toast is gone.
act(() => {
toast.dismiss();
});
await waitFor(() => {
expect(window.electronAPI.setHudOverlayContent).toHaveBeenLastCalledWith({
x: (allocatedWidth - 48) / 2,
y: allocatedHeight - HUD_BAR_BOTTOM - 480,
width: 48,
height: 480,
});
});
});
});
68 changes: 63 additions & 5 deletions src/components/launch/LaunchWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { useRememberedSourceName } from "../../hooks/useRememberedSourceName";
import { useScreenRecorder } from "../../hooks/useScreenRecorder";
import type { WebcamQualityId } from "../../hooks/webcamCaptureTarget";
import { requestCameraAccess } from "../../lib/requestCameraAccess";
import { Toaster } from "../ui/sonner";
import {
HudCameraButton,
HudCursorButton,
Expand All @@ -40,9 +41,12 @@ import {
} from "./HudControls";
import { HudDeviceSettings, type HudDeviceSettingsLabels } from "./HudDeviceSettings";
import {
addHudToastsToStack,
computeHudBarMaxHeight,
computeHudModalMaxHeight,
computeHudPopoverMaxHeight,
computeHudToastBottom,
computeHudToastsHeight,
computeHudWindowSize,
HUD_BAR_BOTTOM,
HUD_GROWTH_RESERVE,
Expand Down Expand Up @@ -169,6 +173,11 @@ export function LaunchWindow() {
const hudBarRef = useRef<HTMLDivElement | null>(null);
const popoverRef = useRef<HTMLDivElement | null>(null);
const hudNoticesRef = useRef<HTMLDivElement | null>(null);
const hudToasterRef = useRef<HTMLDivElement | null>(null);
// Height of the toasts on screen (0 when none), and where they sit: see the toaster at the
// end of the render.
const hudToastsHeightRef = useRef(0);
const [hudToastBottom, setHudToastBottom] = useState(() => computeHudToastBottom(0));

// The camera list is enumerated from mount rather than on first open. It costs
// one enumerateDevices() call and means the picker renders its final content
Expand Down Expand Up @@ -463,13 +472,19 @@ export function LaunchWindow() {
// produce is computed exactly rather than measured a frame late.
const anchorRect = anchorEl?.getBoundingClientRect();
const stackRect = anchorRect?.width && anchorRect.height ? anchorRect : barRect;
const stackWidth = stackRect.width || barWidth;
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,
);
const contentFor = (size: { width: number; height: number }) => ({
x: (size.width - stackWidth) / 2,
y: size.height - HUD_BAR_BOTTOM - stackHeight,
width: stackWidth,
height: stackHeight,
x: (size.width - visible.width) / 2,
y: size.height - HUD_BAR_BOTTOM - visible.height,
width: visible.width,
height: visible.height,
});
// Computed from the allocation too, never read off stackRect: right after a
// resize the observer fires while the old viewport is still laid out, and a
Expand Down Expand Up @@ -519,6 +534,36 @@ export function LaunchWindow() {
};
}, [measureHudSize]);

// Toasts come and go inside sonner, which no ResizeObserver here sees: its toasts are
// absolutely positioned, so nothing around them changes size. What it does change is its
// own markup: a toast added or marked removed, and the front toast's height it measures
// and writes on the list. Watch that, and re-measure when the height they take changes.
useEffect(() => {
const toaster = hudToasterRef.current;
if (!toaster) return;
const onToastsChange = () => {
const list = toaster.querySelector<HTMLElement>("[data-sonner-toaster]");
const count = list
? list.querySelectorAll("[data-sonner-toast]:not([data-removed='true'])").length
: 0;
const frontHeight = Number.parseFloat(
list?.style.getPropertyValue("--front-toast-height") ?? "",
);
const height = computeHudToastsHeight(frontHeight, count);
if (height === hudToastsHeightRef.current) return;
hudToastsHeightRef.current = height;
measureHudSize();
};
const observer = new MutationObserver(onToastsChange);
observer.observe(toaster, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ["style", "data-removed"],
});
return () => observer.disconnect();
}, [measureHudSize]);

// Screen-derived caps follow the display the HUD is on: after a drag onto a
// shorter display, a tall vertical tray would otherwise overflow its work area.
useEffect(() => {
Expand Down Expand Up @@ -1404,6 +1449,19 @@ export function LaunchWindow() {
</div>
)}
</div>
{/* This window's toasts, and only this window mounts a toaster (App skips its own
for the HUD). Centred above the whole stack rather than in sonner's corner,
which is where the bar is (#1149). The wrapper marks them interactive: the
window is click-through everywhere else, so without it a toast's close button
passed the click to the app behind. It has no box of its own, the toasts are
`fixed`, so it adds nothing to the hit area but the toasts themselves. */}
<div ref={hudToasterRef} data-hud-interactive="true" data-testid="hud-toaster">
<Toaster
position="bottom-center"
offset={{ bottom: hudToastBottom }}
mobileOffset={{ bottom: hudToastBottom }}
/>
</div>
</div>
);
}
42 changes: 42 additions & 0 deletions src/components/launch/hudGeometry.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { describe, expect, it } from "vitest";
import {
addHudToastsToStack,
computeHudBarMaxHeight,
computeHudModalMaxHeight,
computeHudPopoverMaxHeight,
computeHudToastBottom,
computeHudToastsHeight,
computeHudTooltipClearance,
computeHudWindowSize,
HUD_BAR_BOTTOM,
Expand All @@ -11,7 +14,11 @@ import {
HUD_POPOVER_GAP,
HUD_POPOVER_MAX_HEIGHT,
HUD_POPOVER_MIN_HEIGHT,
HUD_STACK_GAP,
HUD_STACK_WIDTH,
HUD_TOAST_LIFT,
HUD_TOAST_VISIBLE,
HUD_TOAST_WIDTH,
} from "./hudGeometry";

const SCREEN = 1080;
Expand Down Expand Up @@ -132,3 +139,38 @@ describe("computeHudBarMaxHeight", () => {
);
});
});

describe("HUD toasts", () => {
it("sit above the whole stack, not in the corner where the bar is (#1149)", () => {
// The bar alone, then the bar with a popover open over it.
expect(computeHudToastBottom(56)).toBe(HUD_BAR_BOTTOM + 56 + HUD_STACK_GAP);
expect(computeHudToastBottom(56 + HUD_POPOVER_GAP + 300)).toBe(
HUD_BAR_BOTTOM + 56 + HUD_POPOVER_GAP + 300 + HUD_STACK_GAP,
);
// A vertical bar is tall: the toasts clear all of it.
expect(computeHudToastBottom(480.4)).toBe(HUD_BAR_BOTTOM + 481 + HUD_STACK_GAP);
});

it("take the front toast's height plus one lift per toast behind it, up to sonner's three", () => {
expect(computeHudToastsHeight(0, 0)).toBe(0);
expect(computeHudToastsHeight(64, 0)).toBe(0);
// Not measured by sonner yet.
expect(computeHudToastsHeight(0, 2)).toBe(0);
expect(computeHudToastsHeight(Number.NaN, 2)).toBe(0);
expect(computeHudToastsHeight(63.5, 1)).toBe(64);
expect(computeHudToastsHeight(64, 2)).toBe(64 + HUD_TOAST_LIFT);
expect(computeHudToastsHeight(64, 7)).toBe(64 + (HUD_TOAST_VISIBLE - 1) * HUD_TOAST_LIFT);
});

it("count in the rect kept on screen, as wide as a toast over a narrow vertical bar", () => {
expect(addHudToastsToStack({ width: 48, height: 480 }, 0)).toEqual({ width: 48, height: 480 });
expect(addHudToastsToStack({ width: 48, height: 480 }, 64)).toEqual({
width: HUD_TOAST_WIDTH,
height: 480 + HUD_STACK_GAP + 64,
});
expect(addHudToastsToStack({ width: 600, height: 56 }, 64)).toEqual({
width: 600,
height: 56 + HUD_STACK_GAP + 64,
});
});
});
41 changes: 41 additions & 0 deletions src/components/launch/hudGeometry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,47 @@ export function computeHudWindowSize(input: HudSizeInput): {
};
}

/** Sonner's toast width (its TOAST_WIDTH), which a centred toast takes whatever it says. */
export const HUD_TOAST_WIDTH = 356;
/** How far sonner lifts each toast behind the front one in its collapsed stack (its GAP). */
export const HUD_TOAST_LIFT = 14;
/** How many toasts sonner shows at once (its VISIBLE_TOASTS_AMOUNT). */
export const HUD_TOAST_VISIBLE = 3;

/**
* Where the HUD's toasts sit: their bottom edge this far above the window's bottom edge,
* which is above the whole stack (the bar and anything open over it).
*
* Sonner's default corner of the window is where the bar is, so a toast covered Record,
* Pause and Stop, and since sonner holds a toast while the pointer is over it, it stayed as
* long as the user was reaching for the controls under it (#1149). Above the stack is the
* reserve the window keeps for floating surfaces anyway, in either orientation.
*/
export function computeHudToastBottom(stackHeight: number): number {
return HUD_BAR_BOTTOM + Math.ceil(stackHeight) + HUD_STACK_GAP;
}

/**
* Height of sonner's collapsed stack: the front toast, plus one lift per toast behind it.
* Zero when nothing is up, or before sonner has measured the front toast.
*/
export function computeHudToastsHeight(frontHeight: number, count: number): number {
if (count <= 0 || !(frontHeight > 0)) return 0;
return Math.ceil(frontHeight) + (Math.min(count, HUD_TOAST_VISIBLE) - 1) * HUD_TOAST_LIFT;
Comment thread
EtienneLescot marked this conversation as resolved.
}

/**
* The stack plus the toasts floating above it: what has to stay on screen. Both are centred
* on the window, so only the width and the height grow.
*/
export function addHudToastsToStack(stack: HudSize, toastsHeight: number): HudSize {
if (toastsHeight <= 0) return stack;
return {
width: Math.max(stack.width, HUD_TOAST_WIDTH),
height: stack.height + HUD_STACK_GAP + Math.ceil(toastsHeight),
};
}

/**
* How far past its trigger a tooltip has to sit to clear the BAR, on the side it opens.
*
Expand Down
Loading