feat(linux-client): gamepad library launcher — console-style coverflow (--browse)
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

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>
This commit is contained in:
2026-07-03 21:41:43 +00:00
parent 69609945a3
commit 38078fe7ee
10 changed files with 1888 additions and 83 deletions
+459 -10
View File
@@ -18,6 +18,17 @@
//! 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;
@@ -50,6 +61,169 @@ const ESCAPE_CHORD: [u32; 4] = [wire::BTN_LB, wire::BTN_RB, wire::BTN_START, wir
/// 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,
@@ -133,6 +307,8 @@ enum Ctl {
Attach(Arc<NativeClient>),
Detach,
Pin(Option<String>),
MenuMode(bool),
MenuRumble(MenuPulse),
}
#[derive(Clone)]
@@ -146,6 +322,9 @@ pub struct GamepadService {
/// 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 {
@@ -155,11 +334,12 @@ impl GamepadService {
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) {
if let Err(e) = run(&p, &a, &ctl_rx, &escape_tx, &disconnect_tx, &menu_tx) {
tracing::warn!(error = %e, "gamepad service ended — pads disabled");
}
})
@@ -172,6 +352,7 @@ impl GamepadService {
ctl,
escape_rx,
disconnect_rx,
menu_rx,
}
}
@@ -187,6 +368,25 @@ impl GamepadService {
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()
}
@@ -363,6 +563,11 @@ struct Worker<'a> {
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<'_> {
@@ -421,12 +626,12 @@ impl Worker<'_> {
})
}
/// Hold exactly the right device: the active pad while a session is attached, 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.
/// 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() {
let want = if self.attached.is_some() || self.menu_mode {
self.active_id()
} else {
None
@@ -439,7 +644,15 @@ impl Worker<'_> {
match self.subsystem.open(sdl3::sys::joystick::SDL_JoystickID(id)) {
Ok(pad) => {
self.open = Some((id, pad));
self.set_sensors(true);
// 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"),
}
@@ -645,14 +858,42 @@ impl Worker<'_> {
Ok(Ctl::Detach) => {
self.flush_held();
self.attached = None;
self.sync_open(); // closes the held device
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
}
@@ -784,6 +1025,42 @@ impl Worker<'_> {
}
}
/// 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
@@ -847,6 +1124,7 @@ fn run(
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.
@@ -877,6 +1155,9 @@ fn run(
chord_armed: false,
chord_since: None,
disconnect_fired: false,
menu_mode: false,
menu_nav: MenuNav::new(),
menu_tx: menu_tx.clone(),
};
loop {
@@ -891,8 +1172,13 @@ fn run(
// 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). Idle (no session) wakes lazily at 30 ms for hotplug + ctl.
let timeout = Duration::from_millis(if w.attached.is_some() { 10 } else { 30 });
// 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.
@@ -905,6 +1191,169 @@ fn run(
// 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,
]
);
}
}