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
156 changes: 132 additions & 24 deletions src/components/launch/LaunchWindow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,26 @@ type SelectedSourceChangedListener = Parameters<
>[0];

const platformState = vi.hoisted(() => ({ value: "darwin" }));
const resizeCallbacks = vi.hoisted(() => [] as Array<ResizeObserverCallback>);

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: {
Expand Down Expand Up @@ -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<string, string> = {
"sourceSelector.defaultSourceName": "Screen",
Expand All @@ -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;
},
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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);
});
});
19 changes: 19 additions & 0 deletions src/components/launch/LaunchWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ export function LaunchWindow() {
const languageMenuPanelRef = useRef<HTMLDivElement | null>(null);
const hudBarRef = useRef<HTMLDivElement | null>(null);
const deviceSelectorRef = useRef<HTMLDivElement | null>(null);
const systemLocalePromptRef = useRef<HTMLDivElement | null>(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<{
Expand Down Expand Up @@ -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;
Expand All @@ -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();
Expand Down Expand Up @@ -402,6 +416,10 @@ export function LaunchWindow() {
(el: HTMLDivElement | null) => observeHudElement(el, languageMenuPanelRef),
[observeHudElement],
);
const setSystemLocalePromptEl = useCallback(
(el: HTMLDivElement | null) => observeHudElement(el, systemLocalePromptRef),
[observeHudElement],
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const hudIgnoreMouseEventsRef = useRef<boolean | undefined>(undefined);
const setHudMouseEventsEnabled = useCallback(
Expand Down Expand Up @@ -621,6 +639,7 @@ export function LaunchWindow() {
>
{systemLocaleSuggestion && (
<div
ref={setSystemLocalePromptEl}
data-hud-interactive="true"
className={`fixed top-8 left-1/2 z-30 w-[calc(100vw-1rem)] max-w-[520px] -translate-x-1/2 rounded-xl border border-white/15 bg-[rgba(20,20,28,0.95)] p-3 shadow-2xl backdrop-blur-xl text-white animate-in fade-in-0 zoom-in-95 duration-200 ${styles.electronNoDrag}`}
>
Expand Down
Loading