Skip to content

Commit 7d7f699

Browse files
authored
Merge pull request #120 from constructive-io/feat/embedded-show-fullscreen
feat: full-screen embedded show UI, and speed + fade as the fixed master sliders
2 parents 4b5f035 + dac9ecf commit 7d7f699

6 files changed

Lines changed: 152 additions & 12 deletions

File tree

packages/desktop/__tests__/laser-view.test.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,15 @@ jest.mock('electron', () => ({
2323
let brainProject: string | null = 'grace';
2424
jest.mock('@/main/brain', () => ({ status: () => ({ project: brainProject }) }));
2525
jest.mock('@/main/operator-session', () => ({ embeddedUrl: (url: string) => `${url}#wg_token=t` }));
26+
const windowSend = jest.fn();
2627
jest.mock('@/main/runtime', () => ({
27-
runtime: { mainWindow: { isDestroyed: () => false, contentView: { addChildView: jest.fn() } } }
28+
runtime: {
29+
mainWindow: {
30+
isDestroyed: () => false,
31+
contentView: { addChildView: jest.fn() },
32+
webContents: { send: windowSend }
33+
}
34+
}
2835
}));
2936

3037
import { invalidateLaserView, resetLaserView, syncLaser } from '@/main/laser-view';
@@ -43,6 +50,8 @@ beforeEach(() => {
4350
brainProject = 'grace';
4451
webContents.loadURL.mockReset().mockResolvedValue(undefined);
4552
viewInstance.setVisible.mockClear();
53+
webContents.on.mockClear();
54+
windowSend.mockClear();
4655
});
4756

4857
afterEach(() => jest.useRealTimers());
@@ -154,6 +163,24 @@ it('does not put the stopped show back on screen when the next one starts', asyn
154163
expect(visibility().at(-1)).toBe(true);
155164
});
156165

166+
it('tells the renderer about Escape pressed inside the embedded UI', () => {
167+
show();
168+
const onInput = webContents.on.mock.calls.find(([e]) => e === 'before-input-event')?.[1] as (
169+
e: unknown,
170+
input: { type: string; key: string }
171+
) => void;
172+
173+
// Full screen leaves the embedded UI focused, so the renderer's own keydown
174+
// listener never fires and this is the only way back out.
175+
onInput({}, { type: 'keyDown', key: 'Escape' });
176+
expect(windowSend).toHaveBeenCalledWith('laser:escape');
177+
178+
windowSend.mockClear();
179+
onInput({}, { type: 'keyUp', key: 'Escape' });
180+
onInput({}, { type: 'keyDown', key: 'a' });
181+
expect(windowSend).not.toHaveBeenCalled();
182+
});
183+
157184
it('does not reload an unchanged url on every sync', async () => {
158185
show();
159186
await flush();

packages/desktop/src/main/laser-view.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,13 @@ function ensureView(): WebContentsView | null {
131131
void shell.openExternal(url);
132132
return { action: 'deny' };
133133
});
134+
// While the embedded UI is full screen it is the only focused thing on the
135+
// window, so its own web contents sees Escape and the renderer never would —
136+
// without this there is no keyboard way back out.
137+
created.webContents.on('before-input-event', (_e, input) => {
138+
if (input.type !== 'keyDown' || input.key !== 'Escape') return;
139+
if (!win.isDestroyed()) win.webContents.send('laser:escape');
140+
});
134141
created.webContents.on('did-fail-load', (_e, _code, _desc, _url, isMainFrame) => {
135142
if (isMainFrame && desiredUrl && loadedUrl === desiredUrl) retryLater(desiredUrl);
136143
});

packages/desktop/src/preload.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,11 @@ contextBridge.exposeInMainWorld('wavegrid', api);
128128

129129
// Fire-and-forget channel the renderer uses to position the native laser view.
130130
const laser: WavegridLaser = {
131-
sync: (state: LaserSyncState) => ipcRenderer.send('laser:sync', state)
131+
sync: (state: LaserSyncState) => ipcRenderer.send('laser:sync', state),
132+
onEscape: (handler: () => void) => {
133+
const listener = () => handler();
134+
ipcRenderer.on('laser:escape', listener);
135+
return () => ipcRenderer.off('laser:escape', listener);
136+
}
132137
};
133138
contextBridge.exposeInMainWorld('wavegridLaser', laser);

packages/desktop/src/renderer/routes/show-route.tsx

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { AlertTriangle, MonitorPlay, Play, Square } from 'lucide-react';
1+
import { AlertTriangle, Maximize2, Minimize2, MonitorPlay, Play, Square } from 'lucide-react';
22
import * as React from 'react';
33

44
import { Badge } from '@/components/ui/badge';
@@ -37,10 +37,32 @@ export function ShowRoute({ status, activeProject, onStart, onStop, busy }: Show
3737
// would be buried under it. Hide it for as long as an overlay is up.
3838
const [overlay, setOverlay] = React.useState(false);
3939
React.useEffect(() => watchOverlays(document, setOverlay), []);
40+
// In the windowed panel the grid is a postage stamp; full screen hands the
41+
// embedded UI the whole window and keeps only the bar that gets back out.
42+
const [expanded, setExpanded] = React.useState(false);
4043

4144
const running = status.running;
4245
const url = status.url;
4346

47+
React.useEffect(() => {
48+
if (!expanded) return;
49+
const onKey = (e: KeyboardEvent) => {
50+
if (e.key === 'Escape') setExpanded(false);
51+
};
52+
window.addEventListener('keydown', onKey);
53+
// The embedded UI has focus, so its own web contents — not this document —
54+
// is what sees a keypress while it is full screen.
55+
const offEmbedded = window.wavegridLaser.onEscape(() => setExpanded(false));
56+
return () => {
57+
window.removeEventListener('keydown', onKey);
58+
offEmbedded();
59+
};
60+
}, [expanded]);
61+
62+
React.useEffect(() => {
63+
if (!running) setExpanded(false);
64+
}, [running]);
65+
4466
// Report the laser view's target bounds to the main process on every layout
4567
// change while the show is running; hide it whenever we leave this route.
4668
React.useEffect(() => {
@@ -67,7 +89,26 @@ export function ShowRoute({ status, activeProject, onStart, onStop, busy }: Show
6789
window.removeEventListener('resize', sync);
6890
window.wavegridLaser.sync({ url: null, bounds: { x: 0, y: 0, width: 0, height: 0 }, visible: false });
6991
};
70-
}, [running, url, overlay]);
92+
// Full screen moves the slot, and the native view only follows the bounds we report.
93+
}, [running, url, overlay, expanded]);
94+
95+
if (running && expanded) {
96+
return (
97+
<div className='bg-background fixed inset-0 z-40 flex flex-col'>
98+
<div className='flex items-center justify-between gap-3 border-b px-3 py-1.5'>
99+
<span className='text-muted-foreground truncate text-xs'>
100+
{activeProject ?? 'Show'} · full screen
101+
</span>
102+
<Button size='sm' variant='ghost' onClick={() => setExpanded(false)}>
103+
<Minimize2 />
104+
Exit full screen (Esc)
105+
</Button>
106+
</div>
107+
{/* The native laser WebContentsView is positioned over this slot. */}
108+
<div ref={slotRef} className='flex-1' />
109+
</div>
110+
);
111+
}
71112

72113
return (
73114
<div className='flex h-full flex-col gap-4 p-4'>
@@ -85,6 +126,12 @@ export function ShowRoute({ status, activeProject, onStart, onStop, busy }: Show
85126
{running && status.lanUrls.length > 0 && (
86127
<ShareShow lanUrls={status.lanUrls} />
87128
)}
129+
{running && (
130+
<Button size='sm' variant='outline' className='ml-auto' onClick={() => setExpanded(true)}>
131+
<Maximize2 />
132+
Full screen
133+
</Button>
134+
)}
88135
</div>
89136

90137
{/* Why the show isn't up (or is up without output) — a red dot with no

packages/desktop/src/types/ipc.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,6 +562,9 @@ export interface OscDebugState {
562562

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

567570
declare global {

packages/ui/src/app.tsx

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -379,35 +379,68 @@ function ToolPanel({
379379

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

382+
/** Speed is logarithmic — 0.01x to 5x — so the slider carries a percentage of
383+
* that curve rather than the multiplier itself. */
384+
const SPEED_MIN = 0.01;
385+
const SPEED_MAX = 5.0;
386+
const speedToPct = (v: number) =>
387+
Math.round((Math.log(v / SPEED_MIN) / Math.log(SPEED_MAX / SPEED_MIN)) * 100);
388+
const pctToSpeed = (pct: number) => SPEED_MIN * Math.pow(SPEED_MAX / SPEED_MIN, pct / 100);
389+
const formatSpeed = (v: number) => `${v < 0.1 ? v.toFixed(3) : v < 1 ? v.toFixed(2) : v.toFixed(1)}x`;
390+
391+
interface MasterSlider {
392+
label: string;
393+
value: number;
394+
display: string;
395+
handler: (pct: number) => void;
396+
flex: number;
397+
}
398+
382399
function MasterSliders({
383400
masterBright,
384401
smoothness,
385402
attack,
403+
animSpeed,
386404
onMasterBright,
387405
onSmooth,
388406
onAttack,
407+
onAnimSpeed,
389408
throttledSlider,
390409
vertical
391410
}: {
392411
masterBright: number;
393412
smoothness: number;
394413
attack: number;
414+
animSpeed: number;
395415
onMasterBright: (v: number) => void;
396416
onSmooth: (v: number) => void;
397417
onAttack: (v: number) => void;
418+
onAnimSpeed: (v: number) => void;
398419
throttledSlider: (handler: (v: number) => void) => (e: React.ChangeEvent<HTMLInputElement>) => void;
399420
vertical?: boolean;
400421
}) {
401-
const sliders = [
402-
{ label: 'Bright', value: masterBright, handler: onMasterBright, flex: 1 },
403-
{ label: 'Fade', value: smoothness, handler: onSmooth, flex: 3 },
404-
{ label: 'Attack', value: attack, handler: onAttack, flex: 1 }
422+
// The two an operator reaches for mid-show hold the fixed slots; brightness
423+
// and attack get set once, so they live behind the disclosure.
424+
const fixed: MasterSlider[] = [
425+
{
426+
label: 'Speed',
427+
value: speedToPct(animSpeed),
428+
display: formatSpeed(animSpeed),
429+
handler: (pct) => onAnimSpeed(pctToSpeed(pct)),
430+
flex: 2
431+
},
432+
{ label: 'Fade', value: smoothness, display: String(smoothness), handler: onSmooth, flex: 3 }
433+
];
434+
const extra: MasterSlider[] = [
435+
{ label: 'Bright', value: masterBright, display: String(masterBright), handler: onMasterBright, flex: 1 },
436+
{ label: 'Attack', value: attack, display: String(attack), handler: onAttack, flex: 1 }
405437
];
438+
const [showExtra, setShowExtra] = useState(false);
406439

407440
if (vertical) {
408441
return (
409442
<div className="space-y-3 p-4">
410-
{sliders.map((s) => (
443+
{[...fixed, ...extra].map((s) => (
411444
<div key={s.label} className="flex items-center gap-3">
412445
<span className="text-sm font-medium" style={{ color: '#888898', minWidth: 56 }}>
413446
{s.label}
@@ -421,7 +454,7 @@ function MasterSliders({
421454
onChange={throttledSlider(s.handler)}
422455
/>
423456
<span className="text-sm font-mono" style={{ color: '#888898', minWidth: 32, textAlign: 'right' }}>
424-
{s.value}
457+
{s.display}
425458
</span>
426459
</div>
427460
))}
@@ -431,7 +464,7 @@ function MasterSliders({
431464

432465
return (
433466
<>
434-
{sliders.map((s) => (
467+
{[...fixed, ...(showExtra ? extra : [])].map((s) => (
435468
<div key={s.label} className="flex items-center gap-2" style={{ minWidth: 0, flex: s.flex }}>
436469
<span className="text-xs font-medium shrink-0" style={{ color: '#888898', textTransform: 'uppercase', letterSpacing: '0.05em', fontSize: 11 }}>
437470
{s.label}
@@ -446,10 +479,24 @@ function MasterSliders({
446479
onChange={throttledSlider(s.handler)}
447480
/>
448481
<span className="text-xs font-mono shrink-0" style={{ color: '#888898', minWidth: 28, textAlign: 'right' }}>
449-
{s.value}
482+
{s.display}
450483
</span>
451484
</div>
452485
))}
486+
<button
487+
onClick={() => setShowExtra((v) => !v)}
488+
title={showExtra ? 'Hide brightness and attack' : 'Brightness and attack'}
489+
className="shrink-0 text-xs"
490+
style={{
491+
padding: '2px 8px',
492+
borderRadius: 6,
493+
background: showExtra ? 'rgba(74,124,255,0.15)' : 'transparent',
494+
border: `1px solid ${showExtra ? '#4a7cff' : '#1a1a25'}`,
495+
color: showExtra ? '#4a7cff' : '#888898'
496+
}}
497+
>
498+
499+
</button>
453500
</>
454501
);
455502
}
@@ -896,9 +943,11 @@ export default function Home() {
896943
masterBright={masterBright}
897944
smoothness={smoothness}
898945
attack={attack}
946+
animSpeed={animSpeed}
899947
onMasterBright={handleMasterBright}
900948
onSmooth={handleSmooth}
901949
onAttack={handleAttack}
950+
onAnimSpeed={handleAnimSpeed}
902951
throttledSlider={throttledSlider}
903952
vertical
904953
/>
@@ -1156,9 +1205,11 @@ export default function Home() {
11561205
masterBright={masterBright}
11571206
smoothness={smoothness}
11581207
attack={attack}
1208+
animSpeed={animSpeed}
11591209
onMasterBright={handleMasterBright}
11601210
onSmooth={handleSmooth}
11611211
onAttack={handleAttack}
1212+
onAnimSpeed={handleAnimSpeed}
11621213
throttledSlider={throttledSlider}
11631214
/>
11641215
</div>

0 commit comments

Comments
 (0)