Provide a Linux desktop controller configuration app with evdev input, uinput remapping, profiles, calibration, and reconnect recovery. Co-authored-by: Cursor <cursoragent@cursor.com>
99 lines
3.7 KiB
TypeScript
99 lines
3.7 KiB
TypeScript
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>
|
|
);
|
|
}
|