Provide a Linux desktop controller configuration app with evdev input, uinput remapping, profiles, calibration, and reconnect recovery. Co-authored-by: Cursor <cursoragent@cursor.com>
142 lines
6.2 KiB
TypeScript
142 lines
6.2 KiB
TypeScript
import { clsx } from "clsx";
|
|
import { useState } from "react";
|
|
|
|
import { BUTTON_CAPABILITIES } from "@/constants/controls";
|
|
import { Badge } from "@/components/common/Badge";
|
|
import { Button } from "@/components/common/Button";
|
|
import { Panel } from "@/components/common/Panel";
|
|
import { ControllerDiagram, type ControllerView } from "@/components/controller/ControllerDiagram";
|
|
import { PageShell } from "@/components/layout/PageShell";
|
|
import { EventLog } from "@/components/visualizations/EventLog";
|
|
import { mockControllerBackend } from "@/services/mockControllerBackend";
|
|
import { useControllerStore } from "@/stores/controllerStore";
|
|
import { useProfileStore } from "@/stores/profileStore";
|
|
|
|
export function InputTester() {
|
|
const [view, setView] = useState<ControllerView>("front");
|
|
|
|
const activeController = useControllerStore((s) => s.activeController());
|
|
const liveState = useControllerStore((s) => s.liveState);
|
|
const eventLog = useControllerStore((s) => s.eventLog);
|
|
const pollRateHz = useControllerStore((s) => s.pollRateHz);
|
|
const latencyEstimateMs = useControllerStore((s) => s.latencyEstimateMs);
|
|
const clearEventLog = useControllerStore((s) => s.clearEventLog);
|
|
const activeProfile = useProfileStore((s) => s.activeProfile);
|
|
|
|
const buttons = liveState?.buttons ?? {};
|
|
const axes = liveState?.axes ?? {};
|
|
const hasGyro = activeController?.capabilities.hasGyro ?? false;
|
|
|
|
return (
|
|
<PageShell title="Input Tester" description="Live view of every raw and processed input event.">
|
|
<div className="grid grid-cols-3 gap-5">
|
|
<Panel title="Session info">
|
|
<dl className="grid grid-cols-2 gap-y-2 text-sm">
|
|
<dt className="text-fg-muted">Active profile</dt>
|
|
<dd className="text-right font-medium text-fg">{activeProfile?.name ?? "—"}</dd>
|
|
<dt className="text-fg-muted">Poll rate</dt>
|
|
<dd className="text-right font-mono text-fg">{pollRateHz ? `${pollRateHz} Hz` : "—"}</dd>
|
|
<dt className="text-fg-muted">Latency estimate</dt>
|
|
<dd className="text-right font-mono text-fg">{latencyEstimateMs ? `${latencyEstimateMs} ms` : "—"}</dd>
|
|
<dt className="text-fg-muted">Controller</dt>
|
|
<dd className="text-right text-fg">{activeController ? "Connected" : "None"}</dd>
|
|
</dl>
|
|
</Panel>
|
|
|
|
<Panel title="Analog sticks">
|
|
<dl className="grid grid-cols-2 gap-y-1.5 font-mono text-xs">
|
|
<dt className="text-fg-muted">left_stick_x</dt>
|
|
<dd className="text-right text-fg">{(axes.left_stick_x ?? 0).toFixed(3)}</dd>
|
|
<dt className="text-fg-muted">left_stick_y</dt>
|
|
<dd className="text-right text-fg">{(axes.left_stick_y ?? 0).toFixed(3)}</dd>
|
|
<dt className="text-fg-muted">right_stick_x</dt>
|
|
<dd className="text-right text-fg">{(axes.right_stick_x ?? 0).toFixed(3)}</dd>
|
|
<dt className="text-fg-muted">right_stick_y</dt>
|
|
<dd className="text-right text-fg">{(axes.right_stick_y ?? 0).toFixed(3)}</dd>
|
|
</dl>
|
|
</Panel>
|
|
|
|
<Panel title="Triggers & gyroscope">
|
|
<dl className="grid grid-cols-2 gap-y-1.5 font-mono text-xs">
|
|
<dt className="text-fg-muted">left_trigger</dt>
|
|
<dd className="text-right text-fg">{(axes.left_trigger ?? 0).toFixed(3)}</dd>
|
|
<dt className="text-fg-muted">right_trigger</dt>
|
|
<dd className="text-right text-fg">{(axes.right_trigger ?? 0).toFixed(3)}</dd>
|
|
{hasGyro ? (
|
|
<>
|
|
<dt className="text-fg-muted">gyro_x</dt>
|
|
<dd className="text-right text-fg">{(axes.gyro_x ?? 0).toFixed(3)}</dd>
|
|
<dt className="text-fg-muted">gyro_y</dt>
|
|
<dd className="text-right text-fg">{(axes.gyro_y ?? 0).toFixed(3)}</dd>
|
|
<dt className="text-fg-muted">gyro_z</dt>
|
|
<dd className="text-right text-fg">{(axes.gyro_z ?? 0).toFixed(3)}</dd>
|
|
</>
|
|
) : (
|
|
<dd className="col-span-2 text-fg-subtle">No gyroscope detected</dd>
|
|
)}
|
|
</dl>
|
|
</Panel>
|
|
|
|
<Panel
|
|
title="Controller diagram"
|
|
description={activeController?.isSimulated ? "Click a control to simulate a press." : undefined}
|
|
className="col-span-2"
|
|
actions={
|
|
<div className="flex gap-1.5">
|
|
<Button size="sm" variant={view === "front" ? "primary" : "secondary"} onClick={() => setView("front")}>
|
|
Front
|
|
</Button>
|
|
<Button size="sm" variant={view === "rear" ? "primary" : "secondary"} onClick={() => setView("rear")}>
|
|
Rear
|
|
</Button>
|
|
</div>
|
|
}
|
|
>
|
|
<div className="mx-auto aspect-[480/280] max-w-lg">
|
|
<ControllerDiagram
|
|
view={view}
|
|
buttons={buttons}
|
|
axes={axes}
|
|
onSelect={(id) => mockControllerBackend.pulseButton(id)}
|
|
/>
|
|
</div>
|
|
</Panel>
|
|
|
|
<Panel title="Buttons">
|
|
<div className="grid grid-cols-3 gap-2">
|
|
{BUTTON_CAPABILITIES.map((button) => {
|
|
const pressed = Boolean(buttons[button.id]);
|
|
return (
|
|
<button
|
|
key={button.id}
|
|
onClick={() => mockControllerBackend.pulseButton(button.id)}
|
|
className={clsx(
|
|
"cursor-pointer rounded-lg border px-2 py-1.5 text-center text-xs font-medium transition-colors duration-100",
|
|
pressed ? "border-accent bg-accent text-accent-fg" : "border-border bg-surface-2 text-fg-muted hover:border-border-strong",
|
|
)}
|
|
>
|
|
{button.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</Panel>
|
|
|
|
<Panel
|
|
title="Event log"
|
|
className="col-span-3"
|
|
actions={
|
|
<Badge tone="neutral">
|
|
<Button size="sm" variant="ghost" onClick={clearEventLog}>
|
|
Clear
|
|
</Button>
|
|
</Badge>
|
|
}
|
|
>
|
|
<EventLog events={eventLog} />
|
|
</Panel>
|
|
</div>
|
|
</PageShell>
|
|
);
|
|
}
|