`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]>
190 lines
6.6 KiB
Rust
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()
|
|
}
|