Workstream F core. Pull-based InputSource interface (N axes + discrete actions), three sources (XYPadSource push-based, WebMidiInputSource with learn-map CCs/notes, GamepadSource single/double stick), and an InputLayer that composes active sources into one N-dim vector at the head of the reactive spine. Arity reduction blends >2 axes down to the fixed 2-input WASM head; the true multi-WASM reshape is a documented TODO (inputs-spec). useInputLayer is the React binding (enable/config/status/channel layout).
235 lines
7.6 KiB
TypeScript
235 lines
7.6 KiB
TypeScript
/**
|
|
* useInputLayer — the thin React binding over the framework-neutral
|
|
* {@link InputLayer} + source adapters.
|
|
*
|
|
* Owns:
|
|
* - ONE InputLayer + one instance of each source (XY pad / MIDI / gamepad),
|
|
* created per engine and attached to it.
|
|
* - Which sources are ENABLED (the dock toggles these); enabling starts a
|
|
* source (async for MIDI) and adds it to the layer's composed set.
|
|
* - Per-source config (gamepad stick mode; MIDI learn arm + bindings).
|
|
* - The composed channel layout + per-source status, surfaced for the drawer.
|
|
*
|
|
* The XY pad source is the one consumers push into directly: `pushPad(x,y)` is
|
|
* called from ConsoleApp.onMove so the existing pad keeps working unchanged
|
|
* while still composing with the other sources.
|
|
*
|
|
* Discrete actions (MIDI notes / gamepad buttons) are fanned out via
|
|
* `onAction` so the console can later bind them to verdicts (commit/perturb).
|
|
*/
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import type { EngineApi } from '../engine';
|
|
import { InputLayer } from './input-layer';
|
|
import { XYPadSource } from './xy-pad-source';
|
|
import { WebMidiInputSource, type MidiBinding } from './midi-input-source';
|
|
import { GamepadSource, type StickMode } from './gamepad-source';
|
|
import type { InputAction, InputSource, InputSourceKind, InputSourceStatus } from './types';
|
|
|
|
export interface SourceView {
|
|
kind: InputSourceKind;
|
|
label: string;
|
|
enabled: boolean;
|
|
status: InputSourceStatus;
|
|
axisCount: number;
|
|
}
|
|
|
|
export interface UseInputLayer {
|
|
/** Push the on-screen XY pad position (∈ [0,1]) — call from onMove. */
|
|
pushPad: (x: number, y: number) => void;
|
|
/** Per-source enable + status + axis count for the dock. */
|
|
sources: SourceView[];
|
|
/** Toggle a source on/off. */
|
|
setEnabled: (kind: InputSourceKind, enabled: boolean) => void;
|
|
/** Composed channel layout (per-axis source+label). */
|
|
channelLayout: { source: string; label: string }[];
|
|
/** Total composed axis count. */
|
|
axisCount: number;
|
|
/** Engine input arity (the fixed WASM head = 2). */
|
|
engineInputSize: number;
|
|
|
|
// ---- gamepad config ----
|
|
gamepadStickMode: StickMode;
|
|
setGamepadStickMode: (m: StickMode) => void;
|
|
|
|
// ---- midi learn-map ----
|
|
midiLearnArmed: boolean;
|
|
armMidiLearn: (armed: boolean) => void;
|
|
midiBindings: MidiBinding[];
|
|
clearMidiBinding: (i: number) => void;
|
|
clearMidiBindings: () => void;
|
|
midiInputs: { id: string; name: string }[];
|
|
|
|
/** Subscribe to discrete actions (notes/buttons). */
|
|
onAction: (cb: (a: InputAction) => void) => () => void;
|
|
}
|
|
|
|
export function useInputLayer(engine: EngineApi | null): UseInputLayer {
|
|
// One layer + one of each source, created once per engine.
|
|
const layerRef = useRef<InputLayer | null>(null);
|
|
const padRef = useRef<XYPadSource | null>(null);
|
|
const midiRef = useRef<WebMidiInputSource | null>(null);
|
|
const gamepadRef = useRef<GamepadSource | null>(null);
|
|
|
|
if (!layerRef.current) {
|
|
layerRef.current = new InputLayer();
|
|
padRef.current = new XYPadSource();
|
|
midiRef.current = new WebMidiInputSource();
|
|
gamepadRef.current = new GamepadSource();
|
|
}
|
|
const layer = layerRef.current!;
|
|
const pad = padRef.current!;
|
|
const midi = midiRef.current!;
|
|
const gamepad = gamepadRef.current!;
|
|
|
|
// Enabled set — pad on by default (parity with today's behaviour).
|
|
const [enabled, setEnabledSet] = useState<Record<InputSourceKind, boolean>>({
|
|
'xy-pad': true,
|
|
midi: false,
|
|
gamepad: false,
|
|
});
|
|
const [statuses, setStatuses] = useState<Record<InputSourceKind, InputSourceStatus>>({
|
|
'xy-pad': pad.status(),
|
|
midi: midi.status(),
|
|
gamepad: gamepad.status(),
|
|
});
|
|
const [layoutTick, setLayoutTick] = useState(0);
|
|
const [gamepadStickMode, setGamepadStickModeState] = useState<StickMode>('single');
|
|
const [midiLearnArmed, setMidiLearnArmed] = useState(false);
|
|
const [midiBindings, setMidiBindings] = useState<MidiBinding[]>([]);
|
|
const [midiInputs, setMidiInputs] = useState<{ id: string; name: string }[]>([]);
|
|
|
|
// Attach to engine; start the pad immediately. Wire status/binding listeners.
|
|
useEffect(() => {
|
|
if (!engine) return;
|
|
layer.attach(engine);
|
|
pad.start();
|
|
layer.setSources([pad]);
|
|
layer.start();
|
|
|
|
const unsubs: (() => void)[] = [];
|
|
const wireStatus = (s: InputSource) =>
|
|
unsubs.push(
|
|
s.onStatusChange((st) => setStatuses((m) => ({ ...m, [s.kind]: st }))),
|
|
);
|
|
wireStatus(pad);
|
|
wireStatus(midi);
|
|
wireStatus(gamepad);
|
|
unsubs.push(layer.onLayoutChange(() => setLayoutTick((t) => t + 1)));
|
|
unsubs.push(
|
|
midi.onBindingsChange((b) => {
|
|
setMidiBindings(b);
|
|
setLayoutTick((t) => t + 1);
|
|
setMidiLearnArmed(midi.isLearnArmed());
|
|
}),
|
|
);
|
|
return () => {
|
|
for (const u of unsubs) u();
|
|
layer.dispose();
|
|
void midi.stop();
|
|
gamepad.stop();
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [engine]);
|
|
|
|
// Recompose the active source set whenever the enabled set changes.
|
|
useEffect(() => {
|
|
const active: InputSource[] = [];
|
|
if (enabled['xy-pad']) active.push(pad);
|
|
if (enabled.midi) active.push(midi);
|
|
if (enabled.gamepad) active.push(gamepad);
|
|
layer.setSources(active);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [enabled]);
|
|
|
|
const setEnabled = useCallback(
|
|
(kind: InputSourceKind, on: boolean) => {
|
|
setEnabledSet((m) => ({ ...m, [kind]: on }));
|
|
if (kind === 'midi') {
|
|
if (on) {
|
|
void midi.start().then(() => setMidiInputs(midi.listInputs()));
|
|
} else {
|
|
void midi.stop();
|
|
}
|
|
} else if (kind === 'gamepad') {
|
|
if (on) gamepad.start();
|
|
else gamepad.stop();
|
|
} else if (kind === 'xy-pad') {
|
|
if (on) pad.start();
|
|
else pad.stop();
|
|
}
|
|
},
|
|
[midi, gamepad, pad],
|
|
);
|
|
|
|
const setGamepadStickMode = useCallback(
|
|
(m: StickMode) => {
|
|
gamepad.setStickMode(m);
|
|
setGamepadStickModeState(m);
|
|
setLayoutTick((t) => t + 1);
|
|
},
|
|
[gamepad],
|
|
);
|
|
|
|
const armMidiLearn = useCallback(
|
|
(armed: boolean) => {
|
|
midi.armLearn(armed);
|
|
setMidiLearnArmed(armed);
|
|
},
|
|
[midi],
|
|
);
|
|
|
|
const clearMidiBinding = useCallback(
|
|
(i: number) => {
|
|
midi.clearBinding(i);
|
|
setMidiBindings([...midi.getBindings()]);
|
|
setLayoutTick((t) => t + 1);
|
|
},
|
|
[midi],
|
|
);
|
|
const clearMidiBindings = useCallback(() => {
|
|
midi.clearAllBindings();
|
|
setMidiBindings([]);
|
|
setLayoutTick((t) => t + 1);
|
|
}, [midi]);
|
|
|
|
const pushPad = useCallback((x: number, y: number) => pad.pushAxes(x, y), [pad]);
|
|
const onAction = useCallback((cb: (a: InputAction) => void) => layer.onAction(cb), [layer]);
|
|
|
|
const sources: SourceView[] = useMemo(
|
|
() =>
|
|
([pad, midi, gamepad] as InputSource[]).map((s) => ({
|
|
kind: s.kind,
|
|
label: s.label,
|
|
enabled: enabled[s.kind],
|
|
status: statuses[s.kind],
|
|
axisCount: enabled[s.kind] ? s.axisCount() : 0,
|
|
})),
|
|
// layoutTick forces recompute when axis counts shift (learn-map / stick mode).
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[enabled, statuses, layoutTick, pad, midi, gamepad],
|
|
);
|
|
|
|
const channelLayout = useMemo(
|
|
() => layer.channelLayout(),
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[layoutTick, enabled],
|
|
);
|
|
|
|
return {
|
|
pushPad,
|
|
sources,
|
|
setEnabled,
|
|
channelLayout,
|
|
axisCount: channelLayout.length,
|
|
engineInputSize: engine?.architecture.inputSize ?? 2,
|
|
gamepadStickMode,
|
|
setGamepadStickMode,
|
|
midiLearnArmed,
|
|
armMidiLearn,
|
|
midiBindings,
|
|
clearMidiBinding,
|
|
clearMidiBindings,
|
|
midiInputs,
|
|
onAction,
|
|
};
|
|
}
|