diff --git a/packages/desktop/__tests__/laser-view.test.ts b/packages/desktop/__tests__/laser-view.test.ts index da849ee..399ac97 100644 --- a/packages/desktop/__tests__/laser-view.test.ts +++ b/packages/desktop/__tests__/laser-view.test.ts @@ -23,8 +23,15 @@ jest.mock('electron', () => ({ let brainProject: string | null = 'grace'; jest.mock('@/main/brain', () => ({ status: () => ({ project: brainProject }) })); jest.mock('@/main/operator-session', () => ({ embeddedUrl: (url: string) => `${url}#wg_token=t` })); +const windowSend = jest.fn(); jest.mock('@/main/runtime', () => ({ - runtime: { mainWindow: { isDestroyed: () => false, contentView: { addChildView: jest.fn() } } } + runtime: { + mainWindow: { + isDestroyed: () => false, + contentView: { addChildView: jest.fn() }, + webContents: { send: windowSend } + } + } })); import { invalidateLaserView, resetLaserView, syncLaser } from '@/main/laser-view'; @@ -43,6 +50,8 @@ beforeEach(() => { brainProject = 'grace'; webContents.loadURL.mockReset().mockResolvedValue(undefined); viewInstance.setVisible.mockClear(); + webContents.on.mockClear(); + windowSend.mockClear(); }); afterEach(() => jest.useRealTimers()); @@ -154,6 +163,24 @@ it('does not put the stopped show back on screen when the next one starts', asyn expect(visibility().at(-1)).toBe(true); }); +it('tells the renderer about Escape pressed inside the embedded UI', () => { + show(); + const onInput = webContents.on.mock.calls.find(([e]) => e === 'before-input-event')?.[1] as ( + e: unknown, + input: { type: string; key: string } + ) => void; + + // Full screen leaves the embedded UI focused, so the renderer's own keydown + // listener never fires and this is the only way back out. + onInput({}, { type: 'keyDown', key: 'Escape' }); + expect(windowSend).toHaveBeenCalledWith('laser:escape'); + + windowSend.mockClear(); + onInput({}, { type: 'keyUp', key: 'Escape' }); + onInput({}, { type: 'keyDown', key: 'a' }); + expect(windowSend).not.toHaveBeenCalled(); +}); + it('does not reload an unchanged url on every sync', async () => { show(); await flush(); diff --git a/packages/desktop/src/main/laser-view.ts b/packages/desktop/src/main/laser-view.ts index bef0c59..2b8fab2 100644 --- a/packages/desktop/src/main/laser-view.ts +++ b/packages/desktop/src/main/laser-view.ts @@ -131,6 +131,13 @@ function ensureView(): WebContentsView | null { void shell.openExternal(url); return { action: 'deny' }; }); + // While the embedded UI is full screen it is the only focused thing on the + // window, so its own web contents sees Escape and the renderer never would — + // without this there is no keyboard way back out. + created.webContents.on('before-input-event', (_e, input) => { + if (input.type !== 'keyDown' || input.key !== 'Escape') return; + if (!win.isDestroyed()) win.webContents.send('laser:escape'); + }); created.webContents.on('did-fail-load', (_e, _code, _desc, _url, isMainFrame) => { if (isMainFrame && desiredUrl && loadedUrl === desiredUrl) retryLater(desiredUrl); }); diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts index a4dbc0b..69dbae3 100644 --- a/packages/desktop/src/preload.ts +++ b/packages/desktop/src/preload.ts @@ -128,6 +128,11 @@ contextBridge.exposeInMainWorld('wavegrid', api); // Fire-and-forget channel the renderer uses to position the native laser view. const laser: WavegridLaser = { - sync: (state: LaserSyncState) => ipcRenderer.send('laser:sync', state) + sync: (state: LaserSyncState) => ipcRenderer.send('laser:sync', state), + onEscape: (handler: () => void) => { + const listener = () => handler(); + ipcRenderer.on('laser:escape', listener); + return () => ipcRenderer.off('laser:escape', listener); + } }; contextBridge.exposeInMainWorld('wavegridLaser', laser); diff --git a/packages/desktop/src/renderer/routes/show-route.tsx b/packages/desktop/src/renderer/routes/show-route.tsx index 2e40840..6cb0a5e 100644 --- a/packages/desktop/src/renderer/routes/show-route.tsx +++ b/packages/desktop/src/renderer/routes/show-route.tsx @@ -1,4 +1,4 @@ -import { AlertTriangle, MonitorPlay, Play, Square } from 'lucide-react'; +import { AlertTriangle, Maximize2, Minimize2, MonitorPlay, Play, Square } from 'lucide-react'; import * as React from 'react'; import { Badge } from '@/components/ui/badge'; @@ -37,10 +37,32 @@ export function ShowRoute({ status, activeProject, onStart, onStop, busy }: Show // would be buried under it. Hide it for as long as an overlay is up. const [overlay, setOverlay] = React.useState(false); React.useEffect(() => watchOverlays(document, setOverlay), []); + // In the windowed panel the grid is a postage stamp; full screen hands the + // embedded UI the whole window and keeps only the bar that gets back out. + const [expanded, setExpanded] = React.useState(false); const running = status.running; const url = status.url; + React.useEffect(() => { + if (!expanded) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setExpanded(false); + }; + window.addEventListener('keydown', onKey); + // The embedded UI has focus, so its own web contents — not this document — + // is what sees a keypress while it is full screen. + const offEmbedded = window.wavegridLaser.onEscape(() => setExpanded(false)); + return () => { + window.removeEventListener('keydown', onKey); + offEmbedded(); + }; + }, [expanded]); + + React.useEffect(() => { + if (!running) setExpanded(false); + }, [running]); + // Report the laser view's target bounds to the main process on every layout // change while the show is running; hide it whenever we leave this route. React.useEffect(() => { @@ -67,7 +89,26 @@ export function ShowRoute({ status, activeProject, onStart, onStop, busy }: Show window.removeEventListener('resize', sync); window.wavegridLaser.sync({ url: null, bounds: { x: 0, y: 0, width: 0, height: 0 }, visible: false }); }; - }, [running, url, overlay]); + // Full screen moves the slot, and the native view only follows the bounds we report. + }, [running, url, overlay, expanded]); + + if (running && expanded) { + return ( +