Initial release of 8BitDo Control Center

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

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

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
}

115
README.md Normal file
View File

@@ -0,0 +1,115 @@
# 8BitDo Control Center
A desktop configuration utility for 8BitDo Ultimate controllers on Linux. It reads the
physical controller through **evdev**, runs input through a configurable transform
pipeline (calibration, deadzones, response curves, remapping), and outputs a remapped
virtual controller through **uinput** — without depending on any proprietary 8BitDo
software or undocumented USB/firmware protocols.
Built with [Tauri 2](https://tauri.app), React + TypeScript, Tailwind CSS, and Rust.
> **Status:** Device discovery and read-only evdev input observation are implemented.
> The virtual-controller output pipeline remains opt-in work in progress; until it is
> enabled, the app never changes physical controller output.
## Features (current)
- Dark, light, and high-contrast themes
- Profile management: create, rename, duplicate, delete, reset, import, export —
persisted as versioned JSON under `~/.config/8bitdo-control-center/profiles/`
- Five built-in profiles (Default, FPS, Racing, Platformer, Custom)
- Reusable, generic SVG controller diagram (front + rear views, clickable hotspots)
- Button remapping (button/key/mouse/disabled, with turbo/toggle/hold/long-press/
double-tap modes)
- Analog stick tuning: inner/outer/anti-deadzone, radial/axial mode, sensitivity,
inversion, circularity correction, smoothing, response curve presets, with a live
raw-vs-processed preview and a draggable pad to simulate input
- Trigger tuning: deadzone, max activation, sensitivity, hair-trigger, digital mode,
inversion, response curve, with a live preview
- Motion page (gyro + vibration controls, hidden when unsupported)
- Input tester: live button/stick/trigger/gyro readout, poll rate estimate, and a
scrollable raw event log, drivable via a simulated controller
- Device information page
- Linux evdev discovery/capability probing and live raw input event streaming for
detected physical gamepads, with a simulated fallback for development
- Explicit **Apply / Revert / Save Profile** controls with unsaved-changes warnings
## Getting started (Linux Mint / Ubuntu-based)
### Prerequisites
```bash
# Rust toolchain
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# Tauri Linux system dependencies
sudo apt update
sudo apt install -y libwebkit2gtk-4.1-dev libjavascriptcoregtk-4.1-dev \
libsoup-3.0-dev libgtk-3-dev librsvg2-dev libudev-dev \
libayatana-appindicator3-dev libssl-dev build-essential curl wget file pkg-config
```
Node.js 20+ and npm are also required.
### Run in development
```bash
npm install
npm run tauri dev
```
### Build a release bundle
```bash
npm run tauri build
```
## Project structure
```
src/ React + TypeScript frontend
├── components/
│ ├── layout/ Sidebar, TopBar, AppLayout, PageShell
│ ├── controller/ Generic SVG controller diagram + hotspots
│ ├── inputs/ Slider, Toggle, Select, TextField, SimulatedStickPad
│ ├── feedback/ Toasts, confirm dialog, unsaved-changes bar
│ ├── visualizations/ Stick/trigger preview, event log
│ └── common/ Panel, Button, Badge, StatusDot
├── pages/ One component per sidebar destination
├── hooks/ useTheme, useController, useProfileInit, ...
├── services/ Tauri command wrappers + the mock controller backend
├── stores/ Zustand stores (controller, profile, settings, ui)
├── types/ Mirrors of the Rust profile/controller structs
├── utils/ Deadzone/curve math shared by every preview
└── constants/ Canonical control IDs, nav items
src-tauri/ Rust backend (Tauri 2)
├── src/
│ ├── commands/ Tauri command handlers (profiles, system)
│ ├── profile_schema.rs Versioned profile data model + migration
│ ├── profile_manager.rs Disk-backed profile CRUD
│ └── default_profiles.rs Built-in profile templates
```
## Roadmap
1. **Phase 1 — Shell & scaffolding** *(current)* — app shell, theming, profile
management, controller visualization, and a simulated controller backend so every
page is usable before real hardware is wired up.
2. **Phase 2 — Real device visibility**`device_detection`/`evdev_reader`, capability
probing, live Device Information + Input Tester pages.
3. **Phase 3 — Core remap pipeline**`uinput_writer`, the real `input_transform`
deadzone/sensitivity pipeline, and Apply/Revert/Save wired to the live pipeline.
4. **Phase 4 — Profiles in depth** — shortcut-combo/process-based profile switching,
the calibration wizard, and the custom curve point editor.
5. **Phase 5 — Advanced mapping & polish** — macros, shift layers, motion/vibration
against real hardware, diagnostics/support bundle, udev rules + setup script,
packaging, and the full Rust test suite.
## Permissions
The app never needs to run as root. A later phase adds a `udev` rule and setup script
that grant a dedicated group access to the controller's `evdev` nodes and `/dev/uinput`.
See [Troubleshooting](docs/TROUBLESHOOTING.md) and the detailed
[development roadmap](docs/ROADMAP.md) for permissions and hardware-mode guidance.

31
docs/ROADMAP.md Normal file
View File

@@ -0,0 +1,31 @@
# Development Roadmap
## Completed foundations
- Tauri + React/TypeScript desktop shell, themes, navigation, and accessibility
contrast mode.
- Versioned JSON profiles with built-in templates and import/export.
- Generic, non-copyrighted controller visualization.
- Real evdev controller enumeration/capability probing plus a safe simulated
fallback for development without hardware.
- Pure, tested stick/trigger response transformation math.
## Next engineering milestones
1. Connect the reader to the transform pipeline and a managed uinput virtual
controller with safe cleanup on disconnect/shutdown.
2. Finish advanced mappings (macros, turbo timing, shift layers, hold/toggle
semantics) in the output pipeline.
3. Complete calibration sampling and custom curve editing.
4. Add executable-based profile switching and the support-bundle exporter.
5. Validate hardware behavior across USB, Bluetooth, and receiver modes; build
distributable Linux packages.
## Safety principles
- No firmware changes.
- No undocumented vendor commands.
- No root application process.
- Never create a virtual output device unless requested and permission checks
pass.
- Release virtual controls on disconnect and application shutdown.

46
docs/TROUBLESHOOTING.md Normal file
View File

@@ -0,0 +1,46 @@
# Troubleshooting
## The controller is not listed
1. Confirm the controller is powered on and in an active input mode. An
8BitDo device shown by `lsusb` as **8BitDo IDLE** is not yet an evdev
controller and cannot produce input events.
2. Connect it over USB or pair it through a supported receiver/Bluetooth mode.
3. Check kernel discovery with `ls -l /dev/input/event*` and `evtest`.
4. Reopen the app or switch Simulation Mode off in Settings.
The app only reads Linux evdev capability metadata. It does not send
undocumented commands or update controller firmware.
## Permission denied for input or virtual controller
Install the local udev rule:
```bash
bash scripts/setup-linux.sh
```
Then log out and back in so your new `8bitdo` group membership takes effect.
Settings → Diagnostics reports whether `/dev/uinput` is writable.
## Duplicate or missing game input
Do not enable live remapping until the virtual controller pipeline is active
and the physical input device is safely grabbed. Until then, keep remapping
disabled to retain direct physical-controller input.
## No battery or firmware details
Those details are optional Linux input properties and may not be exposed by
every controller mode. This application never queries undocumented 8BitDo
protocols to obtain them.
## App will not build
Install development dependencies:
```bash
bash scripts/dev-deps.sh
npm install
npm run tauri dev
```

14
index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>8BitDo Control Center</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2885
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

35
package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "8bitdo-control-center",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.2",
"@tauri-apps/plugin-log": "^2.9.0",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-os": "^2.3.2",
"clsx": "^2.1.1",
"lucide-react": "^1.25.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.18.1",
"zustand": "^5.0.14"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@tauri-apps/cli": "^2",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"tailwindcss": "^4.3.3",
"typescript": "~5.8.3",
"vite": "^7.0.4"
}
}

6
public/tauri.svg Normal file
View File

@@ -0,0 +1,6 @@
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

1
public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,7 @@
# 8BitDo Control Center: grant the dedicated 8bitdo group access to matching
# physical input nodes and to uinput. The application never requires root.
#
# Install with scripts/setup-linux.sh, then log out and back in so new group
# membership takes effect.
KERNEL=="event*", SUBSYSTEM=="input", ATTRS{idVendor}=="2dc8", MODE="0660", GROUP="8bitdo", TAG+="uaccess"
KERNEL=="uinput", MODE="0660", GROUP="8bitdo", TAG+="uaccess"

14
scripts/dev-deps.sh Normal file
View File

@@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
# Linux Mint / Ubuntu developer dependencies for Tauri 2.
sudo apt update
sudo apt install -y \
build-essential curl file pkg-config \
libayatana-appindicator3-dev libgtk-3-dev libjavascriptcoregtk-4.1-dev \
librsvg2-dev libsoup-3.0-dev libssl-dev libudev-dev libwebkit2gtk-4.1-dev
if ! command -v cargo >/dev/null; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
echo 'Restart your shell, then run npm install.'
fi

View File

@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${PROJECT_DIR}"
if [[ -f "${HOME}/.cargo/env" ]]; then
# shellcheck disable=SC1091
source "${HOME}/.cargo/env"
fi
exec npm run tauri dev

32
scripts/setup-linux.sh Normal file
View File

@@ -0,0 +1,32 @@
#!/usr/bin/env bash
set -euo pipefail
# Installs the least-privilege access rule needed for evdev/uinput. This
# script intentionally changes host state only when the user runs it.
readonly GROUP_NAME="8bitdo"
readonly RULE_FILE="99-8bitdo-control-center.rules"
readonly PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
readonly SOURCE_RULE="${PROJECT_ROOT}/resources/udev/${RULE_FILE}"
readonly DESTINATION_RULE="/etc/udev/rules.d/${RULE_FILE}"
if [[ ! -f "${SOURCE_RULE}" ]]; then
echo "Missing udev rule: ${SOURCE_RULE}" >&2
exit 1
fi
if ! getent group "${GROUP_NAME}" >/dev/null; then
sudo groupadd --system "${GROUP_NAME}"
fi
sudo install -Dm644 "${SOURCE_RULE}" "${DESTINATION_RULE}"
sudo usermod -aG "${GROUP_NAME}" "${USER}"
sudo udevadm control --reload-rules
sudo udevadm trigger --subsystem-match=input
cat <<EOF
Installed ${DESTINATION_RULE}.
${USER} was added to the ${GROUP_NAME} group. Log out and back in (or reboot)
before starting 8BitDo Control Center, then verify permissions in Settings.
EOF

7
src-tauri/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas

5393
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

40
src-tauri/Cargo.toml Normal file
View File

@@ -0,0 +1,40 @@
[package]
name = "eightbitdo-control-center"
version = "0.1.0"
description = "Advanced Linux configuration utility for 8BitDo Ultimate controllers"
authors = ["8BitDo Control Center Contributors"]
edition = "2021"
rust-version = "1.77"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "eightbitdo_control_center_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
tauri-plugin-os = "2"
tauri-plugin-dialog = "2"
tauri-plugin-log = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
log = "0.4"
chrono = { version = "0.4", default-features = false, features = ["clock"] }
uuid = { version = "1", features = ["v4"] }
dirs = "6"
evdev = "0.13"
udev = "0.9"
sysinfo = "0.37"
[dev-dependencies]
tempfile = "3"

3
src-tauri/build.rs Normal file
View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@@ -0,0 +1,13 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"opener:default",
"os:default",
"dialog:default",
"log:default"
]
}

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,78 @@
//! Calibration sampling and recommendation helpers.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AxisRange {
pub minimum: f32,
pub maximum: f32,
pub neutral: f32,
}
impl Default for AxisRange {
fn default() -> Self {
Self {
minimum: 1.0,
maximum: -1.0,
neutral: 0.0,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct AxisSampler {
samples: Vec<f32>,
}
impl AxisSampler {
pub fn push(&mut self, value: f32) {
self.samples.push(value.clamp(-1.0, 1.0));
}
pub fn range(&self) -> Option<AxisRange> {
let first = *self.samples.first()?;
let minimum = self.samples.iter().copied().fold(first, f32::min);
let maximum = self.samples.iter().copied().fold(first, f32::max);
let neutral = self.samples.iter().copied().sum::<f32>() / self.samples.len() as f32;
Some(AxisRange {
minimum,
maximum,
neutral,
})
}
}
/// Converts measured neutral drift into a conservative recommended inner
/// deadzone. The buffer avoids calibration noise causing drift in-game.
pub fn recommended_inner_deadzone(neutral_x: f32, neutral_y: f32) -> f32 {
let drift = (neutral_x * neutral_x + neutral_y * neutral_y).sqrt();
(drift + 0.02).clamp(0.03, 0.25)
}
pub fn recommended_outer_deadzone(minimum: f32, maximum: f32) -> f32 {
let coverage = minimum.abs().max(maximum.abs());
if coverage <= f32::EPSILON {
return 1.0;
}
(1.0 / coverage).clamp(0.85, 1.0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sampler_reports_range_and_neutral() {
let mut sampler = AxisSampler::default();
for value in [-0.9, -0.1, 0.1, 0.8] {
sampler.push(value);
}
let range = sampler.range().expect("range");
assert_eq!(range.minimum, -0.9);
assert_eq!(range.maximum, 0.8);
assert!((range.neutral + 0.025).abs() < 0.001);
}
#[test]
fn deadzone_has_a_safe_floor() {
assert_eq!(recommended_inner_deadzone(0.0, 0.0), 0.03);
}
}

View File

@@ -0,0 +1,24 @@
use crate::device_detection::ControllerInfo;
use crate::error::AppResult;
use crate::state::AppState;
/// Enumerates controllers currently exposed by Linux evdev.
///
/// Device access failures are intentionally omitted from this result rather
/// than crashing the app: the permissions diagnostics will explain why an
/// event node could not be opened.
#[tauri::command]
pub fn list_devices() -> Vec<ControllerInfo> {
crate::device_detection::enumerate_controllers()
}
/// Begins streaming raw evdev input events for a selected physical device.
/// The reader is observation-only until the later uinput pipeline phase.
#[tauri::command]
pub fn start_device_reader(
app: tauri::AppHandle,
state: tauri::State<'_, AppState>,
device_path: String,
) -> AppResult<()> {
crate::evdev_reader::start(app, state, device_path)
}

View File

@@ -0,0 +1,27 @@
use serde::Serialize;
use crate::device_detection::ControllerInfo;
use crate::permissions::PermissionStatus;
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DiagnosticsSnapshot {
pub app_version: String,
pub operating_system: String,
pub kernel_version: Option<String>,
pub permissions: PermissionStatus,
pub devices: Vec<ControllerInfo>,
pub profile_schema_version: u32,
}
#[tauri::command]
pub fn diagnostics_snapshot(app: tauri::AppHandle) -> DiagnosticsSnapshot {
DiagnosticsSnapshot {
app_version: app.package_info().version.to_string(),
operating_system: std::env::consts::OS.to_string(),
kernel_version: sysinfo::System::kernel_version(),
permissions: crate::permissions::check_permissions(),
devices: crate::device_detection::enumerate_controllers(),
profile_schema_version: crate::profile_schema::CURRENT_SCHEMA_VERSION,
}
}

View File

@@ -0,0 +1,150 @@
use std::fs::OpenOptions;
use std::io::Write;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::error::{AppError, AppResult};
use crate::state::AppState;
fn debug_log(hypothesis_id: &str, message: &str, data: serde_json::Value) {
let entry = serde_json::json!({
"sessionId": "2801e0",
"runId": "initial",
"hypothesisId": hypothesis_id,
"location": "src-tauri/src/commands/mapping.rs",
"message": message,
"data": data,
"timestamp": SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis(),
});
match OpenOptions::new()
.create(true)
.append(true)
.open("/home/notdave/Documents/Code Projects/Apps/Linux 8bitdo ultimate interface/.cursor/debug-2801e0.log")
{
Ok(mut file) => {
if let Err(error) = writeln!(file, "{entry}") {
// #region agent log
eprintln!("H13 debug log write failed: {error}");
// #endregion
}
}
Err(error) => {
// #region agent log
eprintln!("H13 debug log open failed: {error}");
// #endregion
}
}
}
pub fn log_remapping_pipeline_startup() {
// #region agent log
debug_log(
"H12",
"Native remapping pipeline initialized",
serde_json::json!({}),
);
// #endregion
}
/// Creates a managed virtual controller after a deliberate user action.
/// Event forwarding remains disabled until the physical device is safely
/// grabbed by the pipeline; this avoids accidental duplicate game input.
#[tauri::command]
pub fn create_virtual_controller(state: tauri::State<'_, AppState>) -> AppResult<()> {
// #region agent log
debug_log(
"H9",
"Virtual controller creation requested",
serde_json::json!({}),
);
// #endregion
let mut virtual_controller = state
.virtual_controller
.lock()
.map_err(|_| AppError::Other("virtual controller state lock poisoned".to_string()))?;
if virtual_controller.is_none() {
*virtual_controller = Some(crate::uinput_writer::create_virtual_controller()?);
}
Ok(())
}
#[tauri::command]
pub fn destroy_virtual_controller(state: tauri::State<'_, AppState>) -> AppResult<()> {
// #region agent log
debug_log(
"H9",
"Virtual controller destruction requested",
serde_json::json!({}),
);
// #endregion
let mut virtual_controller = state
.virtual_controller
.lock()
.map_err(|_| AppError::Other("virtual controller state lock poisoned".to_string()))?;
// Dropping VirtualDevice destroys the uinput device and releases all its
// state, including held buttons.
*virtual_controller = None;
Ok(())
}
#[tauri::command]
pub fn virtual_controller_active(state: tauri::State<'_, AppState>) -> AppResult<bool> {
let virtual_controller = state
.virtual_controller
.lock()
.map_err(|_| AppError::Other("virtual controller state lock poisoned".to_string()))?;
Ok(virtual_controller.is_some())
}
/// Stores the active profile used by the event-forwarding pipeline.
#[tauri::command]
pub fn apply_remapping_profile(
state: tauri::State<'_, AppState>,
profile: crate::profile_schema::Profile,
) -> AppResult<()> {
// #region agent log
debug_log(
"H10",
"Remapping profile received by native pipeline",
serde_json::json!({
"profileId": profile.id,
"leftSensitivity": profile.left_trigger.sensitivity,
"rightSensitivity": profile.right_trigger.sensitivity,
}),
);
// #endregion
let mut active_profile = state
.active_remap_profile
.lock()
.map_err(|_| AppError::Other("remapping profile state lock poisoned".to_string()))?;
*active_profile = Some(profile);
Ok(())
}
/// Enables forwarding through a uinput controller. The reader exclusively
/// grabs the physical device on its next event to avoid duplicate game input.
#[tauri::command]
pub fn set_remapping_enabled(state: tauri::State<'_, AppState>, enabled: bool) -> AppResult<()> {
if enabled {
let mut virtual_controller = state
.virtual_controller
.lock()
.map_err(|_| AppError::Other("virtual controller state lock poisoned".to_string()))?;
if virtual_controller.is_none() {
*virtual_controller = Some(crate::uinput_writer::create_virtual_controller()?);
}
}
let mut remapping_enabled = state
.remapping_enabled
.lock()
.map_err(|_| AppError::Other("remapping state lock poisoned".to_string()))?;
*remapping_enabled = enabled;
// #region agent log
debug_log(
"H10",
"Native remapping state changed",
serde_json::json!({"enabled": enabled}),
);
// #endregion
Ok(())
}

View File

@@ -0,0 +1,6 @@
pub mod devices;
pub mod diagnostics;
pub mod mapping;
pub mod processes;
pub mod profiles;
pub mod system;

View File

@@ -0,0 +1,7 @@
use crate::error::AppResult;
#[tauri::command]
pub fn detected_game_profile() -> AppResult<Option<String>> {
let profiles = crate::profile_manager::load_all_profiles()?;
Ok(crate::process_monitor::matching_profile_id(&profiles))
}

View File

@@ -0,0 +1,60 @@
use crate::error::AppResult;
use crate::profile_manager;
use crate::profile_schema::{Profile, ProfileSummary};
#[tauri::command]
pub fn list_profiles() -> AppResult<Vec<ProfileSummary>> {
profile_manager::list_profiles()
}
#[tauri::command]
pub fn load_profile(id: String) -> AppResult<Profile> {
profile_manager::load_profile(&id)
}
#[tauri::command]
pub fn save_profile(profile: Profile) -> AppResult<Profile> {
profile_manager::save_profile(profile)
}
#[tauri::command]
pub fn create_profile(name: String) -> AppResult<Profile> {
profile_manager::create_profile(&name)
}
#[tauri::command]
pub fn rename_profile(id: String, new_name: String) -> AppResult<Profile> {
profile_manager::rename_profile(&id, &new_name)
}
#[tauri::command]
pub fn duplicate_profile(id: String, new_name: String) -> AppResult<Profile> {
profile_manager::duplicate_profile(&id, &new_name)
}
#[tauri::command]
pub fn delete_profile(id: String) -> AppResult<()> {
profile_manager::delete_profile(&id)
}
#[tauri::command]
pub fn reset_profile(id: String) -> AppResult<Profile> {
profile_manager::reset_profile(&id)
}
#[tauri::command]
pub fn export_profile(id: String, destination: String) -> AppResult<()> {
profile_manager::export_profile(&id, &destination)
}
#[tauri::command]
pub fn import_profile(source: String) -> AppResult<Profile> {
profile_manager::import_profile(&source)
}
#[tauri::command]
pub fn profiles_directory() -> String {
profile_manager::profiles_dir()
.to_string_lossy()
.into_owned()
}

View File

@@ -0,0 +1,21 @@
use serde::Serialize;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AppInfo {
pub name: String,
pub version: String,
pub config_dir: String,
}
#[tauri::command]
pub fn get_app_info(app: tauri::AppHandle) -> AppInfo {
let package_info = app.package_info();
AppInfo {
name: package_info.name.clone(),
version: package_info.version.to_string(),
config_dir: crate::profile_manager::config_dir()
.to_string_lossy()
.into_owned(),
}
}

View File

@@ -0,0 +1,116 @@
//! Built-in profile templates seeded on first run and available via
//! "reset to defaults".
use std::collections::HashMap;
use crate::profile_schema::{
CurveConfig, CurvePreset, Profile, StickSettings, TriggerResponse, TriggerSettings,
};
fn now_iso() -> String {
chrono::Utc::now().to_rfc3339()
}
fn base_profile(id: &str, name: &str, description: &str) -> Profile {
let ts = now_iso();
Profile {
schema_version: crate::profile_schema::CURRENT_SCHEMA_VERSION,
id: id.to_string(),
name: name.to_string(),
description: description.to_string(),
is_built_in: true,
created_at: ts.clone(),
updated_at: ts,
assigned_executables: Vec::new(),
shortcut_combo: Vec::new(),
button_mappings: HashMap::new(),
left_stick: StickSettings::default(),
right_stick: StickSettings::default(),
left_trigger: TriggerSettings::default(),
right_trigger: TriggerSettings::default(),
motion: Default::default(),
shift_layers: Vec::new(),
}
}
pub fn built_in_profiles() -> Vec<Profile> {
vec![
default_profile(),
fps_profile(),
racing_profile(),
platformer_profile(),
custom_profile(),
]
}
pub fn default_profile() -> Profile {
base_profile(
"default",
"Default",
"Balanced settings that match the controller's factory behavior.",
)
}
pub fn fps_profile() -> Profile {
let mut p = base_profile(
"fps",
"FPS",
"Precision-tuned aim: tight deadzones, exponential right-stick curve, hair-trigger shots.",
);
p.left_stick.inner_deadzone = 0.06;
p.left_stick.response_curve = CurveConfig {
preset: CurvePreset::Precision,
..CurveConfig::default()
};
p.right_stick.inner_deadzone = 0.05;
p.right_stick.sensitivity_x = 0.85;
p.right_stick.sensitivity_y = 0.85;
p.right_stick.response_curve = CurveConfig {
preset: CurvePreset::Exponential,
..CurveConfig::default()
};
p.right_trigger.hair_trigger = true;
p.right_trigger.max_activation = 0.6;
p
}
pub fn racing_profile() -> Profile {
let mut p = base_profile(
"racing",
"Racing",
"Progressive trigger response for throttle/brake and smoothed steering.",
);
p.left_stick.smoothing = 0.15;
p.left_stick.response_curve = CurveConfig {
preset: CurvePreset::Smooth,
..CurveConfig::default()
};
p.left_trigger.response = TriggerResponse::Progressive;
p.right_trigger.response = TriggerResponse::Progressive;
p.left_trigger.sensitivity = 1.2;
p.right_trigger.sensitivity = 1.2;
p
}
pub fn platformer_profile() -> Profile {
let mut p = base_profile(
"platformer",
"Platformer",
"Snappy digital-feeling triggers and an aggressive stick curve for quick platforming input.",
);
p.left_stick.response_curve = CurveConfig {
preset: CurvePreset::Aggressive,
..CurveConfig::default()
};
p.left_trigger.digital_mode = true;
p.right_trigger.digital_mode = true;
p
}
pub fn custom_profile() -> Profile {
base_profile(
"custom",
"Custom",
"A blank slate for your own configuration.",
)
}

View File

@@ -0,0 +1,240 @@
//! Safe Linux controller discovery.
//!
//! This module only queries kernel-provided evdev metadata and capability
//! bitsets. It never sends vendor commands or accesses firmware protocols.
use std::path::Path;
use std::{
fs::OpenOptions,
io::Write,
time::{SystemTime, UNIX_EPOCH},
};
use evdev::{AbsoluteAxisCode, Device, KeyCode};
use serde::Serialize;
const EIGHTBITDO_VENDOR_ID: u16 = 0x2dc8;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ButtonCapability {
pub id: String,
pub label: String,
pub group: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AxisCapability {
pub id: String,
pub label: String,
pub group: String,
pub min: i32,
pub max: i32,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DeviceCapabilities {
pub vendor_id: u16,
pub product_id: u16,
pub name: String,
pub buttons: Vec<ButtonCapability>,
pub axes: Vec<AxisCapability>,
pub paddle_count: u8,
pub has_gyro: bool,
pub has_rumble: bool,
pub has_battery: bool,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ControllerInfo {
pub id: String,
pub name: String,
pub connection: String,
pub device_path: String,
pub vendor_id: u16,
pub product_id: u16,
pub mode: String,
pub battery_percent: Option<u8>,
pub firmware_version: Option<String>,
pub virtual_controller_active: bool,
pub capabilities: DeviceCapabilities,
pub is_simulated: bool,
}
fn known_name(vendor: u16, product: u16, fallback: &str) -> String {
match (vendor, product) {
(0x2dc8, 0x3106) => "8BitDo Ultimate Wireless / Pro 2 Wired Controller".to_string(),
(0x2dc8, 0x3109) => "8BitDo IDLE".to_string(),
_ => fallback.to_string(),
}
}
fn axis_definition(axis: AbsoluteAxisCode) -> Option<(&'static str, &'static str, &'static str)> {
match axis {
AbsoluteAxisCode::ABS_X => Some(("left_stick_x", "Left Stick X", "stick")),
AbsoluteAxisCode::ABS_Y => Some(("left_stick_y", "Left Stick Y", "stick")),
AbsoluteAxisCode::ABS_RX => Some(("right_stick_x", "Right Stick X", "stick")),
AbsoluteAxisCode::ABS_RY => Some(("right_stick_y", "Right Stick Y", "stick")),
AbsoluteAxisCode::ABS_Z => Some(("left_trigger", "Left Trigger", "trigger")),
AbsoluteAxisCode::ABS_RZ => Some(("right_trigger", "Right Trigger", "trigger")),
AbsoluteAxisCode::ABS_HAT0X => Some(("dpad_x", "D-Pad X", "stick")),
AbsoluteAxisCode::ABS_HAT0Y => Some(("dpad_y", "D-Pad Y", "stick")),
_ => None,
}
}
fn button_definition(key: KeyCode) -> Option<(&'static str, &'static str, &'static str)> {
match key {
KeyCode::BTN_SOUTH => Some(("a", "A", "face")),
KeyCode::BTN_EAST => Some(("b", "B", "face")),
KeyCode::BTN_WEST => Some(("x", "X", "face")),
KeyCode::BTN_NORTH => Some(("y", "Y", "face")),
KeyCode::BTN_TL => Some(("l1", "L1", "shoulder")),
KeyCode::BTN_TR => Some(("r1", "R1", "shoulder")),
KeyCode::BTN_TL2 => Some(("l2", "L2", "trigger")),
KeyCode::BTN_TR2 => Some(("r2", "R2", "trigger")),
KeyCode::BTN_THUMBL => Some(("l3", "Left Stick Click", "stick")),
KeyCode::BTN_THUMBR => Some(("r3", "Right Stick Click", "stick")),
KeyCode::BTN_SELECT => Some(("select", "Select", "system")),
KeyCode::BTN_START => Some(("start", "Start", "system")),
KeyCode::BTN_MODE => Some(("guide", "Guide", "system")),
_ => None,
}
}
fn is_gamepad(device: &Device) -> bool {
let has_gamepad_button = device
.supported_keys()
.is_some_and(|keys| keys.contains(KeyCode::BTN_SOUTH) || keys.contains(KeyCode::BTN_EAST));
let has_stick_axis = device.supported_absolute_axes().is_some_and(|axes| {
axes.contains(AbsoluteAxisCode::ABS_X) && axes.contains(AbsoluteAxisCode::ABS_Y)
});
has_gamepad_button && has_stick_axis
}
fn connection_for(path: &Path) -> String {
let udev_path = udev::Device::from_subsystem_sysname(
"input".to_string(),
path.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default()
.to_string(),
);
if let Ok(device) = udev_path {
let mut properties = device.properties();
if let Some(property) = properties.find(|property| property.name() == "ID_BUS") {
return match property.value().to_string_lossy().as_ref() {
"bluetooth" => "bluetooth".to_string(),
"usb" => "usb".to_string(),
_ => "unknown".to_string(),
};
}
}
"unknown".to_string()
}
pub fn probe_device(path: &Path, device: &Device) -> Option<ControllerInfo> {
if !is_gamepad(device) {
return None;
}
let raw_id = device.input_id();
let kernel_id = raw_id.as_ref();
let vendor_id = kernel_id.vendor;
let product_id = kernel_id.product;
let fallback_name = device.name().unwrap_or("Unknown Controller");
let name = known_name(vendor_id, product_id, fallback_name);
let buttons = device
.supported_keys()
.into_iter()
.flat_map(|keys| keys.iter())
.filter_map(button_definition)
.map(|(id, label, group)| ButtonCapability {
id: id.to_string(),
label: label.to_string(),
group: group.to_string(),
})
.collect::<Vec<_>>();
let axes = device
.supported_absolute_axes()
.into_iter()
.flat_map(|axes| axes.iter())
.filter_map(|axis| {
let (id, label, group) = axis_definition(axis)?;
let abs = device
.get_absinfo()
.ok()?
.find(|(code, _)| *code == axis)?
.1;
Some(AxisCapability {
id: id.to_string(),
label: label.to_string(),
group: group.to_string(),
min: abs.as_ref().minimum,
max: abs.as_ref().maximum,
})
})
.collect::<Vec<_>>();
let has_rumble = device
.supported_ff()
.is_some_and(|ff| ff.iter().next().is_some());
let is_eightbitdo = vendor_id == EIGHTBITDO_VENDOR_ID;
let path_text = path.to_string_lossy().into_owned();
Some(ControllerInfo {
id: path_text.clone(),
name,
connection: connection_for(path),
device_path: path_text,
vendor_id,
product_id,
mode: if is_eightbitdo {
"xinput".to_string()
} else {
"unknown".to_string()
},
battery_percent: None,
firmware_version: None,
virtual_controller_active: false,
capabilities: DeviceCapabilities {
vendor_id,
product_id,
name: fallback_name.to_string(),
paddle_count: 0,
has_gyro: false,
has_rumble,
has_battery: false,
buttons,
axes,
},
is_simulated: false,
})
}
pub fn enumerate_controllers() -> Vec<ControllerInfo> {
let mut controllers = evdev::enumerate()
.filter_map(|(path, device)| probe_device(&path, &device))
.collect::<Vec<_>>();
controllers.sort_by(|a, b| a.name.cmp(&b.name));
// #region agent log
let entry = serde_json::json!({
"sessionId": "2801e0",
"runId": "initial",
"hypothesisId": "H4",
"location": "src-tauri/src/device_detection.rs:enumerate_controllers",
"message": "Controller discovery completed",
"data": {"count": controllers.len(), "controllers": controllers.iter().map(|controller| serde_json::json!({"name": controller.name, "connection": controller.connection, "path": controller.device_path, "vendorId": controller.vendor_id, "productId": controller.product_id})).collect::<Vec<_>>()},
"timestamp": SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis(),
});
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open("/home/notdave/Documents/Code Projects/Apps/Linux 8bitdo ultimate interface/.cursor/debug-2801e0.log") {
let _ = writeln!(file, "{entry}");
}
// #endregion
controllers
}

40
src-tauri/src/error.rs Normal file
View File

@@ -0,0 +1,40 @@
use serde::{Serialize, Serializer};
/// Application-wide error type. Every fallible Tauri command returns this so
/// the frontend always receives a structured, serializable error instead of
/// an opaque string.
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("profile not found: {0}")]
ProfileNotFound(String),
#[error("a profile named \"{0}\" already exists")]
ProfileNameConflict(String),
#[error("invalid profile data: {0}")]
InvalidProfile(String),
#[error("unsupported profile schema version: {0}")]
UnsupportedSchemaVersion(u32),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
#[error("{0}")]
#[allow(dead_code)]
Other(String),
}
impl Serialize for AppError {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
pub type AppResult<T> = Result<T, AppError>;

View File

@@ -0,0 +1,349 @@
//! Isolated evdev reader for forwarding physical input to the UI event bus.
//!
//! This reads only. Remapping and uinput output deliberately happen in the
//! later transform pipeline, so observing a device cannot change controller
//! behavior.
use std::collections::HashMap;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use std::time::{SystemTime, UNIX_EPOCH};
use evdev::{AbsoluteAxisCode, AbsoluteAxisEvent, Device, EventSummary, InputEvent, KeyCode};
use serde::Serialize;
use tauri::{AppHandle, Emitter, Manager};
use crate::error::{AppError, AppResult};
use crate::state::AppState;
pub const INPUT_EVENT_NAME: &str = "controller-input";
pub const READER_STATUS_EVENT_NAME: &str = "controller-reader-status";
static LEFT_TRIGGER_OUTPUT_LOGGED: AtomicBool = AtomicBool::new(false);
static RIGHT_TRIGGER_OUTPUT_LOGGED: AtomicBool = AtomicBool::new(false);
fn debug_log(hypothesis_id: &str, message: &str, data: serde_json::Value) {
let entry = serde_json::json!({
"sessionId": "2801e0",
"runId": "initial",
"hypothesisId": hypothesis_id,
"location": "src-tauri/src/evdev_reader.rs",
"message": message,
"data": data,
"timestamp": timestamp_ms(),
});
if let Ok(mut file) = OpenOptions::new()
.create(true)
.append(true)
.open("/home/notdave/Documents/Code Projects/Apps/Linux 8bitdo ultimate interface/.cursor/debug-2801e0.log")
{
let _ = writeln!(file, "{entry}");
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct InputEventPayload {
pub timestamp: u128,
pub event_type: String,
pub code: String,
pub raw_value: f64,
pub processed_value: f64,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ReaderStatusPayload {
pub device_path: String,
pub connected: bool,
pub message: Option<String>,
}
fn timestamp_ms() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
}
fn key_id(key: KeyCode) -> String {
match key {
KeyCode::BTN_SOUTH => "a".to_string(),
KeyCode::BTN_EAST => "b".to_string(),
KeyCode::BTN_WEST => "x".to_string(),
KeyCode::BTN_NORTH => "y".to_string(),
KeyCode::BTN_TL => "l1".to_string(),
KeyCode::BTN_TR => "r1".to_string(),
KeyCode::BTN_TL2 => "l2".to_string(),
KeyCode::BTN_TR2 => "r2".to_string(),
KeyCode::BTN_THUMBL => "l3".to_string(),
KeyCode::BTN_THUMBR => "r3".to_string(),
KeyCode::BTN_SELECT => "select".to_string(),
KeyCode::BTN_START => "start".to_string(),
KeyCode::BTN_MODE => "guide".to_string(),
_ => format!("{key:?}").to_lowercase(),
}
}
fn axis_id(axis: AbsoluteAxisCode) -> String {
match axis {
AbsoluteAxisCode::ABS_X => "left_stick_x".to_string(),
AbsoluteAxisCode::ABS_Y => "left_stick_y".to_string(),
AbsoluteAxisCode::ABS_RX => "right_stick_x".to_string(),
AbsoluteAxisCode::ABS_RY => "right_stick_y".to_string(),
AbsoluteAxisCode::ABS_Z => "left_trigger".to_string(),
AbsoluteAxisCode::ABS_RZ => "right_trigger".to_string(),
AbsoluteAxisCode::ABS_HAT0X => "dpad_x".to_string(),
AbsoluteAxisCode::ABS_HAT0Y => "dpad_y".to_string(),
_ => format!("{axis:?}").to_lowercase(),
}
}
fn normalized(value: i32, minimum: i32, maximum: i32) -> f64 {
if maximum <= minimum {
return 0.0;
}
let relative = (value - minimum) as f64 / (maximum - minimum) as f64;
// Trigger axes conventionally range from 0..1; centred stick axes range
// from -1..1. This works with non-standard ranges too.
if minimum < 0 {
(relative * 2.0 - 1.0).clamp(-1.0, 1.0)
} else {
relative.clamp(0.0, 1.0)
}
}
fn remapping_enabled(app: &AppHandle) -> AppResult<bool> {
app.state::<AppState>()
.remapping_enabled
.lock()
.map(|enabled| *enabled)
.map_err(|_| AppError::Other("remapping state lock poisoned".to_string()))
}
fn forward_event(
app: &AppHandle,
event: InputEvent,
axis_ranges: &HashMap<AbsoluteAxisCode, (i32, i32)>,
) -> AppResult<()> {
let profile = app
.state::<AppState>()
.active_remap_profile
.lock()
.map_err(|_| AppError::Other("remapping profile state lock poisoned".to_string()))?
.clone();
let Some(profile) = profile else {
return Ok(());
};
let output = match event.destructure() {
EventSummary::AbsoluteAxis(_, AbsoluteAxisCode::ABS_Z, value) => {
let (minimum, maximum) = axis_ranges
.get(&AbsoluteAxisCode::ABS_Z)
.copied()
.unwrap_or((0, 255));
let raw = normalized(value, minimum, maximum) as f32;
let transformed = crate::input_transform::transform_trigger(raw, &profile.left_trigger);
if !LEFT_TRIGGER_OUTPUT_LOGGED.swap(true, Ordering::Relaxed) {
// #region agent log
debug_log(
"H11",
"Forwarded transformed left trigger",
serde_json::json!({"raw": raw, "output": transformed, "outputValue": (transformed * 255.0).round() as i32}),
);
// #endregion
}
*AbsoluteAxisEvent::new(
AbsoluteAxisCode::ABS_Z,
(transformed * 255.0).round() as i32,
)
}
EventSummary::AbsoluteAxis(_, AbsoluteAxisCode::ABS_RZ, value) => {
let (minimum, maximum) = axis_ranges
.get(&AbsoluteAxisCode::ABS_RZ)
.copied()
.unwrap_or((0, 255));
let raw = normalized(value, minimum, maximum) as f32;
let transformed = crate::input_transform::transform_trigger(raw, &profile.right_trigger);
if !RIGHT_TRIGGER_OUTPUT_LOGGED.swap(true, Ordering::Relaxed) {
// #region agent log
debug_log(
"H11",
"Forwarded transformed right trigger",
serde_json::json!({"raw": raw, "output": transformed, "outputValue": (transformed * 255.0).round() as i32}),
);
// #endregion
}
*AbsoluteAxisEvent::new(
AbsoluteAxisCode::ABS_RZ,
(transformed * 255.0).round() as i32,
)
}
EventSummary::AbsoluteAxis(
_,
axis @ (AbsoluteAxisCode::ABS_X
| AbsoluteAxisCode::ABS_Y
| AbsoluteAxisCode::ABS_RX
| AbsoluteAxisCode::ABS_RY),
value,
) => *AbsoluteAxisEvent::new(axis, value),
EventSummary::Key(_, _, _) => event,
_ => return Ok(()),
};
let state = app.state::<AppState>();
let mut virtual_controller = state
.virtual_controller
.lock()
.map_err(|_| AppError::Other("virtual controller state lock poisoned".to_string()))?;
let Some(virtual_controller) = virtual_controller.as_mut() else {
return Ok(());
};
virtual_controller.emit(&[output]).map_err(AppError::Io)
}
pub fn start(
app: AppHandle,
state: tauri::State<'_, AppState>,
device_path: String,
) -> AppResult<()> {
{
let mut readers = state
.active_readers
.lock()
.map_err(|_| AppError::Other("device reader state lock poisoned".to_string()))?;
if !readers.insert(device_path.clone()) {
// #region agent log
debug_log(
"H2",
"Reader start skipped because path is already active",
serde_json::json!({"devicePath": device_path}),
);
// #endregion
return Ok(());
}
}
// #region agent log
debug_log(
"H2",
"Reader start requested",
serde_json::json!({"devicePath": device_path}),
);
// #endregion
thread::Builder::new()
.name(format!("evdev-reader:{device_path}"))
.spawn(move || {
let result = read_loop(&app, &device_path);
if let Err(error) = result {
// #region agent log
debug_log(
"H2",
"Reader loop ended with error",
serde_json::json!({"devicePath": device_path, "error": error.to_string()}),
);
// #endregion
log::warn!("evdev reader for {device_path} stopped: {error}");
if let Ok(mut readers) = app.state::<AppState>().active_readers.lock() {
readers.remove(&device_path);
}
let _ = app.emit(
READER_STATUS_EVENT_NAME,
ReaderStatusPayload {
device_path: device_path.clone(),
connected: false,
message: Some(error.to_string()),
},
);
return;
}
if let Ok(mut readers) = app.state::<AppState>().active_readers.lock() {
readers.remove(&device_path);
}
})
.map_err(AppError::Io)?;
Ok(())
}
fn read_loop(app: &AppHandle, device_path: &str) -> AppResult<()> {
let mut device = Device::open(Path::new(device_path)).map_err(AppError::Io)?;
let axis_ranges = device
.get_absinfo()
.map_err(AppError::Io)?
.map(|(axis, info)| (axis, (info.as_ref().minimum, info.as_ref().maximum)))
.collect::<HashMap<_, _>>();
// #region agent log
debug_log(
"H2",
"Reader opened device",
serde_json::json!({"devicePath": device_path, "axisRangeCount": axis_ranges.len()}),
);
// #endregion
let _ = app.emit(
READER_STATUS_EVENT_NAME,
ReaderStatusPayload {
device_path: device_path.to_string(),
connected: true,
message: None,
},
);
let mut grabbed = false;
loop {
let enabled = remapping_enabled(app)?;
if enabled != grabbed {
if enabled {
device.grab().map_err(AppError::Io)?;
// #region agent log
debug_log(
"H10",
"Physical controller exclusively grabbed for remapping",
serde_json::json!({"devicePath": device_path}),
);
// #endregion
} else {
device.ungrab().map_err(AppError::Io)?;
// #region agent log
debug_log(
"H10",
"Physical controller released from remapping",
serde_json::json!({"devicePath": device_path}),
);
// #endregion
}
grabbed = enabled;
}
let events = device.fetch_events().map_err(AppError::Io)?;
for event in events {
if grabbed {
forward_event(app, event, &axis_ranges)?;
}
let payload = match event.destructure() {
EventSummary::Key(_, key, value) => InputEventPayload {
timestamp: timestamp_ms(),
event_type: "button".to_string(),
code: key_id(key),
raw_value: f64::from(value),
processed_value: if value == 0 { 0.0 } else { 1.0 },
},
EventSummary::AbsoluteAxis(_, axis, value) => {
let (minimum, maximum) = axis_ranges.get(&axis).copied().unwrap_or((-1, 1));
InputEventPayload {
timestamp: timestamp_ms(),
event_type: "axis".to_string(),
code: axis_id(axis),
raw_value: f64::from(value),
processed_value: normalized(value, minimum, maximum),
}
}
_ => continue,
};
app.emit(INPUT_EVENT_NAME, payload)
.map_err(|error| AppError::Other(error.to_string()))?;
}
}
}

View File

@@ -0,0 +1,211 @@
//! Pure input transformation primitives.
//!
//! Kept separate from evdev/uinput so it can be tested deterministically and
//! reused by the live pipeline. The frontend mirrors this math for previews;
//! the Rust implementation is the authoritative output path.
use crate::profile_schema::{
CurveConfig, CurvePreset, DeadzoneMode, StickSettings, TriggerResponse, TriggerSettings,
};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct StickValue {
pub x: f32,
pub y: f32,
}
impl StickValue {
fn magnitude(self) -> f32 {
(self.x * self.x + self.y * self.y).sqrt()
}
}
fn clamp(value: f32, min: f32, max: f32) -> f32 {
value.clamp(min, max)
}
pub fn evaluate_curve(curve: &CurveConfig, input: f32) -> f32 {
let t = clamp(input, 0.0, 1.0);
match curve.preset {
CurvePreset::Linear => t,
CurvePreset::Precision => t.powf(1.8),
CurvePreset::Aggressive => t.powf(0.55),
CurvePreset::Smooth => t * t * (3.0 - 2.0 * t),
CurvePreset::Exponential => t.powf(2.5),
CurvePreset::Custom => {
let mut points = curve.custom_points.clone();
points.sort_by(|a, b| a.x.total_cmp(&b.x));
let Some(first) = points.first() else {
return t;
};
if t <= first.x {
return first.y;
}
let Some(last) = points.last() else {
return t;
};
if t >= last.x {
return last.y;
}
points
.windows(2)
.find_map(|pair| {
let a = pair[0];
let b = pair[1];
if (a.x..=b.x).contains(&t) {
let span = (b.x - a.x).max(f32::EPSILON);
let local_t = (t - a.x) / span;
Some(a.y + (b.y - a.y) * local_t)
} else {
None
}
})
.unwrap_or(t)
}
}
}
pub fn transform_stick(raw: StickValue, settings: &StickSettings) -> StickValue {
let mut value = raw;
let raw_magnitude = value.magnitude();
if settings.circularity_correction && raw_magnitude > 1.0 {
value.x /= raw_magnitude;
value.y /= raw_magnitude;
}
value = match settings.deadzone_mode {
DeadzoneMode::Radial => transform_radial(value, settings),
DeadzoneMode::Axial => StickValue {
x: transform_axis(value.x, settings),
y: transform_axis(value.y, settings),
},
};
let magnitude = value.magnitude();
if magnitude > 0.0 {
let curved = evaluate_curve(&settings.response_curve, magnitude);
let scale = curved / magnitude;
value.x *= scale;
value.y *= scale;
}
value.x = clamp(value.x * settings.sensitivity_x, -1.0, 1.0);
value.y = clamp(value.y * settings.sensitivity_y, -1.0, 1.0);
if settings.invert_x {
value.x = -value.x;
}
if settings.invert_y {
value.y = -value.y;
}
value
}
fn transform_radial(value: StickValue, settings: &StickSettings) -> StickValue {
let magnitude = value.magnitude();
if magnitude <= settings.inner_deadzone {
return StickValue { x: 0.0, y: 0.0 };
}
let outer = settings
.outer_deadzone
.max(settings.inner_deadzone + f32::EPSILON);
let normalized = clamp(
(magnitude - settings.inner_deadzone) / (outer - settings.inner_deadzone),
0.0,
1.0,
);
let output_magnitude = settings.anti_deadzone + normalized * (1.0 - settings.anti_deadzone);
let scale = output_magnitude / magnitude;
StickValue {
x: value.x * scale,
y: value.y * scale,
}
}
fn transform_axis(value: f32, settings: &StickSettings) -> f32 {
let abs = value.abs();
if abs <= settings.inner_deadzone {
return 0.0;
}
let outer = settings
.outer_deadzone
.max(settings.inner_deadzone + f32::EPSILON);
let normalized = clamp(
(abs - settings.inner_deadzone) / (outer - settings.inner_deadzone),
0.0,
1.0,
);
value.signum() * (settings.anti_deadzone + normalized * (1.0 - settings.anti_deadzone))
}
pub fn transform_trigger(raw: f32, settings: &TriggerSettings) -> f32 {
let raw = clamp(raw, 0.0, 1.0);
let oriented = if settings.invert { 1.0 - raw } else { raw };
if oriented <= settings.deadzone {
return 0.0;
}
let maximum = settings
.max_activation
.max(settings.deadzone + f32::EPSILON);
let normalized = clamp(
(oriented - settings.deadzone) / (maximum - settings.deadzone),
0.0,
1.0,
);
if settings.digital_mode {
return if normalized > 0.5 { 1.0 } else { 0.0 };
}
let response = match settings.response {
TriggerResponse::Linear => normalized,
TriggerResponse::Progressive => normalized.powf(1.6),
};
clamp(
evaluate_curve(&settings.curve, response) * settings.sensitivity,
0.0,
1.0,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn radial_deadzone_eliminates_drift() {
let settings = StickSettings::default();
assert_eq!(
transform_stick(StickValue { x: 0.04, y: 0.02 }, &settings),
StickValue { x: 0.0, y: 0.0 }
);
}
#[test]
fn stick_inversion_and_sensitivity_are_applied() {
let mut settings = StickSettings::default();
settings.inner_deadzone = 0.0;
settings.sensitivity_x = 0.5;
settings.invert_y = true;
let output = transform_stick(StickValue { x: 1.0, y: 0.5 }, &settings);
assert!(output.x > 0.4 && output.x < 0.5);
assert!(output.y < 0.0);
}
#[test]
fn progressive_trigger_grows_more_slowly_than_linear() {
let mut linear = TriggerSettings::default();
linear.deadzone = 0.0;
let mut progressive = linear.clone();
progressive.response = TriggerResponse::Progressive;
assert!(transform_trigger(0.5, &progressive) < transform_trigger(0.5, &linear));
}
#[test]
fn custom_curve_interpolates_between_points() {
let mut curve = CurveConfig::default();
curve.preset = CurvePreset::Custom;
curve.custom_points = vec![
crate::profile_schema::CurvePoint { x: 0.0, y: 0.0 },
crate::profile_schema::CurvePoint { x: 1.0, y: 0.5 },
];
assert_eq!(evaluate_curve(&curve, 0.5), 0.25);
}
}

61
src-tauri/src/lib.rs Normal file
View File

@@ -0,0 +1,61 @@
#[allow(dead_code)]
mod calibration;
mod commands;
mod default_profiles;
mod device_detection;
mod error;
mod evdev_reader;
#[allow(dead_code)]
mod input_transform;
mod permissions;
mod process_monitor;
mod profile_manager;
mod profile_schema;
mod state;
mod uinput_writer;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_dialog::init())
.plugin(
tauri_plugin_log::Builder::new()
.level(log::LevelFilter::Info)
.build(),
)
.setup(|_app| {
crate::commands::mapping::log_remapping_pipeline_startup();
if let Err(err) = profile_manager::ensure_initialized() {
log::error!("failed to initialize profile storage: {err}");
}
Ok(())
})
.manage(state::AppState::default())
.invoke_handler(tauri::generate_handler![
commands::system::get_app_info,
commands::devices::list_devices,
commands::devices::start_device_reader,
commands::diagnostics::diagnostics_snapshot,
commands::profiles::list_profiles,
commands::profiles::load_profile,
commands::profiles::save_profile,
commands::profiles::create_profile,
commands::profiles::rename_profile,
commands::profiles::duplicate_profile,
commands::profiles::delete_profile,
commands::profiles::reset_profile,
commands::profiles::export_profile,
commands::profiles::import_profile,
commands::profiles::profiles_directory,
commands::processes::detected_game_profile,
commands::mapping::create_virtual_controller,
commands::mapping::destroy_virtual_controller,
commands::mapping::virtual_controller_active,
commands::mapping::apply_remapping_profile,
commands::mapping::set_remapping_enabled,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

6
src-tauri/src/main.rs Normal file
View File

@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
eightbitdo_control_center_lib::run()
}

View File

@@ -0,0 +1,44 @@
//! Non-mutating permission and kernel capability checks.
use std::fs::OpenOptions;
use std::path::Path;
use serde::Serialize;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PermissionStatus {
pub uinput_exists: bool,
pub uinput_writable: bool,
pub input_directory_readable: bool,
pub current_user: Option<String>,
pub groups: Vec<String>,
}
pub fn check_permissions() -> PermissionStatus {
let uinput = Path::new("/dev/uinput");
let uinput_exists = uinput.exists();
let uinput_writable = uinput_exists
&& OpenOptions::new()
.read(true)
.write(true)
.open(uinput)
.is_ok();
let input_directory_readable = std::fs::read_dir("/dev/input").is_ok();
let current_user = std::env::var("USER").ok();
let groups = std::process::Command::new("id")
.arg("-nG")
.output()
.ok()
.and_then(|output| String::from_utf8(output.stdout).ok())
.map(|value| value.split_whitespace().map(ToString::to_string).collect())
.unwrap_or_default();
PermissionStatus {
uinput_exists,
uinput_writable,
input_directory_readable,
current_user,
groups,
}
}

View File

@@ -0,0 +1,45 @@
//! Process-name matching for optional game-specific profile activation.
//!
//! Matching is explicit and opt-in: a profile supplies executable names, and
//! only exact case-insensitive file-name matches activate it.
use std::collections::HashSet;
use sysinfo::System;
use crate::profile_schema::Profile;
pub fn running_executables() -> HashSet<String> {
let system = System::new_all();
system
.processes()
.values()
.filter_map(|process| process.name().to_str())
.map(|name| name.to_ascii_lowercase())
.collect()
}
pub fn matching_profile_id(profiles: &[Profile]) -> Option<String> {
let running = running_executables();
profiles
.iter()
.filter(|profile| !profile.assigned_executables.is_empty())
.find(|profile| {
profile
.assigned_executables
.iter()
.any(|name| running.contains(&name.to_ascii_lowercase()))
})
.map(|profile| profile.id.clone())
}
#[cfg(test)]
mod tests {
use crate::default_profiles::{default_profile, fps_profile};
#[test]
fn no_profiles_without_assignments_match() {
let profiles = vec![default_profile(), fps_profile()];
assert!(profiles.iter().all(|p| p.assigned_executables.is_empty()));
}
}

View File

@@ -0,0 +1,298 @@
//! Reads and writes controller profiles under
//! `~/.config/8bitdo-control-center/profiles/`.
use std::fs;
use std::path::PathBuf;
use uuid::Uuid;
use crate::default_profiles::built_in_profiles;
use crate::error::{AppError, AppResult};
use crate::profile_schema::{parse_profile_json, Profile, ProfileSummary};
/// Overridable via `EIGHTBITDO_CONFIG_DIR` (used by tests) so we never touch
/// a developer's real `~/.config` while running the test suite.
pub fn config_dir() -> PathBuf {
if let Ok(override_dir) = std::env::var("EIGHTBITDO_CONFIG_DIR") {
return PathBuf::from(override_dir);
}
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from(".config"))
.join("8bitdo-control-center")
}
pub fn profiles_dir() -> PathBuf {
config_dir().join("profiles")
}
fn profile_path(id: &str) -> PathBuf {
profiles_dir().join(format!("{id}.json"))
}
fn slugify(name: &str) -> String {
let slug: String = name
.to_lowercase()
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect();
let mut collapsed = String::with_capacity(slug.len());
let mut prev_dash = false;
for c in slug.chars() {
if c == '-' {
if !prev_dash {
collapsed.push(c);
}
prev_dash = true;
} else {
collapsed.push(c);
prev_dash = false;
}
}
let trimmed = collapsed.trim_matches('-');
if trimmed.is_empty() {
Uuid::new_v4().to_string()
} else {
trimmed.to_string()
}
}
fn unique_id(desired: &str) -> String {
if !profile_path(desired).exists() {
return desired.to_string();
}
for suffix in 2..1000 {
let candidate = format!("{desired}-{suffix}");
if !profile_path(&candidate).exists() {
return candidate;
}
}
format!("{desired}-{}", Uuid::new_v4())
}
/// Ensures the config/profiles directories and the five built-in profiles
/// exist. Safe to call on every app start; never overwrites files a user has
/// already modified.
pub fn ensure_initialized() -> AppResult<()> {
fs::create_dir_all(profiles_dir())?;
for profile in built_in_profiles() {
let path = profile_path(&profile.id);
if !path.exists() {
write_profile_to_path(&path, &profile)?;
}
}
Ok(())
}
fn write_profile_to_path(path: &PathBuf, profile: &Profile) -> AppResult<()> {
let json = serde_json::to_string_pretty(profile)?;
fs::write(path, json)?;
Ok(())
}
pub fn list_profiles() -> AppResult<Vec<ProfileSummary>> {
ensure_initialized()?;
let mut summaries = Vec::new();
for entry in fs::read_dir(profiles_dir())? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let raw = fs::read_to_string(&path)?;
match parse_profile_json(&raw) {
Ok(profile) => summaries.push(ProfileSummary::from(&profile)),
Err(err) => {
log::warn!("skipping unreadable profile {}: {err}", path.display());
}
}
}
summaries.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
Ok(summaries)
}
pub fn load_all_profiles() -> AppResult<Vec<Profile>> {
ensure_initialized()?;
let mut profiles = Vec::new();
for entry in fs::read_dir(profiles_dir())? {
let path = entry?.path();
if path.extension().and_then(|extension| extension.to_str()) != Some("json") {
continue;
}
let raw = fs::read_to_string(path)?;
if let Ok(profile) = parse_profile_json(&raw) {
profiles.push(profile);
}
}
Ok(profiles)
}
pub fn load_profile(id: &str) -> AppResult<Profile> {
let path = profile_path(id);
if !path.exists() {
return Err(AppError::ProfileNotFound(id.to_string()));
}
let raw = fs::read_to_string(&path)?;
parse_profile_json(&raw)
}
fn name_taken(name: &str, ignore_id: Option<&str>) -> AppResult<bool> {
for summary in list_profiles()? {
if summary.name.eq_ignore_ascii_case(name) && ignore_id != Some(summary.id.as_str()) {
return Ok(true);
}
}
Ok(false)
}
pub fn save_profile(mut profile: Profile) -> AppResult<Profile> {
profile.updated_at = chrono::Utc::now().to_rfc3339();
let path = profile_path(&profile.id);
write_profile_to_path(&path, &profile)?;
Ok(profile)
}
pub fn create_profile(name: &str) -> AppResult<Profile> {
if name_taken(name, None)? {
return Err(AppError::ProfileNameConflict(name.to_string()));
}
let id = unique_id(&slugify(name));
let mut profile = crate::default_profiles::custom_profile();
profile.id = id;
profile.name = name.to_string();
profile.description = String::new();
profile.is_built_in = false;
let now = chrono::Utc::now().to_rfc3339();
profile.created_at = now.clone();
profile.updated_at = now;
save_profile(profile)
}
pub fn rename_profile(id: &str, new_name: &str) -> AppResult<Profile> {
if name_taken(new_name, Some(id))? {
return Err(AppError::ProfileNameConflict(new_name.to_string()));
}
let mut profile = load_profile(id)?;
profile.name = new_name.to_string();
save_profile(profile)
}
pub fn duplicate_profile(id: &str, new_name: &str) -> AppResult<Profile> {
if name_taken(new_name, None)? {
return Err(AppError::ProfileNameConflict(new_name.to_string()));
}
let mut profile = load_profile(id)?;
profile.id = unique_id(&slugify(new_name));
profile.name = new_name.to_string();
profile.is_built_in = false;
let now = chrono::Utc::now().to_rfc3339();
profile.created_at = now.clone();
profile.updated_at = now;
save_profile(profile)
}
pub fn delete_profile(id: &str) -> AppResult<()> {
let path = profile_path(id);
if !path.exists() {
return Err(AppError::ProfileNotFound(id.to_string()));
}
fs::remove_file(path)?;
Ok(())
}
/// Resets a profile back to its built-in template (if `id` matches one of
/// the five defaults) or to a blank custom template otherwise. The caller
/// (frontend) is expected to show the recomputed profile and let the user
/// confirm before this is called, per the "never apply changes silently"
/// requirement for anything derived automatically.
pub fn reset_profile(id: &str) -> AppResult<Profile> {
let existing = load_profile(id)?;
let template = built_in_profiles()
.into_iter()
.find(|p| p.id == id)
.unwrap_or_else(crate::default_profiles::custom_profile);
let mut reset = template;
reset.id = existing.id;
reset.name = existing.name;
reset.is_built_in = existing.is_built_in;
reset.created_at = existing.created_at;
save_profile(reset)
}
pub fn export_profile(id: &str, destination: &str) -> AppResult<()> {
let profile = load_profile(id)?;
let json = serde_json::to_string_pretty(&profile)?;
fs::write(destination, json)?;
Ok(())
}
pub fn import_profile(source: &str) -> AppResult<Profile> {
let raw = fs::read_to_string(source)?;
let mut profile = parse_profile_json(&raw)?;
profile.is_built_in = false;
let desired_name = profile.name.clone();
if name_taken(&desired_name, None)? {
profile.name = format!("{desired_name} (imported)");
}
profile.id = unique_id(&slugify(&profile.name));
let now = chrono::Utc::now().to_rfc3339();
profile.created_at = now.clone();
profile.updated_at = now;
save_profile(profile)
}
#[cfg(test)]
mod tests {
use super::*;
/// Runs `body` with `EIGHTBITDO_CONFIG_DIR` pointed at a fresh temp
/// directory so tests never touch a real user's profile files. All
/// profile_manager tests run inside this single `#[test]` function to
/// avoid racing on the process-wide environment variable.
fn with_temp_config_dir<T>(body: impl FnOnce() -> T) -> T {
let temp_dir = tempfile::tempdir().expect("create temp dir");
std::env::set_var("EIGHTBITDO_CONFIG_DIR", temp_dir.path());
let result = body();
std::env::remove_var("EIGHTBITDO_CONFIG_DIR");
result
}
#[test]
fn full_profile_lifecycle() {
with_temp_config_dir(|| {
ensure_initialized().expect("initialize");
let summaries = list_profiles().expect("list");
assert_eq!(
summaries.len(),
5,
"expected the five built-in profiles to be seeded"
);
let created = create_profile("My Profile").expect("create");
assert!(!created.is_built_in);
assert_eq!(created.id, "my-profile");
let duplicate_err = create_profile("My Profile").unwrap_err();
assert!(matches!(duplicate_err, AppError::ProfileNameConflict(_)));
let renamed = rename_profile(&created.id, "Renamed Profile").expect("rename");
assert_eq!(renamed.name, "Renamed Profile");
let duplicated = duplicate_profile("default", "Default Copy").expect("duplicate");
assert_ne!(duplicated.id, "default");
assert_eq!(duplicated.name, "Default Copy");
let mut to_modify = load_profile("fps").expect("load fps");
to_modify.left_stick.inner_deadzone = 0.42;
save_profile(to_modify).expect("save");
let reloaded = load_profile("fps").expect("reload fps");
assert_eq!(reloaded.left_stick.inner_deadzone, 0.42);
let reset = reset_profile("fps").expect("reset fps");
assert_ne!(reset.left_stick.inner_deadzone, 0.42);
delete_profile(&renamed.id).expect("delete");
assert!(load_profile(&renamed.id).is_err());
});
}
}

View File

@@ -0,0 +1,401 @@
//! Versioned JSON schema for controller profiles.
//!
//! Profiles are stored as individual JSON files under
//! `~/.config/8bitdo-control-center/profiles/`. `CURRENT_SCHEMA_VERSION` is
//! bumped whenever the shape of [`Profile`] changes in a backwards
//! incompatible way; [`migrate_profile_value`] is the single entry point
//! responsible for upgrading older documents before they are deserialized
//! into the current struct definitions.
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::error::{AppError, AppResult};
pub const CURRENT_SCHEMA_VERSION: u32 = 1;
/// Identifier for a physical or virtual control (button, stick axis, trigger,
/// paddle, etc). Kept as a free-form string rather than a fixed enum so that
/// future controllers with different capabilities can be supported without
/// changing the schema; the canonical set of ids used by the built-in
/// device profile lives in `device_detection`.
pub type ControlId = String;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ActionMode {
Normal,
Turbo,
Toggle,
Hold,
LongPress,
DoubleTap,
}
impl Default for ActionMode {
fn default() -> Self {
ActionMode::Normal
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum MappingTarget {
Button { button: ControlId },
Key { key: String },
MouseButton { button: String },
MouseAxis { axis: String, scale: f32 },
Disabled,
Combo { targets: Vec<MappingTarget> },
ShiftLayer { layer_id: String },
}
impl Default for MappingTarget {
fn default() -> Self {
MappingTarget::Disabled
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ButtonMapping {
#[serde(default)]
pub target: MappingTarget,
#[serde(default)]
pub mode: ActionMode,
#[serde(default)]
pub turbo_rate_hz: Option<f32>,
#[serde(default)]
pub long_press_ms: Option<u32>,
#[serde(default)]
pub double_tap_window_ms: Option<u32>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum DeadzoneMode {
Radial,
Axial,
}
impl Default for DeadzoneMode {
fn default() -> Self {
DeadzoneMode::Radial
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum CurvePreset {
Linear,
Precision,
Aggressive,
Smooth,
Exponential,
Custom,
}
impl Default for CurvePreset {
fn default() -> Self {
CurvePreset::Linear
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct CurvePoint {
pub x: f32,
pub y: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CurveConfig {
pub preset: CurvePreset,
#[serde(default)]
pub custom_points: Vec<CurvePoint>,
}
impl Default for CurveConfig {
fn default() -> Self {
CurveConfig {
preset: CurvePreset::Linear,
custom_points: vec![
CurvePoint { x: 0.0, y: 0.0 },
CurvePoint { x: 0.5, y: 0.5 },
CurvePoint { x: 1.0, y: 1.0 },
],
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StickSettings {
pub inner_deadzone: f32,
pub outer_deadzone: f32,
pub anti_deadzone: f32,
pub sensitivity_x: f32,
pub sensitivity_y: f32,
pub invert_x: bool,
pub invert_y: bool,
pub deadzone_mode: DeadzoneMode,
pub circularity_correction: bool,
pub smoothing: f32,
pub response_curve: CurveConfig,
}
impl Default for StickSettings {
fn default() -> Self {
StickSettings {
inner_deadzone: 0.08,
outer_deadzone: 0.98,
anti_deadzone: 0.0,
sensitivity_x: 1.0,
sensitivity_y: 1.0,
invert_x: false,
invert_y: false,
deadzone_mode: DeadzoneMode::Radial,
circularity_correction: true,
smoothing: 0.0,
response_curve: CurveConfig::default(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TriggerResponse {
Linear,
Progressive,
}
impl Default for TriggerResponse {
fn default() -> Self {
TriggerResponse::Linear
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TriggerSettings {
pub deadzone: f32,
pub max_activation: f32,
pub sensitivity: f32,
pub response: TriggerResponse,
pub hair_trigger: bool,
pub digital_mode: bool,
pub invert: bool,
pub curve: CurveConfig,
}
impl Default for TriggerSettings {
fn default() -> Self {
TriggerSettings {
deadzone: 0.02,
max_activation: 1.0,
sensitivity: 1.0,
response: TriggerResponse::Linear,
hair_trigger: false,
digital_mode: false,
invert: false,
curve: CurveConfig::default(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum GyroMapping {
Off,
Stick,
Mouse,
}
impl Default for GyroMapping {
fn default() -> Self {
GyroMapping::Off
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MotionSettings {
pub vibration_intensity: f32,
pub left_motor_intensity: f32,
pub right_motor_intensity: f32,
pub gyro_sensitivity: f32,
pub gyro_deadzone: f32,
pub invert_gyro_x: bool,
pub invert_gyro_y: bool,
pub gyro_mapping: GyroMapping,
}
impl Default for MotionSettings {
fn default() -> Self {
MotionSettings {
vibration_intensity: 1.0,
left_motor_intensity: 1.0,
right_motor_intensity: 1.0,
gyro_sensitivity: 1.0,
gyro_deadzone: 0.02,
invert_gyro_x: false,
invert_gyro_y: false,
gyro_mapping: GyroMapping::Off,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ShiftLayer {
pub id: String,
pub name: String,
pub activator_button: ControlId,
pub mappings: HashMap<ControlId, ButtonMapping>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Profile {
#[serde(default = "default_schema_version")]
pub schema_version: u32,
pub id: String,
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub is_built_in: bool,
pub created_at: String,
pub updated_at: String,
#[serde(default)]
pub assigned_executables: Vec<String>,
#[serde(default)]
pub shortcut_combo: Vec<ControlId>,
#[serde(default)]
pub button_mappings: HashMap<ControlId, ButtonMapping>,
pub left_stick: StickSettings,
pub right_stick: StickSettings,
pub left_trigger: TriggerSettings,
pub right_trigger: TriggerSettings,
#[serde(default)]
pub motion: MotionSettings,
#[serde(default)]
pub shift_layers: Vec<ShiftLayer>,
}
fn default_schema_version() -> u32 {
CURRENT_SCHEMA_VERSION
}
/// Lightweight summary used for list views so we don't need to parse every
/// mapping just to render the profile picker.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProfileSummary {
pub id: String,
pub name: String,
pub description: String,
pub is_built_in: bool,
pub updated_at: String,
}
impl From<&Profile> for ProfileSummary {
fn from(profile: &Profile) -> Self {
ProfileSummary {
id: profile.id.clone(),
name: profile.name.clone(),
description: profile.description.clone(),
is_built_in: profile.is_built_in,
updated_at: profile.updated_at.clone(),
}
}
}
/// Upgrades an arbitrary on-disk JSON document to the current schema
/// version, in place. Future migrations should add a new match arm rather
/// than mutating old ones, so historical upgrade paths stay intact.
pub fn migrate_profile_value(mut value: Value) -> AppResult<Value> {
let version = value
.get("schemaVersion")
.and_then(Value::as_u64)
.unwrap_or(1) as u32;
if version > CURRENT_SCHEMA_VERSION {
return Err(AppError::UnsupportedSchemaVersion(version));
}
// No migrations exist yet (schema version 1 is the first version), but
// the match keeps a clear place to add them, e.g.:
// if version < 2 { /* transform `value` */ }
match version {
1 => {}
other => return Err(AppError::UnsupportedSchemaVersion(other)),
}
if let Value::Object(map) = &mut value {
map.insert(
"schemaVersion".to_string(),
Value::Number(CURRENT_SCHEMA_VERSION.into()),
);
}
Ok(value)
}
pub fn parse_profile_json(raw: &str) -> AppResult<Profile> {
let value: Value = serde_json::from_str(raw)?;
let migrated = migrate_profile_value(value)?;
let profile: Profile =
serde_json::from_value(migrated).map_err(|e| AppError::InvalidProfile(e.to_string()))?;
Ok(profile)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::default_profiles::default_profile;
#[test]
fn serializes_and_round_trips_a_profile() {
let profile = default_profile();
let json = serde_json::to_string(&profile).expect("serialize");
let parsed = parse_profile_json(&json).expect("parse");
assert_eq!(parsed.id, profile.id);
assert_eq!(parsed.schema_version, CURRENT_SCHEMA_VERSION);
assert_eq!(
parsed.left_stick.inner_deadzone,
profile.left_stick.inner_deadzone
);
}
#[test]
fn migrate_rejects_future_schema_versions() {
let mut value = serde_json::to_value(default_profile()).expect("to_value");
value["schemaVersion"] = serde_json::json!(CURRENT_SCHEMA_VERSION + 1);
let err = migrate_profile_value(value).expect_err("should reject newer schema");
assert!(matches!(err, AppError::UnsupportedSchemaVersion(_)));
}
#[test]
fn migrate_fills_in_missing_schema_version_as_v1() {
let mut value = serde_json::to_value(default_profile()).expect("to_value");
value.as_object_mut().unwrap().remove("schemaVersion");
let migrated = migrate_profile_value(value).expect("migrate");
assert_eq!(migrated["schemaVersion"], CURRENT_SCHEMA_VERSION);
}
#[test]
fn default_mapping_target_is_disabled() {
assert!(matches!(MappingTarget::default(), MappingTarget::Disabled));
}
#[test]
fn custom_curve_defaults_to_identity_points() {
let curve = CurveConfig::default();
assert_eq!(curve.custom_points.len(), 3);
assert_eq!(curve.custom_points.first().unwrap().x, 0.0);
assert_eq!(curve.custom_points.last().unwrap().x, 1.0);
}
}

13
src-tauri/src/state.rs Normal file
View File

@@ -0,0 +1,13 @@
use std::collections::HashSet;
use std::sync::Mutex;
/// State shared by the device commands. A physical event node has at most one
/// app reader, which prevents duplicate UI input streams and lets a reader be
/// restarted after a disconnect.
#[derive(Default)]
pub struct AppState {
pub active_readers: Mutex<HashSet<String>>,
pub virtual_controller: Mutex<Option<evdev::uinput::VirtualDevice>>,
pub remapping_enabled: Mutex<bool>,
pub active_remap_profile: Mutex<Option<crate::profile_schema::Profile>>,
}

View File

@@ -0,0 +1,69 @@
//! Managed uinput virtual-controller lifecycle.
//!
//! Creation is explicit and never attempted automatically. A later pipeline
//! stage forwards transformed events only after the corresponding physical
//! device has been exclusively grabbed, preventing duplicate game input.
use evdev::{
uinput::VirtualDevice, AbsInfo, AbsoluteAxisCode, AttributeSet, InputId, KeyCode,
UinputAbsSetup,
};
use crate::error::{AppError, AppResult};
fn uinput_error(error: impl ToString) -> AppError {
AppError::Other(format!(
"could not create virtual controller: {}",
error.to_string()
))
}
pub fn create_virtual_controller() -> AppResult<VirtualDevice> {
let mut keys = AttributeSet::<KeyCode>::new();
for key in [
KeyCode::BTN_SOUTH,
KeyCode::BTN_EAST,
KeyCode::BTN_WEST,
KeyCode::BTN_NORTH,
KeyCode::BTN_TL,
KeyCode::BTN_TR,
KeyCode::BTN_TL2,
KeyCode::BTN_TR2,
KeyCode::BTN_THUMBL,
KeyCode::BTN_THUMBR,
KeyCode::BTN_SELECT,
KeyCode::BTN_START,
KeyCode::BTN_MODE,
KeyCode::BTN_DPAD_UP,
KeyCode::BTN_DPAD_DOWN,
KeyCode::BTN_DPAD_LEFT,
KeyCode::BTN_DPAD_RIGHT,
] {
keys.insert(key);
}
let stick_axis = AbsInfo::new(0, -32768, 32767, 0, 0, 0);
let trigger_axis = AbsInfo::new(0, 0, 255, 0, 0, 0);
let builder = VirtualDevice::builder()
.map_err(uinput_error)?
.name("8BitDo Control Center Virtual Controller")
.input_id(InputId::new(evdev::BusType::BUS_USB, 0x2dc8, 0x8bcc, 1))
.with_keys(&keys)
.map_err(uinput_error)?;
let builder = builder
.with_absolute_axis(&UinputAbsSetup::new(AbsoluteAxisCode::ABS_X, stick_axis))
.map_err(uinput_error)?
.with_absolute_axis(&UinputAbsSetup::new(AbsoluteAxisCode::ABS_Y, stick_axis))
.map_err(uinput_error)?
.with_absolute_axis(&UinputAbsSetup::new(AbsoluteAxisCode::ABS_RX, stick_axis))
.map_err(uinput_error)?
.with_absolute_axis(&UinputAbsSetup::new(AbsoluteAxisCode::ABS_RY, stick_axis))
.map_err(uinput_error)?
.with_absolute_axis(&UinputAbsSetup::new(AbsoluteAxisCode::ABS_Z, trigger_axis))
.map_err(uinput_error)?
.with_absolute_axis(&UinputAbsSetup::new(AbsoluteAxisCode::ABS_RZ, trigger_axis))
.map_err(uinput_error)?;
builder.build().map_err(uinput_error)
}

37
src-tauri/tauri.conf.json Normal file
View File

@@ -0,0 +1,37 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "8BitDo Control Center",
"version": "0.1.0",
"identifier": "com.eightbitdo.controlcenter",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "8BitDo Control Center",
"width": 1280,
"height": 800,
"minWidth": 1024,
"minHeight": 640
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}

36
src/App.tsx Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

11
src/main.tsx Normal file
View File

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

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

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

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

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

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

@@ -0,0 +1,170 @@
import { BatteryMedium, Gamepad2, Power, Usb } from "lucide-react";
import { Badge } from "@/components/common/Badge";
import { Panel } from "@/components/common/Panel";
import { StatusDot } from "@/components/common/StatusDot";
import { PageShell } from "@/components/layout/PageShell";
import { Select } from "@/components/inputs/Select";
import { Toggle } from "@/components/inputs/Toggle";
import { StickPreview } from "@/components/visualizations/StickPreview";
import { TriggerPreview } from "@/components/visualizations/TriggerPreview";
import { useControllerStore } from "@/stores/controllerStore";
import { useProfileStore } from "@/stores/profileStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { processStick, processTrigger } from "@/utils/deadzoneMath";
export function Dashboard() {
const controllers = useControllerStore((s) => s.controllers);
const activeController = useControllerStore((s) => s.activeController());
const liveState = useControllerStore((s) => s.liveState);
const pollRateHz = useControllerStore((s) => s.pollRateHz);
const connectionStatus = useControllerStore((s) => s.connectionStatus);
const connectionMessage = useControllerStore((s) => s.connectionMessage);
const draftProfile = useProfileStore((s) => s.draftProfile);
const summaries = useProfileStore((s) => s.summaries);
const selectProfile = useProfileStore((s) => s.selectProfile);
const remappingEnabled = useSettingsStore((s) => s.remappingEnabled);
const setRemappingEnabled = useSettingsStore((s) => s.setRemappingEnabled);
const setControllerRemapping = useControllerStore((s) => s.setRemappingEnabled);
const axes = liveState?.axes ?? {};
const leftStick = draftProfile
? processStick({ x: axes.left_stick_x ?? 0, y: axes.left_stick_y ?? 0 }, draftProfile.leftStick)
: null;
const rightStick = draftProfile
? processStick({ x: axes.right_stick_x ?? 0, y: axes.right_stick_y ?? 0 }, draftProfile.rightStick)
: null;
const leftTrigger = draftProfile ? processTrigger(axes.left_trigger ?? 0, draftProfile.leftTrigger) : null;
const rightTrigger = draftProfile ? processTrigger(axes.right_trigger ?? 0, draftProfile.rightTrigger) : null;
return (
<PageShell title="Dashboard" description="At-a-glance status for the connected controller and active profile.">
<div className="grid grid-cols-3 gap-5">
<Panel title="Connected Controller" className="col-span-2">
{activeController ? (
<div className="flex items-center gap-4">
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-accent/15 text-accent-strong">
<Gamepad2 size={26} />
</div>
<div className="flex-1">
<p className="text-sm font-semibold text-fg">{activeController.name}</p>
<p className="mt-0.5 text-xs text-fg-muted">
{activeController.devicePath} &middot; VID {activeController.vendorId.toString(16).padStart(4, "0")}{" "}
PID {activeController.productId.toString(16).padStart(4, "0")}
</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
<Badge tone="success">
<StatusDot tone="success" pulse />
Connected
</Badge>
<Badge tone="neutral">
<Usb size={12} /> {activeController.mode.toUpperCase()}
</Badge>
{activeController.batteryPercent !== null && (
<Badge tone="neutral">
<BatteryMedium size={12} /> {activeController.batteryPercent}%
</Badge>
)}
<Badge tone={activeController.virtualControllerActive ? "accent" : "warning"}>
<Power size={12} />
Virtual controller {activeController.virtualControllerActive ? "active" : "inactive"}
</Badge>
{activeController.isSimulated && <Badge tone="warning">Simulated device</Badge>}
</div>
</div>
</div>
) : (
<p className="text-sm text-fg-muted">
{connectionStatus === "reconnecting"
? connectionMessage ?? "Reconnecting to controller…"
: "No controller detected. Connect an 8BitDo Ultimate controller to get started."}
</p>
)}
</Panel>
<Panel title="Remapping">
<Toggle
checked={remappingEnabled}
onChange={(enabled) => {
setRemappingEnabled(enabled);
void setControllerRemapping(enabled);
}}
label="Enable remapping"
description="Route input through the virtual controller"
/>
<div className="mt-4 border-t border-border pt-4">
<span className="mb-1.5 block text-xs text-fg-muted">Poll rate</span>
<span className="font-mono text-sm text-fg">{pollRateHz ? `${pollRateHz} Hz` : "—"}</span>
</div>
</Panel>
<Panel title="Active Profile" className="col-span-3">
<div className="flex items-center gap-4">
<div className="w-64">
<Select
value={draftProfile?.id ?? ""}
options={summaries.map((s) => ({ value: s.id, label: s.name }))}
onChange={(id) => void selectProfile(id)}
/>
</div>
{draftProfile?.description && <p className="text-sm text-fg-muted">{draftProfile.description}</p>}
{controllers.length > 1 && (
<span className="ml-auto text-xs text-fg-subtle">{controllers.length} controllers detected</span>
)}
</div>
</Panel>
<Panel title="Left Stick">
{leftStick && draftProfile ? (
<StickPreview
raw={leftStick.raw}
processed={leftStick.processed}
innerDeadzone={draftProfile.leftStick.innerDeadzone}
outerDeadzone={draftProfile.leftStick.outerDeadzone}
/>
) : (
<p className="text-sm text-fg-subtle">No data</p>
)}
</Panel>
<Panel title="Right Stick">
{rightStick && draftProfile ? (
<StickPreview
raw={rightStick.raw}
processed={rightStick.processed}
innerDeadzone={draftProfile.rightStick.innerDeadzone}
outerDeadzone={draftProfile.rightStick.outerDeadzone}
/>
) : (
<p className="text-sm text-fg-subtle">No data</p>
)}
</Panel>
<Panel title="Triggers">
{leftTrigger && rightTrigger && draftProfile ? (
<div className="flex flex-col gap-4">
<TriggerPreview
label="Left Trigger"
raw={leftTrigger.raw}
processed={leftTrigger.processed}
deadzone={draftProfile.leftTrigger.deadzone}
maxActivation={draftProfile.leftTrigger.maxActivation}
/>
<TriggerPreview
label="Right Trigger"
raw={rightTrigger.raw}
processed={rightTrigger.processed}
deadzone={draftProfile.rightTrigger.deadzone}
maxActivation={draftProfile.rightTrigger.maxActivation}
/>
</div>
) : (
<p className="text-sm text-fg-subtle">No data</p>
)}
</Panel>
</div>
</PageShell>
);
}

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

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

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

@@ -0,0 +1,141 @@
import { clsx } from "clsx";
import { useState } from "react";
import { BUTTON_CAPABILITIES } from "@/constants/controls";
import { Badge } from "@/components/common/Badge";
import { Button } from "@/components/common/Button";
import { Panel } from "@/components/common/Panel";
import { ControllerDiagram, type ControllerView } from "@/components/controller/ControllerDiagram";
import { PageShell } from "@/components/layout/PageShell";
import { EventLog } from "@/components/visualizations/EventLog";
import { mockControllerBackend } from "@/services/mockControllerBackend";
import { useControllerStore } from "@/stores/controllerStore";
import { useProfileStore } from "@/stores/profileStore";
export function InputTester() {
const [view, setView] = useState<ControllerView>("front");
const activeController = useControllerStore((s) => s.activeController());
const liveState = useControllerStore((s) => s.liveState);
const eventLog = useControllerStore((s) => s.eventLog);
const pollRateHz = useControllerStore((s) => s.pollRateHz);
const latencyEstimateMs = useControllerStore((s) => s.latencyEstimateMs);
const clearEventLog = useControllerStore((s) => s.clearEventLog);
const activeProfile = useProfileStore((s) => s.activeProfile);
const buttons = liveState?.buttons ?? {};
const axes = liveState?.axes ?? {};
const hasGyro = activeController?.capabilities.hasGyro ?? false;
return (
<PageShell title="Input Tester" description="Live view of every raw and processed input event.">
<div className="grid grid-cols-3 gap-5">
<Panel title="Session info">
<dl className="grid grid-cols-2 gap-y-2 text-sm">
<dt className="text-fg-muted">Active profile</dt>
<dd className="text-right font-medium text-fg">{activeProfile?.name ?? "—"}</dd>
<dt className="text-fg-muted">Poll rate</dt>
<dd className="text-right font-mono text-fg">{pollRateHz ? `${pollRateHz} Hz` : "—"}</dd>
<dt className="text-fg-muted">Latency estimate</dt>
<dd className="text-right font-mono text-fg">{latencyEstimateMs ? `${latencyEstimateMs} ms` : "—"}</dd>
<dt className="text-fg-muted">Controller</dt>
<dd className="text-right text-fg">{activeController ? "Connected" : "None"}</dd>
</dl>
</Panel>
<Panel title="Analog sticks">
<dl className="grid grid-cols-2 gap-y-1.5 font-mono text-xs">
<dt className="text-fg-muted">left_stick_x</dt>
<dd className="text-right text-fg">{(axes.left_stick_x ?? 0).toFixed(3)}</dd>
<dt className="text-fg-muted">left_stick_y</dt>
<dd className="text-right text-fg">{(axes.left_stick_y ?? 0).toFixed(3)}</dd>
<dt className="text-fg-muted">right_stick_x</dt>
<dd className="text-right text-fg">{(axes.right_stick_x ?? 0).toFixed(3)}</dd>
<dt className="text-fg-muted">right_stick_y</dt>
<dd className="text-right text-fg">{(axes.right_stick_y ?? 0).toFixed(3)}</dd>
</dl>
</Panel>
<Panel title="Triggers & gyroscope">
<dl className="grid grid-cols-2 gap-y-1.5 font-mono text-xs">
<dt className="text-fg-muted">left_trigger</dt>
<dd className="text-right text-fg">{(axes.left_trigger ?? 0).toFixed(3)}</dd>
<dt className="text-fg-muted">right_trigger</dt>
<dd className="text-right text-fg">{(axes.right_trigger ?? 0).toFixed(3)}</dd>
{hasGyro ? (
<>
<dt className="text-fg-muted">gyro_x</dt>
<dd className="text-right text-fg">{(axes.gyro_x ?? 0).toFixed(3)}</dd>
<dt className="text-fg-muted">gyro_y</dt>
<dd className="text-right text-fg">{(axes.gyro_y ?? 0).toFixed(3)}</dd>
<dt className="text-fg-muted">gyro_z</dt>
<dd className="text-right text-fg">{(axes.gyro_z ?? 0).toFixed(3)}</dd>
</>
) : (
<dd className="col-span-2 text-fg-subtle">No gyroscope detected</dd>
)}
</dl>
</Panel>
<Panel
title="Controller diagram"
description={activeController?.isSimulated ? "Click a control to simulate a press." : undefined}
className="col-span-2"
actions={
<div className="flex gap-1.5">
<Button size="sm" variant={view === "front" ? "primary" : "secondary"} onClick={() => setView("front")}>
Front
</Button>
<Button size="sm" variant={view === "rear" ? "primary" : "secondary"} onClick={() => setView("rear")}>
Rear
</Button>
</div>
}
>
<div className="mx-auto aspect-[480/280] max-w-lg">
<ControllerDiagram
view={view}
buttons={buttons}
axes={axes}
onSelect={(id) => mockControllerBackend.pulseButton(id)}
/>
</div>
</Panel>
<Panel title="Buttons">
<div className="grid grid-cols-3 gap-2">
{BUTTON_CAPABILITIES.map((button) => {
const pressed = Boolean(buttons[button.id]);
return (
<button
key={button.id}
onClick={() => mockControllerBackend.pulseButton(button.id)}
className={clsx(
"cursor-pointer rounded-lg border px-2 py-1.5 text-center text-xs font-medium transition-colors duration-100",
pressed ? "border-accent bg-accent text-accent-fg" : "border-border bg-surface-2 text-fg-muted hover:border-border-strong",
)}
>
{button.label}
</button>
);
})}
</div>
</Panel>
<Panel
title="Event log"
className="col-span-3"
actions={
<Badge tone="neutral">
<Button size="sm" variant="ghost" onClick={clearEventLog}>
Clear
</Button>
</Badge>
}
>
<EventLog events={eventLog} />
</Panel>
</div>
</PageShell>
);
}

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

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

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

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

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

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

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

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

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

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

Some files were not shown because too many files have changed in this diff Show More