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

36
src/App.tsx Normal file
View File

@@ -0,0 +1,36 @@
import { HashRouter, Route, Routes } from "react-router-dom";
import { AppLayout } from "@/components/layout/AppLayout";
import { ButtonMapping } from "@/pages/ButtonMapping";
import { Calibration } from "@/pages/Calibration";
import { Dashboard } from "@/pages/Dashboard";
import { DeviceInfo } from "@/pages/DeviceInfo";
import { InputTester } from "@/pages/InputTester";
import { Motion } from "@/pages/Motion";
import { Profiles } from "@/pages/Profiles";
import { Settings } from "@/pages/Settings";
import { Sticks } from "@/pages/Sticks";
import { Triggers } from "@/pages/Triggers";
function App() {
return (
<HashRouter>
<Routes>
<Route element={<AppLayout />}>
<Route path="/" element={<Dashboard />} />
<Route path="/profiles" element={<Profiles />} />
<Route path="/mapping" element={<ButtonMapping />} />
<Route path="/sticks" element={<Sticks />} />
<Route path="/triggers" element={<Triggers />} />
<Route path="/motion" element={<Motion />} />
<Route path="/calibration" element={<Calibration />} />
<Route path="/tester" element={<InputTester />} />
<Route path="/device" element={<DeviceInfo />} />
<Route path="/settings" element={<Settings />} />
</Route>
</Routes>
</HashRouter>
);
}
export default App;

View File

@@ -0,0 +1,25 @@
import { clsx } from "clsx";
import type { ReactNode } from "react";
export type BadgeTone = "neutral" | "success" | "warning" | "danger" | "accent";
const TONE_CLASSES: Record<BadgeTone, string> = {
neutral: "bg-surface-3 text-fg-muted",
success: "bg-success/15 text-success",
warning: "bg-warning/15 text-warning",
danger: "bg-danger/15 text-danger",
accent: "bg-accent/15 text-accent-strong",
};
export function Badge({ tone = "neutral", children }: { tone?: BadgeTone; children: ReactNode }) {
return (
<span
className={clsx(
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium",
TONE_CLASSES[tone],
)}
>
{children}
</span>
);
}

View File

@@ -0,0 +1,48 @@
import { clsx } from "clsx";
import type { ButtonHTMLAttributes, ReactNode } from "react";
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
export type ButtonSize = "sm" | "md";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
icon?: ReactNode;
}
const VARIANT_CLASSES: Record<ButtonVariant, string> = {
primary: "bg-accent text-accent-fg hover:bg-accent-strong disabled:opacity-40",
secondary:
"bg-surface-2 text-fg border border-border hover:border-border-strong disabled:opacity-40",
ghost: "bg-transparent text-fg-muted hover:bg-surface-2 hover:text-fg disabled:opacity-40",
danger: "bg-transparent text-danger border border-danger/40 hover:bg-danger/10 disabled:opacity-40",
};
const SIZE_CLASSES: Record<ButtonSize, string> = {
sm: "h-8 px-3 text-xs gap-1.5",
md: "h-9 px-4 text-sm gap-2",
};
export function Button({
variant = "secondary",
size = "md",
icon,
className,
children,
...rest
}: ButtonProps) {
return (
<button
className={clsx(
"inline-flex items-center justify-center rounded-lg font-medium transition-colors duration-150 cursor-pointer disabled:cursor-not-allowed",
VARIANT_CLASSES[variant],
SIZE_CLASSES[size],
className,
)}
{...rest}
>
{icon}
{children}
</button>
);
}

View File

@@ -0,0 +1,42 @@
import { clsx } from "clsx";
import type { HTMLAttributes, ReactNode } from "react";
interface PanelProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
title?: ReactNode;
description?: ReactNode;
actions?: ReactNode;
padded?: boolean;
}
/** Rounded, subtly-shadowed surface used for every card/section in the app. */
export function Panel({
title,
description,
actions,
padded = true,
className,
children,
...rest
}: PanelProps) {
return (
<div
className={clsx(
"rounded-2xl border border-border bg-surface shadow-[var(--shadow-panel)]",
padded && "p-5",
className,
)}
{...rest}
>
{(title || actions) && (
<div className="mb-4 flex items-start justify-between gap-3">
<div>
{title && <h3 className="text-sm font-semibold text-fg">{title}</h3>}
{description && <p className="mt-1 text-xs text-fg-muted">{description}</p>}
</div>
{actions && <div className="flex items-center gap-2">{actions}</div>}
</div>
)}
{children}
</div>
);
}

View File

@@ -0,0 +1,21 @@
import { clsx } from "clsx";
export type StatusDotTone = "success" | "warning" | "danger" | "neutral";
const TONE_CLASSES: Record<StatusDotTone, string> = {
success: "bg-success",
warning: "bg-warning",
danger: "bg-danger",
neutral: "bg-fg-subtle",
};
export function StatusDot({ tone, pulse = false }: { tone: StatusDotTone; pulse?: boolean }) {
return (
<span className="relative inline-flex h-2 w-2">
{pulse && (
<span className={clsx("absolute inline-flex h-full w-full animate-ping rounded-full opacity-60", TONE_CLASSES[tone])} />
)}
<span className={clsx("relative inline-flex h-2 w-2 rounded-full", TONE_CLASSES[tone])} />
</span>
);
}

View File

@@ -0,0 +1,57 @@
import { clsx } from "clsx";
import type { ButtonHotspotDef } from "@/components/controller/controllerLayout";
interface ButtonHotspotProps {
def: ButtonHotspotDef;
pressed: boolean;
selected: boolean;
remapped: boolean;
onSelect?: (id: string) => void;
}
export function ButtonHotspot({ def, pressed, selected, remapped, onSelect }: ButtonHotspotProps) {
const shared = clsx(
"cursor-pointer transition-all duration-100",
pressed
? "fill-accent stroke-accent-strong"
: selected
? "fill-surface-2 stroke-accent"
: remapped
? "fill-surface-2 stroke-accent-strong/70"
: "fill-surface-2 stroke-border-strong",
);
return (
<g
onClick={() => onSelect?.(def.id)}
role="button"
aria-label={def.label}
aria-pressed={pressed}
tabIndex={0}
>
{def.shape === "circle" ? (
<circle cx={def.cx} cy={def.cy} r={def.r} strokeWidth={selected ? 2.5 : 1.5} className={shared} />
) : (
<rect
x={def.cx - (def.width ?? 0) / 2}
y={def.cy - (def.height ?? 0) / 2}
width={def.width}
height={def.height}
rx={def.rx}
strokeWidth={selected ? 2.5 : 1.5}
className={shared}
/>
)}
<text
x={def.cx}
y={def.cy + 3.5}
textAnchor="middle"
fontSize={def.shape === "circle" ? 10 : 8}
className={clsx("pointer-events-none select-none font-semibold", pressed ? "fill-accent-fg" : "fill-fg-muted")}
>
{def.label.length <= 3 ? def.label : ""}
</text>
</g>
);
}

View File

@@ -0,0 +1,81 @@
import { BODY_PATH, FRONT_HOTSPOTS, REAR_HOTSPOTS, VIEW_BOX } from "@/components/controller/controllerLayout";
import { ButtonHotspot } from "@/components/controller/ButtonHotspot";
import { StickHotspot } from "@/components/controller/StickHotspot";
import { TriggerHotspot } from "@/components/controller/TriggerHotspot";
export type ControllerView = "front" | "rear";
interface ControllerDiagramProps {
view: ControllerView;
buttons: Record<string, boolean>;
axes: Record<string, number>;
selectedId?: string | null;
remappedIds?: Set<string>;
onSelect?: (id: string) => void;
}
/**
* Generic, reusable SVG gamepad diagram (front and rear views). Deliberately
* abstract/geometric rather than tracing any real product's artwork.
*/
export function ControllerDiagram({
view,
buttons,
axes,
selectedId,
remappedIds,
onSelect,
}: ControllerDiagramProps) {
const hotspots = view === "front" ? FRONT_HOTSPOTS : REAR_HOTSPOTS;
return (
<svg viewBox={VIEW_BOX} className="h-full w-full">
<path
d={BODY_PATH}
className={view === "rear" ? "fill-surface-2/70 stroke-border" : "fill-surface-2 stroke-border"}
strokeWidth={2}
/>
{view === "rear" && (
<text x={240} y={140} textAnchor="middle" fontSize={12} className="fill-fg-subtle select-none">
Rear view
</text>
)}
{hotspots.map((def) => {
if (def.kind === "button") {
return (
<ButtonHotspot
key={def.id}
def={def}
pressed={Boolean(buttons[def.id])}
selected={selectedId === def.id}
remapped={remappedIds?.has(def.id) ?? false}
onSelect={onSelect}
/>
);
}
if (def.kind === "stick") {
return (
<StickHotspot
key={def.id}
def={def}
x={axes[def.axisX] ?? 0}
y={axes[def.axisY] ?? 0}
clicked={Boolean(buttons[def.clickId])}
selected={selectedId === def.id}
onSelect={onSelect}
/>
);
}
return (
<TriggerHotspot
key={def.id}
def={def}
value={axes[def.axis] ?? 0}
selected={selectedId === def.id}
onSelect={onSelect}
/>
);
})}
</svg>
);
}

View File

@@ -0,0 +1,45 @@
import { clsx } from "clsx";
import type { StickHotspotDef } from "@/components/controller/controllerLayout";
interface StickHotspotProps {
def: StickHotspotDef;
x: number;
y: number;
clicked: boolean;
selected: boolean;
onSelect?: (id: string) => void;
}
/** Renders the stick's boundary ring plus a dot showing its current displacement. */
export function StickHotspot({ def, x, y, clicked, selected, onSelect }: StickHotspotProps) {
const dotX = def.cx + x * def.r * 0.72;
const dotY = def.cy + y * def.r * 0.72;
return (
<g
onClick={() => onSelect?.(def.id)}
role="button"
aria-label={def.label}
tabIndex={0}
className="cursor-pointer"
>
<circle
cx={def.cx}
cy={def.cy}
r={def.r}
className={clsx(
"fill-surface-3 transition-colors duration-100",
selected ? "stroke-accent" : "stroke-border-strong",
)}
strokeWidth={selected ? 2.5 : 1.5}
/>
<circle
cx={dotX}
cy={dotY}
r={clicked ? 9 : 7}
className={clicked ? "fill-accent" : "fill-fg-muted"}
/>
</g>
);
}

View File

@@ -0,0 +1,39 @@
import { clsx } from "clsx";
import type { TriggerHotspotDef } from "@/components/controller/controllerLayout";
interface TriggerHotspotProps {
def: TriggerHotspotDef;
value: number;
selected: boolean;
onSelect?: (id: string) => void;
}
/** Shoulder trigger with an analog fill bar reflecting its current value. */
export function TriggerHotspot({ def, value, selected, onSelect }: TriggerHotspotProps) {
const fillWidth = Math.max(0, Math.min(1, value)) * def.width;
return (
<g onClick={() => onSelect?.(def.id)} role="button" aria-label={def.label} tabIndex={0} className="cursor-pointer">
<rect
x={def.x}
y={def.y}
width={def.width}
height={def.height}
rx={def.rx}
className={clsx("fill-surface-3 transition-colors duration-100", selected ? "stroke-accent" : "stroke-border-strong")}
strokeWidth={selected ? 2.5 : 1.5}
/>
<rect x={def.x} y={def.y} width={fillWidth} height={def.height} rx={def.rx} className="fill-accent/70" />
<text
x={def.x + def.width / 2}
y={def.y + def.height / 2 + 3}
textAnchor="middle"
fontSize={8}
className="pointer-events-none select-none fill-fg-muted font-semibold"
>
{def.label}
</text>
</g>
);
}

View File

@@ -0,0 +1,135 @@
/**
* Layout data for the generic controller diagram. Coordinates describe an
* abstract dual-stick gamepad silhouette (not any specific product's
* artwork) inside a 480x280 viewBox, reused for both the front and rear
* views so the component stays simple and themeable.
*/
export const VIEW_BOX = "0 0 480 280";
export const BODY_PATH =
"M150,55 C105,55 65,85 52,135 C40,182 60,225 102,230 C132,233 152,213 168,195 L312,195 C328,213 348,233 378,230 C420,225 440,182 428,135 C415,85 375,55 330,55 Z";
export interface ButtonHotspotDef {
kind: "button";
id: string;
label: string;
shape: "circle" | "rect";
cx: number;
cy: number;
r?: number;
width?: number;
height?: number;
rx?: number;
}
export interface StickHotspotDef {
kind: "stick";
id: string;
clickId: string;
label: string;
cx: number;
cy: number;
r: number;
axisX: string;
axisY: string;
}
export interface TriggerHotspotDef {
kind: "trigger";
id: string;
label: string;
x: number;
y: number;
width: number;
height: number;
rx: number;
axis: string;
}
export type HotspotDef = ButtonHotspotDef | StickHotspotDef | TriggerHotspotDef;
export const FRONT_HOTSPOTS: HotspotDef[] = [
{
kind: "stick",
id: "left_stick",
clickId: "l3",
label: "Left Stick",
cx: 150,
cy: 112,
r: 28,
axisX: "left_stick_x",
axisY: "left_stick_y",
},
{ kind: "button", id: "dpad_up", label: "D-Pad Up", shape: "rect", cx: 150, cy: 168, width: 20, height: 22, rx: 4 },
{
kind: "button",
id: "dpad_down",
label: "D-Pad Down",
shape: "rect",
cx: 150,
cy: 212,
width: 20,
height: 22,
rx: 4,
},
{
kind: "button",
id: "dpad_left",
label: "D-Pad Left",
shape: "rect",
cx: 128,
cy: 190,
width: 22,
height: 20,
rx: 4,
},
{
kind: "button",
id: "dpad_right",
label: "D-Pad Right",
shape: "rect",
cx: 172,
cy: 190,
width: 22,
height: 20,
rx: 4,
},
{
kind: "stick",
id: "right_stick",
clickId: "r3",
label: "Right Stick",
cx: 330,
cy: 190,
r: 28,
axisX: "right_stick_x",
axisY: "right_stick_y",
},
{ kind: "button", id: "y", label: "Y", shape: "circle", cx: 330, cy: 90, r: 14 },
{ kind: "button", id: "x", label: "X", shape: "circle", cx: 308, cy: 112, r: 14 },
{ kind: "button", id: "b", label: "B", shape: "circle", cx: 352, cy: 112, r: 14 },
{ kind: "button", id: "a", label: "A", shape: "circle", cx: 330, cy: 134, r: 14 },
{ kind: "button", id: "select", label: "Select", shape: "rect", cx: 205, cy: 95, width: 24, height: 14, rx: 7 },
{ kind: "button", id: "guide", label: "Guide", shape: "circle", cx: 240, cy: 85, r: 12 },
{ kind: "button", id: "start", label: "Start", shape: "rect", cx: 275, cy: 95, width: 24, height: 14, rx: 7 },
{ kind: "button", id: "l1", label: "L1", shape: "rect", cx: 110, cy: 48, width: 60, height: 16, rx: 8 },
{ kind: "button", id: "r1", label: "R1", shape: "rect", cx: 370, cy: 48, width: 60, height: 16, rx: 8 },
{ kind: "trigger", id: "left_trigger", label: "L2", x: 80, y: 18, width: 60, height: 16, rx: 8, axis: "left_trigger" },
{
kind: "trigger",
id: "right_trigger",
label: "R2",
x: 340,
y: 18,
width: 60,
height: 16,
rx: 8,
axis: "right_trigger",
},
];
export const REAR_HOTSPOTS: HotspotDef[] = [
{ kind: "button", id: "paddle_l1", label: "Paddle L1", shape: "rect", cx: 128, cy: 150, width: 26, height: 84, rx: 13 },
{ kind: "button", id: "paddle_r1", label: "Paddle R1", shape: "rect", cx: 352, cy: 150, width: 26, height: 84, rx: 13 },
];

View File

@@ -0,0 +1,26 @@
import { Button } from "@/components/common/Button";
import { useUIStore } from "@/stores/uiStore";
export function ConfirmDialogHost() {
const confirm = useUIStore((s) => s.confirm);
const resolveConfirm = useUIStore((s) => s.resolveConfirm);
if (!confirm) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="w-full max-w-sm rounded-2xl border border-border bg-surface p-5 shadow-[var(--shadow-panel)]">
<h2 className="text-sm font-semibold text-fg">{confirm.title}</h2>
<p className="mt-2 text-sm text-fg-muted">{confirm.message}</p>
<div className="mt-5 flex justify-end gap-2">
<Button variant="ghost" onClick={() => resolveConfirm(false)}>
{confirm.cancelLabel ?? "Cancel"}
</Button>
<Button variant={confirm.danger ? "danger" : "primary"} onClick={() => resolveConfirm(true)}>
{confirm.confirmLabel ?? "Confirm"}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,53 @@
import { AlertTriangle, CheckCircle2, Info, X, XCircle } from "lucide-react";
import type { Toast, ToastVariant } from "@/stores/uiStore";
import { useUIStore } from "@/stores/uiStore";
const VARIANT_ICON: Record<ToastVariant, typeof Info> = {
info: Info,
success: CheckCircle2,
warning: AlertTriangle,
danger: XCircle,
};
const VARIANT_CLASSES: Record<ToastVariant, string> = {
info: "border-border text-fg",
success: "border-success/40 text-success",
warning: "border-warning/40 text-warning",
danger: "border-danger/40 text-danger",
};
function ToastItem({ toast }: { toast: Toast }) {
const dismissToast = useUIStore((s) => s.dismissToast);
const Icon = VARIANT_ICON[toast.variant];
return (
<div
className={`flex items-start gap-2.5 rounded-xl border bg-surface px-4 py-3 shadow-[var(--shadow-panel)] ${VARIANT_CLASSES[toast.variant]}`}
>
<Icon size={16} className="mt-0.5 shrink-0" />
<p className="flex-1 text-sm text-fg">{toast.message}</p>
<button
onClick={() => dismissToast(toast.id)}
className="cursor-pointer text-fg-subtle hover:text-fg"
aria-label="Dismiss notification"
>
<X size={14} />
</button>
</div>
);
}
export function ToastStack() {
const toasts = useUIStore((s) => s.toasts);
return (
<div className="pointer-events-none fixed right-5 top-5 z-50 flex w-80 flex-col gap-2">
{toasts.map((toast) => (
<div key={toast.id} className="pointer-events-auto">
<ToastItem toast={toast} />
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,46 @@
import { Check, RotateCcw, Save } from "lucide-react";
import { Button } from "@/components/common/Button";
import { useProfileStore } from "@/stores/profileStore";
/** Persistent Apply / Revert / Save Profile bar shown on every editable page. */
export function UnsavedChangesBar() {
const isDirty = useProfileStore((s) => s.isDirty);
const activeProfile = useProfileStore((s) => s.activeProfile);
const applyDraft = useProfileStore((s) => s.applyDraft);
const revertDraft = useProfileStore((s) => s.revertDraft);
const saveDraft = useProfileStore((s) => s.saveDraft);
if (!activeProfile) return null;
return (
<div className="mb-4 flex items-center justify-between rounded-xl border border-border bg-surface-2 px-4 py-2.5">
<div className="flex items-center gap-2 text-sm">
{isDirty ? (
<>
<span className="h-2 w-2 rounded-full bg-warning" />
<span className="text-fg">Unsaved changes to &ldquo;{activeProfile.name}&rdquo;</span>
</>
) : (
<>
<span className="h-2 w-2 rounded-full bg-success" />
<span className="text-fg-muted">
Editing &ldquo;{activeProfile.name}&rdquo; &middot; up to date
</span>
</>
)}
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="ghost" icon={<RotateCcw size={14} />} onClick={revertDraft} disabled={!isDirty}>
Revert
</Button>
<Button size="sm" variant="secondary" icon={<Check size={14} />} onClick={applyDraft} disabled={!isDirty}>
Apply
</Button>
<Button size="sm" variant="primary" icon={<Save size={14} />} onClick={() => void saveDraft()} disabled={!isDirty}>
Save Profile
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,98 @@
import type { PointerEvent as ReactPointerEvent } from "react";
import type { CurvePoint } from "@/types/profile";
import { sampleCurve } from "@/utils/curveMath";
interface CurveEditorProps {
points: CurvePoint[];
onChange: (points: CurvePoint[]) => void;
}
const SIZE = 220;
const PADDING = 18;
const PLOT_SIZE = SIZE - PADDING * 2;
function pointToSvg(point: CurvePoint): { x: number; y: number } {
return { x: PADDING + point.x * PLOT_SIZE, y: PADDING + (1 - point.y) * PLOT_SIZE };
}
function svgToPoint(event: ReactPointerEvent<SVGSVGElement | SVGCircleElement>): CurvePoint {
const svg = event.currentTarget instanceof SVGSVGElement
? event.currentTarget
: event.currentTarget.ownerSVGElement;
if (!svg) return { x: 0, y: 0 };
const bounds = svg.getBoundingClientRect();
return {
x: Math.min(1, Math.max(0, (event.clientX - bounds.left - PADDING) / PLOT_SIZE)),
y: Math.min(1, Math.max(0, 1 - (event.clientY - bounds.top - PADDING) / PLOT_SIZE)),
};
}
/** Interactive, dependency-free custom response-curve point editor. */
export function CurveEditor({ points, onChange }: CurveEditorProps) {
const sorted = [...points].sort((a, b) => a.x - b.x);
const samples = sampleCurve("custom", sorted);
const line = samples.map((point) => {
const svg = pointToSvg(point);
return `${svg.x},${svg.y}`;
}).join(" ");
function addPoint(event: ReactPointerEvent<SVGSVGElement>) {
if ((event.target as Element).tagName !== "svg") return;
const point = svgToPoint(event);
onChange([...sorted, point].sort((a, b) => a.x - b.x));
}
function movePoint(index: number, event: ReactPointerEvent<SVGCircleElement>) {
const next = [...sorted];
const candidate = svgToPoint(event);
// Endpoints remain anchored at input 0/1, preventing invalid curves.
if (index === 0) candidate.x = 0;
if (index === next.length - 1) candidate.x = 1;
const lower = index === 0 ? 0 : next[index - 1].x + 0.01;
const upper = index === next.length - 1 ? 1 : next[index + 1].x - 0.01;
candidate.x = Math.min(upper, Math.max(lower, candidate.x));
next[index] = candidate;
onChange(next);
}
return (
<div>
<svg
viewBox={`0 0 ${SIZE} ${SIZE}`}
className="w-full max-w-[220px] cursor-crosshair rounded-lg border border-border bg-surface-2"
onPointerDown={addPoint}
aria-label="Custom response curve editor"
>
{[0.25, 0.5, 0.75].map((value) => (
<g key={value}>
<line x1={PADDING} x2={SIZE - PADDING} y1={PADDING + value * PLOT_SIZE} y2={PADDING + value * PLOT_SIZE} className="stroke-border" />
<line y1={PADDING} y2={SIZE - PADDING} x1={PADDING + value * PLOT_SIZE} x2={PADDING + value * PLOT_SIZE} className="stroke-border" />
</g>
))}
<polyline points={line} fill="none" className="stroke-accent" strokeWidth={2.5} />
{sorted.map((point, index) => {
const svg = pointToSvg(point);
return (
<circle
key={`${point.x}-${point.y}-${index}`}
cx={svg.x}
cy={svg.y}
r={5}
className="cursor-grab fill-accent stroke-surface active:cursor-grabbing"
strokeWidth={2}
onPointerDown={(event) => {
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
}}
onPointerMove={(event) => {
if (event.currentTarget.hasPointerCapture(event.pointerId)) movePoint(index, event);
}}
/>
);
})}
</svg>
<p className="mt-1 text-xs text-fg-subtle">Click to add a point; drag points to adjust the curve.</p>
</div>
);
}

View File

@@ -0,0 +1,43 @@
import { ChevronDown } from "lucide-react";
interface SelectOption<T extends string> {
value: T;
label: string;
}
interface SelectProps<T extends string> {
label?: string;
value: T;
options: SelectOption<T>[];
onChange: (value: T) => void;
disabled?: boolean;
}
export function Select<T extends string>({ label, value, options, onChange, disabled }: SelectProps<T>) {
const select = (
<div className="relative">
<select
value={value}
disabled={disabled}
onChange={(e) => onChange(e.currentTarget.value as T)}
className="h-9 w-full appearance-none rounded-lg border border-border bg-surface-2 px-3 pr-8 text-sm text-fg outline-none disabled:opacity-40"
>
{options.map((option) => (
<option key={option.value} value={option.value} className="bg-surface-2 text-fg">
{option.label}
</option>
))}
</select>
<ChevronDown size={14} className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-fg-muted" />
</div>
);
if (!label) return select;
return (
<label className="block">
<span className="mb-1.5 block text-sm text-fg">{label}</span>
{select}
</label>
);
}

View File

@@ -0,0 +1,56 @@
import { useRef } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
interface SimulatedStickPadProps {
onMove: (x: number, y: number) => void;
size?: number;
}
/** Drag surface that injects synthetic raw stick input, for testing settings without hardware. */
export function SimulatedStickPad({ onMove, size = 120 }: SimulatedStickPadProps) {
const ref = useRef<HTMLDivElement>(null);
const dragging = useRef(false);
function updateFromPointer(e: ReactPointerEvent<HTMLDivElement>) {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
const radius = rect.width / 2;
let x = (e.clientX - cx) / radius;
let y = (e.clientY - cy) / radius;
const mag = Math.sqrt(x * x + y * y);
if (mag > 1) {
x /= mag;
y /= mag;
}
onMove(x, y);
}
return (
<div
ref={ref}
className="relative shrink-0 cursor-grab select-none rounded-full border-2 border-dashed border-border-strong bg-surface-3 active:cursor-grabbing"
style={{ width: size, height: size }}
onPointerDown={(e) => {
dragging.current = true;
e.currentTarget.setPointerCapture(e.pointerId);
updateFromPointer(e);
}}
onPointerMove={(e) => {
if (dragging.current) updateFromPointer(e);
}}
onPointerUp={() => {
dragging.current = false;
onMove(0, 0);
}}
>
<span className="absolute inset-0 flex items-center justify-center text-center text-[10px] leading-tight text-fg-subtle">
drag to
<br />
test
</span>
</div>
);
}

View File

@@ -0,0 +1,44 @@
interface SliderProps {
label: string;
value: number;
min?: number;
max?: number;
step?: number;
unit?: string;
disabled?: boolean;
onChange: (value: number) => void;
formatValue?: (value: number) => string;
}
export function Slider({
label,
value,
min = 0,
max = 1,
step = 0.01,
unit = "",
disabled,
onChange,
formatValue,
}: SliderProps) {
const displayValue = formatValue ? formatValue(value) : `${Math.round(value * 100) / 100}${unit}`;
return (
<div className="py-1.5">
<div className="mb-1.5 flex items-center justify-between text-sm">
<span className="text-fg">{label}</span>
<span className="font-mono text-xs text-fg-muted">{displayValue}</span>
</div>
<input
type="range"
className="w-full disabled:opacity-40"
min={min}
max={max}
step={step}
value={value}
disabled={disabled}
onChange={(e) => onChange(Number(e.currentTarget.value))}
/>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import type { InputHTMLAttributes } from "react";
interface TextFieldProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
}
export function TextField({ label, className, ...rest }: TextFieldProps) {
const input = (
<input
className="h-9 w-full rounded-lg border border-border bg-surface-2 px-3 text-sm text-fg outline-none placeholder:text-fg-subtle disabled:opacity-40"
{...rest}
/>
);
if (!label) return input;
return (
<label className={className}>
<span className="mb-1.5 block text-sm text-fg">{label}</span>
{input}
</label>
);
}

View File

@@ -0,0 +1,44 @@
import { clsx } from "clsx";
interface ToggleProps {
checked: boolean;
onChange: (checked: boolean) => void;
label?: string;
description?: string;
disabled?: boolean;
}
export function Toggle({ checked, onChange, label, description, disabled }: ToggleProps) {
const switchEl = (
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => onChange(!checked)}
className={clsx(
"relative h-6 w-11 shrink-0 rounded-full transition-colors duration-150 disabled:opacity-40 disabled:cursor-not-allowed",
checked ? "bg-accent" : "bg-surface-3 border border-border-strong",
)}
>
<span
className={clsx(
"absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform duration-150",
checked && "translate-x-5",
)}
/>
</button>
);
if (!label) return switchEl;
return (
<label className="flex items-center justify-between gap-4 py-1">
<span>
<span className="block text-sm text-fg">{label}</span>
{description && <span className="block text-xs text-fg-muted">{description}</span>}
</span>
{switchEl}
</label>
);
}

View File

@@ -0,0 +1,31 @@
import { Outlet } from "react-router-dom";
import { ConfirmDialogHost } from "@/components/feedback/ConfirmDialogHost";
import { ToastStack } from "@/components/feedback/ToastStack";
import { Sidebar } from "@/components/layout/Sidebar";
import { TopBar } from "@/components/layout/TopBar";
import { useControllerInit } from "@/hooks/useController";
import { useProfileInit } from "@/hooks/useProfileInit";
import { useTheme } from "@/hooks/useTheme";
import { useUnsavedChangesWarning } from "@/hooks/useUnsavedChanges";
export function AppLayout() {
useTheme();
useControllerInit();
useProfileInit();
useUnsavedChangesWarning();
return (
<div className="flex h-screen w-screen bg-app text-fg">
<Sidebar />
<div className="flex min-w-0 flex-1 flex-col">
<TopBar />
<main className="flex-1 overflow-y-auto px-8 py-6">
<Outlet />
</main>
</div>
<ToastStack />
<ConfirmDialogHost />
</div>
);
}

View File

@@ -0,0 +1,24 @@
import type { ReactNode } from "react";
interface PageShellProps {
title: string;
description?: string;
actions?: ReactNode;
children: ReactNode;
}
/** Consistent header + content wrapper reused by every page. */
export function PageShell({ title, description, actions, children }: PageShellProps) {
return (
<div className="mx-auto flex max-w-6xl flex-col gap-5">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-lg font-semibold text-fg">{title}</h2>
{description && <p className="mt-1 text-sm text-fg-muted">{description}</p>}
</div>
{actions && <div className="flex items-center gap-2">{actions}</div>}
</div>
{children}
</div>
);
}

View File

@@ -0,0 +1,53 @@
import { clsx } from "clsx";
import { Gamepad2 } from "lucide-react";
import { useLocation, useNavigate } from "react-router-dom";
import { NAV_ITEMS } from "@/constants/navigation";
import { confirmDiscardUnsavedChanges } from "@/hooks/useUnsavedChanges";
export function Sidebar() {
const location = useLocation();
const navigate = useNavigate();
async function handleNavigate(path: string) {
if (path === location.pathname) return;
const canLeave = await confirmDiscardUnsavedChanges();
if (canLeave) navigate(path);
}
return (
<aside className="flex h-full w-60 shrink-0 flex-col border-r border-border bg-surface">
<div className="flex items-center gap-2.5 px-5 py-5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-accent text-accent-fg">
<Gamepad2 size={18} />
</div>
<div>
<p className="text-sm font-semibold text-fg leading-tight">8BitDo Control Center</p>
<p className="text-[11px] text-fg-subtle leading-tight">Ultimate Controller</p>
</div>
</div>
<nav className="flex-1 space-y-0.5 overflow-y-auto px-3 pb-4">
{NAV_ITEMS.map((item) => {
const active = location.pathname === item.path;
const Icon = item.icon;
return (
<button
key={item.path}
onClick={() => handleNavigate(item.path)}
className={clsx(
"flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors duration-150",
active
? "bg-accent/15 text-accent-strong font-medium"
: "text-fg-muted hover:bg-surface-2 hover:text-fg",
)}
>
<Icon size={16} className="shrink-0" />
{item.label}
</button>
);
})}
</nav>
</aside>
);
}

View File

@@ -0,0 +1,58 @@
import { Bluetooth, Cable, Radio, Usb } from "lucide-react";
import { useLocation } from "react-router-dom";
import { Badge } from "@/components/common/Badge";
import { StatusDot } from "@/components/common/StatusDot";
import { NAV_ITEMS } from "@/constants/navigation";
import { useControllerStore } from "@/stores/controllerStore";
import { useProfileStore } from "@/stores/profileStore";
import type { ConnectionKind } from "@/types/controller";
const CONNECTION_ICON: Record<ConnectionKind, typeof Usb> = {
usb: Usb,
bluetooth: Bluetooth,
receiver2_4ghz: Radio,
unknown: Cable,
};
const CONNECTION_LABEL: Record<ConnectionKind, string> = {
usb: "USB",
bluetooth: "Bluetooth",
receiver2_4ghz: "2.4GHz Receiver",
unknown: "Unknown",
};
export function TopBar() {
const location = useLocation();
const activeController = useControllerStore((s) => s.activeController());
const activeProfile = useProfileStore((s) => s.activeProfile);
const pageTitle = NAV_ITEMS.find((item) => item.path === location.pathname)?.label ?? "8BitDo Control Center";
return (
<header className="flex h-14 shrink-0 items-center justify-between border-b border-border bg-surface px-6">
<h1 className="text-base font-semibold text-fg">{pageTitle}</h1>
<div className="flex items-center gap-3">
{activeProfile && <Badge tone="accent">Profile: {activeProfile.name}</Badge>}
{activeController ? (
<Badge tone="success">
<StatusDot tone="success" pulse />
{(() => {
const Icon = CONNECTION_ICON[activeController.connection];
return <Icon size={12} />;
})()}
{activeController.name}
<span className="text-fg-subtle">&middot; {CONNECTION_LABEL[activeController.connection]}</span>
</Badge>
) : (
<Badge tone="neutral">
<StatusDot tone="neutral" />
No controller detected
</Badge>
)}
</div>
</header>
);
}

View File

@@ -0,0 +1,41 @@
import type { RawInputEvent } from "@/types/controller";
function formatTimestamp(ms: number): string {
const totalSeconds = ms / 1000;
const minutes = Math.floor(totalSeconds / 60);
const seconds = (totalSeconds % 60).toFixed(3).padStart(6, "0");
return `${minutes.toString().padStart(2, "0")}:${seconds}`;
}
export function EventLog({ events }: { events: RawInputEvent[] }) {
if (events.length === 0) {
return <p className="py-8 text-center text-sm text-fg-subtle">No input events yet. Press a button to see it here.</p>;
}
return (
<div className="max-h-72 overflow-y-auto rounded-lg border border-border">
<table className="w-full text-left text-xs">
<thead className="sticky top-0 bg-surface-2 text-fg-muted">
<tr>
<th className="px-3 py-2 font-medium">Time</th>
<th className="px-3 py-2 font-medium">Type</th>
<th className="px-3 py-2 font-medium">Code</th>
<th className="px-3 py-2 font-medium">Raw</th>
<th className="px-3 py-2 font-medium">Processed</th>
</tr>
</thead>
<tbody>
{events.map((event, index) => (
<tr key={`${event.timestamp}-${event.code}-${index}`} className="border-t border-border font-mono">
<td className="px-3 py-1.5 text-fg-muted">{formatTimestamp(event.timestamp)}</td>
<td className="px-3 py-1.5 text-fg-muted">{event.type}</td>
<td className="px-3 py-1.5 text-fg">{event.code}</td>
<td className="px-3 py-1.5 text-fg-muted">{event.rawValue.toFixed(2)}</td>
<td className="px-3 py-1.5 text-fg-muted">{event.processedValue.toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,64 @@
interface StickPreviewProps {
raw: { x: number; y: number };
processed: { x: number; y: number };
innerDeadzone: number;
outerDeadzone: number;
size?: number;
label?: string;
}
/** Live raw vs. processed stick position, with deadzone/outer-range boundaries. */
export function StickPreview({
raw,
processed,
innerDeadzone,
outerDeadzone,
size = 160,
label,
}: StickPreviewProps) {
const center = size / 2;
const radius = size / 2 - 14;
const toCanvas = (v: { x: number; y: number }) => ({
x: center + v.x * radius,
y: center + v.y * radius,
});
const rawPoint = toCanvas(raw);
const processedPoint = toCanvas(processed);
return (
<div className="flex flex-col items-center gap-2">
{label && <span className="text-xs font-medium text-fg-muted">{label}</span>}
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
<circle cx={center} cy={center} r={radius} className="fill-surface-3 stroke-border" strokeWidth={1} />
<circle
cx={center}
cy={center}
r={radius * outerDeadzone}
className="fill-none stroke-border-strong"
strokeDasharray="3 3"
strokeWidth={1}
/>
<circle
cx={center}
cy={center}
r={radius * innerDeadzone}
className="fill-none stroke-warning/70"
strokeDasharray="2 2"
strokeWidth={1}
/>
<line x1={center} y1={14} x2={center} y2={size - 14} className="stroke-border" strokeWidth={1} />
<line x1={14} y1={center} x2={size - 14} y2={center} className="stroke-border" strokeWidth={1} />
<circle cx={rawPoint.x} cy={rawPoint.y} r={4} className="fill-none stroke-fg-subtle" strokeWidth={1.5} />
<circle cx={processedPoint.x} cy={processedPoint.y} r={5} className="fill-accent" />
</svg>
<div className="grid grid-cols-2 gap-x-4 gap-y-0.5 text-center font-mono text-[11px] text-fg-muted">
<span>raw x {raw.x.toFixed(2)}</span>
<span>raw y {raw.y.toFixed(2)}</span>
<span>out x {processed.x.toFixed(2)}</span>
<span>out y {processed.y.toFixed(2)}</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,37 @@
interface TriggerPreviewProps {
raw: number;
processed: number;
deadzone: number;
maxActivation: number;
label?: string;
}
/** Horizontal raw vs. processed trigger bar with deadzone/max-activation markers. */
export function TriggerPreview({ raw, processed, deadzone, maxActivation, label }: TriggerPreviewProps) {
const width = 220;
const height = 22;
return (
<div className="flex flex-col gap-1.5">
{label && <span className="text-xs font-medium text-fg-muted">{label}</span>}
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`}>
<rect x={0} y={0} width={width} height={height} rx={6} className="fill-surface-3 stroke-border" strokeWidth={1} />
<rect x={0} y={0} width={width * processed} height={height} rx={6} className="fill-accent/70" />
<rect x={0} y={0} width={width * raw} height={2} className="fill-fg-subtle" />
<line x1={width * deadzone} y1={0} x2={width * deadzone} y2={height} className="stroke-warning" strokeWidth={1.5} />
<line
x1={width * maxActivation}
y1={0}
x2={width * maxActivation}
y2={height}
className="stroke-danger"
strokeWidth={1.5}
/>
</svg>
<div className="flex justify-between font-mono text-[11px] text-fg-muted">
<span>raw {raw.toFixed(2)}</span>
<span>out {processed.toFixed(2)}</span>
</div>
</div>
);
}

59
src/constants/controls.ts Normal file
View File

@@ -0,0 +1,59 @@
import type { AxisCapability, ButtonCapability, DeviceCapabilities } from "@/types/controller";
/**
* Canonical control identifiers for a generic dual-stick gamepad with rear
* paddles, shared by the mock backend, the SVG controller diagram, and the
* built-in profile templates. Real devices probed via evdev in Phase 2 will
* report their own capability list; this constant only describes the
* example/demo device used before hardware wiring lands.
*/
export const BUTTON_CAPABILITIES: ButtonCapability[] = [
{ id: "a", label: "A", group: "face" },
{ id: "b", label: "B", group: "face" },
{ id: "x", label: "X", group: "face" },
{ id: "y", label: "Y", group: "face" },
{ id: "dpad_up", label: "D-Pad Up", group: "dpad" },
{ id: "dpad_down", label: "D-Pad Down", group: "dpad" },
{ id: "dpad_left", label: "D-Pad Left", group: "dpad" },
{ id: "dpad_right", label: "D-Pad Right", group: "dpad" },
{ id: "l1", label: "L1", group: "shoulder" },
{ id: "r1", label: "R1", group: "shoulder" },
{ id: "l3", label: "Left Stick Click", group: "stick" },
{ id: "r3", label: "Right Stick Click", group: "stick" },
{ id: "start", label: "Start", group: "system" },
{ id: "select", label: "Select", group: "system" },
{ id: "guide", label: "Guide", group: "system" },
{ id: "paddle_l1", label: "Paddle L1", group: "paddle" },
{ id: "paddle_r1", label: "Paddle R1", group: "paddle" },
];
export const AXIS_CAPABILITIES: AxisCapability[] = [
{ id: "left_stick_x", label: "Left Stick X", group: "stick", min: -1, max: 1 },
{ id: "left_stick_y", label: "Left Stick Y", group: "stick", min: -1, max: 1 },
{ id: "right_stick_x", label: "Right Stick X", group: "stick", min: -1, max: 1 },
{ id: "right_stick_y", label: "Right Stick Y", group: "stick", min: -1, max: 1 },
{ id: "left_trigger", label: "Left Trigger", group: "trigger", min: 0, max: 1 },
{ id: "right_trigger", label: "Right Trigger", group: "trigger", min: 0, max: 1 },
{ id: "gyro_x", label: "Gyro X", group: "gyro", min: -1, max: 1 },
{ id: "gyro_y", label: "Gyro Y", group: "gyro", min: -1, max: 1 },
{ id: "gyro_z", label: "Gyro Z", group: "gyro", min: -1, max: 1 },
];
export const DEMO_DEVICE_CAPABILITIES: DeviceCapabilities = {
vendorId: 0x2dc8,
productId: 0x3106,
name: "8BitDo Ultimate Wireless / Pro 2 Wired Controller",
buttons: BUTTON_CAPABILITIES,
axes: AXIS_CAPABILITIES,
paddleCount: 2,
hasGyro: true,
hasRumble: true,
hasBattery: true,
};
export const FACE_BUTTON_IDS = ["a", "b", "x", "y"] as const;
export const DPAD_IDS = ["dpad_up", "dpad_down", "dpad_left", "dpad_right"] as const;
export const SHOULDER_BUTTON_IDS = ["l1", "r1"] as const;
export const STICK_CLICK_IDS = ["l3", "r3"] as const;
export const SYSTEM_BUTTON_IDS = ["start", "select", "guide"] as const;
export const PADDLE_IDS = ["paddle_l1", "paddle_r1"] as const;

View File

@@ -0,0 +1,32 @@
import {
Activity,
Crosshair,
Gamepad2,
Info,
LayoutDashboard,
Layers,
Settings as SettingsIcon,
Target,
Waves,
Zap,
type LucideIcon,
} from "lucide-react";
export interface NavItem {
path: string;
label: string;
icon: LucideIcon;
}
export const NAV_ITEMS: NavItem[] = [
{ path: "/", label: "Dashboard", icon: LayoutDashboard },
{ path: "/profiles", label: "Profiles", icon: Layers },
{ path: "/mapping", label: "Button Mapping", icon: Gamepad2 },
{ path: "/sticks", label: "Sticks", icon: Crosshair },
{ path: "/triggers", label: "Triggers", icon: Zap },
{ path: "/motion", label: "Motion", icon: Waves },
{ path: "/calibration", label: "Calibration", icon: Target },
{ path: "/tester", label: "Input Tester", icon: Activity },
{ path: "/device", label: "Device Information", icon: Info },
{ path: "/settings", label: "Settings", icon: SettingsIcon },
];

View File

@@ -0,0 +1,11 @@
import { useEffect } from "react";
import { useControllerStore } from "@/stores/controllerStore";
/** Starts the controller backend once for the lifetime of the app. */
export function useControllerInit(): void {
const init = useControllerStore((s) => s.init);
useEffect(() => {
void init();
}, [init]);
}

View File

@@ -0,0 +1,29 @@
import { useEffect } from "react";
import { processService } from "@/services/processService";
import { useProfileStore } from "@/stores/profileStore";
import { useSettingsStore } from "@/stores/settingsStore";
/** Loads the profile list (and the default active profile) once on startup. */
export function useProfileInit(): void {
const refreshList = useProfileStore((s) => s.refreshList);
const selectProfile = useProfileStore((s) => s.selectProfile);
const autoSwitchProfiles = useSettingsStore((s) => s.autoSwitchProfiles);
useEffect(() => {
void refreshList();
}, [refreshList]);
useEffect(() => {
if (!autoSwitchProfiles) return;
const check = () => {
void processService.detectedGameProfile().then((profileId) => {
if (profileId && useProfileStore.getState().activeProfile?.id !== profileId && !useProfileStore.getState().isDirty) {
void selectProfile(profileId);
}
}).catch(() => undefined);
};
check();
const interval = window.setInterval(check, 5_000);
return () => window.clearInterval(interval);
}, [autoSwitchProfiles, selectProfile]);
}

12
src/hooks/useTheme.ts Normal file
View File

@@ -0,0 +1,12 @@
import { useEffect } from "react";
import { useSettingsStore } from "@/stores/settingsStore";
/** Applies the active theme to the document root so CSS variables switch instantly. */
export function useTheme(): void {
const theme = useSettingsStore((s) => s.theme);
useEffect(() => {
document.documentElement.setAttribute("data-theme", theme);
}, [theme]);
}

View File

@@ -0,0 +1,31 @@
import { useEffect } from "react";
import { useProfileStore } from "@/stores/profileStore";
import { useUIStore } from "@/stores/uiStore";
/** Warns via the native "leave site" prompt if the window is closed with unsaved profile edits. */
export function useUnsavedChangesWarning(): void {
const isDirty = useProfileStore((s) => s.isDirty);
useEffect(() => {
const handler = (event: BeforeUnloadEvent) => {
if (!isDirty) return;
event.preventDefault();
event.returnValue = "";
};
window.addEventListener("beforeunload", handler);
return () => window.removeEventListener("beforeunload", handler);
}, [isDirty]);
}
/** Used by in-app navigation (sidebar, tab switches) to ask before discarding edits. */
export async function confirmDiscardUnsavedChanges(): Promise<boolean> {
if (!useProfileStore.getState().isDirty) return true;
return useUIStore.getState().requestConfirm({
title: "Unsaved changes",
message: "You have unsaved changes to this profile. Leaving now will discard them.",
confirmLabel: "Discard changes",
cancelLabel: "Stay",
danger: true,
});
}

11
src/main.tsx Normal file
View File

@@ -0,0 +1,11 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "@/styles/index.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

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>
);
}

103
src/pages/Calibration.tsx Normal file
View File

@@ -0,0 +1,103 @@
import { CheckCircle2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/common/Button";
import { Panel } from "@/components/common/Panel";
import { PageShell } from "@/components/layout/PageShell";
import { useControllerStore } from "@/stores/controllerStore";
import { useProfileStore } from "@/stores/profileStore";
import { useUIStore } from "@/stores/uiStore";
const STEPS = [
"Detect the controller",
"Ask you to release all controls",
"Measure neutral stick and trigger positions",
"Ask you to rotate each stick fully, several times",
"Ask you to press each trigger through its full range",
"Calculate recommended deadzones and maximum ranges",
"Show the recommended values",
"Let you accept, adjust, or discard them",
];
export function Calibration() {
const pushToast = useUIStore((s) => s.pushToast);
const liveState = useControllerStore((s) => s.liveState);
const updateDraft = useProfileStore((s) => s.updateDraft);
const [step, setStep] = useState(-1);
const [neutralSamples, setNeutralSamples] = useState<{ x: number; y: number }[]>([]);
const [rangeSamples, setRangeSamples] = useState<{ x: number; y: number }[]>([]);
useEffect(() => {
if (!liveState) return;
const axes = liveState.axes;
const sample = { x: axes.left_stick_x ?? 0, y: axes.left_stick_y ?? 0 };
if (step === 1) setNeutralSamples((samples) => [...samples.slice(-199), sample]);
if (step === 2) setRangeSamples((samples) => [...samples.slice(-499), sample]);
}, [liveState, step]);
const recommendation = useMemo(() => {
if (neutralSamples.length === 0 || rangeSamples.length === 0) return null;
const neutral = neutralSamples.reduce((sum, sample) => ({ x: sum.x + sample.x, y: sum.y + sample.y }), { x: 0, y: 0 });
neutral.x /= neutralSamples.length;
neutral.y /= neutralSamples.length;
const drift = Math.hypot(neutral.x, neutral.y);
const innerDeadzone = Math.min(0.25, Math.max(0.03, drift + 0.02));
const maxRange = rangeSamples.reduce((max, sample) => Math.max(max, Math.abs(sample.x), Math.abs(sample.y)), 0);
const outerDeadzone = maxRange > 0 ? Math.min(1, Math.max(0.85, 1 / maxRange)) : 1;
return { innerDeadzone, outerDeadzone };
}, [neutralSamples, rangeSamples]);
function acceptRecommendation() {
if (!recommendation) return;
updateDraft((draft) => ({
...draft,
leftStick: { ...draft.leftStick, ...recommendation },
rightStick: { ...draft.rightStick, ...recommendation },
}));
pushToast("Calibration recommendations added to the profile draft. Save Profile to keep them.", "success");
setStep(-1);
}
return (
<PageShell
title="Calibration"
description="A guided wizard that measures your controller's real neutral position, range, and drift."
>
<Panel
title="Calibration wizard"
>
<ol className="flex flex-col gap-2.5">
{STEPS.map((label, index) => (
<li key={label} className={step >= index ? "flex items-center gap-3 text-sm text-fg" : "flex items-center gap-3 text-sm text-fg-muted"}>
<span className={step >= index ? "flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent text-xs font-medium text-accent-fg" : "flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-surface-3 text-xs font-medium text-fg"}>
{index + 1}
</span>
{label}
</li>
))}
</ol>
<div className="mt-5 flex items-center gap-3 border-t border-border pt-4">
<CheckCircle2 size={16} className="text-fg-subtle" />
<p className="text-xs text-fg-subtle">
Recommended values will always be shown to you before anything is applied calibration never changes
your profile silently.
</p>
</div>
<div className="mt-4 flex gap-2">
{step < 0 && <Button onClick={() => setStep(1)}>Start calibration</Button>}
{step === 1 && <Button onClick={() => setStep(2)}>Neutral recorded measure range</Button>}
{step === 2 && <Button onClick={() => setStep(6)}>Range recorded review recommendations</Button>}
{step === 6 && recommendation && <Button onClick={acceptRecommendation}>Accept recommendations</Button>}
{step >= 0 && <Button variant="ghost" onClick={() => setStep(-1)}>Cancel</Button>}
</div>
{step === 1 && <p className="mt-3 text-sm text-fg-muted">Release both sticks and triggers for a few seconds, then continue.</p>}
{step === 2 && <p className="mt-3 text-sm text-fg-muted">Rotate the left stick around its full edge several times, then continue.</p>}
{step === 6 && (
<p className="mt-3 font-mono text-sm text-fg">
Recommended inner deadzone: {recommendation?.innerDeadzone.toFixed(3) ?? "—"} · outer range: {recommendation?.outerDeadzone.toFixed(3) ?? "—"}
</p>
)}
</Panel>
</PageShell>
);
}

170
src/pages/Dashboard.tsx Normal file
View File

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

111
src/pages/DeviceInfo.tsx Normal file
View File

@@ -0,0 +1,111 @@
import type { ReactNode } from "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 { useControllerStore } from "@/stores/controllerStore";
const CONNECTION_LABEL: Record<string, string> = {
usb: "USB",
bluetooth: "Bluetooth",
receiver2_4ghz: "2.4GHz Receiver",
unknown: "Unknown",
};
function hex(value: number): string {
return `0x${value.toString(16).padStart(4, "0")}`;
}
export function DeviceInfo() {
const controllers = useControllerStore((s) => s.controllers);
const activeControllerId = useControllerStore((s) => s.activeControllerId);
const setActiveControllerId = useControllerStore((s) => s.setActiveControllerId);
const connectionStatus = useControllerStore((s) => s.connectionStatus);
const connectionMessage = useControllerStore((s) => s.connectionMessage);
if (controllers.length === 0) {
return (
<PageShell title="Device Information" description="Details about detected controllers.">
<Panel>
<p className="text-sm text-fg-muted">
{connectionStatus === "reconnecting"
? connectionMessage ?? "Reconnecting to controller…"
: connectionMessage ?? "No controllers detected."}
</p>
</Panel>
</PageShell>
);
}
return (
<PageShell title="Device Information" description="Details reported by each detected controller.">
{controllers.map((controller) => {
const isActive = controller.id === activeControllerId;
return (
<Panel
key={controller.id}
title={controller.name}
actions={
<div className="flex items-center gap-2">
{controller.isSimulated && <Badge tone="warning">Simulated</Badge>}
<Badge tone={isActive ? "accent" : "neutral"}>{isActive ? "Active" : "Available"}</Badge>
{!isActive && (
<button
onClick={() => void setActiveControllerId(controller.id)}
className="cursor-pointer text-xs text-accent hover:underline"
>
Set active
</button>
)}
</div>
}
>
<div className="grid grid-cols-2 gap-x-8 gap-y-3 text-sm">
<Field label="Connection method" value={CONNECTION_LABEL[controller.connection]} />
<Field label="Device path" value={controller.devicePath} mono />
<Field label="Vendor ID" value={hex(controller.vendorId)} mono />
<Field label="Product ID" value={hex(controller.productId)} mono />
<Field label="Input mode" value={controller.mode.toUpperCase()} />
<Field
label="Battery"
value={controller.batteryPercent !== null ? `${controller.batteryPercent}%` : "Not reported"}
/>
<Field label="Firmware version" value={controller.firmwareVersion ?? "Not available"} />
<Field
label="Virtual controller"
value={
<span className="flex items-center gap-1.5">
<StatusDot tone={controller.virtualControllerActive ? "success" : "neutral"} />
{controller.virtualControllerActive ? "Active" : "Inactive"}
</span>
}
/>
</div>
<div className="mt-5 border-t border-border pt-4">
<p className="mb-2 text-xs font-medium text-fg-muted">Capabilities</p>
<div className="flex flex-wrap gap-1.5">
<Badge tone="neutral">{controller.capabilities.buttons.length} buttons</Badge>
<Badge tone="neutral">{controller.capabilities.axes.length} axes</Badge>
<Badge tone="neutral">{controller.capabilities.paddleCount} rear paddles</Badge>
{controller.capabilities.hasGyro && <Badge tone="accent">Gyroscope</Badge>}
{controller.capabilities.hasRumble && <Badge tone="accent">Vibration</Badge>}
{controller.capabilities.hasBattery && <Badge tone="accent">Battery reporting</Badge>}
</div>
</div>
</Panel>
);
})}
</PageShell>
);
}
function Field({ label, value, mono }: { label: string; value: ReactNode; mono?: boolean }) {
return (
<div>
<p className="text-xs text-fg-muted">{label}</p>
<p className={mono ? "font-mono text-fg" : "text-fg"}>{value}</p>
</div>
);
}

141
src/pages/InputTester.tsx Normal file
View File

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

127
src/pages/Motion.tsx Normal file
View File

@@ -0,0 +1,127 @@
import { Button } from "@/components/common/Button";
import { Panel } from "@/components/common/Panel";
import { Select } from "@/components/inputs/Select";
import { Slider } from "@/components/inputs/Slider";
import { Toggle } from "@/components/inputs/Toggle";
import { UnsavedChangesBar } from "@/components/feedback/UnsavedChangesBar";
import { PageShell } from "@/components/layout/PageShell";
import { useControllerStore } from "@/stores/controllerStore";
import { useProfileStore } from "@/stores/profileStore";
import { useUIStore } from "@/stores/uiStore";
import type { GyroMapping, MotionSettings } from "@/types/profile";
const GYRO_MAPPING_OPTIONS: { value: GyroMapping; label: string }[] = [
{ value: "off", label: "Off" },
{ value: "stick", label: "Right stick" },
{ value: "mouse", label: "Mouse" },
];
export function Motion() {
const activeController = useControllerStore((s) => s.activeController());
const draftProfile = useProfileStore((s) => s.draftProfile);
const updateDraft = useProfileStore((s) => s.updateDraft);
const pushToast = useUIStore((s) => s.pushToast);
if (!draftProfile || !activeController) {
return (
<PageShell title="Motion" description="Loading...">
<div />
</PageShell>
);
}
const { hasGyro, hasRumble } = activeController.capabilities;
const settings = draftProfile.motion;
function update(patch: Partial<MotionSettings>) {
updateDraft((draft) => ({ ...draft, motion: { ...draft.motion, ...patch } }));
}
if (!hasGyro && !hasRumble) {
return (
<PageShell title="Motion" description="Gyroscope and vibration settings.">
<Panel>
<p className="text-sm text-fg-muted">
The connected controller doesn&apos;t report gyroscope or vibration support, so there is nothing to
configure here.
</p>
</Panel>
</PageShell>
);
}
return (
<PageShell title="Motion" description="Gyroscope and vibration settings for the connected controller.">
<UnsavedChangesBar />
<div className="grid grid-cols-2 gap-5">
{hasRumble && (
<Panel title="Vibration">
<Slider
label="Overall intensity"
value={settings.vibrationIntensity}
onChange={(vibrationIntensity) => update({ vibrationIntensity })}
/>
<Slider
label="Left motor intensity"
value={settings.leftMotorIntensity}
onChange={(leftMotorIntensity) => update({ leftMotorIntensity })}
/>
<Slider
label="Right motor intensity"
value={settings.rightMotorIntensity}
onChange={(rightMotorIntensity) => update({ rightMotorIntensity })}
/>
<Button
className="mt-2"
variant="secondary"
onClick={() =>
pushToast(
activeController.isSimulated
? "Simulated device — no physical motors to test."
: "Vibration test sent to the controller.",
"info",
)
}
>
Test vibration
</Button>
</Panel>
)}
{hasGyro && (
<Panel title="Gyroscope">
<Slider
label="Sensitivity"
value={settings.gyroSensitivity}
min={0.1}
max={3}
onChange={(gyroSensitivity) => update({ gyroSensitivity })}
/>
<Slider
label="Deadzone"
value={settings.gyroDeadzone}
onChange={(gyroDeadzone) => update({ gyroDeadzone })}
/>
<Toggle
checked={settings.invertGyroX}
onChange={(invertGyroX) => update({ invertGyroX })}
label="Invert X axis"
/>
<Toggle
checked={settings.invertGyroY}
onChange={(invertGyroY) => update({ invertGyroY })}
label="Invert Y axis"
/>
<Select
label="Map gyroscope to"
value={settings.gyroMapping}
options={GYRO_MAPPING_OPTIONS}
onChange={(gyroMapping) => update({ gyroMapping })}
/>
</Panel>
)}
</div>
</PageShell>
);
}

205
src/pages/Profiles.tsx Normal file
View File

@@ -0,0 +1,205 @@
import { clsx } from "clsx";
import { Copy, Download, PenLine, Plus, RotateCcw, Trash2, Upload } from "lucide-react";
import { useState } from "react";
import { Badge } from "@/components/common/Badge";
import { Button } from "@/components/common/Button";
import { Panel } from "@/components/common/Panel";
import { TextField } from "@/components/inputs/TextField";
import { UnsavedChangesBar } from "@/components/feedback/UnsavedChangesBar";
import { PageShell } from "@/components/layout/PageShell";
import { useProfileStore } from "@/stores/profileStore";
import { useUIStore } from "@/stores/uiStore";
export function Profiles() {
const summaries = useProfileStore((s) => s.summaries);
const activeProfile = useProfileStore((s) => s.activeProfile);
const selectProfile = useProfileStore((s) => s.selectProfile);
const createProfile = useProfileStore((s) => s.createProfile);
const renameProfile = useProfileStore((s) => s.renameProfile);
const duplicateProfile = useProfileStore((s) => s.duplicateProfile);
const deleteProfile = useProfileStore((s) => s.deleteProfile);
const resetActiveProfile = useProfileStore((s) => s.resetActiveProfile);
const exportProfile = useProfileStore((s) => s.exportProfile);
const importProfile = useProfileStore((s) => s.importProfile);
const draftProfile = useProfileStore((s) => s.draftProfile);
const updateDraft = useProfileStore((s) => s.updateDraft);
const requestConfirm = useUIStore((s) => s.requestConfirm);
const [newProfileName, setNewProfileName] = useState("");
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState("");
async function handleCreate() {
const name = newProfileName.trim();
if (!name) return;
await createProfile(name);
setNewProfileName("");
}
async function handleDelete(id: string, name: string) {
const confirmed = await requestConfirm({
title: "Delete profile",
message: `Delete "${name}"? This cannot be undone.`,
confirmLabel: "Delete",
danger: true,
});
if (confirmed) await deleteProfile(id);
}
async function handleReset(name: string) {
const confirmed = await requestConfirm({
title: "Reset profile",
message: `Reset "${name}" back to its default settings? Your customizations will be lost.`,
confirmLabel: "Reset",
danger: true,
});
if (confirmed) await resetActiveProfile();
}
return (
<PageShell
title="Profiles"
description="Create, organize, and switch between controller configurations."
actions={
<>
<Button variant="secondary" icon={<Upload size={14} />} onClick={() => void importProfile()}>
Import
</Button>
</>
}
>
<UnsavedChangesBar />
<Panel title="New profile">
<div className="flex gap-2">
<TextField
placeholder="Profile name"
value={newProfileName}
onChange={(e) => setNewProfileName(e.currentTarget.value)}
onKeyDown={(e) => e.key === "Enter" && void handleCreate()}
className="flex-1"
/>
<Button variant="primary" icon={<Plus size={14} />} onClick={() => void handleCreate()}>
Create
</Button>
</div>
</Panel>
{draftProfile && (
<Panel title="Game activation" description="Optional executable names that can automatically activate this profile.">
<TextField
label="Executable names (comma-separated)"
placeholder="example: game.exe, steam_app"
value={draftProfile.assignedExecutables.join(", ")}
onChange={(event) => {
const assignedExecutables = event.currentTarget.value
.split(",")
.map((value) => value.trim())
.filter(Boolean);
updateDraft((draft) => ({ ...draft, assignedExecutables }));
}}
/>
<TextField
className="mt-3"
label="Controller shortcut (control IDs, comma-separated)"
placeholder="example: select, dpad_up"
value={draftProfile.shortcutCombo.join(", ")}
onChange={(event) => {
const shortcutCombo = event.currentTarget.value
.split(",")
.map((value) => value.trim())
.filter(Boolean);
updateDraft((draft) => ({ ...draft, shortcutCombo }));
}}
/>
</Panel>
)}
<div className="grid grid-cols-2 gap-4">
{summaries.map((summary) => {
const isActive = summary.id === activeProfile?.id;
const isRenaming = renamingId === summary.id;
return (
<Panel
key={summary.id}
className={clsx("cursor-pointer transition-colors", isActive && "border-accent")}
onClick={() => !isRenaming && void selectProfile(summary.id)}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
{isRenaming ? (
<TextField
autoFocus
value={renameValue}
onClick={(e) => e.stopPropagation()}
onChange={(e) => setRenameValue(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
void renameProfile(summary.id, renameValue).then(() => setRenamingId(null));
} else if (e.key === "Escape") {
setRenamingId(null);
}
}}
onBlur={() => setRenamingId(null)}
/>
) : (
<p className="text-sm font-semibold text-fg">{summary.name}</p>
)}
<p className="mt-1 text-xs text-fg-muted">{summary.description || "No description"}</p>
</div>
<div className="flex items-center gap-1.5">
{isActive && <Badge tone="accent">Active</Badge>}
{summary.isBuiltIn && <Badge tone="neutral">Built-in</Badge>}
</div>
</div>
<div className="mt-3 flex items-center gap-1.5 border-t border-border pt-3" onClick={(e) => e.stopPropagation()}>
<Button
size="sm"
variant="ghost"
icon={<PenLine size={13} />}
onClick={() => {
setRenamingId(summary.id);
setRenameValue(summary.name);
}}
>
Rename
</Button>
<Button
size="sm"
variant="ghost"
icon={<Copy size={13} />}
onClick={() => void duplicateProfile(summary.id, `${summary.name} copy`)}
>
Duplicate
</Button>
<Button
size="sm"
variant="ghost"
icon={<Download size={13} />}
onClick={() => void exportProfile(summary.id)}
>
Export
</Button>
{isActive && (
<Button size="sm" variant="ghost" icon={<RotateCcw size={13} />} onClick={() => void handleReset(summary.name)}>
Reset
</Button>
)}
<Button
size="sm"
variant="ghost"
icon={<Trash2 size={13} />}
className="ml-auto text-danger hover:bg-danger/10"
onClick={() => void handleDelete(summary.id, summary.name)}
>
Delete
</Button>
</div>
</Panel>
);
})}
</div>
</PageShell>
);
}

139
src/pages/Settings.tsx Normal file
View File

@@ -0,0 +1,139 @@
import { useEffect, useState } from "react";
import { Panel } from "@/components/common/Panel";
import { Button } from "@/components/common/Button";
import { Select } from "@/components/inputs/Select";
import { Toggle } from "@/components/inputs/Toggle";
import { PageShell } from "@/components/layout/PageShell";
import { profileService } from "@/services/profileService";
import { diagnosticsService, type DiagnosticsSnapshot } from "@/services/diagnosticsService";
import { systemService, type AppInfo } from "@/services/systemService";
import { useControllerStore } from "@/stores/controllerStore";
import { useSettingsStore, type ThemeMode } from "@/stores/settingsStore";
const THEME_OPTIONS: { value: ThemeMode; label: string }[] = [
{ value: "dark", label: "Dark" },
{ value: "light", label: "Light" },
{ value: "high-contrast", label: "High Contrast" },
];
export function Settings() {
const theme = useSettingsStore((s) => s.theme);
const setTheme = useSettingsStore((s) => s.setTheme);
const remappingEnabled = useSettingsStore((s) => s.remappingEnabled);
const setRemappingEnabled = useSettingsStore((s) => s.setRemappingEnabled);
const setControllerRemapping = useControllerStore((s) => s.setRemappingEnabled);
const simulationMode = useSettingsStore((s) => s.simulationMode);
const setSimulationMode = useSettingsStore((s) => s.setSimulationMode);
const autoSwitchProfiles = useSettingsStore((s) => s.autoSwitchProfiles);
const setAutoSwitchProfiles = useSettingsStore((s) => s.setAutoSwitchProfiles);
const [appInfo, setAppInfo] = useState<AppInfo | null>(null);
const [profilesDir, setProfilesDir] = useState<string>("");
const [diagnostics, setDiagnostics] = useState<DiagnosticsSnapshot | null>(null);
async function handleRemappingChange(enabled: boolean) {
// #region agent log
fetch('http://127.0.0.1:7773/ingest/f58e9523-b2dd-4860-abd0-167312081aae',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'2801e0'},body:JSON.stringify({sessionId:'2801e0',runId:'initial',hypothesisId:'H14',location:'src/pages/Settings.tsx:handleRemappingChange',message:'Settings remapping toggle changed',data:{enabled},timestamp:Date.now()})}).catch(()=>{});
// #endregion
setRemappingEnabled(enabled);
try {
await setControllerRemapping(enabled);
} catch {
setRemappingEnabled(!enabled);
}
}
useEffect(() => {
void systemService.getAppInfo().then(setAppInfo);
void profileService.directory().then(setProfilesDir);
void diagnosticsService.snapshot().then(setDiagnostics).catch(() => undefined);
}, []);
async function exportSupportBundle() {
const snapshot = await diagnosticsService.snapshot();
const blob = new Blob([JSON.stringify(snapshot, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = "8bitdo-control-center-support.json";
anchor.click();
URL.revokeObjectURL(url);
}
return (
<PageShell title="Settings" description="Application preferences and diagnostic information.">
<Panel title="Appearance">
<Select label="Theme" value={theme} options={THEME_OPTIONS} onChange={setTheme} />
<p className="mt-2 text-xs text-fg-subtle">
High contrast mode maximizes contrast between text, controls, and backgrounds for accessibility.
</p>
</Panel>
<Panel title="Input">
<Toggle
checked={remappingEnabled}
onChange={handleRemappingChange}
label="Enable remapping"
description="When off, the controller passes through unmodified"
/>
<Toggle
checked={simulationMode}
onChange={setSimulationMode}
label="Simulation mode"
description="Use a simulated controller instead of physical hardware"
/>
<Toggle
checked={autoSwitchProfiles}
onChange={setAutoSwitchProfiles}
label="Automatically switch game profiles"
description="Activate profiles with an assigned executable name every five seconds"
/>
</Panel>
<Panel title="Storage">
<div className="text-sm">
<p className="text-fg-muted">Profiles directory</p>
<p className="mt-1 break-all font-mono text-xs text-fg">{profilesDir || "Loading..."}</p>
</div>
</Panel>
<Panel title="About">
<dl className="grid grid-cols-2 gap-y-2 text-sm">
<dt className="text-fg-muted">Application</dt>
<dd className="text-right text-fg">{appInfo?.name ?? "—"}</dd>
<dt className="text-fg-muted">Version</dt>
<dd className="text-right font-mono text-fg">{appInfo?.version ?? "—"}</dd>
</dl>
</Panel>
<Panel
title="Permissions & diagnostics"
description="Read-only checks for evdev and uinput access. No configuration is changed here."
actions={
<>
<Button size="sm" variant="secondary" onClick={() => void exportSupportBundle()}>Export support bundle</Button>
<Button size="sm" onClick={() => void diagnosticsService.snapshot().then(setDiagnostics)}>Refresh</Button>
</>
}
>
{diagnostics ? (
<dl className="grid grid-cols-2 gap-y-2 text-sm">
<dt className="text-fg-muted">Input directory access</dt>
<dd className="text-right text-fg">{diagnostics.permissions.inputDirectoryReadable ? "Available" : "Unavailable"}</dd>
<dt className="text-fg-muted">/dev/uinput exists</dt>
<dd className="text-right text-fg">{diagnostics.permissions.uinputExists ? "Yes" : "No"}</dd>
<dt className="text-fg-muted">/dev/uinput writable</dt>
<dd className="text-right text-fg">{diagnostics.permissions.uinputWritable ? "Yes" : "No — run setup-linux.sh"}</dd>
<dt className="text-fg-muted">Kernel</dt>
<dd className="text-right font-mono text-fg">{diagnostics.kernelVersion ?? "Unknown"}</dd>
<dt className="text-fg-muted">Profile schema</dt>
<dd className="text-right font-mono text-fg">v{diagnostics.profileSchemaVersion}</dd>
</dl>
) : (
<p className="text-sm text-fg-muted">Diagnostics are available when running inside the desktop app.</p>
)}
</Panel>
</PageShell>
);
}

161
src/pages/Sticks.tsx Normal file
View File

@@ -0,0 +1,161 @@
import { useState } from "react";
import { Button } from "@/components/common/Button";
import { Panel } from "@/components/common/Panel";
import { Select } from "@/components/inputs/Select";
import { CurveEditor } from "@/components/inputs/CurveEditor";
import { SimulatedStickPad } from "@/components/inputs/SimulatedStickPad";
import { Slider } from "@/components/inputs/Slider";
import { Toggle } from "@/components/inputs/Toggle";
import { UnsavedChangesBar } from "@/components/feedback/UnsavedChangesBar";
import { PageShell } from "@/components/layout/PageShell";
import { StickPreview } from "@/components/visualizations/StickPreview";
import { mockControllerBackend } from "@/services/mockControllerBackend";
import { useControllerStore } from "@/stores/controllerStore";
import { useProfileStore } from "@/stores/profileStore";
import { CURVE_PRESET_LABELS } from "@/utils/curveMath";
import { processStick } from "@/utils/deadzoneMath";
import type { CurvePreset, DeadzoneMode, StickSettings } from "@/types/profile";
const CURVE_OPTIONS: { value: CurvePreset; label: string }[] = Object.entries(CURVE_PRESET_LABELS).map(
([value, label]) => ({ value: value as CurvePreset, label }),
);
const DEADZONE_MODE_OPTIONS: { value: DeadzoneMode; label: string }[] = [
{ value: "radial", label: "Radial" },
{ value: "axial", label: "Axial" },
];
type StickSide = "leftStick" | "rightStick";
export function Sticks() {
const [side, setSide] = useState<StickSide>("leftStick");
const liveState = useControllerStore((s) => s.liveState);
const draftProfile = useProfileStore((s) => s.draftProfile);
const updateDraft = useProfileStore((s) => s.updateDraft);
if (!draftProfile) {
return (
<PageShell title="Sticks" description="Loading profile...">
<div />
</PageShell>
);
}
const settings = draftProfile[side];
const axisPrefix = side === "leftStick" ? "left_stick" : "right_stick";
const raw = {
x: liveState?.axes[`${axisPrefix}_x`] ?? 0,
y: liveState?.axes[`${axisPrefix}_y`] ?? 0,
};
const computed = processStick(raw, settings);
function update(patch: Partial<StickSettings>) {
updateDraft((draft) => ({ ...draft, [side]: { ...draft[side], ...patch } }));
}
return (
<PageShell title="Sticks" description="Tune deadzones, sensitivity, and response curves for each analog stick.">
<UnsavedChangesBar />
<div className="flex gap-2">
<Button variant={side === "leftStick" ? "primary" : "secondary"} onClick={() => setSide("leftStick")}>
Left Stick
</Button>
<Button variant={side === "rightStick" ? "primary" : "secondary"} onClick={() => setSide("rightStick")}>
Right Stick
</Button>
</div>
<div className="grid grid-cols-[1fr_1.3fr] gap-5">
<Panel title="Live preview">
<div className="flex flex-col items-center gap-4">
<StickPreview
raw={computed.raw}
processed={computed.processed}
innerDeadzone={settings.innerDeadzone}
outerDeadzone={settings.outerDeadzone}
size={200}
/>
<SimulatedStickPad
onMove={(x, y) => {
mockControllerBackend.setAxis(`${axisPrefix}_x`, x);
mockControllerBackend.setAxis(`${axisPrefix}_y`, y);
}}
/>
</div>
</Panel>
<div className="flex flex-col gap-4">
<Panel title="Deadzones">
<Slider
label="Inner deadzone"
value={settings.innerDeadzone}
onChange={(innerDeadzone) => update({ innerDeadzone })}
/>
<Slider
label="Outer deadzone"
value={settings.outerDeadzone}
onChange={(outerDeadzone) => update({ outerDeadzone })}
/>
<Slider
label="Anti-deadzone"
value={settings.antiDeadzone}
onChange={(antiDeadzone) => update({ antiDeadzone })}
/>
<Select
label="Deadzone mode"
value={settings.deadzoneMode}
options={DEADZONE_MODE_OPTIONS}
onChange={(deadzoneMode) => update({ deadzoneMode })}
/>
</Panel>
<Panel title="Sensitivity & orientation">
<Slider
label="Horizontal sensitivity"
value={settings.sensitivityX}
min={0.1}
max={2}
onChange={(sensitivityX) => update({ sensitivityX })}
/>
<Slider
label="Vertical sensitivity"
value={settings.sensitivityY}
min={0.1}
max={2}
onChange={(sensitivityY) => update({ sensitivityY })}
/>
<Slider label="Smoothing" value={settings.smoothing} onChange={(smoothing) => update({ smoothing })} />
<Toggle checked={settings.invertX} onChange={(invertX) => update({ invertX })} label="Invert X axis" />
<Toggle checked={settings.invertY} onChange={(invertY) => update({ invertY })} label="Invert Y axis" />
<Toggle
checked={settings.circularityCorrection}
onChange={(circularityCorrection) => update({ circularityCorrection })}
label="Circularity correction"
description="Normalize square/octagonal raw ranges to a circle"
/>
</Panel>
<Panel title="Response curve">
<Select
label="Preset"
value={settings.responseCurve.preset}
options={CURVE_OPTIONS}
onChange={(preset) => update({ responseCurve: { ...settings.responseCurve, preset } })}
/>
{settings.responseCurve.preset === "custom" && (
<div className="mt-3">
<CurveEditor
points={settings.responseCurve.customPoints}
onChange={(customPoints) => update({ responseCurve: { ...settings.responseCurve, customPoints } })}
/>
</div>
)}
</Panel>
</div>
</div>
</PageShell>
);
}

145
src/pages/Triggers.tsx Normal file
View File

@@ -0,0 +1,145 @@
import { useState } from "react";
import { Button } from "@/components/common/Button";
import { Panel } from "@/components/common/Panel";
import { Select } from "@/components/inputs/Select";
import { CurveEditor } from "@/components/inputs/CurveEditor";
import { Slider } from "@/components/inputs/Slider";
import { Toggle } from "@/components/inputs/Toggle";
import { UnsavedChangesBar } from "@/components/feedback/UnsavedChangesBar";
import { PageShell } from "@/components/layout/PageShell";
import { TriggerPreview } from "@/components/visualizations/TriggerPreview";
import { mockControllerBackend } from "@/services/mockControllerBackend";
import { useControllerStore } from "@/stores/controllerStore";
import { useProfileStore } from "@/stores/profileStore";
import { CURVE_PRESET_LABELS } from "@/utils/curveMath";
import { processTrigger } from "@/utils/deadzoneMath";
import type { CurvePreset, TriggerResponse, TriggerSettings } from "@/types/profile";
const CURVE_OPTIONS: { value: CurvePreset; label: string }[] = Object.entries(CURVE_PRESET_LABELS).map(
([value, label]) => ({ value: value as CurvePreset, label }),
);
const RESPONSE_OPTIONS: { value: TriggerResponse; label: string }[] = [
{ value: "linear", label: "Linear" },
{ value: "progressive", label: "Progressive" },
];
type TriggerSide = "leftTrigger" | "rightTrigger";
export function Triggers() {
const [side, setSide] = useState<TriggerSide>("leftTrigger");
const liveState = useControllerStore((s) => s.liveState);
const draftProfile = useProfileStore((s) => s.draftProfile);
const updateDraft = useProfileStore((s) => s.updateDraft);
if (!draftProfile) {
return (
<PageShell title="Triggers" description="Loading profile...">
<div />
</PageShell>
);
}
const settings = draftProfile[side];
const axisId = side === "leftTrigger" ? "left_trigger" : "right_trigger";
const raw = liveState?.axes[axisId] ?? 0;
const computed = processTrigger(raw, settings);
function update(patch: Partial<TriggerSettings>) {
updateDraft((draft) => ({ ...draft, [side]: { ...draft[side], ...patch } }));
}
return (
<PageShell title="Triggers" description="Tune deadzone, activation range, and response for each analog trigger.">
<UnsavedChangesBar />
<div className="flex gap-2">
<Button variant={side === "leftTrigger" ? "primary" : "secondary"} onClick={() => setSide("leftTrigger")}>
Left Trigger
</Button>
<Button variant={side === "rightTrigger" ? "primary" : "secondary"} onClick={() => setSide("rightTrigger")}>
Right Trigger
</Button>
</div>
<div className="grid grid-cols-[1fr_1.3fr] gap-5">
<Panel title="Live preview">
<div className="flex flex-col gap-4">
<TriggerPreview
raw={computed.raw}
processed={computed.processed}
deadzone={settings.deadzone}
maxActivation={settings.maxActivation}
/>
<div>
<span className="mb-1.5 block text-xs text-fg-muted">Simulated raw input</span>
<input
type="range"
min={0}
max={1}
step={0.01}
defaultValue={0}
className="w-full"
onChange={(e) => mockControllerBackend.setAxis(axisId, Number(e.currentTarget.value))}
/>
</div>
</div>
</Panel>
<div className="flex flex-col gap-4">
<Panel title="Activation range">
<Slider label="Deadzone" value={settings.deadzone} onChange={(deadzone) => update({ deadzone })} />
<Slider
label="Maximum activation point"
value={settings.maxActivation}
onChange={(maxActivation) => update({ maxActivation })}
/>
<Slider
label="Sensitivity"
value={settings.sensitivity}
min={0.1}
max={2}
onChange={(sensitivity) => update({ sensitivity })}
/>
</Panel>
<Panel title="Response mode">
<Select
label="Response curve type"
value={settings.response}
options={RESPONSE_OPTIONS}
onChange={(response) => update({ response })}
/>
<Select
label="Curve preset"
value={settings.curve.preset}
options={CURVE_OPTIONS}
onChange={(preset) => update({ curve: { ...settings.curve, preset } })}
/>
{settings.curve.preset === "custom" && (
<CurveEditor
points={settings.curve.customPoints}
onChange={(customPoints) => update({ curve: { ...settings.curve, customPoints } })}
/>
)}
<Toggle
checked={settings.hairTrigger}
onChange={(hairTrigger) => update({ hairTrigger })}
label="Hair-trigger mode"
description="Register full activation with minimal pull"
/>
<Toggle
checked={settings.digitalMode}
onChange={(digitalMode) => update({ digitalMode })}
label="Digital trigger mode"
description="Behave like an on/off button instead of analog"
/>
<Toggle checked={settings.invert} onChange={(invert) => update({ invert })} label="Invert trigger" />
</Panel>
</div>
</div>
</PageShell>
);
}

View File

@@ -0,0 +1,17 @@
import type { ControllerInfo, ControllerLiveState, RawInputEvent } from "@/types/controller";
/**
* Abstraction over "where controller data comes from". `mockControllerBackend`
* (Phase 1) and the future Tauri-event-backed implementation (Phase 2+)
* both implement this so pages/stores never need to know which one is
* active. This mirrors the backend pipeline: device_manager -> live state ->
* event bus -> UI.
*/
export interface ControllerBackend {
init(): Promise<void>;
dispose(): void;
onControllersChanged(callback: (controllers: ControllerInfo[]) => void): () => void;
onLiveState(callback: (state: ControllerLiveState) => void): () => void;
onRawEvent(callback: (event: RawInputEvent) => void): () => void;
setRemappingEnabled(enabled: boolean): Promise<void>;
}

View File

@@ -0,0 +1,24 @@
import { invoke } from "@tauri-apps/api/core";
import type { ControllerInfo } from "@/types/controller";
export interface PermissionStatus {
uinputExists: boolean;
uinputWritable: boolean;
inputDirectoryReadable: boolean;
currentUser: string | null;
groups: string[];
}
export interface DiagnosticsSnapshot {
appVersion: string;
operatingSystem: string;
kernelVersion: string | null;
permissions: PermissionStatus;
devices: ControllerInfo[];
profileSchemaVersion: number;
}
export const diagnosticsService = {
snapshot: () => invoke<DiagnosticsSnapshot>("diagnostics_snapshot"),
};

View File

@@ -0,0 +1,128 @@
import { AXIS_CAPABILITIES, BUTTON_CAPABILITIES, DEMO_DEVICE_CAPABILITIES } from "@/constants/controls";
import type { ControllerBackend } from "@/services/controllerBackend";
import type { ControllerInfo, ControllerLiveState, RawInputEvent } from "@/types/controller";
import { SimpleEmitter } from "@/utils/eventEmitter";
const POLL_INTERVAL_MS = 8; // ~125Hz, representative of a wireless 8BitDo Ultimate poll rate
/**
* Simulated controller used until the real evdev backend lands in Phase 2,
* and afterwards as a "Simulation Mode" fallback so the UI and pipeline can
* always be exercised without physical hardware.
*/
export class MockControllerBackend implements ControllerBackend {
private readonly controllersEmitter = new SimpleEmitter<ControllerInfo[]>();
private readonly liveStateEmitter = new SimpleEmitter<ControllerLiveState>();
private readonly rawEventEmitter = new SimpleEmitter<RawInputEvent>();
private readonly buttons: Record<string, boolean> = {};
private readonly axes: Record<string, number> = {};
private connected = true;
private intervalId: number | null = null;
private info: ControllerInfo;
constructor() {
for (const button of BUTTON_CAPABILITIES) this.buttons[button.id] = false;
for (const axis of AXIS_CAPABILITIES) this.axes[axis.id] = 0;
this.info = {
id: "simulated-controller-1",
name: DEMO_DEVICE_CAPABILITIES.name,
connection: "usb",
devicePath: "/dev/input/event33 (simulated)",
vendorId: DEMO_DEVICE_CAPABILITIES.vendorId,
productId: DEMO_DEVICE_CAPABILITIES.productId,
mode: "xinput",
batteryPercent: 82,
firmwareVersion: null,
virtualControllerActive: true,
capabilities: DEMO_DEVICE_CAPABILITIES,
isSimulated: true,
};
}
async init(): Promise<void> {
if (this.intervalId !== null) return;
this.intervalId = window.setInterval(() => {
if (!this.connected) return;
this.liveStateEmitter.emit({
timestamp: performance.now(),
buttons: { ...this.buttons },
axes: { ...this.axes },
});
}, POLL_INTERVAL_MS);
}
dispose(): void {
if (this.intervalId !== null) {
window.clearInterval(this.intervalId);
this.intervalId = null;
}
}
onControllersChanged(callback: (controllers: ControllerInfo[]) => void): () => void {
queueMicrotask(() => callback(this.snapshotControllers()));
return this.controllersEmitter.on(callback);
}
onLiveState(callback: (state: ControllerLiveState) => void): () => void {
return this.liveStateEmitter.on(callback);
}
onRawEvent(callback: (event: RawInputEvent) => void): () => void {
return this.rawEventEmitter.on(callback);
}
async setRemappingEnabled(enabled: boolean): Promise<void> {
this.info = { ...this.info, virtualControllerActive: enabled };
this.controllersEmitter.emit(this.snapshotControllers());
}
setSimulatedConnected(connected: boolean): void {
this.connected = connected;
this.controllersEmitter.emit(this.snapshotControllers());
}
setButton(id: string, pressed: boolean): void {
if (this.buttons[id] === pressed) return;
this.buttons[id] = pressed;
this.rawEventEmitter.emit({
timestamp: performance.now(),
type: "button",
code: id,
rawValue: pressed ? 1 : 0,
processedValue: pressed ? 1 : 0,
});
}
pulseButton(id: string, durationMs = 120): void {
this.setButton(id, true);
window.setTimeout(() => this.setButton(id, false), durationMs);
}
setAxis(id: string, value: number): void {
this.axes[id] = value;
this.rawEventEmitter.emit({
timestamp: performance.now(),
type: "axis",
code: id,
rawValue: value,
processedValue: value,
});
}
getButtonState(id: string): boolean {
return this.buttons[id] ?? false;
}
getAxisState(id: string): number {
return this.axes[id] ?? 0;
}
private snapshotControllers(): ControllerInfo[] {
return this.connected ? [this.info] : [];
}
}
export const mockControllerBackend = new MockControllerBackend();

View File

@@ -0,0 +1,5 @@
import { invoke } from "@tauri-apps/api/core";
export const processService = {
detectedGameProfile: () => invoke<string | null>("detected_game_profile"),
};

View File

@@ -0,0 +1,18 @@
import { invoke } from "@tauri-apps/api/core";
import type { Profile, ProfileSummary } from "@/types/profile";
/** Thin typed wrapper around the `profile_manager` Tauri commands. */
export const profileService = {
list: () => invoke<ProfileSummary[]>("list_profiles"),
load: (id: string) => invoke<Profile>("load_profile", { id }),
save: (profile: Profile) => invoke<Profile>("save_profile", { profile }),
create: (name: string) => invoke<Profile>("create_profile", { name }),
rename: (id: string, newName: string) => invoke<Profile>("rename_profile", { id, newName }),
duplicate: (id: string, newName: string) => invoke<Profile>("duplicate_profile", { id, newName }),
remove: (id: string) => invoke<void>("delete_profile", { id }),
reset: (id: string) => invoke<Profile>("reset_profile", { id }),
exportTo: (id: string, destination: string) => invoke<void>("export_profile", { id, destination }),
importFrom: (source: string) => invoke<Profile>("import_profile", { source }),
directory: () => invoke<string>("profiles_directory"),
};

View File

@@ -0,0 +1,11 @@
import { invoke } from "@tauri-apps/api/core";
export interface AppInfo {
name: string;
version: string;
configDir: string;
}
export const systemService = {
getAppInfo: () => invoke<AppInfo>("get_app_info"),
};

View File

@@ -0,0 +1,43 @@
import { listen } from "@tauri-apps/api/event";
import { invoke } from "@tauri-apps/api/core";
import type { ControllerInfo, RawInputEvent } from "@/types/controller";
import type { Profile } from "@/types/profile";
const INPUT_EVENT = "controller-input";
const READER_STATUS_EVENT = "controller-reader-status";
interface NativeInputEvent {
timestamp: number;
eventType: "button" | "axis";
code: string;
rawValue: number;
processedValue: number;
}
interface ReaderStatus {
devicePath: string;
connected: boolean;
message: string | null;
}
function toRawInputEvent(event: NativeInputEvent): RawInputEvent {
return {
timestamp: event.timestamp,
type: event.eventType,
code: event.code,
rawValue: event.rawValue,
processedValue: event.processedValue,
};
}
export const tauriControllerService = {
list: () => invoke<ControllerInfo[]>("list_devices"),
startReader: (devicePath: string) => invoke<void>("start_device_reader", { devicePath }),
applyRemappingProfile: (profile: Profile) => invoke<void>("apply_remapping_profile", { profile }),
setRemappingEnabled: (enabled: boolean) => invoke<void>("set_remapping_enabled", { enabled }),
onInput: async (callback: (event: RawInputEvent) => void): Promise<() => void> =>
listen<NativeInputEvent>(INPUT_EVENT, (event) => callback(toRawInputEvent(event.payload))),
onReaderStatus: async (callback: (status: ReaderStatus) => void): Promise<() => void> =>
listen<ReaderStatus>(READER_STATUS_EVENT, (event) => callback(event.payload)),
};

View File

@@ -0,0 +1,277 @@
import { create } from "zustand";
import { mockControllerBackend } from "@/services/mockControllerBackend";
import { tauriControllerService } from "@/services/tauriControllerService";
import { useSettingsStore } from "@/stores/settingsStore";
import type { ControllerInfo, ControllerLiveState, RawInputEvent } from "@/types/controller";
const MAX_EVENT_LOG = 200;
const MAX_POLL_SAMPLES = 30;
const RECONNECT_INTERVAL_MS = 1_500;
const MAX_RECONNECT_ATTEMPTS = 20;
export type ControllerConnectionStatus = "connected" | "reconnecting" | "disconnected" | "simulation";
function samePhysicalController(a: ControllerInfo, b: ControllerInfo): boolean {
return (
a.vendorId === b.vendorId &&
a.productId === b.productId &&
a.name === b.name &&
a.connection === b.connection &&
a.mode === b.mode
);
}
function estimatePollRateHz(timestamps: number[]): number | null {
if (timestamps.length < 2) return null;
const span = timestamps[timestamps.length - 1] - timestamps[0];
if (span <= 0) return null;
return Math.round(((timestamps.length - 1) / span) * 1000);
}
interface ControllerState {
controllers: ControllerInfo[];
activeControllerId: string | null;
liveState: ControllerLiveState | null;
eventLog: RawInputEvent[];
pollRateHz: number | null;
latencyEstimateMs: number | null;
connectionStatus: ControllerConnectionStatus;
connectionMessage: string | null;
initialized: boolean;
usingSimulation: boolean;
init: () => Promise<void>;
activeController: () => ControllerInfo | null;
setActiveControllerId: (id: string | null) => Promise<void>;
setRemappingEnabled: (enabled: boolean) => Promise<void>;
clearEventLog: () => void;
}
let pollTimestamps: number[] = [];
let hasLoggedFirstPhysicalEvent = false;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let reconnectAttempts = 0;
let refreshInFlight = false;
function clearReconnectTimer(): void {
if (reconnectTimer !== null) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
reconnectAttempts = 0;
}
export const useControllerStore = create<ControllerState>((set, get) => ({
controllers: [],
activeControllerId: null,
liveState: null,
eventLog: [],
pollRateHz: null,
latencyEstimateMs: null,
connectionStatus: "disconnected",
connectionMessage: null,
initialized: false,
usingSimulation: false,
init: async () => {
if (get().initialized) return;
set({ initialized: true });
const simulationRequested = useSettingsStore.getState().simulationMode;
let realControllers: ControllerInfo[] = [];
try {
realControllers = simulationRequested ? [] : await tauriControllerService.list();
} catch {
// Browser-only development and missing permissions both retain the safe
// simulated fallback rather than leaving the UI unusable.
}
const useSimulation = simulationRequested || realControllers.length === 0;
set({
controllers: useSimulation ? [] : realControllers,
activeControllerId: useSimulation ? null : realControllers[0]?.id ?? null,
usingSimulation: useSimulation,
connectionStatus: useSimulation ? "simulation" : "connected",
connectionMessage: useSimulation
? simulationRequested
? "Simulation mode is enabled."
: "No physical controller detected; using simulated input."
: null,
});
// #region agent log
fetch('http://127.0.0.1:7773/ingest/f58e9523-b2dd-4860-abd0-167312081aae',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'2801e0'},body:JSON.stringify({sessionId:'2801e0',runId:'initial',hypothesisId:'H3',location:'src/stores/controllerStore.ts:init',message:'Controller backend selected',data:{simulationRequested,realControllerCount:realControllers.length,useSimulation,devices:realControllers.map((controller)=>({id:controller.id,name:controller.name,connection:controller.connection,devicePath:controller.devicePath}))},timestamp:Date.now()})}).catch(()=>{});
// #endregion
const receiveLiveState = (liveState: ControllerLiveState) => {
pollTimestamps.push(liveState.timestamp);
if (pollTimestamps.length > MAX_POLL_SAMPLES) pollTimestamps.shift();
const pollRateHz = estimatePollRateHz(pollTimestamps);
const latencyEstimateMs = pollRateHz ? Math.round((1000 / pollRateHz) * 10) / 10 : null;
set({ liveState, pollRateHz, latencyEstimateMs });
};
const receiveRawEvent = (event: RawInputEvent) => {
if (!get().usingSimulation && !hasLoggedFirstPhysicalEvent) {
hasLoggedFirstPhysicalEvent = true;
// #region agent log
fetch('http://127.0.0.1:7773/ingest/f58e9523-b2dd-4860-abd0-167312081aae',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'2801e0'},body:JSON.stringify({sessionId:'2801e0',runId:'initial',hypothesisId:'H2',location:'src/stores/controllerStore.ts:receiveRawEvent',message:'First physical input event received',data:{type:event.type,code:event.code,rawValue:event.rawValue,processedValue:event.processedValue},timestamp:Date.now()})}).catch(()=>{});
// #endregion
}
set((state) => ({ eventLog: [event, ...state.eventLog].slice(0, MAX_EVENT_LOG) }));
set((state) => {
const liveState = state.liveState ?? {
timestamp: event.timestamp,
buttons: {},
axes: {},
};
const next = {
timestamp: event.timestamp,
buttons: { ...liveState.buttons },
axes: { ...liveState.axes },
};
if (event.type === "button") next.buttons[event.code] = event.processedValue !== 0;
else next.axes[event.code] = event.processedValue;
return { liveState: next };
});
};
if (!useSimulation) {
const active = realControllers[0];
await tauriControllerService.onInput(receiveRawEvent);
const refreshNativeDevices = async (): Promise<boolean> => {
if (refreshInFlight) return false;
refreshInFlight = true;
try {
const previousActive = get().activeController();
const devices = await tauriControllerService.list();
const nextActive =
devices.find((controller) => controller.id === previousActive?.id) ??
(previousActive ? devices.find((controller) => samePhysicalController(controller, previousActive)) : undefined) ??
devices[0];
if (!nextActive) {
set({
controllers: [],
activeControllerId: null,
liveState: null,
pollRateHz: null,
latencyEstimateMs: null,
connectionStatus: "reconnecting",
connectionMessage: "Waiting for the controller to reconnect…",
});
return false;
}
set({
controllers: devices,
activeControllerId: nextActive.id,
liveState: null,
pollRateHz: null,
latencyEstimateMs: null,
connectionStatus: "connected",
connectionMessage: null,
});
await tauriControllerService.startReader(nextActive.devicePath);
clearReconnectTimer();
return true;
} catch {
set({
connectionStatus: "reconnecting",
connectionMessage: "Unable to access the controller; retrying…",
});
return false;
} finally {
refreshInFlight = false;
}
};
const scheduleReconnect = (): void => {
if (reconnectTimer !== null) return;
const attemptReconnect = async (): Promise<void> => {
reconnectTimer = null;
if (await refreshNativeDevices()) return;
reconnectAttempts += 1;
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
set({
connectionStatus: "disconnected",
connectionMessage: "Controller did not reconnect. Reconnect it or restart input detection.",
});
return;
}
reconnectTimer = setTimeout(() => void attemptReconnect(), RECONNECT_INTERVAL_MS);
};
void attemptReconnect();
};
await tauriControllerService.onReaderStatus((status) => {
// #region agent log
fetch('http://127.0.0.1:7773/ingest/f58e9523-b2dd-4860-abd0-167312081aae',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'2801e0'},body:JSON.stringify({sessionId:'2801e0',runId:'initial',hypothesisId:'H6',location:'src/stores/controllerStore.ts:onReaderStatus',message:'Physical reader status changed',data:status,timestamp:Date.now()})}).catch(()=>{});
// #endregion
const activeController = get().activeController();
if (status.connected && activeController?.devicePath === status.devicePath) {
clearReconnectTimer();
set({ connectionStatus: "connected", connectionMessage: null });
return;
}
if (!status.connected && activeController?.devicePath === status.devicePath) {
set({
liveState: null,
pollRateHz: null,
latencyEstimateMs: null,
connectionStatus: "reconnecting",
connectionMessage: status.message ?? "Controller disconnected; searching for it again…",
});
scheduleReconnect();
}
});
if (active) {
await tauriControllerService.startReader(active.devicePath);
}
return;
}
await mockControllerBackend.init();
mockControllerBackend.onControllersChanged((controllers) => {
set((state) => ({
controllers,
activeControllerId: controllers.some((c) => c.id === state.activeControllerId)
? state.activeControllerId
: controllers[0]?.id ?? null,
}));
});
mockControllerBackend.onLiveState(receiveLiveState);
mockControllerBackend.onRawEvent(receiveRawEvent);
},
activeController: () => {
const state = get();
return state.controllers.find((c) => c.id === state.activeControllerId) ?? null;
},
setActiveControllerId: async (id) => {
const controller = get().controllers.find((candidate) => candidate.id === id) ?? null;
set({
activeControllerId: controller?.id ?? null,
liveState: null,
pollRateHz: null,
latencyEstimateMs: null,
});
if (controller && !get().usingSimulation) {
await tauriControllerService.startReader(controller.devicePath);
}
},
setRemappingEnabled: async (enabled) => {
// #region agent log
fetch('http://127.0.0.1:7773/ingest/f58e9523-b2dd-4860-abd0-167312081aae',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'2801e0'},body:JSON.stringify({sessionId:'2801e0',runId:'initial',hypothesisId:'H8',location:'src/stores/controllerStore.ts:setRemappingEnabled',message:'Remapping toggle requested',data:{enabled,usingSimulation:get().usingSimulation,activeControllerId:get().activeControllerId},timestamp:Date.now()})}).catch(()=>{});
// #endregion
if (get().usingSimulation) {
await mockControllerBackend.setRemappingEnabled(enabled);
return;
}
await tauriControllerService.setRemappingEnabled(enabled);
},
clearEventLog: () => set({ eventLog: [] }),
}));

223
src/stores/profileStore.ts Normal file
View File

@@ -0,0 +1,223 @@
import { open, save } from "@tauri-apps/plugin-dialog";
import { create } from "zustand";
import { profileService } from "@/services/profileService";
import { tauriControllerService } from "@/services/tauriControllerService";
import { useUIStore } from "@/stores/uiStore";
import type { Profile, ProfileSummary } from "@/types/profile";
function deepClone<T>(value: T): T {
return JSON.parse(JSON.stringify(value));
}
function isEqual(a: unknown, b: unknown): boolean {
return JSON.stringify(a) === JSON.stringify(b);
}
interface ProfileState {
summaries: ProfileSummary[];
activeProfile: Profile | null;
draftProfile: Profile | null;
isDirty: boolean;
loading: boolean;
error: string | null;
refreshList: () => Promise<void>;
selectProfile: (id: string) => Promise<void>;
updateDraft: (updater: (draft: Profile) => Profile) => void;
applyDraft: () => Promise<void>;
revertDraft: () => void;
saveDraft: () => Promise<void>;
createProfile: (name: string) => Promise<Profile | null>;
renameProfile: (id: string, newName: string) => Promise<void>;
duplicateProfile: (id: string, newName: string) => Promise<Profile | null>;
deleteProfile: (id: string) => Promise<void>;
resetActiveProfile: () => Promise<void>;
exportProfile: (id: string) => Promise<void>;
importProfile: () => Promise<void>;
}
export const useProfileStore = create<ProfileState>((set, get) => ({
summaries: [],
activeProfile: null,
draftProfile: null,
isDirty: false,
loading: false,
error: null,
refreshList: async () => {
set({ loading: true, error: null });
try {
const summaries = await profileService.list();
set({ summaries, loading: false });
if (!get().activeProfile && summaries.length > 0) {
await get().selectProfile(summaries.find((s) => s.id === "default")?.id ?? summaries[0].id);
}
} catch (err) {
set({ loading: false, error: String(err) });
}
},
selectProfile: async (id) => {
set({ loading: true, error: null });
try {
const profile = await profileService.load(id);
set({ activeProfile: profile, draftProfile: deepClone(profile), isDirty: false, loading: false });
} catch (err) {
set({ loading: false, error: String(err) });
}
},
updateDraft: (updater) => {
const draft = get().draftProfile;
if (!draft) return;
const nextDraft = updater(deepClone(draft));
const isDirty = !isEqual(nextDraft, get().activeProfile);
set({ draftProfile: nextDraft, isDirty });
// #region agent log
fetch('http://127.0.0.1:7773/ingest/f58e9523-b2dd-4860-abd0-167312081aae',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'2801e0'},body:JSON.stringify({sessionId:'2801e0',runId:'initial',hypothesisId:'H1',location:'src/stores/profileStore.ts:updateDraft',message:'Profile draft updated',data:{profileId:nextDraft.id,isDirty,leftTrigger:nextDraft.leftTrigger,rightTrigger:nextDraft.rightTrigger},timestamp:Date.now()})}).catch(()=>{});
// #endregion
},
applyDraft: async () => {
const draft = get().draftProfile;
if (!draft) return;
try {
await tauriControllerService.applyRemappingProfile(draft);
useUIStore.getState().pushToast("Settings applied to the virtual controller.", "success");
} catch (err) {
useUIStore.getState().pushToast(`Could not apply remapping: ${err}`, "danger");
return;
}
// #region agent log
fetch('http://127.0.0.1:7773/ingest/f58e9523-b2dd-4860-abd0-167312081aae',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'2801e0'},body:JSON.stringify({sessionId:'2801e0',runId:'post-fix',hypothesisId:'H7',location:'src/stores/profileStore.ts:applyDraft',message:'Profile successfully applied to native remapping pipeline',data:{profileId:draft.id},timestamp:Date.now()})}).catch(()=>{});
// #endregion
},
revertDraft: () => {
const active = get().activeProfile;
if (!active) return;
set({ draftProfile: deepClone(active), isDirty: false });
useUIStore.getState().pushToast("Reverted unsaved changes.", "info");
},
saveDraft: async () => {
const draft = get().draftProfile;
if (!draft) return;
set({ loading: true, error: null });
try {
const saved = await profileService.save(draft);
set({ activeProfile: saved, draftProfile: deepClone(saved), isDirty: false, loading: false });
await get().refreshList();
useUIStore.getState().pushToast(`Saved profile "${saved.name}".`, "success");
// #region agent log
fetch('http://127.0.0.1:7773/ingest/f58e9523-b2dd-4860-abd0-167312081aae',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'2801e0'},body:JSON.stringify({sessionId:'2801e0',runId:'initial',hypothesisId:'H7',location:'src/stores/profileStore.ts:saveDraft',message:'Profile draft saved',data:{profileId:saved.id},timestamp:Date.now()})}).catch(()=>{});
// #endregion
} catch (err) {
set({ loading: false, error: String(err) });
useUIStore.getState().pushToast(`Failed to save profile: ${err}`, "danger");
}
},
createProfile: async (name) => {
try {
const profile = await profileService.create(name);
await get().refreshList();
await get().selectProfile(profile.id);
useUIStore.getState().pushToast(`Created profile "${profile.name}".`, "success");
return profile;
} catch (err) {
useUIStore.getState().pushToast(`Failed to create profile: ${err}`, "danger");
return null;
}
},
renameProfile: async (id, newName) => {
try {
const profile = await profileService.rename(id, newName);
await get().refreshList();
if (get().activeProfile?.id === id) {
set({ activeProfile: profile, draftProfile: deepClone(profile), isDirty: false });
}
useUIStore.getState().pushToast(`Renamed profile to "${newName}".`, "success");
} catch (err) {
useUIStore.getState().pushToast(`Failed to rename profile: ${err}`, "danger");
}
},
duplicateProfile: async (id, newName) => {
try {
const profile = await profileService.duplicate(id, newName);
await get().refreshList();
await get().selectProfile(profile.id);
useUIStore.getState().pushToast(`Duplicated as "${profile.name}".`, "success");
return profile;
} catch (err) {
useUIStore.getState().pushToast(`Failed to duplicate profile: ${err}`, "danger");
return null;
}
},
deleteProfile: async (id) => {
try {
await profileService.remove(id);
const wasActive = get().activeProfile?.id === id;
await get().refreshList();
if (wasActive) {
const next = get().summaries[0];
if (next) await get().selectProfile(next.id);
else set({ activeProfile: null, draftProfile: null, isDirty: false });
}
useUIStore.getState().pushToast("Profile deleted.", "info");
} catch (err) {
useUIStore.getState().pushToast(`Failed to delete profile: ${err}`, "danger");
}
},
resetActiveProfile: async () => {
const active = get().activeProfile;
if (!active) return;
try {
const reset = await profileService.reset(active.id);
set({ activeProfile: reset, draftProfile: deepClone(reset), isDirty: false });
await get().refreshList();
useUIStore.getState().pushToast(`Reset "${reset.name}" to defaults.`, "success");
} catch (err) {
useUIStore.getState().pushToast(`Failed to reset profile: ${err}`, "danger");
}
},
exportProfile: async (id) => {
const summary = get().summaries.find((s) => s.id === id);
try {
const destination = await save({
title: "Export profile",
defaultPath: `${summary?.name ?? id}.json`,
filters: [{ name: "Profile JSON", extensions: ["json"] }],
});
if (!destination) return;
await profileService.exportTo(id, destination);
useUIStore.getState().pushToast("Profile exported.", "success");
} catch (err) {
useUIStore.getState().pushToast(`Failed to export profile: ${err}`, "danger");
}
},
importProfile: async () => {
try {
const source = await open({
title: "Import profile",
multiple: false,
filters: [{ name: "Profile JSON", extensions: ["json"] }],
});
if (!source || Array.isArray(source)) return;
const profile = await profileService.importFrom(source);
await get().refreshList();
await get().selectProfile(profile.id);
useUIStore.getState().pushToast(`Imported profile "${profile.name}".`, "success");
} catch (err) {
useUIStore.getState().pushToast(`Failed to import profile: ${err}`, "danger");
}
},
}));

View File

@@ -0,0 +1,36 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
export type ThemeMode = "dark" | "light" | "high-contrast";
interface SettingsState {
theme: ThemeMode;
setTheme: (theme: ThemeMode) => void;
remappingEnabled: boolean;
setRemappingEnabled: (enabled: boolean) => void;
simulationMode: boolean;
setSimulationMode: (enabled: boolean) => void;
autoSwitchProfiles: boolean;
setAutoSwitchProfiles: (enabled: boolean) => void;
}
export const useSettingsStore = create<SettingsState>()(
persist(
(set) => ({
theme: "dark",
setTheme: (theme) => set({ theme }),
remappingEnabled: true,
setRemappingEnabled: (remappingEnabled) => set({ remappingEnabled }),
simulationMode: false,
setSimulationMode: (simulationMode) => {
// #region agent log
fetch('http://127.0.0.1:7773/ingest/f58e9523-b2dd-4860-abd0-167312081aae',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'2801e0'},body:JSON.stringify({sessionId:'2801e0',runId:'initial',hypothesisId:'H5',location:'src/stores/settingsStore.ts:setSimulationMode',message:'Simulation mode changed',data:{simulationMode},timestamp:Date.now()})}).catch(()=>{});
// #endregion
set({ simulationMode });
},
autoSwitchProfiles: false,
setAutoSwitchProfiles: (autoSwitchProfiles) => set({ autoSwitchProfiles }),
}),
{ name: "8bitdo-control-center:settings" },
),
);

49
src/stores/uiStore.ts Normal file
View File

@@ -0,0 +1,49 @@
import { create } from "zustand";
export type ToastVariant = "info" | "success" | "warning" | "danger";
export interface Toast {
id: string;
message: string;
variant: ToastVariant;
}
export interface ConfirmOptions {
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
danger?: boolean;
}
interface ConfirmState extends ConfirmOptions {
resolve: (value: boolean) => void;
}
interface UIState {
toasts: Toast[];
pushToast: (message: string, variant?: ToastVariant) => void;
dismissToast: (id: string) => void;
confirm: ConfirmState | null;
requestConfirm: (options: ConfirmOptions) => Promise<boolean>;
resolveConfirm: (value: boolean) => void;
}
export const useUIStore = create<UIState>((set, get) => ({
toasts: [],
pushToast: (message, variant = "info") => {
const id = crypto.randomUUID();
set((s) => ({ toasts: [...s.toasts, { id, message, variant }] }));
window.setTimeout(() => get().dismissToast(id), 4500);
},
dismissToast: (id) => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),
confirm: null,
requestConfirm: (options) =>
new Promise<boolean>((resolve) => {
set({ confirm: { ...options, resolve } });
}),
resolveConfirm: (value) => {
get().confirm?.resolve(value);
set({ confirm: null });
},
}));

153
src/styles/index.css Normal file
View File

@@ -0,0 +1,153 @@
@import "tailwindcss";
@theme {
--color-app: var(--app-bg);
--color-surface: var(--surface);
--color-surface-2: var(--surface-2);
--color-surface-3: var(--surface-3);
--color-border: var(--border);
--color-border-strong: var(--border-strong);
--color-fg: var(--fg);
--color-fg-muted: var(--fg-muted);
--color-fg-subtle: var(--fg-subtle);
--color-accent: var(--accent);
--color-accent-strong: var(--accent-strong);
--color-accent-fg: var(--accent-fg);
--color-success: var(--success);
--color-warning: var(--warning);
--color-danger: var(--danger);
--font-sans: "Inter", "Segoe UI", ui-sans-serif, system-ui, -apple-system, sans-serif;
--shadow-panel: 0 1px 2px rgba(0, 0, 0, 0.24), 0 8px 24px -8px rgba(0, 0, 0, 0.35);
}
:root,
[data-theme="dark"] {
color-scheme: dark;
--app-bg: #0a0b0f;
--surface: #12141b;
--surface-2: #1a1d27;
--surface-3: rgba(255, 255, 255, 0.04);
--border: #262a37;
--border-strong: #363c4d;
--fg: #eceef3;
--fg-muted: #9199ad;
--fg-subtle: #656d82;
--accent: #7c5cff;
--accent-strong: #9779ff;
--accent-fg: #ffffff;
--success: #33d399;
--warning: #f5b83d;
--danger: #f2545b;
}
[data-theme="light"] {
color-scheme: light;
--app-bg: #f2f3f7;
--surface: #ffffff;
--surface-2: #f5f6fa;
--surface-3: rgba(15, 17, 26, 0.04);
--border: #e2e4eb;
--border-strong: #cbcfda;
--fg: #14161f;
--fg-muted: #5b6072;
--fg-subtle: #868c9d;
--accent: #6a45f0;
--accent-strong: #5934dd;
--accent-fg: #ffffff;
--success: #17966b;
--warning: #a86608;
--danger: #d13438;
}
[data-theme="high-contrast"] {
color-scheme: dark;
--app-bg: #000000;
--surface: #0a0a0a;
--surface-2: #161616;
--surface-3: rgba(255, 255, 255, 0.08);
--border: #ffffff;
--border-strong: #ffffff;
--fg: #ffffff;
--fg-muted: #e6e6e6;
--fg-subtle: #c7c7c7;
--accent: #ffd60a;
--accent-strong: #ffe45c;
--accent-fg: #000000;
--success: #00ff85;
--warning: #ffd60a;
--danger: #ff453a;
}
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
font-family: var(--font-sans);
background-color: var(--app-bg);
color: var(--fg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow: hidden;
}
* {
scrollbar-width: thin;
scrollbar-color: var(--border-strong) transparent;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
background-color: var(--border-strong);
border-radius: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
[data-theme="high-contrast"] :focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 6px;
}
input[type="range"] {
-webkit-appearance: none;
appearance: none;
height: 4px;
border-radius: 999px;
background: var(--border-strong);
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
border: 2px solid var(--surface);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.35);
}
input[type="range"]:disabled::-webkit-slider-thumb {
background: var(--fg-subtle);
cursor: not-allowed;
}

69
src/types/controller.ts Normal file
View File

@@ -0,0 +1,69 @@
/**
* Device abstraction layer types. `DeviceCapabilities` is intentionally
* generic (string-keyed buttons/axes) so devices other than the 8BitDo
* Ultimate can be supported later without changing this shape — see the
* plan's "abstraction layer" requirement. The real backend will populate
* these by probing evdev (`EVIOCGBIT`) in Phase 2; for now
* `mockControllerBackend` provides a realistic example.
*/
export type ConnectionKind = "usb" | "bluetooth" | "receiver2_4ghz" | "unknown";
export type ControllerMode = "xinput" | "directinput" | "switch" | "unknown";
export interface ButtonCapability {
id: string;
label: string;
/** Grouping used by the SVG diagram and mapping UI. */
group: "face" | "dpad" | "shoulder" | "trigger" | "stick" | "system" | "paddle";
}
export interface AxisCapability {
id: string;
label: string;
group: "stick" | "trigger" | "gyro";
min: number;
max: number;
}
export interface DeviceCapabilities {
vendorId: number;
productId: number;
name: string;
buttons: ButtonCapability[];
axes: AxisCapability[];
paddleCount: 0 | 2 | 4;
hasGyro: boolean;
hasRumble: boolean;
hasBattery: boolean;
}
export interface ControllerInfo {
id: string;
name: string;
connection: ConnectionKind;
devicePath: string;
vendorId: number;
productId: number;
mode: ControllerMode;
batteryPercent: number | null;
firmwareVersion: string | null;
virtualControllerActive: boolean;
capabilities: DeviceCapabilities;
isSimulated: boolean;
}
/** Instantaneous input state for the active controller. */
export interface ControllerLiveState {
timestamp: number;
buttons: Record<string, boolean>;
axes: Record<string, number>;
}
export interface RawInputEvent {
timestamp: number;
type: "button" | "axis";
code: string;
rawValue: number;
processedValue: number;
}

114
src/types/profile.ts Normal file
View File

@@ -0,0 +1,114 @@
/**
* Mirrors the Rust structs in `src-tauri/src/profile_schema.rs`. Keep these
* in sync manually — there is a Rust-side unit test that snapshots the JSON
* shape of the default profiles so drift is caught quickly.
*/
export type ControlId = string;
export type ActionMode = "normal" | "turbo" | "toggle" | "hold" | "longPress" | "doubleTap";
export type MappingTarget =
| { kind: "button"; button: ControlId }
| { kind: "key"; key: string }
| { kind: "mouseButton"; button: string }
| { kind: "mouseAxis"; axis: string; scale: number }
| { kind: "disabled" }
| { kind: "combo"; targets: MappingTarget[] }
| { kind: "shiftLayer"; layerId: string };
export interface ButtonMapping {
target: MappingTarget;
mode: ActionMode;
turboRateHz?: number | null;
longPressMs?: number | null;
doubleTapWindowMs?: number | null;
}
export type DeadzoneMode = "radial" | "axial";
export type CurvePreset = "linear" | "precision" | "aggressive" | "smooth" | "exponential" | "custom";
export interface CurvePoint {
x: number;
y: number;
}
export interface CurveConfig {
preset: CurvePreset;
customPoints: CurvePoint[];
}
export interface StickSettings {
innerDeadzone: number;
outerDeadzone: number;
antiDeadzone: number;
sensitivityX: number;
sensitivityY: number;
invertX: boolean;
invertY: boolean;
deadzoneMode: DeadzoneMode;
circularityCorrection: boolean;
smoothing: number;
responseCurve: CurveConfig;
}
export type TriggerResponse = "linear" | "progressive";
export interface TriggerSettings {
deadzone: number;
maxActivation: number;
sensitivity: number;
response: TriggerResponse;
hairTrigger: boolean;
digitalMode: boolean;
invert: boolean;
curve: CurveConfig;
}
export type GyroMapping = "off" | "stick" | "mouse";
export interface MotionSettings {
vibrationIntensity: number;
leftMotorIntensity: number;
rightMotorIntensity: number;
gyroSensitivity: number;
gyroDeadzone: number;
invertGyroX: boolean;
invertGyroY: boolean;
gyroMapping: GyroMapping;
}
export interface ShiftLayer {
id: string;
name: string;
activatorButton: ControlId;
mappings: Record<ControlId, ButtonMapping>;
}
export interface Profile {
schemaVersion: number;
id: string;
name: string;
description: string;
isBuiltIn: boolean;
createdAt: string;
updatedAt: string;
assignedExecutables: string[];
shortcutCombo: ControlId[];
buttonMappings: Record<ControlId, ButtonMapping>;
leftStick: StickSettings;
rightStick: StickSettings;
leftTrigger: TriggerSettings;
rightTrigger: TriggerSettings;
motion: MotionSettings;
shiftLayers: ShiftLayer[];
}
export interface ProfileSummary {
id: string;
name: string;
description: string;
isBuiltIn: boolean;
updatedAt: string;
}

71
src/utils/curveMath.ts Normal file
View File

@@ -0,0 +1,71 @@
import type { CurveConfig, CurvePoint, CurvePreset } from "@/types/profile";
export function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function smoothstep(t: number): number {
return t * t * (3 - 2 * t);
}
function evaluateCustomCurve(points: CurvePoint[], t: number): number {
if (points.length === 0) return t;
const sorted = [...points].sort((a, b) => a.x - b.x);
if (t <= sorted[0].x) return sorted[0].y;
const last = sorted[sorted.length - 1];
if (t >= last.x) return last.y;
for (let i = 0; i < sorted.length - 1; i++) {
const p0 = sorted[i];
const p1 = sorted[i + 1];
if (t >= p0.x && t <= p1.x) {
const span = p1.x - p0.x;
const localT = span === 0 ? 0 : (t - p0.x) / span;
return p0.y + (p1.y - p0.y) * localT;
}
}
return t;
}
/** Evaluates a response curve preset (or custom control points) at `t` in [0, 1]. */
export function evaluateCurve(preset: CurvePreset, points: CurvePoint[], t: number): number {
const clamped = clamp(t, 0, 1);
switch (preset) {
case "linear":
return clamped;
case "precision":
return Math.pow(clamped, 1.8);
case "aggressive":
return Math.pow(clamped, 0.55);
case "smooth":
return smoothstep(clamped);
case "exponential":
return Math.pow(clamped, 2.5);
case "custom":
return evaluateCustomCurve(points, clamped);
default:
return clamped;
}
}
export function evaluateCurveConfig(curve: CurveConfig, t: number): number {
return evaluateCurve(curve.preset, curve.customPoints, t);
}
export const CURVE_PRESET_LABELS: Record<CurvePreset, string> = {
linear: "Linear",
precision: "Precision",
aggressive: "Aggressive",
smooth: "Smooth",
exponential: "Exponential",
custom: "Custom",
};
/** Sample points used to draw a preview of a curve without a custom editor. */
export function sampleCurve(preset: CurvePreset, points: CurvePoint[], steps = 48): CurvePoint[] {
const samples: CurvePoint[] = [];
for (let i = 0; i <= steps; i++) {
const x = i / steps;
samples.push({ x, y: evaluateCurve(preset, points, x) });
}
return samples;
}

112
src/utils/deadzoneMath.ts Normal file
View File

@@ -0,0 +1,112 @@
import type { StickSettings, TriggerSettings } from "@/types/profile";
import { clamp, evaluateCurveConfig } from "@/utils/curveMath";
export interface Vec2 {
x: number;
y: number;
}
export interface StickComputation {
raw: Vec2;
processed: Vec2;
rawMagnitude: number;
processedMagnitude: number;
}
function magnitude(v: Vec2): number {
return Math.sqrt(v.x * v.x + v.y * v.y);
}
/**
* Clamps a raw stick reading so its magnitude never exceeds 1, correcting
* for controllers whose physical range is closer to a square/octagon than a
* perfect circle (a diagonal raw reading like (0.9, 0.9) would otherwise
* report a magnitude of ~1.27).
*/
function correctCircularity(v: Vec2): Vec2 {
const mag = magnitude(v);
if (mag <= 1 || mag === 0) return v;
return { x: v.x / mag, y: v.y / mag };
}
function applyRadialDeadzone(v: Vec2, inner: number, outer: number, antiDeadzone: number): Vec2 {
const mag = magnitude(v);
if (mag <= inner) return { x: 0, y: 0 };
const clampedOuter = Math.max(outer, inner + 0.0001);
const normalizedMag = clamp((mag - inner) / (clampedOuter - inner), 0, 1);
const outputMag = antiDeadzone + normalizedMag * (1 - antiDeadzone);
const scale = mag === 0 ? 0 : outputMag / mag;
return { x: v.x * scale, y: v.y * scale };
}
function applyAxialDeadzone(v: Vec2, inner: number, outer: number, antiDeadzone: number): Vec2 {
const applyAxis = (value: number) => {
const abs = Math.abs(value);
if (abs <= inner) return 0;
const clampedOuter = Math.max(outer, inner + 0.0001);
const normalized = clamp((abs - inner) / (clampedOuter - inner), 0, 1);
const outputAbs = antiDeadzone + normalized * (1 - antiDeadzone);
return Math.sign(value) * outputAbs;
};
return { x: applyAxis(v.x), y: applyAxis(v.y) };
}
/** Full stick processing pipeline mirroring the Rust `input_transform` stage order. */
export function processStick(raw: Vec2, settings: StickSettings): StickComputation {
let v = correctCircularity(settings.circularityCorrection ? raw : { ...raw });
v =
settings.deadzoneMode === "radial"
? applyRadialDeadzone(v, settings.innerDeadzone, settings.outerDeadzone, settings.antiDeadzone)
: applyAxialDeadzone(v, settings.innerDeadzone, settings.outerDeadzone, settings.antiDeadzone);
const mag = magnitude(v);
if (mag > 0) {
const curved = evaluateCurveConfig(settings.responseCurve, mag);
const scale = curved / mag;
v = { x: v.x * scale, y: v.y * scale };
}
v = {
x: clamp(v.x * settings.sensitivityX, -1, 1),
y: clamp(v.y * settings.sensitivityY, -1, 1),
};
if (settings.invertX) v = { ...v, x: -v.x };
if (settings.invertY) v = { ...v, y: -v.y };
return {
raw,
processed: v,
rawMagnitude: magnitude(raw),
processedMagnitude: magnitude(v),
};
}
export interface TriggerComputation {
raw: number;
processed: number;
}
export function processTrigger(raw: number, settings: TriggerSettings): TriggerComputation {
const clampedRaw = clamp(raw, 0, 1);
const oriented = settings.invert ? 1 - clampedRaw : clampedRaw;
if (oriented <= settings.deadzone) {
return { raw, processed: 0 };
}
const maxActivation = Math.max(settings.maxActivation, settings.deadzone + 0.0001);
const normalized = clamp((oriented - settings.deadzone) / (maxActivation - settings.deadzone), 0, 1);
if (settings.digitalMode) {
return { raw, processed: normalized > 0.5 ? 1 : 0 };
}
const responseAdjusted =
settings.response === "progressive" ? Math.pow(normalized, 1.6) : normalized;
const curved = evaluateCurveConfig(settings.curve, responseAdjusted);
const withSensitivity = clamp(curved * settings.sensitivity, 0, 1);
return { raw, processed: withSensitivity };
}

15
src/utils/eventEmitter.ts Normal file
View File

@@ -0,0 +1,15 @@
type Listener<T> = (payload: T) => void;
/** Minimal typed pub/sub used by the controller backends (mock and, later, Tauri-backed). */
export class SimpleEmitter<T> {
private listeners = new Set<Listener<T>>();
on(listener: Listener<T>): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
emit(payload: T): void {
for (const listener of this.listeners) listener(payload);
}
}

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />