Skip to main content

repose_platform/
gamepad.rs

1//! Gamepad hardware backends.
2//!
3//! Platform-agnostic types live in [`repose_core::input`] (`GamepadEvent`,
4//! `GamepadButton`, `GamepadAxis`); UI routing lives in
5//! [`repose_app::ReposeRuntime::handle_gamepad`]. This module only feeds
6//! hardware into events. Backends implement [`GamepadBackend`]:
7//! - desktop (Linux/macOS/Windows) and web: gilrs driver (evdev / HID /
8//!   XInput-WGI / Web Gamepad API), `gamepad` feature.
9//! - android: [`AndroidBackend`] (non-joystick).
10
11use repose_core::input::{GamepadAxis, GamepadButton, GamepadEvent, GamepadId};
12
13/// Hardware poller: drain pending events since the last call.
14pub trait GamepadBackend {
15    fn poll(&mut self) -> Vec<GamepadEvent>;
16    /// Start rumble on `id`. Returns `true` when the backend accepted it
17    /// (device connected + FF supported). Default: unsupported.
18    fn set_rumble(
19        &mut self,
20        _id: GamepadId,
21        _low_freq: f32,
22        _high_freq: f32,
23        _duration_ms: u32,
24    ) -> bool {
25        false
26    }
27    /// Stop any active rumble on `id`. Default: no-op.
28    fn stop_rumble(&mut self, _id: GamepadId) {}
29    /// Whether `id` currently supports rumble. Default: false.
30    fn is_rumble_supported(&self, _id: GamepadId) -> bool {
31        false
32    }
33}
34
35/// Stick deadzone applied by all backends before emitting axis events.
36pub const STICK_DEADZONE: f32 = 0.2;
37
38pub(crate) fn apply_stick_deadzone(v: f32) -> f32 {
39    if v.abs() < STICK_DEADZONE {
40        0.0
41    } else {
42        v.signum() * (v.abs() - STICK_DEADZONE) / (1.0 - STICK_DEADZONE)
43    }
44}
45
46/// Desktop backend driven by gilrs (its mapping database is the reference
47/// implementation; repose owns the types and routing above it).
48#[cfg(all(feature = "gamepad", not(target_os = "android")))]
49pub struct GilrsBackend {
50    gilrs: gilrs::Gilrs,
51    ff_effects: std::collections::HashMap<u32, gilrs::ff::Effect>,
52}
53
54#[cfg(all(feature = "gamepad", not(target_os = "android")))]
55impl GilrsBackend {
56    pub fn new() -> Option<Self> {
57        match gilrs::Gilrs::new() {
58            Ok(gilrs) => Some(Self {
59                gilrs,
60                ff_effects: std::collections::HashMap::new(),
61            }),
62            Err(e) => {
63                log::warn!("gamepad: gilrs init failed ({e}); gamepad input disabled");
64                None
65            }
66        }
67    }
68
69    fn map_button(b: gilrs::Button) -> Option<GamepadButton> {
70        use gilrs::Button as G;
71        Some(match b {
72            G::South => GamepadButton::South,
73            G::East => GamepadButton::East,
74            G::West => GamepadButton::West,
75            G::North => GamepadButton::North,
76            G::Start => GamepadButton::Start,
77            G::Select => GamepadButton::Select,
78            G::LeftTrigger => GamepadButton::LeftShoulder,
79            G::RightTrigger => GamepadButton::RightShoulder,
80            G::LeftThumb => GamepadButton::LeftStick,
81            G::RightThumb => GamepadButton::RightStick,
82            G::DPadUp => GamepadButton::DPadUp,
83            G::DPadDown => GamepadButton::DPadDown,
84            G::DPadLeft => GamepadButton::DPadLeft,
85            G::DPadRight => GamepadButton::DPadRight,
86            _ => return None,
87        })
88    }
89
90    fn map_axis(a: gilrs::Axis) -> Option<GamepadAxis> {
91        use gilrs::Axis as G;
92        Some(match a {
93            G::LeftStickX => GamepadAxis::LeftStickX,
94            G::LeftStickY => GamepadAxis::LeftStickY,
95            G::RightStickX => GamepadAxis::RightStickX,
96            G::RightStickY => GamepadAxis::RightStickY,
97            _ => return None,
98        })
99    }
100}
101
102#[cfg(all(feature = "gamepad", not(target_os = "android")))]
103impl GamepadBackend for GilrsBackend {
104    fn poll(&mut self) -> Vec<GamepadEvent> {
105        use gilrs::EventType as E;
106        let mut out = Vec::new();
107        while let Some(ev) = self.gilrs.next_event() {
108            let id = GamepadId(usize::from(ev.id) as u32);
109            match ev.event {
110                E::Connected => {
111                    let name = self.gilrs.gamepad(ev.id).name().to_string();
112                    out.push(GamepadEvent::Connected { id, name });
113                }
114                E::Disconnected => out.push(GamepadEvent::Disconnected { id }),
115                E::ButtonPressed(b, _) | E::ButtonRepeated(b, _) => {
116                    if let Some(button) = Self::map_button(b) {
117                        out.push(GamepadEvent::Button {
118                            id,
119                            button,
120                            pressed: true,
121                        });
122                    }
123                }
124                E::ButtonReleased(b, _) => {
125                    if let Some(button) = Self::map_button(b) {
126                        out.push(GamepadEvent::Button {
127                            id,
128                            button,
129                            pressed: false,
130                        });
131                    }
132                }
133                E::ButtonChanged(b, v, _) => {
134                    let axis = match b {
135                        gilrs::Button::LeftTrigger2 => Some(GamepadAxis::LeftTrigger),
136                        gilrs::Button::RightTrigger2 => Some(GamepadAxis::RightTrigger),
137                        _ => None,
138                    };
139                    if let Some(axis) = axis {
140                        out.push(GamepadEvent::Axis {
141                            id,
142                            axis,
143                            value: v.clamp(0.0, 1.0),
144                        });
145                    }
146                }
147                E::AxisChanged(a, v, _) => {
148                    if let Some(axis) = Self::map_axis(a) {
149                        out.push(GamepadEvent::Axis {
150                            id,
151                            axis,
152                            value: apply_stick_deadzone(v),
153                        });
154                    }
155                }
156                E::Dropped | E::ForceFeedbackEffectCompleted => {}
157                _ => {}
158            }
159        }
160        out
161    }
162
163    fn is_rumble_supported(&self, id: GamepadId) -> bool {
164        let want = id.0 as usize;
165        self.gilrs
166            .gamepads()
167            .find(|(gid, _)| usize::from(*gid) == want)
168            .map(|(_, pad)| pad.is_ff_supported())
169            .unwrap_or(false)
170    }
171
172    fn set_rumble(
173        &mut self,
174        id: GamepadId,
175        low_freq: f32,
176        high_freq: f32,
177        duration_ms: u32,
178    ) -> bool {
179        use gilrs::ff::{BaseEffect, BaseEffectType, EffectBuilder, Repeat, Replay, Ticks};
180        let low = low_freq.clamp(0.0, 1.0);
181        let high = high_freq.clamp(0.0, 1.0);
182        if low <= 0.0 && high <= 0.0 {
183            self.stop_rumble(id);
184            return true;
185        }
186        let want = id.0 as usize;
187        let gid = match self
188            .gilrs
189            .gamepads()
190            .find(|(gid, _)| usize::from(*gid) == want)
191            .map(|(gid, _)| gid)
192        {
193            Some(g) => g,
194            None => return false,
195        };
196        if !self
197            .gilrs
198            .connected_gamepad(gid)
199            .map(|p| p.is_ff_supported())
200            .unwrap_or(false)
201        {
202            return false;
203        }
204        let duration = Ticks::from_ms(duration_ms.max(1));
205        let mut builder = EffectBuilder::new();
206        builder
207            .add_effect(BaseEffect {
208                kind: BaseEffectType::Strong {
209                    magnitude: (low * u16::MAX as f32) as u16,
210                },
211                scheduling: Replay {
212                    play_for: duration,
213                    ..Default::default()
214                },
215                ..Default::default()
216            })
217            .add_effect(BaseEffect {
218                kind: BaseEffectType::Weak {
219                    magnitude: (high * u16::MAX as f32) as u16,
220                },
221                scheduling: Replay {
222                    play_for: duration,
223                    ..Default::default()
224                },
225                ..Default::default()
226            })
227            .repeat(Repeat::For(duration))
228            .gamepads(&[gid]);
229        match builder.finish(&mut self.gilrs) {
230            Ok(effect) => {
231                let _ = effect.play();
232                self.ff_effects.insert(id.0, effect);
233                true
234            }
235            Err(e) => {
236                log::warn!("gamepad: rumble failed for pad {} ({e:?})", id.0);
237                false
238            }
239        }
240    }
241
242    fn stop_rumble(&mut self, id: GamepadId) {
243        if let Some(effect) = self.ff_effects.remove(&id.0) {
244            let _ = effect.stop();
245        }
246    }
247}
248
249/// Create the platform backend, or `None` when the `gamepad` feature is off.
250///
251/// One labeled arm per target (see module docs for the gilrs-Android swap).
252pub fn create_backend() -> Option<impl GamepadBackend> {
253    // Android: native-keycode backend (buttons via winit key path).
254    #[cfg(all(feature = "gamepad", target_os = "android"))]
255    {
256        AndroidBackend::new()
257    }
258    // Desktop + web: gilrs driver.
259    #[cfg(all(feature = "gamepad", not(target_os = "android")))]
260    {
261        GilrsBackend::new()
262    }
263    #[cfg(not(feature = "gamepad"))]
264    {
265        None::<NoBackend>
266    }
267}
268
269/// Android backend constructor with a concrete return type (for runners
270/// that need [`AndroidBackend::key_button`], which is not on the trait).
271#[cfg(all(feature = "gamepad", target_os = "android"))]
272pub fn create_android_backend() -> Option<AndroidBackend> {
273    AndroidBackend::new()
274}
275
276/// Android controller backend: buttons arrive as native keycodes through
277/// winit (`Key::Unidentified(NativeKeyCode::Android(code))`  - winit maps
278/// `AKEYCODE_BUTTON_*` there deliberately), so there is nothing to poll;
279/// [`AndroidBackend::key_button`] translates at the key-event site.
280/// Sticks/triggers need a future Paddleboat/JNI driver on this trait.
281#[cfg(all(feature = "gamepad", target_os = "android"))]
282pub struct AndroidBackend {
283    connected: bool,
284}
285
286#[cfg(all(feature = "gamepad", target_os = "android"))]
287impl AndroidBackend {
288    pub fn new() -> Option<Self> {
289        Some(Self { connected: false })
290    }
291
292    /// Translate a native Android keycode press/release into gamepad events.
293    /// Emits a synthetic `Connected` (virtual pad id 0) on first sight.
294    /// NOTE: Since Android offers no hotplug event through winit, it returns empty
295    /// for non-controller codes so callers can fall through to keyboard.
296    pub fn key_button(&mut self, code: u32, pressed: bool) -> Vec<GamepadEvent> {
297        let Some(button) = android_code_to_button(code) else {
298            return Vec::new();
299        };
300        let mut out = Vec::with_capacity(2);
301        if !self.connected {
302            self.connected = true;
303            out.push(GamepadEvent::Connected {
304                id: GamepadId(0),
305                name: "Android controller".to_string(),
306            });
307        }
308        out.push(GamepadEvent::Button {
309            id: GamepadId(0),
310            button,
311            pressed,
312        });
313        out
314    }
315}
316
317#[cfg(all(feature = "gamepad", target_os = "android"))]
318impl GamepadBackend for AndroidBackend {
319    fn poll(&mut self) -> Vec<GamepadEvent> {
320        Vec::new()
321    }
322}
323
324#[cfg(feature = "gamepad")]
325pub fn android_code_to_button(code: u32) -> Option<GamepadButton> {
326    Some(match code {
327        19 => GamepadButton::DPadUp,
328        20 => GamepadButton::DPadDown,
329        21 => GamepadButton::DPadLeft,
330        22 => GamepadButton::DPadRight,
331        23 => GamepadButton::South,          // DPAD_CENTER
332        96 => GamepadButton::South,          // BUTTON_A
333        97 => GamepadButton::East,           // BUTTON_B
334        99 => GamepadButton::West,           // BUTTON_X
335        100 => GamepadButton::North,         // BUTTON_Y
336        102 => GamepadButton::LeftShoulder,  // BUTTON_L1
337        103 => GamepadButton::RightShoulder, // BUTTON_R1
338        106 => GamepadButton::LeftStick,     // BUTTON_THUMBL
339        107 => GamepadButton::RightStick,    // BUTTON_THUMBR
340        108 => GamepadButton::Start,         // BUTTON_START
341        109 => GamepadButton::Select,        // BUTTON_SELECT
342        _ => return None,
343    })
344}
345
346/// Placeholder backend for targets without a driver yet.
347pub struct NoBackend;
348
349impl GamepadBackend for NoBackend {
350    fn poll(&mut self) -> Vec<GamepadEvent> {
351        Vec::new()
352    }
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358    #[test]
359    fn stick_deadzone_snaps_and_rescales() {
360        assert_eq!(apply_stick_deadzone(0.0), 0.0);
361        assert_eq!(apply_stick_deadzone(0.19), 0.0);
362        assert_eq!(apply_stick_deadzone(-0.19), 0.0);
363        assert_eq!(apply_stick_deadzone(1.0), 1.0);
364        assert_eq!(apply_stick_deadzone(-1.0), -1.0);
365        let mid = apply_stick_deadzone(0.6);
366        assert!((mid - 0.5).abs() < 1e-6);
367    }
368
369    #[test]
370    fn rumble_unsupported_by_default() {
371        let mut backend = NoBackend;
372        assert!(!backend.is_rumble_supported(GamepadId(0)));
373        assert!(!backend.set_rumble(GamepadId(0), 1.0, 1.0, 100));
374        backend.stop_rumble(GamepadId(0));
375    }
376
377    #[cfg(feature = "gamepad")]
378    #[test]
379    fn android_codes_map_to_standard_layout() {
380        assert_eq!(android_code_to_button(96), Some(GamepadButton::South));
381        assert_eq!(android_code_to_button(97), Some(GamepadButton::East));
382        assert_eq!(android_code_to_button(99), Some(GamepadButton::West));
383        assert_eq!(android_code_to_button(100), Some(GamepadButton::North));
384        assert_eq!(
385            android_code_to_button(102),
386            Some(GamepadButton::LeftShoulder)
387        );
388        assert_eq!(android_code_to_button(108), Some(GamepadButton::Start));
389        assert_eq!(android_code_to_button(19), Some(GamepadButton::DPadUp));
390        assert_eq!(android_code_to_button(23), Some(GamepadButton::South));
391        // Non-controller codes fall through to keyboard.
392        assert_eq!(android_code_to_button(29), None); // KEYCODE_A
393        assert_eq!(android_code_to_button(98), None); // BUTTON_C
394        assert_eq!(android_code_to_button(110), None); // BUTTON_MODE
395    }
396}