Initial release of 8BitDo Control Center

Provide a Linux desktop controller configuration app with evdev input, uinput remapping, profiles, calibration, and reconnect recovery.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-21 12:18:10 -07:00
commit 6507f5f8b3
121 changed files with 15415 additions and 0 deletions

228
src/pages/ButtonMapping.tsx Normal file
View File

@@ -0,0 +1,228 @@
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>
);
}