Skip to content

Commit d7bf1a4

Browse files
committed
fix(hud): place toasts above the bar and let their close button take the click
Sonner drew the HUD window's toasts in its default bottom-right corner, which is where the bar is, so a toast covered Record, Pause and Stop. The window is click-through except over [data-hud-interactive='true'], and the toaster was not marked, so its close button passed the click to the app behind, while sonner held the toast as long as the pointer was over it. The HUD now mounts its own toaster, centred above the whole stack (the bar and anything open over it), marked interactive, and reports the toasts in the rect the main process keeps on screen. Fixes #1149
1 parent 9c55596 commit d7bf1a4

5 files changed

Lines changed: 290 additions & 7 deletions

File tree

‎src/App.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,8 @@ export default function App() {
169169
return (
170170
<TooltipProvider>
171171
{showNotes ? <NotesWindow /> : content}
172-
<Toaster />
172+
{/* The HUD mounts its own, placed clear of the bar (LaunchWindow). */}
173+
{windowType !== "hud-overlay" && <Toaster />}
173174
</TooltipProvider>
174175
);
175176
}

‎src/components/launch/LaunchWindow.test.tsx‎

Lines changed: 142 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,18 @@ import "@testing-library/jest-dom";
33
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
44
import userEvent from "@testing-library/user-event";
55
import { StrictMode } from "react";
6+
import { toast } from "sonner";
67
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
78
import { nativeBridgeClient } from "@/native";
89
import type { NativePlatform } from "@/native/contracts";
910
import { TooltipProvider } from "../ui/tooltip";
10-
import { HUD_BAR_BOTTOM, HUD_POPOVER_GAP, HUD_POPOVER_MAX_HEIGHT } from "./hudGeometry";
11+
import {
12+
HUD_BAR_BOTTOM,
13+
HUD_POPOVER_GAP,
14+
HUD_POPOVER_MAX_HEIGHT,
15+
HUD_STACK_GAP,
16+
HUD_TOAST_WIDTH,
17+
} from "./hudGeometry";
1118
import { LaunchWindow } from "./LaunchWindow";
1219

1320
type SelectedSourceChangedListener = Parameters<
@@ -2206,3 +2213,137 @@ describe("LaunchWindow software encoder fallback notice", () => {
22062213
expect(recorderState.value.dismissSoftwareEncoderFallbackNotice).toHaveBeenCalledWith(true);
22072214
});
22082215
});
2216+
2217+
// #1149: sonner's default corner of the HUD window is where the bar is, so a toast covered
2218+
// Record, Pause and Stop, and the window being click-through there passed a click on its
2219+
// close button to the app behind.
2220+
describe("LaunchWindow toasts", () => {
2221+
const DISK_SPACE = "Not enough disk space to start recording (only 298 MB free).";
2222+
2223+
beforeEach(() => {
2224+
platformState.value = "darwin";
2225+
resetLaunchMocks();
2226+
resizeCallbacks.length = 0;
2227+
vi.stubGlobal("ResizeObserver", CapturingResizeObserver);
2228+
});
2229+
2230+
afterEach(() => {
2231+
act(() => {
2232+
toast.dismiss();
2233+
});
2234+
cleanup();
2235+
vi.unstubAllGlobals();
2236+
});
2237+
2238+
async function showToast() {
2239+
act(() => {
2240+
toast.error(DISK_SPACE);
2241+
});
2242+
const text = await screen.findByText(DISK_SPACE);
2243+
const item = text.closest("[data-sonner-toast]") as HTMLElement;
2244+
const list = text.closest("[data-sonner-toaster]") as HTMLElement;
2245+
return { item, list };
2246+
}
2247+
2248+
async function stubBar(width: number, height: number) {
2249+
const bar = (await screen.findByTestId("hud-drag-handle")).closest(
2250+
"[data-tray-layout]",
2251+
) as HTMLElement;
2252+
stubBox(bar, width, height);
2253+
return bar;
2254+
}
2255+
2256+
it("centres the toasts above the bar instead of in the window's corner", async () => {
2257+
renderLaunchWindow();
2258+
await stubBar(400, 56);
2259+
await flushResizeObservers();
2260+
2261+
const { list } = await showToast();
2262+
2263+
expect(list).toHaveAttribute("data-y-position", "bottom");
2264+
expect(list).toHaveAttribute("data-x-position", "center");
2265+
expect(list.style.getPropertyValue("--offset-bottom")).toBe(
2266+
`${HUD_BAR_BOTTOM + 56 + HUD_STACK_GAP}px`,
2267+
);
2268+
});
2269+
2270+
it("moves the toasts above a popover opened over the bar", async () => {
2271+
renderLaunchWindow();
2272+
const bar = await stubBar(400, 56);
2273+
await flushResizeObservers();
2274+
const { list } = await showToast();
2275+
2276+
fireEvent.click(screen.getByRole("button", { name: "Language: English" }));
2277+
await screen.findByTestId("hud-language-menu");
2278+
const stackHeight = 56 + HUD_POPOVER_GAP + 300;
2279+
stubBox(bar.parentElement as HTMLElement, 400, stackHeight);
2280+
await flushResizeObservers();
2281+
2282+
expect(list.style.getPropertyValue("--offset-bottom")).toBe(
2283+
`${HUD_BAR_BOTTOM + stackHeight + HUD_STACK_GAP}px`,
2284+
);
2285+
});
2286+
2287+
it("marks a toast interactive, so the pushed cursor over it lifts click-through", async () => {
2288+
renderLaunchWindow();
2289+
await waitFor(() => {
2290+
expect(window.electronAPI.setHudOverlayIgnoreMouseEvents).toHaveBeenLastCalledWith(true);
2291+
});
2292+
const { item } = await showToast();
2293+
2294+
expect(item.closest("[data-hud-interactive='true']")).toBe(screen.getByTestId("hud-toaster"));
2295+
2296+
// jsdom has no hit test: the point resolves to the toast's close button, as it
2297+
// would in the browser.
2298+
const closeButton = item.querySelector("[data-close-button]");
2299+
expect(closeButton).not.toBeNull();
2300+
Object.defineProperty(document, "elementFromPoint", {
2301+
value: vi.fn(() => closeButton),
2302+
configurable: true,
2303+
});
2304+
const setIgnore = vi.mocked(window.electronAPI.setHudOverlayIgnoreMouseEvents);
2305+
try {
2306+
setIgnore.mockClear();
2307+
for (const listener of hudCursorListeners) listener(450, 300);
2308+
expect(setIgnore).toHaveBeenCalledWith(false);
2309+
} finally {
2310+
Reflect.deleteProperty(document, "elementFromPoint");
2311+
}
2312+
});
2313+
2314+
it("keeps the toasts in the rect the main process holds on screen", async () => {
2315+
renderLaunchWindow();
2316+
// A vertical bar, narrower than a toast.
2317+
await stubBar(48, 480);
2318+
await flushResizeObservers();
2319+
const { list } = await showToast();
2320+
// Sonner writes the front toast's height on its list once it has measured it; jsdom
2321+
// has no layout, so the measurement is written here.
2322+
list.style.setProperty("--front-toast-height", "64px");
2323+
2324+
const sizeCalls = vi.mocked(window.electronAPI.setHudOverlaySize).mock.calls;
2325+
const [allocatedWidth, allocatedHeight] = sizeCalls[sizeCalls.length - 1];
2326+
const height = 480 + HUD_STACK_GAP + 64;
2327+
await waitFor(() => {
2328+
expect(window.electronAPI.setHudOverlayContent).toHaveBeenLastCalledWith({
2329+
x: (allocatedWidth - HUD_TOAST_WIDTH) / 2,
2330+
y: allocatedHeight - HUD_BAR_BOTTOM - height,
2331+
width: HUD_TOAST_WIDTH,
2332+
height,
2333+
});
2334+
});
2335+
2336+
// And out of it once the toast is gone.
2337+
act(() => {
2338+
toast.dismiss();
2339+
});
2340+
await waitFor(() => {
2341+
expect(window.electronAPI.setHudOverlayContent).toHaveBeenLastCalledWith({
2342+
x: (allocatedWidth - 48) / 2,
2343+
y: allocatedHeight - HUD_BAR_BOTTOM - 480,
2344+
width: 48,
2345+
height: 480,
2346+
});
2347+
});
2348+
});
2349+
});

‎src/components/launch/LaunchWindow.tsx‎

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { useRememberedSourceName } from "../../hooks/useRememberedSourceName";
1818
import { useScreenRecorder } from "../../hooks/useScreenRecorder";
1919
import type { WebcamQualityId } from "../../hooks/webcamCaptureTarget";
2020
import { requestCameraAccess } from "../../lib/requestCameraAccess";
21+
import { Toaster } from "../ui/sonner";
2122
import {
2223
HudCameraButton,
2324
HudCursorButton,
@@ -40,9 +41,12 @@ import {
4041
} from "./HudControls";
4142
import { HudDeviceSettings, type HudDeviceSettingsLabels } from "./HudDeviceSettings";
4243
import {
44+
addHudToastsToStack,
4345
computeHudBarMaxHeight,
4446
computeHudModalMaxHeight,
4547
computeHudPopoverMaxHeight,
48+
computeHudToastBottom,
49+
computeHudToastsHeight,
4650
computeHudWindowSize,
4751
HUD_BAR_BOTTOM,
4852
HUD_GROWTH_RESERVE,
@@ -169,6 +173,11 @@ export function LaunchWindow() {
169173
const hudBarRef = useRef<HTMLDivElement | null>(null);
170174
const popoverRef = useRef<HTMLDivElement | null>(null);
171175
const hudNoticesRef = useRef<HTMLDivElement | null>(null);
176+
const hudToasterRef = useRef<HTMLDivElement | null>(null);
177+
// Height of the toasts on screen (0 when none), and where they sit: see the toaster at the
178+
// end of the render.
179+
const hudToastsHeightRef = useRef(0);
180+
const [hudToastBottom, setHudToastBottom] = useState(() => computeHudToastBottom(0));
172181

173182
// The camera list is enumerated from mount rather than on first open. It costs
174183
// one enumerateDevices() call and means the picker renders its final content
@@ -463,13 +472,19 @@ export function LaunchWindow() {
463472
// produce is computed exactly rather than measured a frame late.
464473
const anchorRect = anchorEl?.getBoundingClientRect();
465474
const stackRect = anchorRect?.width && anchorRect.height ? anchorRect : barRect;
466-
const stackWidth = stackRect.width || barWidth;
467475
const stackHeight = stackRect.height || barHeight;
476+
// Toasts sit above the stack, and they are just as visible: a toast pushed off the top
477+
// of the screen is an error nobody reads.
478+
setHudToastBottom(computeHudToastBottom(stackHeight));
479+
const visible = addHudToastsToStack(
480+
{ width: stackRect.width || barWidth, height: stackHeight },
481+
hudToastsHeightRef.current,
482+
);
468483
const contentFor = (size: { width: number; height: number }) => ({
469-
x: (size.width - stackWidth) / 2,
470-
y: size.height - HUD_BAR_BOTTOM - stackHeight,
471-
width: stackWidth,
472-
height: stackHeight,
484+
x: (size.width - visible.width) / 2,
485+
y: size.height - HUD_BAR_BOTTOM - visible.height,
486+
width: visible.width,
487+
height: visible.height,
473488
});
474489
// Computed from the allocation too, never read off stackRect: right after a
475490
// resize the observer fires while the old viewport is still laid out, and a
@@ -519,6 +534,36 @@ export function LaunchWindow() {
519534
};
520535
}, [measureHudSize]);
521536

537+
// Toasts come and go inside sonner, which no ResizeObserver here sees: its toasts are
538+
// absolutely positioned, so nothing around them changes size. What it does change is its
539+
// own markup: a toast added or marked removed, and the front toast's height it measures
540+
// and writes on the list. Watch that, and re-measure when the height they take changes.
541+
useEffect(() => {
542+
const toaster = hudToasterRef.current;
543+
if (!toaster) return;
544+
const onToastsChange = () => {
545+
const list = toaster.querySelector<HTMLElement>("[data-sonner-toaster]");
546+
const count = list
547+
? list.querySelectorAll("[data-sonner-toast]:not([data-removed='true'])").length
548+
: 0;
549+
const frontHeight = Number.parseFloat(
550+
list?.style.getPropertyValue("--front-toast-height") ?? "",
551+
);
552+
const height = computeHudToastsHeight(frontHeight, count);
553+
if (height === hudToastsHeightRef.current) return;
554+
hudToastsHeightRef.current = height;
555+
measureHudSize();
556+
};
557+
const observer = new MutationObserver(onToastsChange);
558+
observer.observe(toaster, {
559+
subtree: true,
560+
childList: true,
561+
attributes: true,
562+
attributeFilter: ["style", "data-removed"],
563+
});
564+
return () => observer.disconnect();
565+
}, [measureHudSize]);
566+
522567
// Screen-derived caps follow the display the HUD is on: after a drag onto a
523568
// shorter display, a tall vertical tray would otherwise overflow its work area.
524569
useEffect(() => {
@@ -1404,6 +1449,19 @@ export function LaunchWindow() {
14041449
</div>
14051450
)}
14061451
</div>
1452+
{/* This window's toasts, and only this window mounts a toaster (App skips its own
1453+
for the HUD). Centred above the whole stack rather than in sonner's corner,
1454+
which is where the bar is (#1149). The wrapper marks them interactive: the
1455+
window is click-through everywhere else, so without it a toast's close button
1456+
passed the click to the app behind. It has no box of its own, the toasts are
1457+
`fixed`, so it adds nothing to the hit area but the toasts themselves. */}
1458+
<div ref={hudToasterRef} data-hud-interactive="true" data-testid="hud-toaster">
1459+
<Toaster
1460+
position="bottom-center"
1461+
offset={{ bottom: hudToastBottom }}
1462+
mobileOffset={{ bottom: hudToastBottom }}
1463+
/>
1464+
</div>
14071465
</div>
14081466
);
14091467
}

‎src/components/launch/hudGeometry.test.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import { describe, expect, it } from "vitest";
22
import {
3+
addHudToastsToStack,
34
computeHudBarMaxHeight,
45
computeHudModalMaxHeight,
56
computeHudPopoverMaxHeight,
7+
computeHudToastBottom,
8+
computeHudToastsHeight,
69
computeHudTooltipClearance,
710
computeHudWindowSize,
811
HUD_BAR_BOTTOM,
@@ -11,7 +14,11 @@ import {
1114
HUD_POPOVER_GAP,
1215
HUD_POPOVER_MAX_HEIGHT,
1316
HUD_POPOVER_MIN_HEIGHT,
17+
HUD_STACK_GAP,
1418
HUD_STACK_WIDTH,
19+
HUD_TOAST_LIFT,
20+
HUD_TOAST_VISIBLE,
21+
HUD_TOAST_WIDTH,
1522
} from "./hudGeometry";
1623

1724
const SCREEN = 1080;
@@ -132,3 +139,38 @@ describe("computeHudBarMaxHeight", () => {
132139
);
133140
});
134141
});
142+
143+
describe("HUD toasts", () => {
144+
it("sit above the whole stack, not in the corner where the bar is (#1149)", () => {
145+
// The bar alone, then the bar with a popover open over it.
146+
expect(computeHudToastBottom(56)).toBe(HUD_BAR_BOTTOM + 56 + HUD_STACK_GAP);
147+
expect(computeHudToastBottom(56 + HUD_POPOVER_GAP + 300)).toBe(
148+
HUD_BAR_BOTTOM + 56 + HUD_POPOVER_GAP + 300 + HUD_STACK_GAP,
149+
);
150+
// A vertical bar is tall: the toasts clear all of it.
151+
expect(computeHudToastBottom(480.4)).toBe(HUD_BAR_BOTTOM + 481 + HUD_STACK_GAP);
152+
});
153+
154+
it("take the front toast's height plus one lift per toast behind it, up to sonner's three", () => {
155+
expect(computeHudToastsHeight(0, 0)).toBe(0);
156+
expect(computeHudToastsHeight(64, 0)).toBe(0);
157+
// Not measured by sonner yet.
158+
expect(computeHudToastsHeight(0, 2)).toBe(0);
159+
expect(computeHudToastsHeight(Number.NaN, 2)).toBe(0);
160+
expect(computeHudToastsHeight(63.5, 1)).toBe(64);
161+
expect(computeHudToastsHeight(64, 2)).toBe(64 + HUD_TOAST_LIFT);
162+
expect(computeHudToastsHeight(64, 7)).toBe(64 + (HUD_TOAST_VISIBLE - 1) * HUD_TOAST_LIFT);
163+
});
164+
165+
it("count in the rect kept on screen, as wide as a toast over a narrow vertical bar", () => {
166+
expect(addHudToastsToStack({ width: 48, height: 480 }, 0)).toEqual({ width: 48, height: 480 });
167+
expect(addHudToastsToStack({ width: 48, height: 480 }, 64)).toEqual({
168+
width: HUD_TOAST_WIDTH,
169+
height: 480 + HUD_STACK_GAP + 64,
170+
});
171+
expect(addHudToastsToStack({ width: 600, height: 56 }, 64)).toEqual({
172+
width: 600,
173+
height: 56 + HUD_STACK_GAP + 64,
174+
});
175+
});
176+
});

‎src/components/launch/hudGeometry.ts‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,47 @@ export function computeHudWindowSize(input: HudSizeInput): {
150150
};
151151
}
152152

153+
/** Sonner's toast width (its TOAST_WIDTH), which a centred toast takes whatever it says. */
154+
export const HUD_TOAST_WIDTH = 356;
155+
/** How far sonner lifts each toast behind the front one in its collapsed stack (its GAP). */
156+
export const HUD_TOAST_LIFT = 14;
157+
/** How many toasts sonner shows at once (its VISIBLE_TOASTS_AMOUNT). */
158+
export const HUD_TOAST_VISIBLE = 3;
159+
160+
/**
161+
* Where the HUD's toasts sit: their bottom edge this far above the window's bottom edge,
162+
* which is above the whole stack (the bar and anything open over it).
163+
*
164+
* Sonner's default corner of the window is where the bar is, so a toast covered Record,
165+
* Pause and Stop, and since sonner holds a toast while the pointer is over it, it stayed as
166+
* long as the user was reaching for the controls under it (#1149). Above the stack is the
167+
* reserve the window keeps for floating surfaces anyway, in either orientation.
168+
*/
169+
export function computeHudToastBottom(stackHeight: number): number {
170+
return HUD_BAR_BOTTOM + Math.ceil(stackHeight) + HUD_STACK_GAP;
171+
}
172+
173+
/**
174+
* Height of sonner's collapsed stack: the front toast, plus one lift per toast behind it.
175+
* Zero when nothing is up, or before sonner has measured the front toast.
176+
*/
177+
export function computeHudToastsHeight(frontHeight: number, count: number): number {
178+
if (count <= 0 || !(frontHeight > 0)) return 0;
179+
return Math.ceil(frontHeight) + (Math.min(count, HUD_TOAST_VISIBLE) - 1) * HUD_TOAST_LIFT;
180+
}
181+
182+
/**
183+
* The stack plus the toasts floating above it: what has to stay on screen. Both are centred
184+
* on the window, so only the width and the height grow.
185+
*/
186+
export function addHudToastsToStack(stack: HudSize, toastsHeight: number): HudSize {
187+
if (toastsHeight <= 0) return stack;
188+
return {
189+
width: Math.max(stack.width, HUD_TOAST_WIDTH),
190+
height: stack.height + HUD_STACK_GAP + Math.ceil(toastsHeight),
191+
};
192+
}
193+
153194
/**
154195
* How far past its trigger a tooltip has to sit to clear the BAR, on the side it opens.
155196
*

0 commit comments

Comments
 (0)