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
29 changes: 28 additions & 1 deletion packages/desktop/__tests__/laser-view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -43,6 +50,8 @@ beforeEach(() => {
brainProject = 'grace';
webContents.loadURL.mockReset().mockResolvedValue(undefined);
viewInstance.setVisible.mockClear();
webContents.on.mockClear();
windowSend.mockClear();
});

afterEach(() => jest.useRealTimers());
Expand Down Expand Up @@ -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();
Expand Down
7 changes: 7 additions & 0 deletions packages/desktop/src/main/laser-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
7 changes: 6 additions & 1 deletion packages/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
51 changes: 49 additions & 2 deletions packages/desktop/src/renderer/routes/show-route.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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(() => {
Expand All @@ -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 (
<div className='bg-background fixed inset-0 z-40 flex flex-col'>
<div className='flex items-center justify-between gap-3 border-b px-3 py-1.5'>
<span className='text-muted-foreground truncate text-xs'>
{activeProject ?? 'Show'} · full screen
</span>
<Button size='sm' variant='ghost' onClick={() => setExpanded(false)}>
<Minimize2 />
Exit full screen (Esc)
</Button>
</div>
{/* The native laser WebContentsView is positioned over this slot. */}
<div ref={slotRef} className='flex-1' />
</div>
);
}

return (
<div className='flex h-full flex-col gap-4 p-4'>
Expand All @@ -85,6 +126,12 @@ export function ShowRoute({ status, activeProject, onStart, onStop, busy }: Show
{running && status.lanUrls.length > 0 && (
<ShareShow lanUrls={status.lanUrls} />
)}
{running && (
<Button size='sm' variant='outline' className='ml-auto' onClick={() => setExpanded(true)}>
<Maximize2 />
Full screen
</Button>
)}
</div>

{/* Why the show isn't up (or is up without output) — a red dot with no
Expand Down
3 changes: 3 additions & 0 deletions packages/desktop/src/types/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,9 @@ export interface OscDebugState {

export interface WavegridLaser {
sync(state: LaserSyncState): void;
/** Escape pressed inside the embedded UI, which owns its own key events and is
* the only thing focused while it is full screen. Returns an unsubscribe. */
onEscape(handler: () => void): () => void;
}

declare global {
Expand Down
67 changes: 59 additions & 8 deletions packages/ui/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -379,35 +379,68 @@ function ToolPanel({

/* ---------- Master sliders (top bar on desktop, expandable on phone) ---------- */

/** Speed is logarithmic — 0.01x to 5x — so the slider carries a percentage of
* that curve rather than the multiplier itself. */
const SPEED_MIN = 0.01;
const SPEED_MAX = 5.0;
const speedToPct = (v: number) =>
Math.round((Math.log(v / SPEED_MIN) / Math.log(SPEED_MAX / SPEED_MIN)) * 100);
const pctToSpeed = (pct: number) => SPEED_MIN * Math.pow(SPEED_MAX / SPEED_MIN, pct / 100);
const formatSpeed = (v: number) => `${v < 0.1 ? v.toFixed(3) : v < 1 ? v.toFixed(2) : v.toFixed(1)}x`;

interface MasterSlider {
label: string;
value: number;
display: string;
handler: (pct: number) => void;
flex: number;
}

function MasterSliders({
masterBright,
smoothness,
attack,
animSpeed,
onMasterBright,
onSmooth,
onAttack,
onAnimSpeed,
throttledSlider,
vertical
}: {
masterBright: number;
smoothness: number;
attack: number;
animSpeed: number;
onMasterBright: (v: number) => void;
onSmooth: (v: number) => void;
onAttack: (v: number) => void;
onAnimSpeed: (v: number) => void;
throttledSlider: (handler: (v: number) => void) => (e: React.ChangeEvent<HTMLInputElement>) => void;
vertical?: boolean;
}) {
const sliders = [
{ label: 'Bright', value: masterBright, handler: onMasterBright, flex: 1 },
{ label: 'Fade', value: smoothness, handler: onSmooth, flex: 3 },
{ label: 'Attack', value: attack, handler: onAttack, flex: 1 }
// The two an operator reaches for mid-show hold the fixed slots; brightness
// and attack get set once, so they live behind the disclosure.
const fixed: MasterSlider[] = [
{
label: 'Speed',
value: speedToPct(animSpeed),
display: formatSpeed(animSpeed),
handler: (pct) => onAnimSpeed(pctToSpeed(pct)),
flex: 2
},
{ label: 'Fade', value: smoothness, display: String(smoothness), handler: onSmooth, flex: 3 }
];
const extra: MasterSlider[] = [
{ label: 'Bright', value: masterBright, display: String(masterBright), handler: onMasterBright, flex: 1 },
{ label: 'Attack', value: attack, display: String(attack), handler: onAttack, flex: 1 }
];
const [showExtra, setShowExtra] = useState(false);

if (vertical) {
return (
<div className="space-y-3 p-4">
{sliders.map((s) => (
{[...fixed, ...extra].map((s) => (
<div key={s.label} className="flex items-center gap-3">
<span className="text-sm font-medium" style={{ color: '#888898', minWidth: 56 }}>
{s.label}
Expand All @@ -421,7 +454,7 @@ function MasterSliders({
onChange={throttledSlider(s.handler)}
/>
<span className="text-sm font-mono" style={{ color: '#888898', minWidth: 32, textAlign: 'right' }}>
{s.value}
{s.display}
</span>
</div>
))}
Expand All @@ -431,7 +464,7 @@ function MasterSliders({

return (
<>
{sliders.map((s) => (
{[...fixed, ...(showExtra ? extra : [])].map((s) => (
<div key={s.label} className="flex items-center gap-2" style={{ minWidth: 0, flex: s.flex }}>
<span className="text-xs font-medium shrink-0" style={{ color: '#888898', textTransform: 'uppercase', letterSpacing: '0.05em', fontSize: 11 }}>
{s.label}
Expand All @@ -446,10 +479,24 @@ function MasterSliders({
onChange={throttledSlider(s.handler)}
/>
<span className="text-xs font-mono shrink-0" style={{ color: '#888898', minWidth: 28, textAlign: 'right' }}>
{s.value}
{s.display}
</span>
</div>
))}
<button
onClick={() => setShowExtra((v) => !v)}
title={showExtra ? 'Hide brightness and attack' : 'Brightness and attack'}
className="shrink-0 text-xs"
style={{
padding: '2px 8px',
borderRadius: 6,
background: showExtra ? 'rgba(74,124,255,0.15)' : 'transparent',
border: `1px solid ${showExtra ? '#4a7cff' : '#1a1a25'}`,
color: showExtra ? '#4a7cff' : '#888898'
}}
>
</button>
</>
);
}
Expand Down Expand Up @@ -896,9 +943,11 @@ export default function Home() {
masterBright={masterBright}
smoothness={smoothness}
attack={attack}
animSpeed={animSpeed}
onMasterBright={handleMasterBright}
onSmooth={handleSmooth}
onAttack={handleAttack}
onAnimSpeed={handleAnimSpeed}
throttledSlider={throttledSlider}
vertical
/>
Expand Down Expand Up @@ -1156,9 +1205,11 @@ export default function Home() {
masterBright={masterBright}
smoothness={smoothness}
attack={attack}
animSpeed={animSpeed}
onMasterBright={handleMasterBright}
onSmooth={handleSmooth}
onAttack={handleAttack}
onAnimSpeed={handleAnimSpeed}
throttledSlider={throttledSlider}
/>
</div>
Expand Down
Loading