Skip to main content

sgl_input/
lib.rs

1//! Caller-polled controller input. Games own the window/event loop, focus,
2//! bindings, active-player selection and menu behavior.
3//!
4//! Construct and poll on the window's main thread. macOS uses Apple's Game
5//! Controller framework; other targets use Gilrs. See the `controllers` example.
6use std::collections::{BTreeMap, HashMap, HashSet};
7
8#[cfg(target_os = "macos")]
9#[allow(unsafe_code)]
10mod macos;
11#[cfg(target_os = "macos")]
12use macos::Backend;
13#[cfg(not(target_os = "macos"))]
14mod portable;
15#[cfg(not(target_os = "macos"))]
16use portable::Backend;
17
18#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
19pub struct GamepadId(pub(crate) usize);
20
21/// Face buttons are named by position: South is Xbox A / PlayStation Cross.
22#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
23pub enum Button {
24    South,
25    East,
26    West,
27    North,
28    DPadUp,
29    DPadDown,
30    DPadLeft,
31    DPadRight,
32    LeftTrigger,
33    RightTrigger,
34    LeftTrigger2,
35    RightTrigger2,
36    LeftThumb,
37    RightThumb,
38    Select,
39    Start,
40    Mode,
41}
42
43/// Stick axes range from -1 to 1. Positive Y always points up.
44#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
45pub enum Axis {
46    LeftStickX,
47    LeftStickY,
48    RightStickX,
49    RightStickY,
50}
51
52#[derive(Clone, Copy, Debug, PartialEq)]
53pub enum EventType {
54    Connected,
55    Disconnected,
56    ButtonPressed(Button),
57    ButtonReleased(Button),
58    AxisChanged(Axis, f32),
59}
60#[derive(Clone, Copy, Debug, PartialEq)]
61pub struct Event {
62    pub id: GamepadId,
63    pub event: EventType,
64}
65
66#[derive(Debug)]
67pub struct Gamepad {
68    name: String,
69    pressed: HashSet<Button>,
70    axes: HashMap<Axis, f32>,
71}
72impl Gamepad {
73    pub fn name(&self) -> &str {
74        &self.name
75    }
76    pub fn is_pressed(&self, button: Button) -> bool {
77        self.pressed.contains(&button)
78    }
79    pub fn value(&self, axis: Axis) -> f32 {
80        self.axes.get(&axis).copied().unwrap_or(0.0)
81    }
82}
83
84pub(crate) enum Update {
85    Connected(GamepadId, String),
86    Input(Event),
87}
88
89#[derive(Default)]
90struct State {
91    pads: BTreeMap<GamepadId, Gamepad>,
92}
93impl State {
94    fn apply(&mut self, update: Update) -> Option<Event> {
95        let event = match update {
96            Update::Connected(id, name) => {
97                self.pads.insert(
98                    id,
99                    Gamepad {
100                        name,
101                        pressed: HashSet::new(),
102                        axes: HashMap::new(),
103                    },
104                );
105                return Some(Event {
106                    id,
107                    event: EventType::Connected,
108                });
109            }
110            Update::Input(event) => event,
111        };
112        if event.event == EventType::Disconnected {
113            self.pads.remove(&event.id)?;
114        } else {
115            // Callbacks already in flight for a removed controller are ignored.
116            let pad = self.pads.get_mut(&event.id)?;
117            match event.event {
118                EventType::ButtonPressed(button) => {
119                    pad.pressed.insert(button);
120                }
121                EventType::ButtonReleased(button) => {
122                    pad.pressed.remove(&button);
123                }
124                EventType::AxisChanged(axis, value) => {
125                    pad.axes.insert(axis, value);
126                }
127                _ => {}
128            }
129        }
130        Some(event)
131    }
132}
133
134pub struct Gamepads {
135    backend: Backend,
136    state: State,
137}
138impl Gamepads {
139    pub fn new() -> Result<Self, String> {
140        Ok(Self {
141            backend: Backend::new()?,
142            state: State::default(),
143        })
144    }
145    /// Drain ordered events and update current state. Poll even while unfocused
146    /// so disconnects/releases are consumed; gate game actions on window focus.
147    /// Press/release pairs remain in the event list even within a single poll.
148    pub fn poll(&mut self) -> Vec<Event> {
149        self.backend
150            .poll()
151            .into_iter()
152            .filter_map(|event| self.state.apply(event))
153            .collect()
154    }
155    pub fn gamepad(&self, id: GamepadId) -> Option<&Gamepad> {
156        self.state.pads.get(&id)
157    }
158    pub fn gamepads(&self) -> impl Iterator<Item = (GamepadId, &Gamepad)> {
159        self.state.pads.iter().map(|(&id, pad)| (id, pad))
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    #[test]
167    fn quick_taps_survive_polling_and_disconnect_clears_held_state() {
168        let mut state = State::default();
169        let id = GamepadId(0);
170        state.apply(Update::Connected(id, "pad".into()));
171        let events: Vec<_> = [
172            EventType::ButtonPressed(Button::South),
173            EventType::ButtonReleased(Button::South),
174        ]
175        .into_iter()
176        .filter_map(|event| state.apply(Update::Input(Event { id, event })))
177        .collect();
178        assert_eq!(
179            events.len(),
180            2,
181            "a complete tap between frames must remain actionable"
182        );
183        assert!(!state.pads[&id].is_pressed(Button::South));
184        state.apply(Update::Input(Event {
185            id,
186            event: EventType::ButtonPressed(Button::DPadLeft),
187        }));
188        assert!(state.pads[&id].is_pressed(Button::DPadLeft));
189        state.apply(Update::Input(Event {
190            id,
191            event: EventType::Disconnected,
192        }));
193        assert!(state.pads.is_empty());
194        assert!(
195            state
196                .apply(Update::Input(Event {
197                    id,
198                    event: EventType::ButtonPressed(Button::South)
199                }))
200                .is_none()
201        );
202        let new_id = GamepadId(1);
203        state.apply(Update::Connected(new_id, "pad".into()));
204        assert!(!state.pads[&new_id].is_pressed(Button::DPadLeft));
205    }
206}