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>
24
.gitignore
vendored
Normal 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
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
|
||||
}
|
||||
115
README.md
Normal 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
@@ -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
@@ -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
@@ -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
35
package.json
Normal 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
@@ -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
@@ -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 |
7
resources/udev/99-8bitdo-control-center.rules
Normal 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
@@ -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
|
||||
12
scripts/launch-8bitdo-control-center.sh
Executable 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
@@ -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
@@ -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
40
src-tauri/Cargo.toml
Normal 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
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
13
src-tauri/capabilities/default.json
Normal 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
|
After Width: | Height: | Size: 3.4 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 974 B |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 903 B |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
78
src-tauri/src/calibration.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
24
src-tauri/src/commands/devices.rs
Normal 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)
|
||||
}
|
||||
27
src-tauri/src/commands/diagnostics.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
150
src-tauri/src/commands/mapping.rs
Normal 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(())
|
||||
}
|
||||
6
src-tauri/src/commands/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod devices;
|
||||
pub mod diagnostics;
|
||||
pub mod mapping;
|
||||
pub mod processes;
|
||||
pub mod profiles;
|
||||
pub mod system;
|
||||
7
src-tauri/src/commands/processes.rs
Normal 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))
|
||||
}
|
||||
60
src-tauri/src/commands/profiles.rs
Normal 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()
|
||||
}
|
||||
21
src-tauri/src/commands/system.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
116
src-tauri/src/default_profiles.rs
Normal 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.",
|
||||
)
|
||||
}
|
||||
240
src-tauri/src/device_detection.rs
Normal 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
@@ -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>;
|
||||
349
src-tauri/src/evdev_reader.rs
Normal 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()))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
211
src-tauri/src/input_transform.rs
Normal 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
@@ -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
@@ -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()
|
||||
}
|
||||
44
src-tauri/src/permissions.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
45
src-tauri/src/process_monitor.rs
Normal 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()));
|
||||
}
|
||||
}
|
||||
298
src-tauri/src/profile_manager.rs
Normal 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());
|
||||
});
|
||||
}
|
||||
}
|
||||
401
src-tauri/src/profile_schema.rs
Normal 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
@@ -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>>,
|
||||
}
|
||||
69
src-tauri/src/uinput_writer.rs
Normal 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
@@ -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
@@ -0,0 +1,36 @@
|
||||
import { HashRouter, Route, Routes } from "react-router-dom";
|
||||
|
||||
import { AppLayout } from "@/components/layout/AppLayout";
|
||||
import { ButtonMapping } from "@/pages/ButtonMapping";
|
||||
import { Calibration } from "@/pages/Calibration";
|
||||
import { Dashboard } from "@/pages/Dashboard";
|
||||
import { DeviceInfo } from "@/pages/DeviceInfo";
|
||||
import { InputTester } from "@/pages/InputTester";
|
||||
import { Motion } from "@/pages/Motion";
|
||||
import { Profiles } from "@/pages/Profiles";
|
||||
import { Settings } from "@/pages/Settings";
|
||||
import { Sticks } from "@/pages/Sticks";
|
||||
import { Triggers } from "@/pages/Triggers";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/profiles" element={<Profiles />} />
|
||||
<Route path="/mapping" element={<ButtonMapping />} />
|
||||
<Route path="/sticks" element={<Sticks />} />
|
||||
<Route path="/triggers" element={<Triggers />} />
|
||||
<Route path="/motion" element={<Motion />} />
|
||||
<Route path="/calibration" element={<Calibration />} />
|
||||
<Route path="/tester" element={<InputTester />} />
|
||||
<Route path="/device" element={<DeviceInfo />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
25
src/components/common/Badge.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { clsx } from "clsx";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type BadgeTone = "neutral" | "success" | "warning" | "danger" | "accent";
|
||||
|
||||
const TONE_CLASSES: Record<BadgeTone, string> = {
|
||||
neutral: "bg-surface-3 text-fg-muted",
|
||||
success: "bg-success/15 text-success",
|
||||
warning: "bg-warning/15 text-warning",
|
||||
danger: "bg-danger/15 text-danger",
|
||||
accent: "bg-accent/15 text-accent-strong",
|
||||
};
|
||||
|
||||
export function Badge({ tone = "neutral", children }: { tone?: BadgeTone; children: ReactNode }) {
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium",
|
||||
TONE_CLASSES[tone],
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
48
src/components/common/Button.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { clsx } from "clsx";
|
||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
|
||||
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
|
||||
export type ButtonSize = "sm" | "md";
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
const VARIANT_CLASSES: Record<ButtonVariant, string> = {
|
||||
primary: "bg-accent text-accent-fg hover:bg-accent-strong disabled:opacity-40",
|
||||
secondary:
|
||||
"bg-surface-2 text-fg border border-border hover:border-border-strong disabled:opacity-40",
|
||||
ghost: "bg-transparent text-fg-muted hover:bg-surface-2 hover:text-fg disabled:opacity-40",
|
||||
danger: "bg-transparent text-danger border border-danger/40 hover:bg-danger/10 disabled:opacity-40",
|
||||
};
|
||||
|
||||
const SIZE_CLASSES: Record<ButtonSize, string> = {
|
||||
sm: "h-8 px-3 text-xs gap-1.5",
|
||||
md: "h-9 px-4 text-sm gap-2",
|
||||
};
|
||||
|
||||
export function Button({
|
||||
variant = "secondary",
|
||||
size = "md",
|
||||
icon,
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
"inline-flex items-center justify-center rounded-lg font-medium transition-colors duration-150 cursor-pointer disabled:cursor-not-allowed",
|
||||
VARIANT_CLASSES[variant],
|
||||
SIZE_CLASSES[size],
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{icon}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
42
src/components/common/Panel.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { clsx } from "clsx";
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
interface PanelProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
|
||||
title?: ReactNode;
|
||||
description?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
padded?: boolean;
|
||||
}
|
||||
|
||||
/** Rounded, subtly-shadowed surface used for every card/section in the app. */
|
||||
export function Panel({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
padded = true,
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: PanelProps) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"rounded-2xl border border-border bg-surface shadow-[var(--shadow-panel)]",
|
||||
padded && "p-5",
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{(title || actions) && (
|
||||
<div className="mb-4 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
{title && <h3 className="text-sm font-semibold text-fg">{title}</h3>}
|
||||
{description && <p className="mt-1 text-xs text-fg-muted">{description}</p>}
|
||||
</div>
|
||||
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
src/components/common/StatusDot.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { clsx } from "clsx";
|
||||
|
||||
export type StatusDotTone = "success" | "warning" | "danger" | "neutral";
|
||||
|
||||
const TONE_CLASSES: Record<StatusDotTone, string> = {
|
||||
success: "bg-success",
|
||||
warning: "bg-warning",
|
||||
danger: "bg-danger",
|
||||
neutral: "bg-fg-subtle",
|
||||
};
|
||||
|
||||
export function StatusDot({ tone, pulse = false }: { tone: StatusDotTone; pulse?: boolean }) {
|
||||
return (
|
||||
<span className="relative inline-flex h-2 w-2">
|
||||
{pulse && (
|
||||
<span className={clsx("absolute inline-flex h-full w-full animate-ping rounded-full opacity-60", TONE_CLASSES[tone])} />
|
||||
)}
|
||||
<span className={clsx("relative inline-flex h-2 w-2 rounded-full", TONE_CLASSES[tone])} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
57
src/components/controller/ButtonHotspot.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { clsx } from "clsx";
|
||||
|
||||
import type { ButtonHotspotDef } from "@/components/controller/controllerLayout";
|
||||
|
||||
interface ButtonHotspotProps {
|
||||
def: ButtonHotspotDef;
|
||||
pressed: boolean;
|
||||
selected: boolean;
|
||||
remapped: boolean;
|
||||
onSelect?: (id: string) => void;
|
||||
}
|
||||
|
||||
export function ButtonHotspot({ def, pressed, selected, remapped, onSelect }: ButtonHotspotProps) {
|
||||
const shared = clsx(
|
||||
"cursor-pointer transition-all duration-100",
|
||||
pressed
|
||||
? "fill-accent stroke-accent-strong"
|
||||
: selected
|
||||
? "fill-surface-2 stroke-accent"
|
||||
: remapped
|
||||
? "fill-surface-2 stroke-accent-strong/70"
|
||||
: "fill-surface-2 stroke-border-strong",
|
||||
);
|
||||
|
||||
return (
|
||||
<g
|
||||
onClick={() => onSelect?.(def.id)}
|
||||
role="button"
|
||||
aria-label={def.label}
|
||||
aria-pressed={pressed}
|
||||
tabIndex={0}
|
||||
>
|
||||
{def.shape === "circle" ? (
|
||||
<circle cx={def.cx} cy={def.cy} r={def.r} strokeWidth={selected ? 2.5 : 1.5} className={shared} />
|
||||
) : (
|
||||
<rect
|
||||
x={def.cx - (def.width ?? 0) / 2}
|
||||
y={def.cy - (def.height ?? 0) / 2}
|
||||
width={def.width}
|
||||
height={def.height}
|
||||
rx={def.rx}
|
||||
strokeWidth={selected ? 2.5 : 1.5}
|
||||
className={shared}
|
||||
/>
|
||||
)}
|
||||
<text
|
||||
x={def.cx}
|
||||
y={def.cy + 3.5}
|
||||
textAnchor="middle"
|
||||
fontSize={def.shape === "circle" ? 10 : 8}
|
||||
className={clsx("pointer-events-none select-none font-semibold", pressed ? "fill-accent-fg" : "fill-fg-muted")}
|
||||
>
|
||||
{def.label.length <= 3 ? def.label : ""}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
81
src/components/controller/ControllerDiagram.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { BODY_PATH, FRONT_HOTSPOTS, REAR_HOTSPOTS, VIEW_BOX } from "@/components/controller/controllerLayout";
|
||||
import { ButtonHotspot } from "@/components/controller/ButtonHotspot";
|
||||
import { StickHotspot } from "@/components/controller/StickHotspot";
|
||||
import { TriggerHotspot } from "@/components/controller/TriggerHotspot";
|
||||
|
||||
export type ControllerView = "front" | "rear";
|
||||
|
||||
interface ControllerDiagramProps {
|
||||
view: ControllerView;
|
||||
buttons: Record<string, boolean>;
|
||||
axes: Record<string, number>;
|
||||
selectedId?: string | null;
|
||||
remappedIds?: Set<string>;
|
||||
onSelect?: (id: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic, reusable SVG gamepad diagram (front and rear views). Deliberately
|
||||
* abstract/geometric rather than tracing any real product's artwork.
|
||||
*/
|
||||
export function ControllerDiagram({
|
||||
view,
|
||||
buttons,
|
||||
axes,
|
||||
selectedId,
|
||||
remappedIds,
|
||||
onSelect,
|
||||
}: ControllerDiagramProps) {
|
||||
const hotspots = view === "front" ? FRONT_HOTSPOTS : REAR_HOTSPOTS;
|
||||
|
||||
return (
|
||||
<svg viewBox={VIEW_BOX} className="h-full w-full">
|
||||
<path
|
||||
d={BODY_PATH}
|
||||
className={view === "rear" ? "fill-surface-2/70 stroke-border" : "fill-surface-2 stroke-border"}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
{view === "rear" && (
|
||||
<text x={240} y={140} textAnchor="middle" fontSize={12} className="fill-fg-subtle select-none">
|
||||
Rear view
|
||||
</text>
|
||||
)}
|
||||
{hotspots.map((def) => {
|
||||
if (def.kind === "button") {
|
||||
return (
|
||||
<ButtonHotspot
|
||||
key={def.id}
|
||||
def={def}
|
||||
pressed={Boolean(buttons[def.id])}
|
||||
selected={selectedId === def.id}
|
||||
remapped={remappedIds?.has(def.id) ?? false}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (def.kind === "stick") {
|
||||
return (
|
||||
<StickHotspot
|
||||
key={def.id}
|
||||
def={def}
|
||||
x={axes[def.axisX] ?? 0}
|
||||
y={axes[def.axisY] ?? 0}
|
||||
clicked={Boolean(buttons[def.clickId])}
|
||||
selected={selectedId === def.id}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TriggerHotspot
|
||||
key={def.id}
|
||||
def={def}
|
||||
value={axes[def.axis] ?? 0}
|
||||
selected={selectedId === def.id}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
45
src/components/controller/StickHotspot.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { clsx } from "clsx";
|
||||
|
||||
import type { StickHotspotDef } from "@/components/controller/controllerLayout";
|
||||
|
||||
interface StickHotspotProps {
|
||||
def: StickHotspotDef;
|
||||
x: number;
|
||||
y: number;
|
||||
clicked: boolean;
|
||||
selected: boolean;
|
||||
onSelect?: (id: string) => void;
|
||||
}
|
||||
|
||||
/** Renders the stick's boundary ring plus a dot showing its current displacement. */
|
||||
export function StickHotspot({ def, x, y, clicked, selected, onSelect }: StickHotspotProps) {
|
||||
const dotX = def.cx + x * def.r * 0.72;
|
||||
const dotY = def.cy + y * def.r * 0.72;
|
||||
|
||||
return (
|
||||
<g
|
||||
onClick={() => onSelect?.(def.id)}
|
||||
role="button"
|
||||
aria-label={def.label}
|
||||
tabIndex={0}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<circle
|
||||
cx={def.cx}
|
||||
cy={def.cy}
|
||||
r={def.r}
|
||||
className={clsx(
|
||||
"fill-surface-3 transition-colors duration-100",
|
||||
selected ? "stroke-accent" : "stroke-border-strong",
|
||||
)}
|
||||
strokeWidth={selected ? 2.5 : 1.5}
|
||||
/>
|
||||
<circle
|
||||
cx={dotX}
|
||||
cy={dotY}
|
||||
r={clicked ? 9 : 7}
|
||||
className={clicked ? "fill-accent" : "fill-fg-muted"}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
39
src/components/controller/TriggerHotspot.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { clsx } from "clsx";
|
||||
|
||||
import type { TriggerHotspotDef } from "@/components/controller/controllerLayout";
|
||||
|
||||
interface TriggerHotspotProps {
|
||||
def: TriggerHotspotDef;
|
||||
value: number;
|
||||
selected: boolean;
|
||||
onSelect?: (id: string) => void;
|
||||
}
|
||||
|
||||
/** Shoulder trigger with an analog fill bar reflecting its current value. */
|
||||
export function TriggerHotspot({ def, value, selected, onSelect }: TriggerHotspotProps) {
|
||||
const fillWidth = Math.max(0, Math.min(1, value)) * def.width;
|
||||
|
||||
return (
|
||||
<g onClick={() => onSelect?.(def.id)} role="button" aria-label={def.label} tabIndex={0} className="cursor-pointer">
|
||||
<rect
|
||||
x={def.x}
|
||||
y={def.y}
|
||||
width={def.width}
|
||||
height={def.height}
|
||||
rx={def.rx}
|
||||
className={clsx("fill-surface-3 transition-colors duration-100", selected ? "stroke-accent" : "stroke-border-strong")}
|
||||
strokeWidth={selected ? 2.5 : 1.5}
|
||||
/>
|
||||
<rect x={def.x} y={def.y} width={fillWidth} height={def.height} rx={def.rx} className="fill-accent/70" />
|
||||
<text
|
||||
x={def.x + def.width / 2}
|
||||
y={def.y + def.height / 2 + 3}
|
||||
textAnchor="middle"
|
||||
fontSize={8}
|
||||
className="pointer-events-none select-none fill-fg-muted font-semibold"
|
||||
>
|
||||
{def.label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
135
src/components/controller/controllerLayout.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Layout data for the generic controller diagram. Coordinates describe an
|
||||
* abstract dual-stick gamepad silhouette (not any specific product's
|
||||
* artwork) inside a 480x280 viewBox, reused for both the front and rear
|
||||
* views so the component stays simple and themeable.
|
||||
*/
|
||||
|
||||
export const VIEW_BOX = "0 0 480 280";
|
||||
|
||||
export const BODY_PATH =
|
||||
"M150,55 C105,55 65,85 52,135 C40,182 60,225 102,230 C132,233 152,213 168,195 L312,195 C328,213 348,233 378,230 C420,225 440,182 428,135 C415,85 375,55 330,55 Z";
|
||||
|
||||
export interface ButtonHotspotDef {
|
||||
kind: "button";
|
||||
id: string;
|
||||
label: string;
|
||||
shape: "circle" | "rect";
|
||||
cx: number;
|
||||
cy: number;
|
||||
r?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
rx?: number;
|
||||
}
|
||||
|
||||
export interface StickHotspotDef {
|
||||
kind: "stick";
|
||||
id: string;
|
||||
clickId: string;
|
||||
label: string;
|
||||
cx: number;
|
||||
cy: number;
|
||||
r: number;
|
||||
axisX: string;
|
||||
axisY: string;
|
||||
}
|
||||
|
||||
export interface TriggerHotspotDef {
|
||||
kind: "trigger";
|
||||
id: string;
|
||||
label: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rx: number;
|
||||
axis: string;
|
||||
}
|
||||
|
||||
export type HotspotDef = ButtonHotspotDef | StickHotspotDef | TriggerHotspotDef;
|
||||
|
||||
export const FRONT_HOTSPOTS: HotspotDef[] = [
|
||||
{
|
||||
kind: "stick",
|
||||
id: "left_stick",
|
||||
clickId: "l3",
|
||||
label: "Left Stick",
|
||||
cx: 150,
|
||||
cy: 112,
|
||||
r: 28,
|
||||
axisX: "left_stick_x",
|
||||
axisY: "left_stick_y",
|
||||
},
|
||||
{ kind: "button", id: "dpad_up", label: "D-Pad Up", shape: "rect", cx: 150, cy: 168, width: 20, height: 22, rx: 4 },
|
||||
{
|
||||
kind: "button",
|
||||
id: "dpad_down",
|
||||
label: "D-Pad Down",
|
||||
shape: "rect",
|
||||
cx: 150,
|
||||
cy: 212,
|
||||
width: 20,
|
||||
height: 22,
|
||||
rx: 4,
|
||||
},
|
||||
{
|
||||
kind: "button",
|
||||
id: "dpad_left",
|
||||
label: "D-Pad Left",
|
||||
shape: "rect",
|
||||
cx: 128,
|
||||
cy: 190,
|
||||
width: 22,
|
||||
height: 20,
|
||||
rx: 4,
|
||||
},
|
||||
{
|
||||
kind: "button",
|
||||
id: "dpad_right",
|
||||
label: "D-Pad Right",
|
||||
shape: "rect",
|
||||
cx: 172,
|
||||
cy: 190,
|
||||
width: 22,
|
||||
height: 20,
|
||||
rx: 4,
|
||||
},
|
||||
{
|
||||
kind: "stick",
|
||||
id: "right_stick",
|
||||
clickId: "r3",
|
||||
label: "Right Stick",
|
||||
cx: 330,
|
||||
cy: 190,
|
||||
r: 28,
|
||||
axisX: "right_stick_x",
|
||||
axisY: "right_stick_y",
|
||||
},
|
||||
{ kind: "button", id: "y", label: "Y", shape: "circle", cx: 330, cy: 90, r: 14 },
|
||||
{ kind: "button", id: "x", label: "X", shape: "circle", cx: 308, cy: 112, r: 14 },
|
||||
{ kind: "button", id: "b", label: "B", shape: "circle", cx: 352, cy: 112, r: 14 },
|
||||
{ kind: "button", id: "a", label: "A", shape: "circle", cx: 330, cy: 134, r: 14 },
|
||||
{ kind: "button", id: "select", label: "Select", shape: "rect", cx: 205, cy: 95, width: 24, height: 14, rx: 7 },
|
||||
{ kind: "button", id: "guide", label: "Guide", shape: "circle", cx: 240, cy: 85, r: 12 },
|
||||
{ kind: "button", id: "start", label: "Start", shape: "rect", cx: 275, cy: 95, width: 24, height: 14, rx: 7 },
|
||||
{ kind: "button", id: "l1", label: "L1", shape: "rect", cx: 110, cy: 48, width: 60, height: 16, rx: 8 },
|
||||
{ kind: "button", id: "r1", label: "R1", shape: "rect", cx: 370, cy: 48, width: 60, height: 16, rx: 8 },
|
||||
{ kind: "trigger", id: "left_trigger", label: "L2", x: 80, y: 18, width: 60, height: 16, rx: 8, axis: "left_trigger" },
|
||||
{
|
||||
kind: "trigger",
|
||||
id: "right_trigger",
|
||||
label: "R2",
|
||||
x: 340,
|
||||
y: 18,
|
||||
width: 60,
|
||||
height: 16,
|
||||
rx: 8,
|
||||
axis: "right_trigger",
|
||||
},
|
||||
];
|
||||
|
||||
export const REAR_HOTSPOTS: HotspotDef[] = [
|
||||
{ kind: "button", id: "paddle_l1", label: "Paddle L1", shape: "rect", cx: 128, cy: 150, width: 26, height: 84, rx: 13 },
|
||||
{ kind: "button", id: "paddle_r1", label: "Paddle R1", shape: "rect", cx: 352, cy: 150, width: 26, height: 84, rx: 13 },
|
||||
];
|
||||
26
src/components/feedback/ConfirmDialogHost.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Button } from "@/components/common/Button";
|
||||
import { useUIStore } from "@/stores/uiStore";
|
||||
|
||||
export function ConfirmDialogHost() {
|
||||
const confirm = useUIStore((s) => s.confirm);
|
||||
const resolveConfirm = useUIStore((s) => s.resolveConfirm);
|
||||
|
||||
if (!confirm) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="w-full max-w-sm rounded-2xl border border-border bg-surface p-5 shadow-[var(--shadow-panel)]">
|
||||
<h2 className="text-sm font-semibold text-fg">{confirm.title}</h2>
|
||||
<p className="mt-2 text-sm text-fg-muted">{confirm.message}</p>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => resolveConfirm(false)}>
|
||||
{confirm.cancelLabel ?? "Cancel"}
|
||||
</Button>
|
||||
<Button variant={confirm.danger ? "danger" : "primary"} onClick={() => resolveConfirm(true)}>
|
||||
{confirm.confirmLabel ?? "Confirm"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
src/components/feedback/ToastStack.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { AlertTriangle, CheckCircle2, Info, X, XCircle } from "lucide-react";
|
||||
|
||||
import type { Toast, ToastVariant } from "@/stores/uiStore";
|
||||
import { useUIStore } from "@/stores/uiStore";
|
||||
|
||||
const VARIANT_ICON: Record<ToastVariant, typeof Info> = {
|
||||
info: Info,
|
||||
success: CheckCircle2,
|
||||
warning: AlertTriangle,
|
||||
danger: XCircle,
|
||||
};
|
||||
|
||||
const VARIANT_CLASSES: Record<ToastVariant, string> = {
|
||||
info: "border-border text-fg",
|
||||
success: "border-success/40 text-success",
|
||||
warning: "border-warning/40 text-warning",
|
||||
danger: "border-danger/40 text-danger",
|
||||
};
|
||||
|
||||
function ToastItem({ toast }: { toast: Toast }) {
|
||||
const dismissToast = useUIStore((s) => s.dismissToast);
|
||||
const Icon = VARIANT_ICON[toast.variant];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-start gap-2.5 rounded-xl border bg-surface px-4 py-3 shadow-[var(--shadow-panel)] ${VARIANT_CLASSES[toast.variant]}`}
|
||||
>
|
||||
<Icon size={16} className="mt-0.5 shrink-0" />
|
||||
<p className="flex-1 text-sm text-fg">{toast.message}</p>
|
||||
<button
|
||||
onClick={() => dismissToast(toast.id)}
|
||||
className="cursor-pointer text-fg-subtle hover:text-fg"
|
||||
aria-label="Dismiss notification"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToastStack() {
|
||||
const toasts = useUIStore((s) => s.toasts);
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed right-5 top-5 z-50 flex w-80 flex-col gap-2">
|
||||
{toasts.map((toast) => (
|
||||
<div key={toast.id} className="pointer-events-auto">
|
||||
<ToastItem toast={toast} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
src/components/feedback/UnsavedChangesBar.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Check, RotateCcw, Save } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/common/Button";
|
||||
import { useProfileStore } from "@/stores/profileStore";
|
||||
|
||||
/** Persistent Apply / Revert / Save Profile bar shown on every editable page. */
|
||||
export function UnsavedChangesBar() {
|
||||
const isDirty = useProfileStore((s) => s.isDirty);
|
||||
const activeProfile = useProfileStore((s) => s.activeProfile);
|
||||
const applyDraft = useProfileStore((s) => s.applyDraft);
|
||||
const revertDraft = useProfileStore((s) => s.revertDraft);
|
||||
const saveDraft = useProfileStore((s) => s.saveDraft);
|
||||
|
||||
if (!activeProfile) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-4 flex items-center justify-between rounded-xl border border-border bg-surface-2 px-4 py-2.5">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{isDirty ? (
|
||||
<>
|
||||
<span className="h-2 w-2 rounded-full bg-warning" />
|
||||
<span className="text-fg">Unsaved changes to “{activeProfile.name}”</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="h-2 w-2 rounded-full bg-success" />
|
||||
<span className="text-fg-muted">
|
||||
Editing “{activeProfile.name}” · up to date
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="ghost" icon={<RotateCcw size={14} />} onClick={revertDraft} disabled={!isDirty}>
|
||||
Revert
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" icon={<Check size={14} />} onClick={applyDraft} disabled={!isDirty}>
|
||||
Apply
|
||||
</Button>
|
||||
<Button size="sm" variant="primary" icon={<Save size={14} />} onClick={() => void saveDraft()} disabled={!isDirty}>
|
||||
Save Profile
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
src/components/inputs/CurveEditor.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { PointerEvent as ReactPointerEvent } from "react";
|
||||
|
||||
import type { CurvePoint } from "@/types/profile";
|
||||
import { sampleCurve } from "@/utils/curveMath";
|
||||
|
||||
interface CurveEditorProps {
|
||||
points: CurvePoint[];
|
||||
onChange: (points: CurvePoint[]) => void;
|
||||
}
|
||||
|
||||
const SIZE = 220;
|
||||
const PADDING = 18;
|
||||
const PLOT_SIZE = SIZE - PADDING * 2;
|
||||
|
||||
function pointToSvg(point: CurvePoint): { x: number; y: number } {
|
||||
return { x: PADDING + point.x * PLOT_SIZE, y: PADDING + (1 - point.y) * PLOT_SIZE };
|
||||
}
|
||||
|
||||
function svgToPoint(event: ReactPointerEvent<SVGSVGElement | SVGCircleElement>): CurvePoint {
|
||||
const svg = event.currentTarget instanceof SVGSVGElement
|
||||
? event.currentTarget
|
||||
: event.currentTarget.ownerSVGElement;
|
||||
if (!svg) return { x: 0, y: 0 };
|
||||
const bounds = svg.getBoundingClientRect();
|
||||
return {
|
||||
x: Math.min(1, Math.max(0, (event.clientX - bounds.left - PADDING) / PLOT_SIZE)),
|
||||
y: Math.min(1, Math.max(0, 1 - (event.clientY - bounds.top - PADDING) / PLOT_SIZE)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Interactive, dependency-free custom response-curve point editor. */
|
||||
export function CurveEditor({ points, onChange }: CurveEditorProps) {
|
||||
const sorted = [...points].sort((a, b) => a.x - b.x);
|
||||
const samples = sampleCurve("custom", sorted);
|
||||
const line = samples.map((point) => {
|
||||
const svg = pointToSvg(point);
|
||||
return `${svg.x},${svg.y}`;
|
||||
}).join(" ");
|
||||
|
||||
function addPoint(event: ReactPointerEvent<SVGSVGElement>) {
|
||||
if ((event.target as Element).tagName !== "svg") return;
|
||||
const point = svgToPoint(event);
|
||||
onChange([...sorted, point].sort((a, b) => a.x - b.x));
|
||||
}
|
||||
|
||||
function movePoint(index: number, event: ReactPointerEvent<SVGCircleElement>) {
|
||||
const next = [...sorted];
|
||||
const candidate = svgToPoint(event);
|
||||
// Endpoints remain anchored at input 0/1, preventing invalid curves.
|
||||
if (index === 0) candidate.x = 0;
|
||||
if (index === next.length - 1) candidate.x = 1;
|
||||
const lower = index === 0 ? 0 : next[index - 1].x + 0.01;
|
||||
const upper = index === next.length - 1 ? 1 : next[index + 1].x - 0.01;
|
||||
candidate.x = Math.min(upper, Math.max(lower, candidate.x));
|
||||
next[index] = candidate;
|
||||
onChange(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<svg
|
||||
viewBox={`0 0 ${SIZE} ${SIZE}`}
|
||||
className="w-full max-w-[220px] cursor-crosshair rounded-lg border border-border bg-surface-2"
|
||||
onPointerDown={addPoint}
|
||||
aria-label="Custom response curve editor"
|
||||
>
|
||||
{[0.25, 0.5, 0.75].map((value) => (
|
||||
<g key={value}>
|
||||
<line x1={PADDING} x2={SIZE - PADDING} y1={PADDING + value * PLOT_SIZE} y2={PADDING + value * PLOT_SIZE} className="stroke-border" />
|
||||
<line y1={PADDING} y2={SIZE - PADDING} x1={PADDING + value * PLOT_SIZE} x2={PADDING + value * PLOT_SIZE} className="stroke-border" />
|
||||
</g>
|
||||
))}
|
||||
<polyline points={line} fill="none" className="stroke-accent" strokeWidth={2.5} />
|
||||
{sorted.map((point, index) => {
|
||||
const svg = pointToSvg(point);
|
||||
return (
|
||||
<circle
|
||||
key={`${point.x}-${point.y}-${index}`}
|
||||
cx={svg.x}
|
||||
cy={svg.y}
|
||||
r={5}
|
||||
className="cursor-grab fill-accent stroke-surface active:cursor-grabbing"
|
||||
strokeWidth={2}
|
||||
onPointerDown={(event) => {
|
||||
event.stopPropagation();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) movePoint(index, event);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
<p className="mt-1 text-xs text-fg-subtle">Click to add a point; drag points to adjust the curve.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
src/components/inputs/Select.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
interface SelectOption<T extends string> {
|
||||
value: T;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface SelectProps<T extends string> {
|
||||
label?: string;
|
||||
value: T;
|
||||
options: SelectOption<T>[];
|
||||
onChange: (value: T) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function Select<T extends string>({ label, value, options, onChange, disabled }: SelectProps<T>) {
|
||||
const select = (
|
||||
<div className="relative">
|
||||
<select
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.currentTarget.value as T)}
|
||||
className="h-9 w-full appearance-none rounded-lg border border-border bg-surface-2 px-3 pr-8 text-sm text-fg outline-none disabled:opacity-40"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value} className="bg-surface-2 text-fg">
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown size={14} className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-fg-muted" />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!label) return select;
|
||||
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm text-fg">{label}</span>
|
||||
{select}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
56
src/components/inputs/SimulatedStickPad.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useRef } from "react";
|
||||
import type { PointerEvent as ReactPointerEvent } from "react";
|
||||
|
||||
interface SimulatedStickPadProps {
|
||||
onMove: (x: number, y: number) => void;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
/** Drag surface that injects synthetic raw stick input, for testing settings without hardware. */
|
||||
export function SimulatedStickPad({ onMove, size = 120 }: SimulatedStickPadProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const dragging = useRef(false);
|
||||
|
||||
function updateFromPointer(e: ReactPointerEvent<HTMLDivElement>) {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const cx = rect.left + rect.width / 2;
|
||||
const cy = rect.top + rect.height / 2;
|
||||
const radius = rect.width / 2;
|
||||
let x = (e.clientX - cx) / radius;
|
||||
let y = (e.clientY - cy) / radius;
|
||||
const mag = Math.sqrt(x * x + y * y);
|
||||
if (mag > 1) {
|
||||
x /= mag;
|
||||
y /= mag;
|
||||
}
|
||||
onMove(x, y);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="relative shrink-0 cursor-grab select-none rounded-full border-2 border-dashed border-border-strong bg-surface-3 active:cursor-grabbing"
|
||||
style={{ width: size, height: size }}
|
||||
onPointerDown={(e) => {
|
||||
dragging.current = true;
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
updateFromPointer(e);
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
if (dragging.current) updateFromPointer(e);
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
dragging.current = false;
|
||||
onMove(0, 0);
|
||||
}}
|
||||
>
|
||||
<span className="absolute inset-0 flex items-center justify-center text-center text-[10px] leading-tight text-fg-subtle">
|
||||
drag to
|
||||
<br />
|
||||
test
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
src/components/inputs/Slider.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
interface SliderProps {
|
||||
label: string;
|
||||
value: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
unit?: string;
|
||||
disabled?: boolean;
|
||||
onChange: (value: number) => void;
|
||||
formatValue?: (value: number) => string;
|
||||
}
|
||||
|
||||
export function Slider({
|
||||
label,
|
||||
value,
|
||||
min = 0,
|
||||
max = 1,
|
||||
step = 0.01,
|
||||
unit = "",
|
||||
disabled,
|
||||
onChange,
|
||||
formatValue,
|
||||
}: SliderProps) {
|
||||
const displayValue = formatValue ? formatValue(value) : `${Math.round(value * 100) / 100}${unit}`;
|
||||
|
||||
return (
|
||||
<div className="py-1.5">
|
||||
<div className="mb-1.5 flex items-center justify-between text-sm">
|
||||
<span className="text-fg">{label}</span>
|
||||
<span className="font-mono text-xs text-fg-muted">{displayValue}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
className="w-full disabled:opacity-40"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(Number(e.currentTarget.value))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
src/components/inputs/TextField.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { InputHTMLAttributes } from "react";
|
||||
|
||||
interface TextFieldProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function TextField({ label, className, ...rest }: TextFieldProps) {
|
||||
const input = (
|
||||
<input
|
||||
className="h-9 w-full rounded-lg border border-border bg-surface-2 px-3 text-sm text-fg outline-none placeholder:text-fg-subtle disabled:opacity-40"
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!label) return input;
|
||||
|
||||
return (
|
||||
<label className={className}>
|
||||
<span className="mb-1.5 block text-sm text-fg">{label}</span>
|
||||
{input}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
44
src/components/inputs/Toggle.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { clsx } from "clsx";
|
||||
|
||||
interface ToggleProps {
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
label?: string;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function Toggle({ checked, onChange, label, description, disabled }: ToggleProps) {
|
||||
const switchEl = (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={clsx(
|
||||
"relative h-6 w-11 shrink-0 rounded-full transition-colors duration-150 disabled:opacity-40 disabled:cursor-not-allowed",
|
||||
checked ? "bg-accent" : "bg-surface-3 border border-border-strong",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
"absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform duration-150",
|
||||
checked && "translate-x-5",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
|
||||
if (!label) return switchEl;
|
||||
|
||||
return (
|
||||
<label className="flex items-center justify-between gap-4 py-1">
|
||||
<span>
|
||||
<span className="block text-sm text-fg">{label}</span>
|
||||
{description && <span className="block text-xs text-fg-muted">{description}</span>}
|
||||
</span>
|
||||
{switchEl}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
31
src/components/layout/AppLayout.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
|
||||
import { ConfirmDialogHost } from "@/components/feedback/ConfirmDialogHost";
|
||||
import { ToastStack } from "@/components/feedback/ToastStack";
|
||||
import { Sidebar } from "@/components/layout/Sidebar";
|
||||
import { TopBar } from "@/components/layout/TopBar";
|
||||
import { useControllerInit } from "@/hooks/useController";
|
||||
import { useProfileInit } from "@/hooks/useProfileInit";
|
||||
import { useTheme } from "@/hooks/useTheme";
|
||||
import { useUnsavedChangesWarning } from "@/hooks/useUnsavedChanges";
|
||||
|
||||
export function AppLayout() {
|
||||
useTheme();
|
||||
useControllerInit();
|
||||
useProfileInit();
|
||||
useUnsavedChangesWarning();
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen bg-app text-fg">
|
||||
<Sidebar />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<TopBar />
|
||||
<main className="flex-1 overflow-y-auto px-8 py-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
<ToastStack />
|
||||
<ConfirmDialogHost />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
src/components/layout/PageShell.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface PageShellProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** Consistent header + content wrapper reused by every page. */
|
||||
export function PageShell({ title, description, actions, children }: PageShellProps) {
|
||||
return (
|
||||
<div className="mx-auto flex max-w-6xl flex-col gap-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-fg">{title}</h2>
|
||||
{description && <p className="mt-1 text-sm text-fg-muted">{description}</p>}
|
||||
</div>
|
||||
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { clsx } from "clsx";
|
||||
import { Gamepad2 } from "lucide-react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import { NAV_ITEMS } from "@/constants/navigation";
|
||||
import { confirmDiscardUnsavedChanges } from "@/hooks/useUnsavedChanges";
|
||||
|
||||
export function Sidebar() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
async function handleNavigate(path: string) {
|
||||
if (path === location.pathname) return;
|
||||
const canLeave = await confirmDiscardUnsavedChanges();
|
||||
if (canLeave) navigate(path);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="flex h-full w-60 shrink-0 flex-col border-r border-border bg-surface">
|
||||
<div className="flex items-center gap-2.5 px-5 py-5">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-accent text-accent-fg">
|
||||
<Gamepad2 size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-fg leading-tight">8BitDo Control Center</p>
|
||||
<p className="text-[11px] text-fg-subtle leading-tight">Ultimate Controller</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-0.5 overflow-y-auto px-3 pb-4">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const active = location.pathname === item.path;
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
key={item.path}
|
||||
onClick={() => handleNavigate(item.path)}
|
||||
className={clsx(
|
||||
"flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors duration-150",
|
||||
active
|
||||
? "bg-accent/15 text-accent-strong font-medium"
|
||||
: "text-fg-muted hover:bg-surface-2 hover:text-fg",
|
||||
)}
|
||||
>
|
||||
<Icon size={16} className="shrink-0" />
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
58
src/components/layout/TopBar.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Bluetooth, Cable, Radio, Usb } from "lucide-react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
import { Badge } from "@/components/common/Badge";
|
||||
import { StatusDot } from "@/components/common/StatusDot";
|
||||
import { NAV_ITEMS } from "@/constants/navigation";
|
||||
import { useControllerStore } from "@/stores/controllerStore";
|
||||
import { useProfileStore } from "@/stores/profileStore";
|
||||
import type { ConnectionKind } from "@/types/controller";
|
||||
|
||||
const CONNECTION_ICON: Record<ConnectionKind, typeof Usb> = {
|
||||
usb: Usb,
|
||||
bluetooth: Bluetooth,
|
||||
receiver2_4ghz: Radio,
|
||||
unknown: Cable,
|
||||
};
|
||||
|
||||
const CONNECTION_LABEL: Record<ConnectionKind, string> = {
|
||||
usb: "USB",
|
||||
bluetooth: "Bluetooth",
|
||||
receiver2_4ghz: "2.4GHz Receiver",
|
||||
unknown: "Unknown",
|
||||
};
|
||||
|
||||
export function TopBar() {
|
||||
const location = useLocation();
|
||||
const activeController = useControllerStore((s) => s.activeController());
|
||||
const activeProfile = useProfileStore((s) => s.activeProfile);
|
||||
|
||||
const pageTitle = NAV_ITEMS.find((item) => item.path === location.pathname)?.label ?? "8BitDo Control Center";
|
||||
|
||||
return (
|
||||
<header className="flex h-14 shrink-0 items-center justify-between border-b border-border bg-surface px-6">
|
||||
<h1 className="text-base font-semibold text-fg">{pageTitle}</h1>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{activeProfile && <Badge tone="accent">Profile: {activeProfile.name}</Badge>}
|
||||
|
||||
{activeController ? (
|
||||
<Badge tone="success">
|
||||
<StatusDot tone="success" pulse />
|
||||
{(() => {
|
||||
const Icon = CONNECTION_ICON[activeController.connection];
|
||||
return <Icon size={12} />;
|
||||
})()}
|
||||
{activeController.name}
|
||||
<span className="text-fg-subtle">· {CONNECTION_LABEL[activeController.connection]}</span>
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge tone="neutral">
|
||||
<StatusDot tone="neutral" />
|
||||
No controller detected
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
41
src/components/visualizations/EventLog.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { RawInputEvent } from "@/types/controller";
|
||||
|
||||
function formatTimestamp(ms: number): string {
|
||||
const totalSeconds = ms / 1000;
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = (totalSeconds % 60).toFixed(3).padStart(6, "0");
|
||||
return `${minutes.toString().padStart(2, "0")}:${seconds}`;
|
||||
}
|
||||
|
||||
export function EventLog({ events }: { events: RawInputEvent[] }) {
|
||||
if (events.length === 0) {
|
||||
return <p className="py-8 text-center text-sm text-fg-subtle">No input events yet. Press a button to see it here.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-h-72 overflow-y-auto rounded-lg border border-border">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="sticky top-0 bg-surface-2 text-fg-muted">
|
||||
<tr>
|
||||
<th className="px-3 py-2 font-medium">Time</th>
|
||||
<th className="px-3 py-2 font-medium">Type</th>
|
||||
<th className="px-3 py-2 font-medium">Code</th>
|
||||
<th className="px-3 py-2 font-medium">Raw</th>
|
||||
<th className="px-3 py-2 font-medium">Processed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{events.map((event, index) => (
|
||||
<tr key={`${event.timestamp}-${event.code}-${index}`} className="border-t border-border font-mono">
|
||||
<td className="px-3 py-1.5 text-fg-muted">{formatTimestamp(event.timestamp)}</td>
|
||||
<td className="px-3 py-1.5 text-fg-muted">{event.type}</td>
|
||||
<td className="px-3 py-1.5 text-fg">{event.code}</td>
|
||||
<td className="px-3 py-1.5 text-fg-muted">{event.rawValue.toFixed(2)}</td>
|
||||
<td className="px-3 py-1.5 text-fg-muted">{event.processedValue.toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
src/components/visualizations/StickPreview.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
interface StickPreviewProps {
|
||||
raw: { x: number; y: number };
|
||||
processed: { x: number; y: number };
|
||||
innerDeadzone: number;
|
||||
outerDeadzone: number;
|
||||
size?: number;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/** Live raw vs. processed stick position, with deadzone/outer-range boundaries. */
|
||||
export function StickPreview({
|
||||
raw,
|
||||
processed,
|
||||
innerDeadzone,
|
||||
outerDeadzone,
|
||||
size = 160,
|
||||
label,
|
||||
}: StickPreviewProps) {
|
||||
const center = size / 2;
|
||||
const radius = size / 2 - 14;
|
||||
|
||||
const toCanvas = (v: { x: number; y: number }) => ({
|
||||
x: center + v.x * radius,
|
||||
y: center + v.y * radius,
|
||||
});
|
||||
|
||||
const rawPoint = toCanvas(raw);
|
||||
const processedPoint = toCanvas(processed);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
{label && <span className="text-xs font-medium text-fg-muted">{label}</span>}
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
||||
<circle cx={center} cy={center} r={radius} className="fill-surface-3 stroke-border" strokeWidth={1} />
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius * outerDeadzone}
|
||||
className="fill-none stroke-border-strong"
|
||||
strokeDasharray="3 3"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius * innerDeadzone}
|
||||
className="fill-none stroke-warning/70"
|
||||
strokeDasharray="2 2"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<line x1={center} y1={14} x2={center} y2={size - 14} className="stroke-border" strokeWidth={1} />
|
||||
<line x1={14} y1={center} x2={size - 14} y2={center} className="stroke-border" strokeWidth={1} />
|
||||
<circle cx={rawPoint.x} cy={rawPoint.y} r={4} className="fill-none stroke-fg-subtle" strokeWidth={1.5} />
|
||||
<circle cx={processedPoint.x} cy={processedPoint.y} r={5} className="fill-accent" />
|
||||
</svg>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-0.5 text-center font-mono text-[11px] text-fg-muted">
|
||||
<span>raw x {raw.x.toFixed(2)}</span>
|
||||
<span>raw y {raw.y.toFixed(2)}</span>
|
||||
<span>out x {processed.x.toFixed(2)}</span>
|
||||
<span>out y {processed.y.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
src/components/visualizations/TriggerPreview.tsx
Normal file
@@ -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
@@ -0,0 +1,59 @@
|
||||
import type { AxisCapability, ButtonCapability, DeviceCapabilities } from "@/types/controller";
|
||||
|
||||
/**
|
||||
* Canonical control identifiers for a generic dual-stick gamepad with rear
|
||||
* paddles, shared by the mock backend, the SVG controller diagram, and the
|
||||
* built-in profile templates. Real devices probed via evdev in Phase 2 will
|
||||
* report their own capability list; this constant only describes the
|
||||
* example/demo device used before hardware wiring lands.
|
||||
*/
|
||||
export const BUTTON_CAPABILITIES: ButtonCapability[] = [
|
||||
{ id: "a", label: "A", group: "face" },
|
||||
{ id: "b", label: "B", group: "face" },
|
||||
{ id: "x", label: "X", group: "face" },
|
||||
{ id: "y", label: "Y", group: "face" },
|
||||
{ id: "dpad_up", label: "D-Pad Up", group: "dpad" },
|
||||
{ id: "dpad_down", label: "D-Pad Down", group: "dpad" },
|
||||
{ id: "dpad_left", label: "D-Pad Left", group: "dpad" },
|
||||
{ id: "dpad_right", label: "D-Pad Right", group: "dpad" },
|
||||
{ id: "l1", label: "L1", group: "shoulder" },
|
||||
{ id: "r1", label: "R1", group: "shoulder" },
|
||||
{ id: "l3", label: "Left Stick Click", group: "stick" },
|
||||
{ id: "r3", label: "Right Stick Click", group: "stick" },
|
||||
{ id: "start", label: "Start", group: "system" },
|
||||
{ id: "select", label: "Select", group: "system" },
|
||||
{ id: "guide", label: "Guide", group: "system" },
|
||||
{ id: "paddle_l1", label: "Paddle L1", group: "paddle" },
|
||||
{ id: "paddle_r1", label: "Paddle R1", group: "paddle" },
|
||||
];
|
||||
|
||||
export const AXIS_CAPABILITIES: AxisCapability[] = [
|
||||
{ id: "left_stick_x", label: "Left Stick X", group: "stick", min: -1, max: 1 },
|
||||
{ id: "left_stick_y", label: "Left Stick Y", group: "stick", min: -1, max: 1 },
|
||||
{ id: "right_stick_x", label: "Right Stick X", group: "stick", min: -1, max: 1 },
|
||||
{ id: "right_stick_y", label: "Right Stick Y", group: "stick", min: -1, max: 1 },
|
||||
{ id: "left_trigger", label: "Left Trigger", group: "trigger", min: 0, max: 1 },
|
||||
{ id: "right_trigger", label: "Right Trigger", group: "trigger", min: 0, max: 1 },
|
||||
{ id: "gyro_x", label: "Gyro X", group: "gyro", min: -1, max: 1 },
|
||||
{ id: "gyro_y", label: "Gyro Y", group: "gyro", min: -1, max: 1 },
|
||||
{ id: "gyro_z", label: "Gyro Z", group: "gyro", min: -1, max: 1 },
|
||||
];
|
||||
|
||||
export const DEMO_DEVICE_CAPABILITIES: DeviceCapabilities = {
|
||||
vendorId: 0x2dc8,
|
||||
productId: 0x3106,
|
||||
name: "8BitDo Ultimate Wireless / Pro 2 Wired Controller",
|
||||
buttons: BUTTON_CAPABILITIES,
|
||||
axes: AXIS_CAPABILITIES,
|
||||
paddleCount: 2,
|
||||
hasGyro: true,
|
||||
hasRumble: true,
|
||||
hasBattery: true,
|
||||
};
|
||||
|
||||
export const FACE_BUTTON_IDS = ["a", "b", "x", "y"] as const;
|
||||
export const DPAD_IDS = ["dpad_up", "dpad_down", "dpad_left", "dpad_right"] as const;
|
||||
export const SHOULDER_BUTTON_IDS = ["l1", "r1"] as const;
|
||||
export const STICK_CLICK_IDS = ["l3", "r3"] as const;
|
||||
export const SYSTEM_BUTTON_IDS = ["start", "select", "guide"] as const;
|
||||
export const PADDLE_IDS = ["paddle_l1", "paddle_r1"] as const;
|
||||
32
src/constants/navigation.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
Activity,
|
||||
Crosshair,
|
||||
Gamepad2,
|
||||
Info,
|
||||
LayoutDashboard,
|
||||
Layers,
|
||||
Settings as SettingsIcon,
|
||||
Target,
|
||||
Waves,
|
||||
Zap,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
export interface NavItem {
|
||||
path: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
export const NAV_ITEMS: NavItem[] = [
|
||||
{ path: "/", label: "Dashboard", icon: LayoutDashboard },
|
||||
{ path: "/profiles", label: "Profiles", icon: Layers },
|
||||
{ path: "/mapping", label: "Button Mapping", icon: Gamepad2 },
|
||||
{ path: "/sticks", label: "Sticks", icon: Crosshair },
|
||||
{ path: "/triggers", label: "Triggers", icon: Zap },
|
||||
{ path: "/motion", label: "Motion", icon: Waves },
|
||||
{ path: "/calibration", label: "Calibration", icon: Target },
|
||||
{ path: "/tester", label: "Input Tester", icon: Activity },
|
||||
{ path: "/device", label: "Device Information", icon: Info },
|
||||
{ path: "/settings", label: "Settings", icon: SettingsIcon },
|
||||
];
|
||||
11
src/hooks/useController.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { useControllerStore } from "@/stores/controllerStore";
|
||||
|
||||
/** Starts the controller backend once for the lifetime of the app. */
|
||||
export function useControllerInit(): void {
|
||||
const init = useControllerStore((s) => s.init);
|
||||
useEffect(() => {
|
||||
void init();
|
||||
}, [init]);
|
||||
}
|
||||
29
src/hooks/useProfileInit.ts
Normal file
@@ -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
@@ -0,0 +1,12 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
|
||||
/** Applies the active theme to the document root so CSS variables switch instantly. */
|
||||
export function useTheme(): void {
|
||||
const theme = useSettingsStore((s) => s.theme);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
}, [theme]);
|
||||
}
|
||||
31
src/hooks/useUnsavedChanges.ts
Normal file
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,170 @@
|
||||
import { BatteryMedium, Gamepad2, Power, Usb } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/common/Badge";
|
||||
import { Panel } from "@/components/common/Panel";
|
||||
import { StatusDot } from "@/components/common/StatusDot";
|
||||
import { PageShell } from "@/components/layout/PageShell";
|
||||
import { Select } from "@/components/inputs/Select";
|
||||
import { Toggle } from "@/components/inputs/Toggle";
|
||||
import { StickPreview } from "@/components/visualizations/StickPreview";
|
||||
import { TriggerPreview } from "@/components/visualizations/TriggerPreview";
|
||||
import { useControllerStore } from "@/stores/controllerStore";
|
||||
import { useProfileStore } from "@/stores/profileStore";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { processStick, processTrigger } from "@/utils/deadzoneMath";
|
||||
|
||||
export function Dashboard() {
|
||||
const controllers = useControllerStore((s) => s.controllers);
|
||||
const activeController = useControllerStore((s) => s.activeController());
|
||||
const liveState = useControllerStore((s) => s.liveState);
|
||||
const pollRateHz = useControllerStore((s) => s.pollRateHz);
|
||||
const connectionStatus = useControllerStore((s) => s.connectionStatus);
|
||||
const connectionMessage = useControllerStore((s) => s.connectionMessage);
|
||||
|
||||
const draftProfile = useProfileStore((s) => s.draftProfile);
|
||||
const summaries = useProfileStore((s) => s.summaries);
|
||||
const selectProfile = useProfileStore((s) => s.selectProfile);
|
||||
|
||||
const remappingEnabled = useSettingsStore((s) => s.remappingEnabled);
|
||||
const setRemappingEnabled = useSettingsStore((s) => s.setRemappingEnabled);
|
||||
const setControllerRemapping = useControllerStore((s) => s.setRemappingEnabled);
|
||||
|
||||
const axes = liveState?.axes ?? {};
|
||||
const leftStick = draftProfile
|
||||
? processStick({ x: axes.left_stick_x ?? 0, y: axes.left_stick_y ?? 0 }, draftProfile.leftStick)
|
||||
: null;
|
||||
const rightStick = draftProfile
|
||||
? processStick({ x: axes.right_stick_x ?? 0, y: axes.right_stick_y ?? 0 }, draftProfile.rightStick)
|
||||
: null;
|
||||
const leftTrigger = draftProfile ? processTrigger(axes.left_trigger ?? 0, draftProfile.leftTrigger) : null;
|
||||
const rightTrigger = draftProfile ? processTrigger(axes.right_trigger ?? 0, draftProfile.rightTrigger) : null;
|
||||
|
||||
return (
|
||||
<PageShell title="Dashboard" description="At-a-glance status for the connected controller and active profile.">
|
||||
<div className="grid grid-cols-3 gap-5">
|
||||
<Panel title="Connected Controller" className="col-span-2">
|
||||
{activeController ? (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-accent/15 text-accent-strong">
|
||||
<Gamepad2 size={26} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-semibold text-fg">{activeController.name}</p>
|
||||
<p className="mt-0.5 text-xs text-fg-muted">
|
||||
{activeController.devicePath} · VID {activeController.vendorId.toString(16).padStart(4, "0")}{" "}
|
||||
PID {activeController.productId.toString(16).padStart(4, "0")}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<Badge tone="success">
|
||||
<StatusDot tone="success" pulse />
|
||||
Connected
|
||||
</Badge>
|
||||
<Badge tone="neutral">
|
||||
<Usb size={12} /> {activeController.mode.toUpperCase()}
|
||||
</Badge>
|
||||
{activeController.batteryPercent !== null && (
|
||||
<Badge tone="neutral">
|
||||
<BatteryMedium size={12} /> {activeController.batteryPercent}%
|
||||
</Badge>
|
||||
)}
|
||||
<Badge tone={activeController.virtualControllerActive ? "accent" : "warning"}>
|
||||
<Power size={12} />
|
||||
Virtual controller {activeController.virtualControllerActive ? "active" : "inactive"}
|
||||
</Badge>
|
||||
{activeController.isSimulated && <Badge tone="warning">Simulated device</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-fg-muted">
|
||||
{connectionStatus === "reconnecting"
|
||||
? connectionMessage ?? "Reconnecting to controller…"
|
||||
: "No controller detected. Connect an 8BitDo Ultimate controller to get started."}
|
||||
</p>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel title="Remapping">
|
||||
<Toggle
|
||||
checked={remappingEnabled}
|
||||
onChange={(enabled) => {
|
||||
setRemappingEnabled(enabled);
|
||||
void setControllerRemapping(enabled);
|
||||
}}
|
||||
label="Enable remapping"
|
||||
description="Route input through the virtual controller"
|
||||
/>
|
||||
<div className="mt-4 border-t border-border pt-4">
|
||||
<span className="mb-1.5 block text-xs text-fg-muted">Poll rate</span>
|
||||
<span className="font-mono text-sm text-fg">{pollRateHz ? `${pollRateHz} Hz` : "—"}</span>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Active Profile" className="col-span-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-64">
|
||||
<Select
|
||||
value={draftProfile?.id ?? ""}
|
||||
options={summaries.map((s) => ({ value: s.id, label: s.name }))}
|
||||
onChange={(id) => void selectProfile(id)}
|
||||
/>
|
||||
</div>
|
||||
{draftProfile?.description && <p className="text-sm text-fg-muted">{draftProfile.description}</p>}
|
||||
{controllers.length > 1 && (
|
||||
<span className="ml-auto text-xs text-fg-subtle">{controllers.length} controllers detected</span>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Left Stick">
|
||||
{leftStick && draftProfile ? (
|
||||
<StickPreview
|
||||
raw={leftStick.raw}
|
||||
processed={leftStick.processed}
|
||||
innerDeadzone={draftProfile.leftStick.innerDeadzone}
|
||||
outerDeadzone={draftProfile.leftStick.outerDeadzone}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-fg-subtle">No data</p>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel title="Right Stick">
|
||||
{rightStick && draftProfile ? (
|
||||
<StickPreview
|
||||
raw={rightStick.raw}
|
||||
processed={rightStick.processed}
|
||||
innerDeadzone={draftProfile.rightStick.innerDeadzone}
|
||||
outerDeadzone={draftProfile.rightStick.outerDeadzone}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-fg-subtle">No data</p>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel title="Triggers">
|
||||
{leftTrigger && rightTrigger && draftProfile ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<TriggerPreview
|
||||
label="Left Trigger"
|
||||
raw={leftTrigger.raw}
|
||||
processed={leftTrigger.processed}
|
||||
deadzone={draftProfile.leftTrigger.deadzone}
|
||||
maxActivation={draftProfile.leftTrigger.maxActivation}
|
||||
/>
|
||||
<TriggerPreview
|
||||
label="Right Trigger"
|
||||
raw={rightTrigger.raw}
|
||||
processed={rightTrigger.processed}
|
||||
deadzone={draftProfile.rightTrigger.deadzone}
|
||||
maxActivation={draftProfile.rightTrigger.maxActivation}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-fg-subtle">No data</p>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
111
src/pages/DeviceInfo.tsx
Normal file
@@ -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
@@ -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
@@ -0,0 +1,127 @@
|
||||
import { Button } from "@/components/common/Button";
|
||||
import { Panel } from "@/components/common/Panel";
|
||||
import { Select } from "@/components/inputs/Select";
|
||||
import { Slider } from "@/components/inputs/Slider";
|
||||
import { Toggle } from "@/components/inputs/Toggle";
|
||||
import { UnsavedChangesBar } from "@/components/feedback/UnsavedChangesBar";
|
||||
import { PageShell } from "@/components/layout/PageShell";
|
||||
import { useControllerStore } from "@/stores/controllerStore";
|
||||
import { useProfileStore } from "@/stores/profileStore";
|
||||
import { useUIStore } from "@/stores/uiStore";
|
||||
import type { GyroMapping, MotionSettings } from "@/types/profile";
|
||||
|
||||
const GYRO_MAPPING_OPTIONS: { value: GyroMapping; label: string }[] = [
|
||||
{ value: "off", label: "Off" },
|
||||
{ value: "stick", label: "Right stick" },
|
||||
{ value: "mouse", label: "Mouse" },
|
||||
];
|
||||
|
||||
export function Motion() {
|
||||
const activeController = useControllerStore((s) => s.activeController());
|
||||
const draftProfile = useProfileStore((s) => s.draftProfile);
|
||||
const updateDraft = useProfileStore((s) => s.updateDraft);
|
||||
const pushToast = useUIStore((s) => s.pushToast);
|
||||
|
||||
if (!draftProfile || !activeController) {
|
||||
return (
|
||||
<PageShell title="Motion" description="Loading...">
|
||||
<div />
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const { hasGyro, hasRumble } = activeController.capabilities;
|
||||
const settings = draftProfile.motion;
|
||||
|
||||
function update(patch: Partial<MotionSettings>) {
|
||||
updateDraft((draft) => ({ ...draft, motion: { ...draft.motion, ...patch } }));
|
||||
}
|
||||
|
||||
if (!hasGyro && !hasRumble) {
|
||||
return (
|
||||
<PageShell title="Motion" description="Gyroscope and vibration settings.">
|
||||
<Panel>
|
||||
<p className="text-sm text-fg-muted">
|
||||
The connected controller doesn't report gyroscope or vibration support, so there is nothing to
|
||||
configure here.
|
||||
</p>
|
||||
</Panel>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell title="Motion" description="Gyroscope and vibration settings for the connected controller.">
|
||||
<UnsavedChangesBar />
|
||||
|
||||
<div className="grid grid-cols-2 gap-5">
|
||||
{hasRumble && (
|
||||
<Panel title="Vibration">
|
||||
<Slider
|
||||
label="Overall intensity"
|
||||
value={settings.vibrationIntensity}
|
||||
onChange={(vibrationIntensity) => update({ vibrationIntensity })}
|
||||
/>
|
||||
<Slider
|
||||
label="Left motor intensity"
|
||||
value={settings.leftMotorIntensity}
|
||||
onChange={(leftMotorIntensity) => update({ leftMotorIntensity })}
|
||||
/>
|
||||
<Slider
|
||||
label="Right motor intensity"
|
||||
value={settings.rightMotorIntensity}
|
||||
onChange={(rightMotorIntensity) => update({ rightMotorIntensity })}
|
||||
/>
|
||||
<Button
|
||||
className="mt-2"
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
pushToast(
|
||||
activeController.isSimulated
|
||||
? "Simulated device — no physical motors to test."
|
||||
: "Vibration test sent to the controller.",
|
||||
"info",
|
||||
)
|
||||
}
|
||||
>
|
||||
Test vibration
|
||||
</Button>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{hasGyro && (
|
||||
<Panel title="Gyroscope">
|
||||
<Slider
|
||||
label="Sensitivity"
|
||||
value={settings.gyroSensitivity}
|
||||
min={0.1}
|
||||
max={3}
|
||||
onChange={(gyroSensitivity) => update({ gyroSensitivity })}
|
||||
/>
|
||||
<Slider
|
||||
label="Deadzone"
|
||||
value={settings.gyroDeadzone}
|
||||
onChange={(gyroDeadzone) => update({ gyroDeadzone })}
|
||||
/>
|
||||
<Toggle
|
||||
checked={settings.invertGyroX}
|
||||
onChange={(invertGyroX) => update({ invertGyroX })}
|
||||
label="Invert X axis"
|
||||
/>
|
||||
<Toggle
|
||||
checked={settings.invertGyroY}
|
||||
onChange={(invertGyroY) => update({ invertGyroY })}
|
||||
label="Invert Y axis"
|
||||
/>
|
||||
<Select
|
||||
label="Map gyroscope to"
|
||||
value={settings.gyroMapping}
|
||||
options={GYRO_MAPPING_OPTIONS}
|
||||
onChange={(gyroMapping) => update({ gyroMapping })}
|
||||
/>
|
||||
</Panel>
|
||||
)}
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
205
src/pages/Profiles.tsx
Normal file
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||