diff --git a/src/components/launch/LaunchWindow.test.tsx b/src/components/launch/LaunchWindow.test.tsx index 29a67374b..bfdddad26 100644 --- a/src/components/launch/LaunchWindow.test.tsx +++ b/src/components/launch/LaunchWindow.test.tsx @@ -9,6 +9,26 @@ type SelectedSourceChangedListener = Parameters< >[0]; const platformState = vi.hoisted(() => ({ value: "darwin" })); +const resizeCallbacks = vi.hoisted(() => [] as Array); + +class StubResizeObserver { + observe() { + return undefined; + } + unobserve() { + return undefined; + } + disconnect() { + return undefined; + } +} + +class CapturingResizeObserver extends StubResizeObserver { + constructor(callback: ResizeObserverCallback) { + super(); + resizeCallbacks.push(callback); + } +} const recorderState = vi.hoisted(() => ({ value: { @@ -78,20 +98,24 @@ vi.mock("@/native", () => ({ }, })); +const i18nState = vi.hoisted(() => ({ + value: { + locale: "en", + setLocale: vi.fn(), + systemLocaleSuggestion: null as string | null, + acceptSystemLocaleSuggestion: vi.fn(), + dismissSystemLocaleSuggestion: vi.fn(), + resolveSystemLocaleSuggestion: vi.fn(), + }, +})); + vi.mock("@/i18n/loader", () => ({ getAvailableLocales: () => ["en"], getLocaleName: () => "English", })); vi.mock("@/contexts/I18nContext", () => ({ - useI18n: () => ({ - locale: "en", - setLocale: vi.fn(), - systemLocaleSuggestion: null, - acceptSystemLocaleSuggestion: vi.fn(), - dismissSystemLocaleSuggestion: vi.fn(), - resolveSystemLocaleSuggestion: vi.fn(), - }), + useI18n: () => i18nState.value, useScopedT: () => (key: string) => { const translations: Record = { "sourceSelector.defaultSourceName": "Screen", @@ -115,6 +139,11 @@ vi.mock("@/contexts/I18nContext", () => ({ "tooltips.hideHUD": "Hide HUD", "tooltips.closeApp": "Close App", language: "Language", + "systemLanguagePrompt.title": "Use your system language?", + "systemLanguagePrompt.description": + "We detected English as your system language. Do you want to switch OpenScreen to English?", + "systemLanguagePrompt.keepDefault": "Keep current language", + "systemLanguagePrompt.switch": "Switch to English", }; return translations[key] ?? key; }, @@ -190,25 +219,22 @@ function emitSourceSelectorClosed() { }); } +function resetLaunchMocks() { + vi.stubGlobal("ResizeObserver", StubResizeObserver); + recorderState.value.toggleRecording.mockClear(); + selectedSourceChangedListeners = []; + sourceSelectorClosedListeners = []; + i18nState.value.systemLocaleSuggestion = null; + i18nState.value.acceptSystemLocaleSuggestion.mockClear(); + i18nState.value.dismissSystemLocaleSuggestion.mockClear(); + i18nState.value.resolveSystemLocaleSuggestion.mockClear(); + stubElectronAPI(vi.fn(async () => null)); +} + describe("LaunchWindow record button", () => { beforeEach(() => { platformState.value = "darwin"; - class ResizeObserver { - observe() { - return undefined; - } - unobserve() { - return undefined; - } - disconnect() { - return undefined; - } - } - vi.stubGlobal("ResizeObserver", ResizeObserver); - recorderState.value.toggleRecording.mockClear(); - selectedSourceChangedListeners = []; - sourceSelectorClosedListeners = []; - stubElectronAPI(vi.fn(async () => null)); + resetLaunchMocks(); }); afterEach(() => { @@ -341,3 +367,85 @@ describe("LaunchWindow record button", () => { }); }); }); + +describe("LaunchWindow system language prompt", () => { + beforeEach(() => { + platformState.value = "darwin"; + resetLaunchMocks(); + resizeCallbacks.length = 0; + vi.stubGlobal("ResizeObserver", CapturingResizeObserver); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("grows the HUD overlay tall enough to fit the prompt so its buttons stay clickable", async () => { + i18nState.value.systemLocaleSuggestion = "zh-CN"; + + renderLaunchWindow(); + + const prompt = await screen.findByText("Use your system language?"); + expect(prompt).toBeInTheDocument(); + + // jsdom reports zero layout, so stub both the bar and the prompt to mimic a real HUD. + const viewportHeight = 800; + const barHeight = 56; + const bottomMargin = 20; + const barBottom = viewportHeight - bottomMargin; + const bar = prompt.parentElement?.parentElement?.querySelector( + "[data-tray-layout]", + ) as HTMLElement | null; + if (bar) { + vi.spyOn(bar, "getBoundingClientRect").mockReturnValue({ + top: barBottom - barHeight, + left: 200, + right: 600, + bottom: barBottom, + width: 400, + height: barHeight, + x: 200, + y: barBottom - barHeight, + toJSON: () => ({}), + }); + Object.defineProperty(bar, "scrollHeight", { value: barHeight, configurable: true }); + Object.defineProperty(bar, "scrollWidth", { value: 400, configurable: true }); + } + + const promptBox = { width: 480, height: 130 }; + const promptPanel = prompt.parentElement as HTMLElement; + vi.spyOn(promptPanel, "getBoundingClientRect").mockReturnValue({ + top: 32, + left: 60, + right: 60 + promptBox.width, + bottom: 32 + promptBox.height, + width: promptBox.width, + height: promptBox.height, + x: 60, + y: 32, + toJSON: () => ({}), + }); + + // Fire any observers attached during render so the spied rect is actually consumed. + await act(async () => { + for (const callback of resizeCallbacks) { + callback([], {} as ResizeObserver); + } + }); + + await waitFor(() => { + expect(window.electronAPI.setHudOverlaySize).toHaveBeenCalled(); + }); + + const sizeMock = window.electronAPI.setHudOverlaySize as unknown as { + mock: { calls: Array<[number, number]> }; + }; + const [, height] = sizeMock.mock.calls[sizeMock.mock.calls.length - 1]; + // Must at least cover the prompt plus the TOP_MARGIN slack (24). + expect(height).toBeGreaterThanOrEqual(32 + promptBox.height + 24); + // And must be less than the full viewport — guards against regressions that always + // grow to the full viewport because of a missed bottom anchor. + expect(height).toBeLessThan(viewportHeight + 24); + }); +}); diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 6b614caae..24d273b49 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -148,6 +148,7 @@ export function LaunchWindow() { const languageMenuPanelRef = useRef(null); const hudBarRef = useRef(null); const deviceSelectorRef = useRef(null); + const systemLocalePromptRef = useRef(null); // Measured bar height, anchors the popups above the tall vertical tray so they don't overlap it. const [hudBarHeight, setHudBarHeight] = useState(0); const [languageMenuStyle, setLanguageMenuStyle] = useState<{ @@ -351,6 +352,16 @@ export function LaunchWindow() { halfWidth = Math.max(halfWidth, centerX - rect.left, rect.right - centerX); } + // Prompt sits at `fixed top-8`; grow the window to fit it so its buttons don't clip (issue #30). + if (systemLocalePromptRef.current) { + const rect = systemLocalePromptRef.current.getBoundingClientRect(); + const promptHeight = rect.height || systemLocalePromptRef.current.scrollHeight; + if (promptHeight > 0) { + topFromBottom = Math.max(topFromBottom, rect.top + promptHeight); + } + halfWidth = Math.max(halfWidth, centerX - rect.left, rect.right - centerX); + } + setHudBarHeight((prev) => { const next = Math.round(barEl.scrollHeight); return Math.abs(prev - next) > 1 ? next : prev; @@ -373,6 +384,9 @@ export function LaunchWindow() { hudResizeObserverRef.current = observer; if (hudBarRef.current) observer.observe(hudBarRef.current); if (deviceSelectorRef.current) observer.observe(deviceSelectorRef.current); + // Backfill refs set before the observer existed (e.g. the prompt or language menu). + if (systemLocalePromptRef.current) observer.observe(systemLocalePromptRef.current); + if (languageMenuPanelRef.current) observer.observe(languageMenuPanelRef.current); measureHudSize(); return () => { observer.disconnect(); @@ -402,6 +416,10 @@ export function LaunchWindow() { (el: HTMLDivElement | null) => observeHudElement(el, languageMenuPanelRef), [observeHudElement], ); + const setSystemLocalePromptEl = useCallback( + (el: HTMLDivElement | null) => observeHudElement(el, systemLocalePromptRef), + [observeHudElement], + ); const hudIgnoreMouseEventsRef = useRef(undefined); const setHudMouseEventsEnabled = useCallback( @@ -621,6 +639,7 @@ export function LaunchWindow() { > {systemLocaleSuggestion && (