diff --git a/src/App.tsx b/src/App.tsx index 56f801190..681bb5993 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -169,7 +169,8 @@ export default function App() { return ( {showNotes ? : content} - + {/* The HUD mounts its own, placed clear of the bar (LaunchWindow). */} + {windowType !== "hud-overlay" && } ); } diff --git a/src/components/launch/LaunchWindow.test.tsx b/src/components/launch/LaunchWindow.test.tsx index f77d75d35..f1b9d2629 100644 --- a/src/components/launch/LaunchWindow.test.tsx +++ b/src/components/launch/LaunchWindow.test.tsx @@ -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< @@ -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, + }); + }); + }); +}); diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 4a310e07d..0aa3127b5 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -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, @@ -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, @@ -169,6 +173,11 @@ export function LaunchWindow() { const hudBarRef = useRef(null); const popoverRef = useRef(null); const hudNoticesRef = useRef(null); + const hudToasterRef = useRef(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 @@ -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 @@ -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("[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(() => { @@ -1404,6 +1449,19 @@ export function LaunchWindow() { )} + {/* 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. */} +
+ +
); } diff --git a/src/components/launch/hudGeometry.test.ts b/src/components/launch/hudGeometry.test.ts index 2ebb7c0ff..beca204da 100644 --- a/src/components/launch/hudGeometry.test.ts +++ b/src/components/launch/hudGeometry.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it } from "vitest"; import { + addHudToastsToStack, computeHudBarMaxHeight, computeHudModalMaxHeight, computeHudPopoverMaxHeight, + computeHudToastBottom, + computeHudToastsHeight, computeHudTooltipClearance, computeHudWindowSize, HUD_BAR_BOTTOM, @@ -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; @@ -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, + }); + }); +}); diff --git a/src/components/launch/hudGeometry.ts b/src/components/launch/hudGeometry.ts index ae0787225..da93d456b 100644 --- a/src/components/launch/hudGeometry.ts +++ b/src/components/launch/hudGeometry.ts @@ -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; +} + +/** + * 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. *