Files
punktfunk/clients/linux/src/gamepad.rs
T
enricobuehler 38078fe7ee
ci / rust (push) Successful in 1m54s
ci / web (push) Successful in 54s
ci / docs-site (push) Successful in 1m2s
windows-host / package (push) Successful in 6m43s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m12s
ci / bench (push) Successful in 4m47s
apple / swift (push) Successful in 1m9s
android / android (push) Successful in 3m33s
deb / build-publish (push) Successful in 4m36s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
decky / build-publish (push) Successful in 15s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 51s
release / apple (push) Successful in 8m30s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 48s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 53s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m15s
flatpak / build-publish (push) Successful in 4m4s
apple / screenshots (push) Successful in 5m31s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m48s
docker / deploy-docs (push) Successful in 24s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m20s
feat(linux-client): gamepad library launcher — console-style coverflow (--browse)
A controller-driven, chrome-less library launcher for the Steam Deck flow
(the Decky plugin's "Open library on screen" + pinned games, 8470419):
`--browse host[:port]` opens a paired host's game library as a coverflow
over a drifting aurora — A streams the focused title (the id rides the
Hello), session end returns to the launcher, B quits back to Gaming Mode.
`--connect` gains `--launch <id>` for direct-to-game starts; `--mgmt`
overrides the library port. Scope is deliberately library-only: host
selection/settings stay in the touch UI, pairing stays in the plugin (no
dialog can map under gamescope — every state renders in-page).

- gamepad.rs menu mode: the worker holds the active pad open while idle
  (WITHOUT the Valve HIDAPI drivers — Deck lizard mode survives) and
  translates it through a pure MenuNav state machine: edge-triggered
  buttons, held-state snapshot on entry/detach (the escape chord that ends
  a stream can't ghost-fire in the menu), 380/160 ms stick/dpad repeat,
  menu rumble ticks. Keyboard fallback (arrows/Enter/Esc) drives the same
  handler — fully usable with no pad, no host (PUNKTFUNK_FAKE_LIBRARY).
- Coverflow: ±38° corridor-facing tilt under per-card perspective
  (gsk rotate_3d), dense overlapping side shelves with paint-order
  restacking (gtk::Fixed draws in child order), opaque card faces + a
  darkening veil for the recede (translucency would bleed the stack
  through). The strip lives in an External-policy ScrolledWindow because
  a bare gtk::Fixed measures its TRANSFORMED children and inflates the
  page min-width past the window.
- Spring-driven motion: semi-implicit Euler in ≤8 ms substeps (a raw
  50 ms frame leaves the stiff recoil spring ringing at ω·dt ≈ 1.2 —
  regression-tested), ζ≈0.85 cursor chase + ζ≈0.55 boundary wobble;
  velocity carries across retargets so held-repeat scrolling glides.
- Shot scene `gamepad-library` (GTK animations force-disabled in shot mode
  — nav transitions froze mid-slide in headless captures); shared poster
  fetch extracted to library::spawn_art_fetch.

Verified here: 21 unit tests (MenuNav, cursor stepping, spring
convergence/stability), clippy -D warnings clean, screenshot scene
pixel-checked, --browse smoke runs (fake-library + unpaired) on the
headless session. On-Deck validation pending (virtual-pad input, lizard
mode, rumble via Steam Input, full Decky→browse→stream→launcher loop).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 21:41:43 +00:00

1360 lines
55 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! App-lifetime gamepad service over SDL3 (mirrors the Swift client's `GamepadManager` +
//! `GamepadCapture`/`GamepadFeedback`).
//!
//! One worker thread owns SDL for the process lifetime: it tracks connected pads for the
//! Settings UI (metadata only — see below), selects the ONE controller forwarded as pad 0
//! (the user pin — persisted in Settings by stable `vid:pid:name` key — else the most
//! recently connected real pad; Steam Input's virtual pad is skipped), and — while a
//! session is attached — forwards buttons/axes, DualSense touchpad contacts and motion
//! samples (0xCC), and renders feedback: rumble, lightbar via SDL, and on a real DualSense
//! the raw effects packet (adaptive-trigger blocks replayed verbatim, player LEDs). Held
//! state is zeroed on the wire when the active pad switches or the session detaches, so
//! nothing sticks down.
//!
//! **Idle means hands off the hardware.** Outside an attached session the worker never
//! opens a device and keeps SDL's Valve HIDAPI drivers disabled ([`set_valve_hidapi`]):
//! the Steam Deck driver clears the built-in controller's "lizard mode" (trackpad-mouse,
//! clicky pads) the moment the device *enumerates* and keeps feeding that watchdog — so an
//! idle host-list window would kill the Deck's system input. The pad list for Settings is
//! built from SDL's ID-based metadata getters, which need no open.
//!
//! **Menu mode is the one idle exception.** The gamepad library launcher (`--browse`)
//! flips [`GamepadService::set_menu_mode`] on for its lifetime: the worker then holds the
//! active pad open and translates its buttons/stick into [`MenuEvent`]s (polled off the
//! open handle each loop — Apple `GamepadMenuInput` parity: edge-triggered buttons,
//! snapshot-on-entry so a button still held from a previous screen or stream can't ghost-
//! fire, stick/dpad direction with initial-delay auto-repeat). The Valve HIDAPI drivers
//! stay OFF — a plain SDL open of the virtual X360 / evdev pad doesn't touch lizard mode —
//! and an attached session always supersedes menu translation (the stream path is
//! untouched); detach re-snapshots so the escape chord that ended the session fires
//! nothing in the menu.
//!
//! This thread is also the single consumer of the rumble and HID-output pull planes.
use punktfunk_core::client::NativeClient;
use punktfunk_core::config::GamepadPref;
use punktfunk_core::input::{gamepad as wire, InputEvent, InputKind};
use punktfunk_core::quic::{HidOutput, RichInput};
use std::sync::mpsc::{Receiver, Sender};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
/// Motion scale constants, shared convention with the Swift client (`GamepadWire`):
/// derived from hid-playstation's math over the host's fixed calibration blob. SDL hands
/// us gyro in rad/s and accel in m/s²; the DualSense report wants raw LSBs.
const GYRO_LSB_PER_RAD_S: f32 = 20.0 * 180.0 / std::f32::consts::PI;
const ACCEL_LSB_PER_G: f32 = 10_000.0;
const G: f32 = 9.80665;
/// The controller "escape" chord (Moonlight convention): L1 + R1 + Start + Select held
/// together. Intercepted by the client to leave fullscreen + release input capture — the
/// Deck has no F11 key and fullscreen hides the window chrome, so with a controller this
/// is the only way out. Four simultaneous buttons that no game uses as a deliberate
/// combo, so it can't be triggered by normal play. Still forwarded to the host (the user
/// is leaving anyway); we only also raise the escape signal.
///
/// **Escalation:** a quick press leaves fullscreen / releases capture; *holding* the same
/// chord for [`DISCONNECT_HOLD`] ends the session. Deliberately NOT the Steam / QAM buttons —
/// those are the marquee pass-through controls that now reach the host's game-mode UI.
const ESCAPE_CHORD: [u32; 4] = [wire::BTN_LB, wire::BTN_RB, wire::BTN_START, wire::BTN_BACK];
/// Hold the [`ESCAPE_CHORD`] at least this long to disconnect (escalates the leave-fullscreen press).
const DISCONNECT_HOLD: Duration = Duration::from_millis(1500);
/// Stick deflection below this is ignored for menu navigation (0.5 of full scale — Apple
/// `GamepadMenuInput` parity; menus want deliberate flicks, not drift).
const MENU_DEADZONE: u16 = 16384;
/// A held direction starts auto-repeating after this initial delay…
const MENU_REPEAT_DELAY: Duration = Duration::from_millis(380);
/// …and then repeats at this cadence until released or changed.
const MENU_REPEAT_INTERVAL: Duration = Duration::from_millis(160);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MenuDir {
Up,
Down,
Left,
Right,
}
/// One controller action for the launcher UI, translated from the open pad while menu
/// mode is on and no session is attached. Buttons are edge-triggered; `Move` debounces
/// the stick/dpad and auto-repeats ([`MENU_REPEAT_DELAY`]/[`MENU_REPEAT_INTERVAL`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MenuEvent {
Move(MenuDir),
/// A — activate the focused item.
Confirm,
/// B — back / quit.
Back,
/// Y (Apple "secondary"; unused by the launcher today, kept for parity).
Secondary,
/// X (Apple "tertiary"; unused).
Tertiary,
/// L1 — jump back 5.
JumpBack,
/// R1 — jump forward 5.
JumpForward,
}
/// Menu haptic pulses — short rumble ticks on the menu pad (never during a stream).
#[derive(Clone, Copy, Debug)]
pub enum MenuPulse {
Move,
Confirm,
Boundary,
}
/// Raw pad state sampled once per worker iteration for menu translation.
#[derive(Clone, Copy, Default)]
struct MenuSample {
/// a, b, x, y, l1, r1 — the order [`MenuNav::poll`] maps to events.
buttons: [bool; 6],
/// Left stick, SDL convention (+y = down).
lx: i16,
ly: i16,
/// up, down, left, right.
dpad: [bool; 4],
}
/// The pure menu-input state machine (no SDL types — unit-tested below). Port of the
/// Swift client's `GamepadMenuInput`: the poll after a [`reset`](Self::reset) adopts the
/// currently-held buttons and direction WITHOUT firing, so a press that crossed a screen
/// handoff (the B that closed a stream, a held A on mode entry) must be released before
/// it can act; buttons fire on the rising edge only.
struct MenuNav {
/// Adopt the next sample silently (set on mode entry / stream detach / pad change).
snapshot_pending: bool,
/// Previous button states, [`MenuSample::buttons`] order.
was: [bool; 6],
dir: Option<MenuDir>,
/// When `dir` engaged — start of the initial-repeat delay.
dir_since: Instant,
last_repeat: Instant,
}
impl MenuNav {
fn new() -> MenuNav {
MenuNav {
snapshot_pending: true,
was: [false; 6],
dir: None,
dir_since: Instant::now(),
last_repeat: Instant::now(),
}
}
/// Arm the snapshot: the next poll adopts held state without firing.
fn reset(&mut self) {
self.snapshot_pending = true;
self.dir = None;
}
/// Direction from the left stick (dominant axis wins past the deadzone), falling back
/// to the discrete dpad. SDL sticks are +y = down.
fn resolve_dir(s: &MenuSample) -> Option<MenuDir> {
let (ax, ay) = (s.lx.unsigned_abs(), s.ly.unsigned_abs());
if ax > MENU_DEADZONE || ay > MENU_DEADZONE {
return Some(if ax >= ay {
if s.lx > 0 {
MenuDir::Right
} else {
MenuDir::Left
}
} else if s.ly > 0 {
MenuDir::Down
} else {
MenuDir::Up
});
}
let [up, down, left, right] = s.dpad;
if left {
Some(MenuDir::Left)
} else if right {
Some(MenuDir::Right)
} else if up {
Some(MenuDir::Up)
} else if down {
Some(MenuDir::Down)
} else {
None
}
}
fn poll(&mut self, s: &MenuSample, now: Instant, out: &mut Vec<MenuEvent>) {
let dir = Self::resolve_dir(s);
if self.snapshot_pending {
self.snapshot_pending = false;
self.was = s.buttons;
self.dir = dir;
self.dir_since = now;
self.last_repeat = now;
return;
}
// buttons order a, b, x, y, l1, r1 → the matching event per index.
const EVENTS: [MenuEvent; 6] = [
MenuEvent::Confirm,
MenuEvent::Back,
MenuEvent::Tertiary,
MenuEvent::Secondary,
MenuEvent::JumpBack,
MenuEvent::JumpForward,
];
for (i, ev) in EVENTS.iter().enumerate() {
if s.buttons[i] && !self.was[i] {
out.push(*ev);
}
self.was[i] = s.buttons[i];
}
if dir != self.dir {
self.dir = dir;
self.dir_since = now;
self.last_repeat = now;
if let Some(d) = dir {
out.push(MenuEvent::Move(d));
}
} else if let Some(d) = dir {
if now.duration_since(self.dir_since) >= MENU_REPEAT_DELAY
&& now.duration_since(self.last_repeat) >= MENU_REPEAT_INTERVAL
{
self.last_repeat = now;
out.push(MenuEvent::Move(d));
}
}
}
}
#[derive(Clone, Debug)]
pub struct PadInfo {
pub name: String,
/// Stable identity (`vid:pid:name`) for pinning across restarts — SDL instance ids are
/// per-run, so [`Settings::forward_pad`](crate::trust::Settings) persists this instead.
pub key: String,
/// The virtual pad "Automatic" resolves to for this physical controller (so the host creates a
/// matching pad: DualSense → DualSense, DS4 → DualShock 4, Xbox One/Series → Xbox One, anything
/// else → Xbox 360). Drives [`GamepadService::auto_pref`] and the rich-feedback render path.
pub pref: GamepadPref,
/// Steam Input's emulated pad ("Steam Virtual Gamepad", Valve 28de:11ff). It shadows the
/// physical controller and has no sensors/touchpad, so auto-selection skips it while a real
/// pad is connected — otherwise gyro silently dies on Bazzite/Deck game mode.
pub steam_virtual: bool,
}
impl PadInfo {
/// True for a real DualSense — the only pad whose lightbar / player-LED / adaptive-trigger
/// feedback we replay as raw DS5 HID effect packets (a DS4 uses SDL's generic `set_led`).
fn is_dualsense(&self) -> bool {
self.pref == GamepadPref::DualSense
}
/// A short controller-kind label for the Settings list (`""` for a plain Xbox/standard pad).
pub fn kind_label(&self) -> &'static str {
match self.pref {
GamepadPref::DualSense => "DualSense",
GamepadPref::DualShock4 => "DualShock 4",
GamepadPref::XboxOne => "Xbox One",
GamepadPref::SteamDeck => "Steam Deck",
_ => "",
}
}
}
/// Enable/disable SDL's Valve HIDAPI drivers at runtime. The Steam Deck driver sends
/// `ID_CLEAR_DIGITAL_MAPPINGS` + `TRACKPAD_NONE` in `InitDevice` — at *enumeration*, before
/// any open — and its `UpdateDevice` keeps feeding the firmware's lizard-mode watchdog
/// (`SDL_hidapi_steamdeck.c`), so a Deck's built-in trackpad-mouse dies for the whole
/// system while the driver merely runs. These drivers therefore run ONLY while a session
/// is attached (input is captured then anyway, and streaming wants the paddles, both
/// trackpads, and gyro first-class). SDL3 applies the hint changes live: disabling detaches
/// the driver and the firmware watchdog restores lizard mode within seconds.
///
/// On a Deck in Game Mode, Steam Input still holds the device — the user must disable
/// Steam Input for this app (see the Decky UX); on a desktop client (or a Deck with Steam
/// Input off) the in-session enable just works.
fn set_valve_hidapi(enabled: bool) {
let v = if enabled { "1" } else { "0" };
sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAMDECK", v);
sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAM", v);
}
/// Map the SDL-reported controller type to the virtual pad we'd ask the host to create.
fn pref_for_type(t: sdl3::gamepad::GamepadType) -> GamepadPref {
use sdl3::gamepad::GamepadType as T;
match t {
T::PS5 => GamepadPref::DualSense,
T::PS4 => GamepadPref::DualShock4,
T::XboxOne => GamepadPref::XboxOne,
_ => GamepadPref::Xbox360,
}
}
/// Best-effort "this machine is a Steam Deck". The Gaming-Mode env short-circuits; desktop
/// mode falls back to DMI (Valve board, Jupiter = LCD / Galileo = OLED — readable inside the
/// flatpak sandbox). Cached: the answer can't change while we run.
pub fn is_steam_deck() -> bool {
static DECK: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*DECK.get_or_init(|| {
if std::env::var_os("SteamDeck").is_some() {
return true;
}
let dmi = |f: &str| std::fs::read_to_string(format!("/sys/class/dmi/id/{f}"));
dmi("board_vendor").is_ok_and(|v| v.trim() == "Valve")
&& dmi("product_name").is_ok_and(|p| matches!(p.trim(), "Jupiter" | "Galileo"))
})
}
enum Ctl {
Attach(Arc<NativeClient>),
Detach,
Pin(Option<String>),
MenuMode(bool),
MenuRumble(MenuPulse),
}
#[derive(Clone)]
pub struct GamepadService {
pads: Arc<Mutex<Vec<PadInfo>>>,
active: Arc<Mutex<Option<PadInfo>>>,
ctl: Sender<Ctl>,
/// Fires once per press of the [`ESCAPE_CHORD`]; the stream page consumes it to leave
/// fullscreen + release capture.
escape_rx: async_channel::Receiver<()>,
/// Fires once when the [`ESCAPE_CHORD`] is held past [`DISCONNECT_HOLD`]; the stream page
/// consumes it to end the session (the controller equivalent of Ctrl+Alt+Shift+D).
disconnect_rx: async_channel::Receiver<()>,
/// Menu-navigation events while menu mode is on and no session is attached; the
/// launcher page consumes them.
menu_rx: async_channel::Receiver<MenuEvent>,
}
impl GamepadService {
pub fn start() -> GamepadService {
let pads = Arc::new(Mutex::new(Vec::new()));
let active = Arc::new(Mutex::new(None));
let (ctl, ctl_rx) = std::sync::mpsc::channel();
let (escape_tx, escape_rx) = async_channel::unbounded();
let (disconnect_tx, disconnect_rx) = async_channel::unbounded();
let (menu_tx, menu_rx) = async_channel::unbounded();
let (p, a) = (pads.clone(), active.clone());
if let Err(e) = std::thread::Builder::new()
.name("punktfunk-gamepad".into())
.spawn(move || {
if let Err(e) = run(&p, &a, &ctl_rx, &escape_tx, &disconnect_tx, &menu_tx) {
tracing::warn!(error = %e, "gamepad service ended — pads disabled");
}
})
{
tracing::warn!(error = %e, "gamepad service failed to start");
}
GamepadService {
pads,
active,
ctl,
escape_rx,
disconnect_rx,
menu_rx,
}
}
/// A receiver that yields one `()` each time the controller escape chord is pressed.
/// A fresh clone per call (shared mpmc channel); the stream page spawns a future on it.
pub fn escape_events(&self) -> async_channel::Receiver<()> {
self.escape_rx.clone()
}
/// A receiver that yields one `()` when the escape chord is held past [`DISCONNECT_HOLD`]
/// (controller disconnect). A fresh clone per call; the stream page spawns a future on it.
pub fn disconnect_events(&self) -> async_channel::Receiver<()> {
self.disconnect_rx.clone()
}
/// Menu-navigation events ([`MenuEvent`]) — flowing only while menu mode is on and no
/// session is attached. A fresh clone per call; the launcher spawns a future on it.
pub fn menu_events(&self) -> async_channel::Receiver<MenuEvent> {
self.menu_rx.clone()
}
/// Turn menu mode on/off: while on (and no session attached) the worker holds the
/// active pad open and translates it into [`MenuEvent`]s. The launcher flips this on
/// once for its lifetime — an attached session supersedes translation automatically.
pub fn set_menu_mode(&self, on: bool) {
let _ = self.ctl.send(Ctl::MenuMode(on));
}
/// Play a short menu haptic tick on the menu pad (no-op while a session is attached
/// or no pad is open; best-effort on pads without rumble).
pub fn menu_rumble(&self, pulse: MenuPulse) {
let _ = self.ctl.send(Ctl::MenuRumble(pulse));
}
pub fn pads(&self) -> Vec<PadInfo> {
self.pads.lock().unwrap().clone()
}
pub fn active(&self) -> Option<PadInfo> {
self.active.lock().unwrap().clone()
}
/// Pin the forwarded controller by stable key (`PadInfo::key`) — `None` = automatic.
/// The pin persists as `Settings::forward_pad` (the UI's source of truth) and survives
/// the pad disconnecting: it re-applies the moment a matching controller shows up again.
pub fn set_pinned(&self, key: Option<String>) {
let _ = self.ctl.send(Ctl::Pin(key));
}
pub fn attach(&self, connector: Arc<NativeClient>) {
let _ = self.ctl.send(Ctl::Attach(connector));
}
pub fn detach(&self) {
let _ = self.ctl.send(Ctl::Detach);
}
/// What "Automatic" resolves to right now — the virtual pad matching the physical one
/// (Swift parity); no pad connected leaves the host's own default.
///
/// **Steam Deck special case:** this is read at session start, *before* attach — but the
/// Deck's built-in controller is only enumerable with its real 28DE:1205 identity while
/// the Valve HIDAPI drivers run, and those are enabled on attach only (see
/// [`set_valve_hidapi`]); with Steam Input on, SDL sees nothing but Steam's virtual
/// X360 pad anyway. Both cases used to fall through to Xbox 360. On a Deck, a virtual
/// pad (or no pad at all) means the physical controller behind it IS the built-in one —
/// resolve to the Steam Deck virtual pad so the paddles/trackpads/gyro have somewhere
/// to land. A real external controller still wins (it's the one that gets forwarded).
pub fn auto_pref(&self) -> GamepadPref {
match self.active() {
Some(p) if !p.steam_virtual => p.pref,
_ if is_steam_deck() => GamepadPref::SteamDeck,
Some(p) => p.pref,
None => GamepadPref::Auto,
}
}
}
fn send(connector: &NativeClient, kind: InputKind, code: u32, x: i32) {
let _ = connector.send_input(&InputEvent {
kind,
_pad: [0; 3],
code,
x,
y: 0,
flags: 0, // pad index 0 — single-pad model
});
}
fn button_bit(b: sdl3::gamepad::Button) -> Option<u32> {
use sdl3::gamepad::Button;
Some(match b {
Button::South => wire::BTN_A,
Button::East => wire::BTN_B,
Button::West => wire::BTN_X,
Button::North => wire::BTN_Y,
Button::Back => wire::BTN_BACK,
Button::Start => wire::BTN_START,
Button::Guide => wire::BTN_GUIDE,
Button::LeftStick => wire::BTN_LS_CLICK,
Button::RightStick => wire::BTN_RS_CLICK,
Button::LeftShoulder => wire::BTN_LB,
Button::RightShoulder => wire::BTN_RB,
Button::DPadUp => wire::BTN_DPAD_UP,
Button::DPadDown => wire::BTN_DPAD_DOWN,
Button::DPadLeft => wire::BTN_DPAD_LEFT,
Button::DPadRight => wire::BTN_DPAD_RIGHT,
Button::Touchpad => wire::BTN_TOUCHPAD,
// Back grips / paddles (Steam Deck L4/L5/R4/R5, Xbox Elite P1P4) + the misc/Share button.
// PADDLE1/2/3/4 = R4/L4/R5/L5 (see the host `input::gamepad`).
Button::RightPaddle1 => wire::BTN_PADDLE1,
Button::LeftPaddle1 => wire::BTN_PADDLE2,
Button::RightPaddle2 => wire::BTN_PADDLE3,
Button::LeftPaddle2 => wire::BTN_PADDLE4,
Button::Misc1 => wire::BTN_MISC1,
_ => return None,
})
}
/// SDL axis → (wire axis id, wire value). SDL sticks are +y = down; the wire (XInput
/// convention) is +y = up. SDL triggers span 0..32767; the wire wants 0..255.
fn axis_value(axis: sdl3::gamepad::Axis, v: i16) -> (u32, i32) {
use sdl3::gamepad::Axis;
match axis {
Axis::LeftX => (wire::AXIS_LS_X, v as i32),
Axis::LeftY => (wire::AXIS_LS_Y, -(v as i32).max(-32767)),
Axis::RightX => (wire::AXIS_RS_X, v as i32),
Axis::RightY => (wire::AXIS_RS_Y, -(v as i32).max(-32767)),
Axis::TriggerLeft => (wire::AXIS_LT, (v as i32).clamp(0, 32767) >> 7),
Axis::TriggerRight => (wire::AXIS_RT, (v as i32).clamp(0, 32767) >> 7),
}
}
/// The DualSense effects packet (SDL `DS5EffectsState_t`, 47 bytes) — the same layout the
/// host parses off its virtual pad; the wire's 11-byte trigger blocks drop in verbatim.
/// Enable bits select only the fields each update touches, so rumble (driven separately
/// through SDL) and untouched fields keep their state.
struct Ds5Feedback;
impl Ds5Feedback {
const RIGHT_TRIGGER: usize = 10;
const LEFT_TRIGGER: usize = 21;
const PAD_LIGHTS: usize = 43;
const LED_RGB: usize = 44;
fn trigger_packet(which: u8, effect: &[u8]) -> [u8; 47] {
let mut p = [0u8; 47];
let (flag, off) = if which == 1 {
(0x04, Self::RIGHT_TRIGGER)
} else {
(0x08, Self::LEFT_TRIGGER)
};
p[0] = flag;
let n = effect.len().min(11);
p[off..off + n].copy_from_slice(&effect[..n]);
p
}
fn lightbar_packet(r: u8, g: u8, b: u8) -> [u8; 47] {
let mut p = [0u8; 47];
p[1] = 0x04; // lightbar enable
p[Self::LED_RGB] = r;
p[Self::LED_RGB + 1] = g;
p[Self::LED_RGB + 2] = b;
p
}
fn player_packet(bits: u8) -> [u8; 47] {
let mut p = [0u8; 47];
p[1] = 0x10; // player-LED enable
p[Self::PAD_LIGHTS] = bits & 0x1F;
p
}
}
struct Worker<'a> {
subsystem: sdl3::GamepadSubsystem,
/// UI-facing state (the `GamepadService` accessors): pad list, active pad, pin.
pads_out: &'a Mutex<Vec<PadInfo>>,
active_out: &'a Mutex<Option<PadInfo>>,
/// The ONE device held open — the active pad while a session is attached, `None`
/// otherwise. Opening is what grabs the hardware (SDL's HIDAPI drivers take the
/// hidraw device away from the system), so idle keeps this empty; see the module doc.
open: Option<(u32, sdl3::gamepad::Gamepad)>,
/// Connected pad ids in connection order (metadata only, no device open); the most
/// recently connected is the auto selection.
order: Vec<u32>,
/// Stable key of the user-pinned controller (persisted in Settings) — matched against
/// connected pads, so it survives restarts and disconnects.
pinned: Option<String>,
attached: Option<Arc<NativeClient>>,
/// Wire state of the active pad — zeroed on the wire at switch/detach.
last_axis: [i32; 6],
held_buttons: Vec<u32>,
/// Touchpad contacts the host believes are down, keyed by `(surface, finger)` — lifted on pad
/// switch / detach so a contact held at that moment doesn't stick. surface 0 = the legacy single
/// touchpad, 1/2 = a Steam left/right pad.
held_touches: std::collections::HashSet<(u8, u8)>,
last_accel: [i16; 3],
/// Raises the UI escape signal; the escape chord fires it once per press.
escape_tx: async_channel::Sender<()>,
/// Raises the UI disconnect signal when the escape chord is held past [`DISCONNECT_HOLD`].
disconnect_tx: async_channel::Sender<()>,
/// The escape chord is fully held — latched so it fires once, not every poll.
chord_armed: bool,
/// When the escape chord became fully held (drives the hold-to-disconnect escalation); `None`
/// when the chord is broken.
chord_since: Option<Instant>,
/// The disconnect signal already fired for the current hold — latched so it fires once.
disconnect_fired: bool,
/// Menu mode ([`GamepadService::set_menu_mode`]): hold the active pad open while idle
/// and translate it into [`MenuEvent`]s. An attached session pauses translation.
menu_mode: bool,
menu_nav: MenuNav,
menu_tx: async_channel::Sender<MenuEvent>,
}
impl Worker<'_> {
fn active_id(&self) -> Option<u32> {
// The pin matches by stable key (most recently connected wins if two identical pads
// share one); an unmatched pin falls through to automatic without being cleared.
if let Some(key) = &self.pinned {
if let Some(id) = self
.order
.iter()
.rev()
.copied()
.find(|&id| self.pad_info(id).is_some_and(|p| &p.key == key))
{
return Some(id);
}
}
// Automatic: the most recently connected pad — but never Steam Input's virtual pad
// while a real controller is present (see `PadInfo::steam_virtual`).
self.order
.iter()
.rev()
.copied()
.find(|&id| self.pad_info(id).is_some_and(|p| !p.steam_virtual))
.or_else(|| self.order.last().copied())
}
/// Pad metadata from SDL's ID-based getters — deliberately NO device open (see the
/// module doc; an open would grab the hardware).
fn pad_info(&self, id: u32) -> Option<PadInfo> {
if !self.order.contains(&id) {
return None;
}
let jid = sdl3::sys::joystick::SDL_JoystickID(id);
let mut pref = pref_for_type(self.subsystem.type_for_id(jid));
let (vid, pid) = (
self.subsystem.vendor_for_id(jid).unwrap_or(0),
self.subsystem.product_for_id(jid).unwrap_or(0),
);
// There is no SDL gamepad type for the Steam Deck / Steam Controller, so detect Valve by
// VID/PID (Deck 0x1205, SC wired 0x1102, SC dongle 0x1142) — the host then builds the virtual
// hid-steam pad with the back grips + dual trackpads and the right glyph identity.
if vid == 0x28DE && matches!(pid, 0x1205 | 0x1102 | 0x1142) {
pref = GamepadPref::SteamDeck;
}
let name = self
.subsystem
.name_for_id(jid)
.unwrap_or_else(|_| "Controller".into());
Some(PadInfo {
key: format!("{vid:04x}:{pid:04x}:{name}"),
steam_virtual: (vid == 0x28DE && pid == 0x11FF)
|| name.starts_with("Steam Virtual Gamepad"),
name,
pref,
})
}
/// Hold exactly the right device: the active pad while a session is attached or menu
/// mode owns navigation, nothing otherwise. The single place that decides to open
/// (= grab) hardware; dropping the old handle closes it (`SDL_CloseGamepad`) — on a
/// Deck the firmware watchdog then restores lizard mode.
fn sync_open(&mut self) {
let want = if self.attached.is_some() || self.menu_mode {
self.active_id()
} else {
None
};
if self.open.as_ref().map(|(id, _)| *id) == want {
return;
}
self.open = None;
let Some(id) = want else { return };
match self.subsystem.open(sdl3::sys::joystick::SDL_JoystickID(id)) {
Ok(pad) => {
self.open = Some((id, pad));
// Sensors stream only for an attached session (USB/BT bandwidth); the
// menu needs buttons + stick only.
if self.attached.is_some() {
self.set_sensors(true);
} else {
// The menu pad changed under us (hot-plug while the launcher is
// open): adopt the new pad's held state instead of firing it.
self.menu_nav.reset();
}
}
Err(e) => tracing::warn!(id, error = %e, "gamepad open failed"),
}
}
/// React to anything that may have moved the active-pad selection (hotplug, pin
/// change): flush held wire state if it did, then re-sync the opened device and the
/// UI-facing snapshot.
fn refresh_active(&mut self, before: Option<u32>) {
if self.active_id() != before {
self.flush_held();
}
self.sync_open();
self.publish();
}
/// Zero everything the host believes is held — on pad switch and detach.
fn flush_held(&mut self) {
if let Some(c) = &self.attached {
for b in self.held_buttons.drain(..) {
send(c, InputKind::GamepadButton, b, 0);
}
for (id, v) in self.last_axis.iter_mut().enumerate() {
if *v != 0 && *v != i32::MIN {
send(c, InputKind::GamepadAxis, id as u32, 0);
}
*v = i32::MIN;
}
// Lift any touchpad contact the host still believes is down (surface 0 = legacy pad).
for (surface, finger) in self.held_touches.drain() {
let rich = if surface == 0 {
RichInput::Touchpad {
pad: 0,
finger,
active: false,
x: 0,
y: 0,
}
} else {
RichInput::TouchpadEx {
pad: 0,
surface,
finger,
touch: false,
click: false,
x: 0,
y: 0,
pressure: 0,
}
};
let _ = c.send_rich_input(rich);
}
} else {
self.held_buttons.clear();
self.last_axis = [i32::MIN; 6];
self.held_touches.clear();
}
// A held chord doesn't survive a flush (detach / pad-switch) — clear its latches too.
self.reset_chord();
}
/// Raise the UI escape signal when the [`ESCAPE_CHORD`] just completed (latched so it
/// fires once per press) and start the hold-to-disconnect timer. Called after each
/// button-down updates `held_buttons`.
fn maybe_fire_escape(&mut self) {
if self.chord_armed {
return;
}
if ESCAPE_CHORD.iter().all(|b| self.held_buttons.contains(b)) {
self.chord_armed = true;
self.chord_since = Some(Instant::now());
let _ = self.escape_tx.try_send(());
tracing::info!(
"gamepad escape chord (L1+R1+Start+Select) — leaving fullscreen (hold to disconnect)"
);
}
}
/// Fire the disconnect signal once the escape chord has been continuously held past
/// [`DISCONNECT_HOLD`]. Polled from the main loop so the hold completes without new events.
fn maybe_fire_disconnect(&mut self) {
if self.disconnect_fired {
return;
}
if let Some(since) = self.chord_since {
if since.elapsed() >= DISCONNECT_HOLD {
self.disconnect_fired = true;
let _ = self.disconnect_tx.try_send(());
tracing::info!("gamepad escape chord held — disconnecting");
}
}
}
/// Re-arm once the chord is broken (any of its buttons released).
fn rearm_escape(&mut self) {
if self.chord_armed && !ESCAPE_CHORD.iter().all(|b| self.held_buttons.contains(b)) {
self.reset_chord();
}
}
/// Clear the escape/disconnect chord latches. Called at every session boundary
/// ([`flush_held`](Self::flush_held) on detach/pad-switch + on attach): the hold-to-disconnect
/// path *always* ends the session while the chord is still physically held, so the matching
/// button-up events arrive after detach (dropped by the `attached` guard) and `rearm_escape`
/// never runs — without this the latched state would leak into the next session and either
/// swallow its first chord press or fire a stale disconnect on connect.
fn reset_chord(&mut self) {
self.chord_armed = false;
self.chord_since = None;
self.disconnect_fired = false;
}
/// Sensors stream only while a session wants them (they cost USB/BT bandwidth).
fn set_sensors(&mut self, enabled: bool) {
if let Some((_, pad)) = self.open.as_mut() {
use sdl3::sensor::SensorType;
for s in [SensorType::Gyroscope, SensorType::Accelerometer] {
if unsafe { pad.has_sensor(s) } {
let _ = pad.sensor_set_enabled(s, enabled);
}
}
}
}
/// Forward one touchpad contact on the rich-input plane. A multi-touchpad pad (Steam Deck / Steam
/// Controller) sends `TouchpadEx` with the surface (SDL touchpad 0 = left → 1, 1 = right → 2) and
/// signed coordinates; a single-touchpad pad (DualSense) keeps the legacy `Touchpad` (unsigned).
fn forward_touch(
&mut self,
which: u32,
touchpad: u32,
finger: u8,
x: f32,
y: f32,
active: bool,
) {
let Some(c) = self.attached.as_ref() else {
return;
};
let multi = self
.open
.as_ref()
.filter(|(id, _)| *id == which)
.map(|(_, p)| p.touchpads_count() >= 2)
.unwrap_or(false);
let (cx, cy) = (x.clamp(0.0, 1.0), y.clamp(0.0, 1.0));
let surface = if multi { (touchpad as u8) + 1 } else { 0 };
let rich = if multi {
RichInput::TouchpadEx {
pad: 0,
surface,
finger,
touch: active,
click: false,
x: (cx * 65535.0 - 32768.0) as i16,
y: (cy * 65535.0 - 32768.0) as i16,
pressure: 0,
}
} else {
RichInput::Touchpad {
pad: 0,
finger,
active,
x: (cx * 65535.0) as u16,
y: (cy * 65535.0) as u16,
}
};
let _ = c.send_rich_input(rich);
if active {
self.held_touches.insert((surface, finger));
} else {
self.held_touches.remove(&(surface, finger));
}
}
/// Publish the pad list, active pad, and pin to the UI-facing mutexes.
fn publish(&self) {
let mut list: Vec<PadInfo> = self
.order
.iter()
.filter_map(|&id| self.pad_info(id))
.collect();
list.reverse(); // most recent first — the Settings list order
*self.pads_out.lock().unwrap() = list;
*self.active_out.lock().unwrap() = self.active_id().and_then(|id| self.pad_info(id));
}
/// Apply queued control-plane messages from the UI thread. Returns false when the
/// app side is gone and the worker should exit.
fn drain_ctl(&mut self, ctl: &Receiver<Ctl>) -> bool {
loop {
match ctl.try_recv() {
Ok(Ctl::Attach(c)) => {
self.attached = Some(c);
self.last_axis = [i32::MIN; 6];
self.reset_chord(); // every session starts un-latched (Attach doesn't flush)
// The Valve HIDAPI drivers run only in-session (see set_valve_hidapi);
// enabling them re-enumerates a Deck's built-in pad with paddles/
// trackpads/gyro first-class — sync_open follows the churn events.
set_valve_hidapi(true);
self.sync_open();
}
Ok(Ctl::Detach) => {
self.flush_held();
self.attached = None;
self.sync_open(); // closes the held device (menu mode keeps it)
set_valve_hidapi(false);
if self.menu_mode {
// Back to the launcher: adopt whatever is still physically held
// (the escape chord that ended the session, a lingering B) so it
// can't ghost-fire menu actions.
self.menu_nav.reset();
}
}
Ok(Ctl::Pin(key)) => {
let before = self.active_id();
self.pinned = key;
self.refresh_active(before);
}
Ok(Ctl::MenuMode(on)) => {
self.menu_mode = on;
if on {
self.menu_nav.reset();
}
self.sync_open();
}
Ok(Ctl::MenuRumble(pulse)) => {
if self.attached.is_none() {
if let Some((_, pad)) = self.open.as_mut() {
let (low, high, ms) = match pulse {
// Light high-freq detent — won't jackhammer at repeat rate.
MenuPulse::Move => (0, 0x3000, 25),
// Fuller both-motor thunk.
MenuPulse::Confirm => (0x5000, 0x5000, 60),
// Dull low-freq wall.
MenuPulse::Boundary => (0x6000, 0, 60),
};
let _ = pad.set_rumble(low, high, ms);
}
}
}
Err(std::sync::mpsc::TryRecvError::Empty) => return true,
Err(std::sync::mpsc::TryRecvError::Disconnected) => return false, // app gone
}
}
}
/// Route one SDL event: pad hotplug bookkeeping, and — while a session is attached —
/// buttons/axes/touchpads/motion of the active pad onto the wire.
fn handle_event(&mut self, event: sdl3::event::Event) {
use sdl3::event::Event;
let active = self.active_id();
match event {
Event::ControllerDeviceAdded { which, .. } => {
if !self.order.contains(&which) {
self.order.push(which);
if let Some(p) = self.pad_info(which) {
tracing::info!(name = p.name, "gamepad attached");
}
self.refresh_active(active);
}
}
Event::ControllerDeviceRemoved { which, .. } => {
if self.order.contains(&which) {
self.order.retain(|&id| id != which);
if self.open.as_ref().map(|(id, _)| *id) == Some(which) {
self.open = None; // the device is gone; drop our handle
}
tracing::info!("gamepad detached");
self.refresh_active(active);
}
}
Event::ControllerButtonDown { which, button, .. } if active == Some(which) => {
let Some(c) = self.attached.clone() else {
return;
};
if let Some(bit) = button_bit(button) {
self.held_buttons.push(bit);
send(&c, InputKind::GamepadButton, bit, 1);
self.maybe_fire_escape();
}
}
Event::ControllerButtonUp { which, button, .. } if active == Some(which) => {
let Some(c) = self.attached.clone() else {
return;
};
if let Some(bit) = button_bit(button) {
self.held_buttons.retain(|&b| b != bit);
send(&c, InputKind::GamepadButton, bit, 0);
self.rearm_escape();
}
}
Event::ControllerAxisMotion {
which, axis, value, ..
} if active == Some(which) => {
let Some(c) = self.attached.clone() else {
return;
};
let (id, v) = axis_value(axis, value);
if self.last_axis[id as usize] != v {
self.last_axis[id as usize] = v;
send(&c, InputKind::GamepadAxis, id, v);
}
}
// Touchpad contacts → the rich-input plane. One pad (DualSense) keeps the legacy
// `Touchpad`; two pads (Steam Deck / Steam Controller) send `TouchpadEx` per surface.
Event::ControllerTouchpadDown {
which,
touchpad,
finger,
x,
y,
..
}
| Event::ControllerTouchpadMotion {
which,
touchpad,
finger,
x,
y,
..
} if active == Some(which) && self.attached.is_some() => {
self.forward_touch(which, touchpad as u32, finger as u8, x, y, true);
}
Event::ControllerTouchpadUp {
which,
touchpad,
finger,
x,
y,
..
} if active == Some(which) && self.attached.is_some() => {
self.forward_touch(which, touchpad as u32, finger as u8, x, y, false);
}
// Motion: accel events update the cache; each gyro event ships a sample
// (the DualSense reports both at ~250 Hz). Scale convention shared with
// the Swift client — sign/scale derived, not yet live-verified.
Event::ControllerSensorUpdated {
which,
sensor,
data,
..
} if active == Some(which) => {
let Some(c) = self.attached.clone() else {
return;
};
use sdl3::sensor::SensorType;
match sensor {
SensorType::Accelerometer => {
for (i, v) in data.iter().enumerate() {
self.last_accel[i] =
(v / G * ACCEL_LSB_PER_G).clamp(-32768.0, 32767.0) as i16;
}
}
SensorType::Gyroscope => {
let mut gyro = [0i16; 3];
for (i, v) in data.iter().enumerate() {
gyro[i] = (v * GYRO_LSB_PER_RAD_S).clamp(-32768.0, 32767.0) as i16;
}
let _ = c.send_rich_input(RichInput::Motion {
pad: 0,
gyro,
accel: self.last_accel,
});
}
_ => {}
}
}
_ => {}
}
}
/// Sample the open pad and translate it into [`MenuEvent`]s — only while menu mode is
/// on and no session is attached (attach supersedes; SDL events merely wake the loop,
/// so a press is translated the iteration it arrives).
fn menu_poll(&mut self) {
if !self.menu_mode || self.attached.is_some() {
return;
}
let Some((_, pad)) = self.open.as_ref() else {
return;
};
use sdl3::gamepad::{Axis, Button};
let s = MenuSample {
buttons: [
pad.button(Button::South),
pad.button(Button::East),
pad.button(Button::West),
pad.button(Button::North),
pad.button(Button::LeftShoulder),
pad.button(Button::RightShoulder),
],
lx: pad.axis(Axis::LeftX),
ly: pad.axis(Axis::LeftY),
dpad: [
pad.button(Button::DPadUp),
pad.button(Button::DPadDown),
pad.button(Button::DPadLeft),
pad.button(Button::DPadRight),
],
};
let mut out = Vec::new();
self.menu_nav.poll(&s, Instant::now(), &mut out);
for e in out {
let _ = self.menu_tx.try_send(e);
}
}
/// Drain and render the feedback planes — rumble plus HID output (lightbar /
/// player LEDs / adaptive triggers) — on the active pad; this thread is their single
/// consumer. The host re-sends rumble state periodically, so a generous duration with
/// refresh-on-update is safe — a dropped stop heals within ~500 ms.
fn render_feedback(&mut self) {
let Some(connector) = self.attached.clone() else {
return;
};
while let Ok((pad, low, high)) = connector.next_rumble(Duration::ZERO) {
if pad == 0 {
if let Some((_, p)) = self.open.as_mut() {
// Surface a failed SDL rumble write: a swallowed error here (DualSense not in
// the right HIDAPI mode, etc.) reads exactly like "rumble doesn't work". The
// host logs the send side on 0xCA, so the two together pinpoint host-game vs
// client-render.
if let Err(e) = p.set_rumble(low, high, 5_000) {
tracing::warn!(low, high, error = %e, "rumble: SDL set_rumble failed");
} else {
tracing::debug!(low, high, "rumble: rendered");
}
} else {
tracing::debug!(low, high, "rumble: received but no active pad to render");
}
}
}
while let Ok(hid) = connector.next_hidout(Duration::ZERO) {
let is_ds = self
.open
.as_ref()
.and_then(|(id, _)| self.pad_info(*id))
.is_some_and(|p| p.is_dualsense());
let Some((_, pad)) = self.open.as_mut() else {
continue;
};
match hid {
HidOutput::Led { pad: 0, r, g, b } if is_ds => {
let _ = pad.send_effect(&Ds5Feedback::lightbar_packet(r, g, b));
}
HidOutput::Led { pad: 0, r, g, b } => {
let _ = pad.set_led(r, g, b);
}
HidOutput::PlayerLeds { pad: 0, bits } if is_ds => {
let _ = pad.send_effect(&Ds5Feedback::player_packet(bits));
}
HidOutput::Trigger {
pad: 0,
which,
ref effect,
} if is_ds => {
let _ = pad.send_effect(&Ds5Feedback::trigger_packet(which, effect));
}
_ => {}
}
}
}
}
fn run(
pads_out: &Mutex<Vec<PadInfo>>,
active_out: &Mutex<Option<PadInfo>>,
ctl: &Receiver<Ctl>,
escape_tx: &async_channel::Sender<()>,
disconnect_tx: &async_channel::Sender<()>,
menu_tx: &async_channel::Sender<MenuEvent>,
) -> Result<(), String> {
// Off-main-thread + no video subsystem: keep SDL away from signals, poll pads on its
// own thread.
sdl3::hint::set("SDL_NO_SIGNAL_HANDLERS", "1");
sdl3::hint::set("SDL_JOYSTICK_THREAD", "1");
// The Valve HIDAPI drivers start DISABLED (SDL defaults the Deck one ON, and its mere
// enumeration kills the Deck's trackpad-mouse system-wide — see set_valve_hidapi);
// they are enabled for the duration of an attached session only.
set_valve_hidapi(false);
let sdl = sdl3::init().map_err(|e| e.to_string())?;
let subsystem = sdl.gamepad().map_err(|e| e.to_string())?;
let mut pump = sdl.event_pump().map_err(|e| e.to_string())?;
let mut w = Worker {
subsystem,
pads_out,
active_out,
open: None,
order: Vec::new(),
pinned: None,
attached: None,
last_axis: [i32::MIN; 6],
held_buttons: Vec::new(),
held_touches: std::collections::HashSet::new(),
last_accel: [0; 3],
escape_tx: escape_tx.clone(),
disconnect_tx: disconnect_tx.clone(),
chord_armed: false,
chord_since: None,
disconnect_fired: false,
menu_mode: false,
menu_nav: MenuNav::new(),
menu_tx: menu_tx.clone(),
};
loop {
// Control plane from the UI thread.
if !w.drain_ctl(ctl) {
return Ok(());
}
// Block in SDL's own event wait instead of a fixed-interval sleep+poll: input
// events are handled the moment they arrive (the old 2 ms sleep added up to 2 ms
// per event), while the timeout bounds the polled work below — ctl messages,
// rumble/HID feedback, and the escape-chord hold check all run once per wakeup,
// so their worst case is one timeout (~10 ms attached, imperceptible for
// haptics; DISCONNECT_HOLD is 1500 ms, so 10 ms hold-check granularity is far
// inside tolerance; menu mode needs the same cadence for its repeat timing).
// Idle (no session, no menu) wakes lazily at 30 ms for hotplug + ctl.
let timeout = Duration::from_millis(if w.attached.is_some() || w.menu_mode {
10
} else {
30
});
if let Some(event) = pump.wait_event_timeout(timeout) {
w.handle_event(event);
// Drain whatever else queued while we were waiting or handling.
while let Some(event) = pump.poll_event() {
w.handle_event(event);
}
}
// Escalate a held escape chord to a disconnect (polled — the hold completes with no
// new button events; the chord itself is only detected while a session is attached).
w.maybe_fire_disconnect();
w.menu_poll();
w.render_feedback();
}
}
#[cfg(test)]
mod menu_nav_tests {
use super::*;
fn sample() -> MenuSample {
MenuSample::default()
}
fn events(nav: &mut MenuNav, s: &MenuSample, at: Instant) -> Vec<MenuEvent> {
let mut out = Vec::new();
nav.poll(s, at, &mut out);
out
}
#[test]
fn snapshot_adopts_held_state_without_firing() {
let mut nav = MenuNav::new();
let t = Instant::now();
let mut held = sample();
held.buttons[0] = true; // A held on entry
held.lx = 30000; // stick already deflected right
assert!(events(&mut nav, &held, t).is_empty(), "snapshot poll fired");
// Still held: nothing (no rising edge, direction unchanged since snapshot).
assert!(events(&mut nav, &held, t + Duration::from_millis(10)).is_empty());
// Release, then press again → now it fires.
assert!(events(&mut nav, &sample(), t + Duration::from_millis(20)).is_empty());
assert_eq!(
events(&mut nav, &held, t + Duration::from_millis(30)),
vec![MenuEvent::Confirm, MenuEvent::Move(MenuDir::Right)]
);
}
#[test]
fn buttons_fire_on_rising_edge_only() {
let mut nav = MenuNav::new();
let t = Instant::now();
events(&mut nav, &sample(), t); // consume the snapshot
let mut s = sample();
s.buttons[1] = true; // B down
assert_eq!(
events(&mut nav, &s, t + Duration::from_millis(10)),
vec![MenuEvent::Back]
);
for i in 2..20 {
assert!(
events(&mut nav, &s, t + Duration::from_millis(10 * i)).is_empty(),
"held button re-fired"
);
}
}
#[test]
fn reset_rearms_the_snapshot() {
let mut nav = MenuNav::new();
let t = Instant::now();
events(&mut nav, &sample(), t);
nav.reset();
let mut s = sample();
s.buttons[1] = true;
assert!(
events(&mut nav, &s, t + Duration::from_millis(10)).is_empty(),
"post-reset poll fired a held button"
);
}
#[test]
fn direction_repeats_after_delay_at_interval() {
let mut nav = MenuNav::new();
let t = Instant::now();
events(&mut nav, &sample(), t);
let mut s = sample();
s.dpad[3] = true; // dpad right
// Engage: fires immediately.
assert_eq!(
events(&mut nav, &s, t + Duration::from_millis(10)),
vec![MenuEvent::Move(MenuDir::Right)]
);
// Inside the initial delay: silent.
assert!(events(&mut nav, &s, t + Duration::from_millis(300)).is_empty());
// Past the delay: repeats…
assert_eq!(
events(&mut nav, &s, t + Duration::from_millis(400)),
vec![MenuEvent::Move(MenuDir::Right)]
);
// …but not faster than the interval…
assert!(events(&mut nav, &s, t + Duration::from_millis(500)).is_empty());
// …and again once it elapses.
assert_eq!(
events(&mut nav, &s, t + Duration::from_millis(570)),
vec![MenuEvent::Move(MenuDir::Right)]
);
// Release cancels; re-engage fires immediately again.
assert!(events(&mut nav, &sample(), t + Duration::from_millis(580)).is_empty());
assert_eq!(
events(&mut nav, &s, t + Duration::from_millis(590)),
vec![MenuEvent::Move(MenuDir::Right)]
);
}
#[test]
fn direction_change_fires_immediately() {
let mut nav = MenuNav::new();
let t = Instant::now();
events(&mut nav, &sample(), t);
let mut right = sample();
right.lx = 30000;
let mut left = sample();
left.lx = -30000;
assert_eq!(
events(&mut nav, &right, t + Duration::from_millis(10)),
vec![MenuEvent::Move(MenuDir::Right)]
);
assert_eq!(
events(&mut nav, &left, t + Duration::from_millis(20)),
vec![MenuEvent::Move(MenuDir::Left)]
);
}
#[test]
fn direction_resolution() {
// Below the deadzone: nothing.
let mut s = sample();
s.lx = MENU_DEADZONE as i16;
assert_eq!(MenuNav::resolve_dir(&s), None);
// Dominant axis wins; SDL +y = down.
s.lx = 20000;
s.ly = 25000;
assert_eq!(MenuNav::resolve_dir(&s), Some(MenuDir::Down));
s.ly = -25000;
assert_eq!(MenuNav::resolve_dir(&s), Some(MenuDir::Up));
s.lx = 26000;
assert_eq!(MenuNav::resolve_dir(&s), Some(MenuDir::Right));
s.lx = -26000;
assert_eq!(MenuNav::resolve_dir(&s), Some(MenuDir::Left));
// Dpad fallback…
let mut d = sample();
d.dpad[1] = true;
assert_eq!(MenuNav::resolve_dir(&d), Some(MenuDir::Down));
// …but the stick overrides it.
d.lx = 30000;
assert_eq!(MenuNav::resolve_dir(&d), Some(MenuDir::Right));
}
#[test]
fn shoulder_and_face_button_mapping() {
let mut nav = MenuNav::new();
let t = Instant::now();
events(&mut nav, &sample(), t);
let mut s = sample();
s.buttons = [false, false, true, true, true, true]; // x, y, l1, r1
assert_eq!(
events(&mut nav, &s, t + Duration::from_millis(10)),
vec![
MenuEvent::Tertiary,
MenuEvent::Secondary,
MenuEvent::JumpBack,
MenuEvent::JumpForward,
]
);
}
}