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:
36
src/App.tsx
Normal file
36
src/App.tsx
Normal 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;
|
||||
25
src/components/common/Badge.tsx
Normal file
25
src/components/common/Badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
src/components/common/Button.tsx
Normal file
48
src/components/common/Button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
src/components/common/Panel.tsx
Normal file
42
src/components/common/Panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
src/components/common/StatusDot.tsx
Normal file
21
src/components/common/StatusDot.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
src/components/controller/ButtonHotspot.tsx
Normal file
57
src/components/controller/ButtonHotspot.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
81
src/components/controller/ControllerDiagram.tsx
Normal file
81
src/components/controller/ControllerDiagram.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
src/components/controller/StickHotspot.tsx
Normal file
45
src/components/controller/StickHotspot.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
src/components/controller/TriggerHotspot.tsx
Normal file
39
src/components/controller/TriggerHotspot.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
135
src/components/controller/controllerLayout.ts
Normal file
135
src/components/controller/controllerLayout.ts
Normal 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 },
|
||||
];
|
||||
26
src/components/feedback/ConfirmDialogHost.tsx
Normal file
26
src/components/feedback/ConfirmDialogHost.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
src/components/feedback/ToastStack.tsx
Normal file
53
src/components/feedback/ToastStack.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
src/components/feedback/UnsavedChangesBar.tsx
Normal file
46
src/components/feedback/UnsavedChangesBar.tsx
Normal 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 “{activeProfile.name}”</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="h-2 w-2 rounded-full bg-success" />
|
||||
<span className="text-fg-muted">
|
||||
Editing “{activeProfile.name}” · 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>
|
||||
);
|
||||
}
|
||||
98
src/components/inputs/CurveEditor.tsx
Normal file
98
src/components/inputs/CurveEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
src/components/inputs/Select.tsx
Normal file
43
src/components/inputs/Select.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
src/components/inputs/SimulatedStickPad.tsx
Normal file
56
src/components/inputs/SimulatedStickPad.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
src/components/inputs/Slider.tsx
Normal file
44
src/components/inputs/Slider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
src/components/inputs/TextField.tsx
Normal file
23
src/components/inputs/TextField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
src/components/inputs/Toggle.tsx
Normal file
44
src/components/inputs/Toggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
src/components/layout/AppLayout.tsx
Normal file
31
src/components/layout/AppLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
src/components/layout/PageShell.tsx
Normal file
24
src/components/layout/PageShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
src/components/layout/Sidebar.tsx
Normal file
53
src/components/layout/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
src/components/layout/TopBar.tsx
Normal file
58
src/components/layout/TopBar.tsx
Normal 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">· {CONNECTION_LABEL[activeController.connection]}</span>
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge tone="neutral">
|
||||
<StatusDot tone="neutral" />
|
||||
No controller detected
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
41
src/components/visualizations/EventLog.tsx
Normal file
41
src/components/visualizations/EventLog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
src/components/visualizations/StickPreview.tsx
Normal file
64
src/components/visualizations/StickPreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
src/components/visualizations/TriggerPreview.tsx
Normal file
37
src/components/visualizations/TriggerPreview.tsx
Normal 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
59
src/constants/controls.ts
Normal 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;
|
||||
32
src/constants/navigation.ts
Normal file
32
src/constants/navigation.ts
Normal 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 },
|
||||
];
|
||||
11
src/hooks/useController.ts
Normal file
11
src/hooks/useController.ts
Normal 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]);
|
||||
}
|
||||
29
src/hooks/useProfileInit.ts
Normal file
29
src/hooks/useProfileInit.ts
Normal 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
12
src/hooks/useTheme.ts
Normal 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]);
|
||||
}
|
||||
31
src/hooks/useUnsavedChanges.ts
Normal file
31
src/hooks/useUnsavedChanges.ts
Normal 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
11
src/main.tsx
Normal 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
228
src/pages/ButtonMapping.tsx
Normal 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
103
src/pages/Calibration.tsx
Normal 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
170
src/pages/Dashboard.tsx
Normal 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} · 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
111
src/pages/DeviceInfo.tsx
Normal 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
141
src/pages/InputTester.tsx
Normal 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
127
src/pages/Motion.tsx
Normal 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'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
205
src/pages/Profiles.tsx
Normal 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
139
src/pages/Settings.tsx
Normal 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
161
src/pages/Sticks.tsx
Normal 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
145
src/pages/Triggers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
src/services/controllerBackend.ts
Normal file
17
src/services/controllerBackend.ts
Normal 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>;
|
||||
}
|
||||
24
src/services/diagnosticsService.ts
Normal file
24
src/services/diagnosticsService.ts
Normal 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"),
|
||||
};
|
||||
128
src/services/mockControllerBackend.ts
Normal file
128
src/services/mockControllerBackend.ts
Normal 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();
|
||||
5
src/services/processService.ts
Normal file
5
src/services/processService.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
export const processService = {
|
||||
detectedGameProfile: () => invoke<string | null>("detected_game_profile"),
|
||||
};
|
||||
18
src/services/profileService.ts
Normal file
18
src/services/profileService.ts
Normal 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"),
|
||||
};
|
||||
11
src/services/systemService.ts
Normal file
11
src/services/systemService.ts
Normal 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"),
|
||||
};
|
||||
43
src/services/tauriControllerService.ts
Normal file
43
src/services/tauriControllerService.ts
Normal 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)),
|
||||
};
|
||||
277
src/stores/controllerStore.ts
Normal file
277
src/stores/controllerStore.ts
Normal 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
223
src/stores/profileStore.ts
Normal 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");
|
||||
}
|
||||
},
|
||||
}));
|
||||
36
src/stores/settingsStore.ts
Normal file
36
src/stores/settingsStore.ts
Normal 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
49
src/stores/uiStore.ts
Normal 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
153
src/styles/index.css
Normal 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
69
src/types/controller.ts
Normal 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
114
src/types/profile.ts
Normal 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
71
src/utils/curveMath.ts
Normal 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
112
src/utils/deadzoneMath.ts
Normal 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
15
src/utils/eventEmitter.ts
Normal 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
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user