open-standards/aac/aac-rs/crates/aac-web/src/gamepad.rs
Transaction Science 4ac7a10baa aac-web: clippy clean with -D warnings, for the server and the browser build
`cargo clippy -p aac-web --features ssr -- -D warnings` and the hydrate
build (`--lib --target wasm32-unknown-unknown --features hydrate`, with and
without nc-symbols) failed with 17 and 26 lints already on main. Each is
fixed with no change to what anyone sees or hears:

- clones of closures that are Copy dropped (print view's close, the
  partner panel's buttons); where a closure is Copy in one build and not in
  the other (the lock's hold button, Find a word's listen button), it is
  copied in one and cloned in the other, by cfg
- collapsible ifs as let chains (camera release and check, gamepad check,
  analytics CSV download); map_or(true, ..) as is_none_or; strip_suffix
  for the -ing form; is_multiple_of for cm and seconds
- a redundant closure, a needless return, a useless .into(), an
  enumerate whose index was unused
- type aliases for the navigation bar's tools and the cell editor's
  colours; Default for AacAppState (AacAppState::new)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 16:37:27 -04:00

190 lines
6.6 KiB
Rust

//! Gamepad API bridge for switch access and joystick input.
//!
//! No other browser-based AAC app uses the Web Gamepad API.
//! This allows adaptive switches, Xbox Adaptive Controller,
//! and any USB/Bluetooth gamepad to control AAC navigation.
//!
//! Every button reports going down and coming up, by its index, so any of
//! them can be one of the person's switches (switches.rs; by default button
//! 1 is switch 1 and button 2 switch 2: an Xbox Adaptive Controller's A and
//! B, or an adaptive switch that appears as a one-button gamepad). Outside
//! switch scanning, button 2 is Back / Cancel (it closes a panel). The d-pad
//! and the left stick give directions.
#[cfg(feature = "hydrate")]
use wasm_bindgen::prelude::*;
#[cfg(feature = "hydrate")]
use web_sys::window;
/// Gamepad button state for debouncing.
#[derive(Debug, Clone)]
pub struct GamepadState {
/// Each connected pad's previous buttons and axes, by its index: shared
/// between two pads, one pad's held button read as pressed again each
/// time the other was polled.
pads: std::collections::HashMap<u32, PadState>,
/// Dead zone threshold for analog sticks.
dead_zone: f64,
}
/// One pad's previous buttons (for edge detection) and axes (for the dead
/// zone).
#[derive(Debug, Clone, Default)]
struct PadState {
buttons: Vec<bool>,
axes: Vec<f64>,
}
impl Default for GamepadState {
fn default() -> Self {
Self {
pads: std::collections::HashMap::new(),
dead_zone: 0.25,
}
}
}
/// An input event from a gamepad.
#[derive(Debug, Clone, PartialEq)]
pub enum GamepadEvent {
/// A button went down, by its index on the pad.
Down(u32),
/// A button came up again (a switch's release time counts from here).
Up(u32),
/// Directional input.
Direction(GamepadDirection),
}
/// Directional input from d-pad or analog stick.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GamepadDirection {
Up,
Down,
Left,
Right,
}
/// Poll connected gamepads and return new events since last poll.
/// Call this on each animation frame.
#[cfg(feature = "hydrate")]
pub fn poll_gamepad(state: &mut GamepadState) -> Vec<GamepadEvent> {
let mut events = Vec::new();
let Some(win) = window() else { return events };
let Some(nav) = win.navigator().get_gamepads().ok() else { return events };
let mut seen = Vec::new();
for i in 0..nav.length() {
let gp_val = nav.get(i);
if gp_val.is_null() || gp_val.is_undefined() {
continue;
}
let Some(gp) = gp_val.dyn_ref::<web_sys::Gamepad>() else { continue };
seen.push(gp.index());
let dead_zone = state.dead_zone;
let pad = state.pads.entry(gp.index()).or_default();
let buttons = gp.buttons();
let axes = gp.axes();
// Buttons: an event on each edge, down and up.
pad.buttons.resize(pad.buttons.len().max(buttons.length() as usize), false);
for j in 0..buttons.length() {
let Some(btn) = buttons.get(j).dyn_into::<web_sys::GamepadButton>().ok() else { continue };
let pressed = btn.pressed();
let was_pressed = pad.buttons.get(j as usize).copied().unwrap_or(false);
if pressed && !was_pressed {
events.push(GamepadEvent::Down(j));
match j {
12 => events.push(GamepadEvent::Direction(GamepadDirection::Up)),
13 => events.push(GamepadEvent::Direction(GamepadDirection::Down)),
14 => events.push(GamepadEvent::Direction(GamepadDirection::Left)),
15 => events.push(GamepadEvent::Direction(GamepadDirection::Right)),
_ => {}
}
} else if !pressed && was_pressed {
events.push(GamepadEvent::Up(j));
}
if let Some(slot) = pad.buttons.get_mut(j as usize) {
*slot = pressed;
}
}
// Analog stick axes (with dead zone).
pad.axes.resize(pad.axes.len().max(axes.length() as usize), 0.0);
for j in 0..axes.length() {
let Some(axis_val) = axes.get(j).as_f64() else { continue };
let prev = pad.axes.get(j as usize).copied().unwrap_or(0.0);
let was_active = prev.abs() > dead_zone;
let is_active = axis_val.abs() > dead_zone;
if is_active && !was_active {
match j {
0 => { // Left stick X
if axis_val > dead_zone {
events.push(GamepadEvent::Direction(GamepadDirection::Right));
} else if axis_val < -dead_zone {
events.push(GamepadEvent::Direction(GamepadDirection::Left));
}
}
1 => { // Left stick Y
if axis_val > dead_zone {
events.push(GamepadEvent::Direction(GamepadDirection::Down));
} else if axis_val < -dead_zone {
events.push(GamepadEvent::Direction(GamepadDirection::Up));
}
}
_ => {}
}
}
if let Some(slot) = pad.axes.get_mut(j as usize) {
*slot = axis_val;
}
}
}
// A pad unplugged: its buttons held down come up (else the switch they
// are would stay down, and its next press never count), and it is
// forgotten (plugged in again, it starts afresh).
for (_, pad) in state.pads.iter().filter(|(index, _)| !seen.contains(index)) {
for (j, &down) in pad.buttons.iter().enumerate() {
if down {
events.push(GamepadEvent::Up(j as u32));
}
}
}
state.pads.retain(|index, _| seen.contains(index));
events
}
impl GamepadState {
/// Whether a pad was read at the last poll (one gone since is read once
/// more, so its held buttons come up).
pub fn has_pads(&self) -> bool {
!self.pads.is_empty()
}
}
/// Check if any gamepad is connected.
#[cfg(feature = "hydrate")]
pub fn is_gamepad_connected() -> bool {
if let Some(win) = window()
&& let Ok(gamepads) = win.navigator().get_gamepads()
{
for i in 0..gamepads.length() {
let gp = gamepads.get(i);
if !gp.is_null() && !gp.is_undefined() {
return true;
}
}
}
false
}
#[cfg(not(feature = "hydrate"))]
pub fn is_gamepad_connected() -> bool {
false
}
#[cfg(not(feature = "hydrate"))]
pub fn poll_gamepad(_state: &mut GamepadState) -> Vec<GamepadEvent> {
Vec::new()
}