Provide a Linux desktop controller configuration app with evdev input, uinput remapping, profiles, calibration, and reconnect recovery. Co-authored-by: Cursor <cursoragent@cursor.com>
229 lines
8.7 KiB
TypeScript
229 lines
8.7 KiB
TypeScript
import { useMemo, useState } from "react";
|
|
|
|
import { BUTTON_CAPABILITIES } from "@/constants/controls";
|
|
import { Button } from "@/components/common/Button";
|
|
import { Panel } from "@/components/common/Panel";
|
|
import { Select } from "@/components/inputs/Select";
|
|
import { TextField } from "@/components/inputs/TextField";
|
|
import { UnsavedChangesBar } from "@/components/feedback/UnsavedChangesBar";
|
|
import { PageShell } from "@/components/layout/PageShell";
|
|
import { ControllerDiagram, type ControllerView } from "@/components/controller/ControllerDiagram";
|
|
import { useControllerStore } from "@/stores/controllerStore";
|
|
import { useProfileStore } from "@/stores/profileStore";
|
|
import { useUIStore } from "@/stores/uiStore";
|
|
import type { ActionMode, ButtonMapping as ButtonMappingType, MappingTarget } from "@/types/profile";
|
|
|
|
/** Buttons that must always stay functional so the user can never lose control of the app or controller. */
|
|
const PROTECTED_CONTROL_IDS = new Set(["guide"]);
|
|
|
|
const MODE_OPTIONS: { value: ActionMode; label: string }[] = [
|
|
{ value: "normal", label: "Normal" },
|
|
{ value: "turbo", label: "Turbo" },
|
|
{ value: "toggle", label: "Toggle" },
|
|
{ value: "hold", label: "Hold" },
|
|
{ value: "longPress", label: "Long Press" },
|
|
{ value: "doubleTap", label: "Double Tap" },
|
|
];
|
|
|
|
type TargetKind = MappingTarget["kind"];
|
|
|
|
const TARGET_KIND_OPTIONS: { value: TargetKind; label: string }[] = [
|
|
{ value: "button", label: "Button" },
|
|
{ value: "key", label: "Keyboard Key" },
|
|
{ value: "mouseButton", label: "Mouse Button" },
|
|
{ value: "disabled", label: "Disabled" },
|
|
];
|
|
|
|
function defaultMappingFor(controlId: string): ButtonMappingType {
|
|
return { target: { kind: "button", button: controlId }, mode: "normal" };
|
|
}
|
|
|
|
function isRemapped(controlId: string, mapping: ButtonMappingType | undefined): boolean {
|
|
if (!mapping) return false;
|
|
return !(mapping.target.kind === "button" && mapping.target.button === controlId) || mapping.mode !== "normal";
|
|
}
|
|
|
|
export function ButtonMapping() {
|
|
const [view, setView] = useState<ControllerView>("front");
|
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
|
|
const liveState = useControllerStore((s) => s.liveState);
|
|
const draftProfile = useProfileStore((s) => s.draftProfile);
|
|
const updateDraft = useProfileStore((s) => s.updateDraft);
|
|
const pushToast = useUIStore((s) => s.pushToast);
|
|
|
|
const remappedIds = useMemo(() => {
|
|
if (!draftProfile) return new Set<string>();
|
|
const ids = Object.entries(draftProfile.buttonMappings)
|
|
.filter(([id, mapping]) => isRemapped(id, mapping))
|
|
.map(([id]) => id);
|
|
return new Set(ids);
|
|
}, [draftProfile]);
|
|
|
|
if (!draftProfile) {
|
|
return (
|
|
<PageShell title="Button Mapping" description="Loading profile...">
|
|
<div />
|
|
</PageShell>
|
|
);
|
|
}
|
|
|
|
const selectedButton = selectedId ? BUTTON_CAPABILITIES.find((b) => b.id === selectedId) : null;
|
|
const mapping = selectedId ? draftProfile.buttonMappings[selectedId] ?? defaultMappingFor(selectedId) : null;
|
|
|
|
function updateMapping(controlId: string, next: ButtonMappingType) {
|
|
if (PROTECTED_CONTROL_IDS.has(controlId) && next.target.kind === "disabled") {
|
|
pushToast("The Guide button can't be disabled — it's kept available so you can't lock yourself out.", "warning");
|
|
return;
|
|
}
|
|
updateDraft((draft) => ({
|
|
...draft,
|
|
buttonMappings: { ...draft.buttonMappings, [controlId]: next },
|
|
}));
|
|
}
|
|
|
|
return (
|
|
<PageShell title="Button Mapping" description="Click any control on the diagram to remap it.">
|
|
<UnsavedChangesBar />
|
|
|
|
<div className="grid grid-cols-[1.3fr_1fr] gap-5">
|
|
<Panel
|
|
title={view === "front" ? "Front view" : "Rear view"}
|
|
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-xl">
|
|
<ControllerDiagram
|
|
view={view}
|
|
buttons={liveState?.buttons ?? {}}
|
|
axes={liveState?.axes ?? {}}
|
|
selectedId={selectedId}
|
|
remappedIds={remappedIds}
|
|
onSelect={setSelectedId}
|
|
/>
|
|
</div>
|
|
</Panel>
|
|
|
|
<Panel title={selectedButton ? `Mapping: ${selectedButton.label}` : "Select a control"}>
|
|
{!selectedId || !mapping ? (
|
|
<p className="text-sm text-fg-subtle">
|
|
Click a button, D-pad direction, shoulder button, stick click, or paddle on the diagram to configure it.
|
|
</p>
|
|
) : (
|
|
<div className="flex flex-col gap-4">
|
|
<Select
|
|
label="Action"
|
|
value={mapping.target.kind}
|
|
options={TARGET_KIND_OPTIONS}
|
|
onChange={(kind) => {
|
|
const target: MappingTarget =
|
|
kind === "button"
|
|
? { kind: "button", button: selectedId }
|
|
: kind === "key"
|
|
? { kind: "key", key: "" }
|
|
: kind === "mouseButton"
|
|
? { kind: "mouseButton", button: "left" }
|
|
: { kind: "disabled" };
|
|
updateMapping(selectedId, { ...mapping, target });
|
|
}}
|
|
/>
|
|
|
|
{mapping.target.kind === "button" && (
|
|
<Select
|
|
label="Remap to button"
|
|
value={mapping.target.button}
|
|
options={BUTTON_CAPABILITIES.map((b) => ({ value: b.id, label: b.label }))}
|
|
onChange={(button) => updateMapping(selectedId, { ...mapping, target: { kind: "button", button } })}
|
|
/>
|
|
)}
|
|
|
|
{mapping.target.kind === "key" && (
|
|
<TextField
|
|
label="Key name"
|
|
placeholder="e.g. Space, KeyW, Enter"
|
|
value={mapping.target.key}
|
|
onChange={(e) =>
|
|
updateMapping(selectedId, { ...mapping, target: { kind: "key", key: e.currentTarget.value } })
|
|
}
|
|
/>
|
|
)}
|
|
|
|
{mapping.target.kind === "mouseButton" && (
|
|
<Select
|
|
label="Mouse button"
|
|
value={mapping.target.button}
|
|
options={[
|
|
{ value: "left", label: "Left" },
|
|
{ value: "right", label: "Right" },
|
|
{ value: "middle", label: "Middle" },
|
|
]}
|
|
onChange={(button) =>
|
|
updateMapping(selectedId, { ...mapping, target: { kind: "mouseButton", button } })
|
|
}
|
|
/>
|
|
)}
|
|
|
|
<Select
|
|
label="Mode"
|
|
value={mapping.mode}
|
|
options={MODE_OPTIONS}
|
|
onChange={(mode) => updateMapping(selectedId, { ...mapping, mode })}
|
|
/>
|
|
|
|
{mapping.mode === "turbo" && (
|
|
<TextField
|
|
label="Turbo rate (Hz)"
|
|
type="number"
|
|
min={1}
|
|
max={30}
|
|
value={mapping.turboRateHz ?? 10}
|
|
onChange={(e) => updateMapping(selectedId, { ...mapping, turboRateHz: Number(e.currentTarget.value) })}
|
|
/>
|
|
)}
|
|
|
|
{mapping.mode === "longPress" && (
|
|
<TextField
|
|
label="Long-press threshold (ms)"
|
|
type="number"
|
|
min={100}
|
|
max={3000}
|
|
value={mapping.longPressMs ?? 500}
|
|
onChange={(e) => updateMapping(selectedId, { ...mapping, longPressMs: Number(e.currentTarget.value) })}
|
|
/>
|
|
)}
|
|
|
|
{mapping.mode === "doubleTap" && (
|
|
<TextField
|
|
label="Double-tap window (ms)"
|
|
type="number"
|
|
min={100}
|
|
max={1000}
|
|
value={mapping.doubleTapWindowMs ?? 300}
|
|
onChange={(e) =>
|
|
updateMapping(selectedId, { ...mapping, doubleTapWindowMs: Number(e.currentTarget.value) })
|
|
}
|
|
/>
|
|
)}
|
|
|
|
<Button
|
|
variant="ghost"
|
|
onClick={() => updateMapping(selectedId, defaultMappingFor(selectedId))}
|
|
>
|
|
Reset this control
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</Panel>
|
|
</div>
|
|
</PageShell>
|
|
);
|
|
}
|