Provide a Linux desktop controller configuration app with evdev input, uinput remapping, profiles, calibration, and reconnect recovery. Co-authored-by: Cursor <cursoragent@cursor.com>
171 lines
7.5 KiB
TypeScript
171 lines
7.5 KiB
TypeScript
import { BatteryMedium, Gamepad2, Power, Usb } from "lucide-react";
|
|
|
|
import { Badge } from "@/components/common/Badge";
|
|
import { Panel } from "@/components/common/Panel";
|
|
import { StatusDot } from "@/components/common/StatusDot";
|
|
import { PageShell } from "@/components/layout/PageShell";
|
|
import { Select } from "@/components/inputs/Select";
|
|
import { Toggle } from "@/components/inputs/Toggle";
|
|
import { StickPreview } from "@/components/visualizations/StickPreview";
|
|
import { TriggerPreview } from "@/components/visualizations/TriggerPreview";
|
|
import { useControllerStore } from "@/stores/controllerStore";
|
|
import { useProfileStore } from "@/stores/profileStore";
|
|
import { useSettingsStore } from "@/stores/settingsStore";
|
|
import { processStick, processTrigger } from "@/utils/deadzoneMath";
|
|
|
|
export function Dashboard() {
|
|
const controllers = useControllerStore((s) => s.controllers);
|
|
const activeController = useControllerStore((s) => s.activeController());
|
|
const liveState = useControllerStore((s) => s.liveState);
|
|
const pollRateHz = useControllerStore((s) => s.pollRateHz);
|
|
const connectionStatus = useControllerStore((s) => s.connectionStatus);
|
|
const connectionMessage = useControllerStore((s) => s.connectionMessage);
|
|
|
|
const draftProfile = useProfileStore((s) => s.draftProfile);
|
|
const summaries = useProfileStore((s) => s.summaries);
|
|
const selectProfile = useProfileStore((s) => s.selectProfile);
|
|
|
|
const remappingEnabled = useSettingsStore((s) => s.remappingEnabled);
|
|
const setRemappingEnabled = useSettingsStore((s) => s.setRemappingEnabled);
|
|
const setControllerRemapping = useControllerStore((s) => s.setRemappingEnabled);
|
|
|
|
const axes = liveState?.axes ?? {};
|
|
const leftStick = draftProfile
|
|
? processStick({ x: axes.left_stick_x ?? 0, y: axes.left_stick_y ?? 0 }, draftProfile.leftStick)
|
|
: null;
|
|
const rightStick = draftProfile
|
|
? processStick({ x: axes.right_stick_x ?? 0, y: axes.right_stick_y ?? 0 }, draftProfile.rightStick)
|
|
: null;
|
|
const leftTrigger = draftProfile ? processTrigger(axes.left_trigger ?? 0, draftProfile.leftTrigger) : null;
|
|
const rightTrigger = draftProfile ? processTrigger(axes.right_trigger ?? 0, draftProfile.rightTrigger) : null;
|
|
|
|
return (
|
|
<PageShell title="Dashboard" description="At-a-glance status for the connected controller and active profile.">
|
|
<div className="grid grid-cols-3 gap-5">
|
|
<Panel title="Connected Controller" className="col-span-2">
|
|
{activeController ? (
|
|
<div className="flex items-center gap-4">
|
|
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-accent/15 text-accent-strong">
|
|
<Gamepad2 size={26} />
|
|
</div>
|
|
<div className="flex-1">
|
|
<p className="text-sm font-semibold text-fg">{activeController.name}</p>
|
|
<p className="mt-0.5 text-xs text-fg-muted">
|
|
{activeController.devicePath} · VID {activeController.vendorId.toString(16).padStart(4, "0")}{" "}
|
|
PID {activeController.productId.toString(16).padStart(4, "0")}
|
|
</p>
|
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
|
<Badge tone="success">
|
|
<StatusDot tone="success" pulse />
|
|
Connected
|
|
</Badge>
|
|
<Badge tone="neutral">
|
|
<Usb size={12} /> {activeController.mode.toUpperCase()}
|
|
</Badge>
|
|
{activeController.batteryPercent !== null && (
|
|
<Badge tone="neutral">
|
|
<BatteryMedium size={12} /> {activeController.batteryPercent}%
|
|
</Badge>
|
|
)}
|
|
<Badge tone={activeController.virtualControllerActive ? "accent" : "warning"}>
|
|
<Power size={12} />
|
|
Virtual controller {activeController.virtualControllerActive ? "active" : "inactive"}
|
|
</Badge>
|
|
{activeController.isSimulated && <Badge tone="warning">Simulated device</Badge>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-fg-muted">
|
|
{connectionStatus === "reconnecting"
|
|
? connectionMessage ?? "Reconnecting to controller…"
|
|
: "No controller detected. Connect an 8BitDo Ultimate controller to get started."}
|
|
</p>
|
|
)}
|
|
</Panel>
|
|
|
|
<Panel title="Remapping">
|
|
<Toggle
|
|
checked={remappingEnabled}
|
|
onChange={(enabled) => {
|
|
setRemappingEnabled(enabled);
|
|
void setControllerRemapping(enabled);
|
|
}}
|
|
label="Enable remapping"
|
|
description="Route input through the virtual controller"
|
|
/>
|
|
<div className="mt-4 border-t border-border pt-4">
|
|
<span className="mb-1.5 block text-xs text-fg-muted">Poll rate</span>
|
|
<span className="font-mono text-sm text-fg">{pollRateHz ? `${pollRateHz} Hz` : "—"}</span>
|
|
</div>
|
|
</Panel>
|
|
|
|
<Panel title="Active Profile" className="col-span-3">
|
|
<div className="flex items-center gap-4">
|
|
<div className="w-64">
|
|
<Select
|
|
value={draftProfile?.id ?? ""}
|
|
options={summaries.map((s) => ({ value: s.id, label: s.name }))}
|
|
onChange={(id) => void selectProfile(id)}
|
|
/>
|
|
</div>
|
|
{draftProfile?.description && <p className="text-sm text-fg-muted">{draftProfile.description}</p>}
|
|
{controllers.length > 1 && (
|
|
<span className="ml-auto text-xs text-fg-subtle">{controllers.length} controllers detected</span>
|
|
)}
|
|
</div>
|
|
</Panel>
|
|
|
|
<Panel title="Left Stick">
|
|
{leftStick && draftProfile ? (
|
|
<StickPreview
|
|
raw={leftStick.raw}
|
|
processed={leftStick.processed}
|
|
innerDeadzone={draftProfile.leftStick.innerDeadzone}
|
|
outerDeadzone={draftProfile.leftStick.outerDeadzone}
|
|
/>
|
|
) : (
|
|
<p className="text-sm text-fg-subtle">No data</p>
|
|
)}
|
|
</Panel>
|
|
|
|
<Panel title="Right Stick">
|
|
{rightStick && draftProfile ? (
|
|
<StickPreview
|
|
raw={rightStick.raw}
|
|
processed={rightStick.processed}
|
|
innerDeadzone={draftProfile.rightStick.innerDeadzone}
|
|
outerDeadzone={draftProfile.rightStick.outerDeadzone}
|
|
/>
|
|
) : (
|
|
<p className="text-sm text-fg-subtle">No data</p>
|
|
)}
|
|
</Panel>
|
|
|
|
<Panel title="Triggers">
|
|
{leftTrigger && rightTrigger && draftProfile ? (
|
|
<div className="flex flex-col gap-4">
|
|
<TriggerPreview
|
|
label="Left Trigger"
|
|
raw={leftTrigger.raw}
|
|
processed={leftTrigger.processed}
|
|
deadzone={draftProfile.leftTrigger.deadzone}
|
|
maxActivation={draftProfile.leftTrigger.maxActivation}
|
|
/>
|
|
<TriggerPreview
|
|
label="Right Trigger"
|
|
raw={rightTrigger.raw}
|
|
processed={rightTrigger.processed}
|
|
deadzone={draftProfile.rightTrigger.deadzone}
|
|
maxActivation={draftProfile.rightTrigger.maxActivation}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-fg-subtle">No data</p>
|
|
)}
|
|
</Panel>
|
|
</div>
|
|
</PageShell>
|
|
);
|
|
}
|