Skip to main content

khora_core/platform/
input_map.rs

1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Action / binding abstraction over raw [`InputEvent`]s.
16//!
17//! Game code asks "is the `jump` action pressed?" instead of pattern-matching
18//! on every key. Bindings can be reconfigured at runtime (rebind menus,
19//! per-profile mappings) without touching gameplay logic.
20//!
21//! The map is updated once per frame from the engine's input queue; query
22//! functions never block and never allocate.
23//!
24//! # Example
25//!
26//! ```
27//! # use khora_core::platform::input_map::{Action, InputBinding, InputMap};
28//! # use khora_core::platform::{InputEvent, KeyCode, MouseButton};
29//! let mut map = InputMap::new();
30//! map.bind("jump", InputBinding::Key(KeyCode::Space));
31//! map.bind("jump", InputBinding::Mouse(MouseButton::Left)); // also fires on click
32//!
33//! map.update(&[InputEvent::KeyPressed { key_code: KeyCode::Space }]);
34//! assert!(map.is_pressed("jump"));
35//! assert!(map.just_pressed("jump"));
36//!
37//! map.update(&[]); // next frame, no event
38//! assert!(map.is_pressed("jump"));        // still held — no Released event
39//! assert!(!map.just_pressed("jump"));     // edge cleared
40//! ```
41
42use std::borrow::Cow;
43use std::collections::{HashMap, HashSet};
44
45use super::input::{InputEvent, KeyCode, MouseButton};
46
47/// A user-defined action name like `"jump"`, `"fire"`, `"menu_back"`.
48///
49/// `Cow<'static, str>` lets you bind from `&'static str` literals without
50/// allocating, while still allowing dynamic action names from config files.
51#[derive(Debug, Clone, PartialEq, Eq, Hash)]
52pub struct Action(pub Cow<'static, str>);
53
54impl Action {
55    /// Returns the action name as a string slice.
56    pub fn as_str(&self) -> &str {
57        &self.0
58    }
59}
60
61impl From<&'static str> for Action {
62    fn from(s: &'static str) -> Self {
63        Self(Cow::Borrowed(s))
64    }
65}
66
67impl From<String> for Action {
68    fn from(s: String) -> Self {
69        Self(Cow::Owned(s))
70    }
71}
72
73/// Anything that can be bound to an [`Action`].
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
75pub enum InputBinding {
76    /// A keyboard key (physical code).
77    Key(KeyCode),
78    /// A mouse button.
79    Mouse(MouseButton),
80}
81
82/// Action / binding map. One per engine — registered as a service in the
83/// `ServiceRegistry` and updated once per frame from the input queue.
84#[derive(Debug, Default)]
85pub struct InputMap {
86    /// `action → list of bindings` (multiple bindings → action fires when ANY of them fires).
87    bindings: HashMap<Action, Vec<InputBinding>>,
88    /// Inverse index for O(1) update — `binding → list of actions it fires`.
89    inverse: HashMap<InputBinding, Vec<Action>>,
90    /// Currently-held actions.
91    pressed: HashSet<Action>,
92    /// Actions that became pressed this frame (cleared at the start of `update`).
93    just_pressed: HashSet<Action>,
94    /// Actions that became released this frame (cleared at the start of `update`).
95    just_released: HashSet<Action>,
96}
97
98impl InputMap {
99    /// Creates an empty map with no bindings.
100    pub fn new() -> Self {
101        Self::default()
102    }
103
104    /// Adds a binding for `action`. An action can have multiple bindings —
105    /// it fires when ANY of them fires (logical OR).
106    pub fn bind(&mut self, action: impl Into<Action>, binding: InputBinding) {
107        let action = action.into();
108        self.bindings
109            .entry(action.clone())
110            .or_default()
111            .push(binding);
112        self.inverse.entry(binding).or_default().push(action);
113    }
114
115    /// Removes a single binding for `action`. Returns `true` if found.
116    pub fn unbind(&mut self, action: &Action, binding: &InputBinding) -> bool {
117        let removed_from_bindings = self
118            .bindings
119            .get_mut(action)
120            .map(|v| {
121                let len_before = v.len();
122                v.retain(|b| b != binding);
123                v.len() != len_before
124            })
125            .unwrap_or(false);
126        if removed_from_bindings {
127            if let Some(actions) = self.inverse.get_mut(binding) {
128                actions.retain(|a| a != action);
129            }
130        }
131        removed_from_bindings
132    }
133
134    /// Removes every binding for `action`.
135    pub fn clear_action(&mut self, action: &Action) {
136        if let Some(bindings) = self.bindings.remove(action) {
137            for b in bindings {
138                if let Some(actions) = self.inverse.get_mut(&b) {
139                    actions.retain(|a| a != action);
140                }
141            }
142        }
143        self.pressed.remove(action);
144        self.just_pressed.remove(action);
145        self.just_released.remove(action);
146    }
147
148    /// Returns `true` when the named action is currently held.
149    pub fn is_pressed(&self, action: &str) -> bool {
150        self.pressed.iter().any(|a| a.as_str() == action)
151    }
152
153    /// Returns `true` only on the frame the action transitioned from
154    /// released → pressed. Cleared on the next `update` call.
155    pub fn just_pressed(&self, action: &str) -> bool {
156        self.just_pressed.iter().any(|a| a.as_str() == action)
157    }
158
159    /// Returns `true` only on the frame the action transitioned from
160    /// pressed → released. Cleared on the next `update` call.
161    pub fn just_released(&self, action: &str) -> bool {
162        self.just_released.iter().any(|a| a.as_str() == action)
163    }
164
165    /// Drains a frame of input events into the action sets. Call once per
166    /// frame **before** game logic queries the map. Edge sets (`just_*`)
167    /// are cleared first; held set persists across frames until a Released
168    /// event arrives.
169    pub fn update(&mut self, events: &[InputEvent]) {
170        self.just_pressed.clear();
171        self.just_released.clear();
172
173        for event in events {
174            match event {
175                InputEvent::KeyPressed { key_code } => {
176                    self.fire_press(InputBinding::Key(*key_code));
177                }
178                InputEvent::KeyReleased { key_code } => {
179                    self.fire_release(InputBinding::Key(*key_code));
180                }
181                InputEvent::MouseButtonPressed { button } => {
182                    self.fire_press(InputBinding::Mouse(*button));
183                }
184                InputEvent::MouseButtonReleased { button } => {
185                    self.fire_release(InputBinding::Mouse(*button));
186                }
187                _ => {} // mouse motion / wheel don't drive actions today
188            }
189        }
190    }
191
192    fn fire_press(&mut self, binding: InputBinding) {
193        if let Some(actions) = self.inverse.get(&binding) {
194            for action in actions {
195                if self.pressed.insert(action.clone()) {
196                    self.just_pressed.insert(action.clone());
197                }
198            }
199        }
200    }
201
202    fn fire_release(&mut self, binding: InputBinding) {
203        if let Some(actions) = self.inverse.get(&binding) {
204            for action in actions {
205                if self.pressed.remove(action) {
206                    self.just_released.insert(action.clone());
207                }
208            }
209        }
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    #[test]
218    fn bind_and_press() {
219        let mut m = InputMap::new();
220        m.bind("jump", InputBinding::Key(KeyCode::Space));
221        m.update(&[InputEvent::KeyPressed {
222            key_code: KeyCode::Space,
223        }]);
224        assert!(m.is_pressed("jump"));
225        assert!(m.just_pressed("jump"));
226    }
227
228    #[test]
229    fn just_pressed_clears_next_frame() {
230        let mut m = InputMap::new();
231        m.bind("jump", InputBinding::Key(KeyCode::Space));
232        m.update(&[InputEvent::KeyPressed {
233            key_code: KeyCode::Space,
234        }]);
235        assert!(m.just_pressed("jump"));
236
237        m.update(&[]);
238        assert!(m.is_pressed("jump"), "still held — no release event");
239        assert!(!m.just_pressed("jump"), "edge should be cleared");
240    }
241
242    #[test]
243    fn release_event_clears_pressed() {
244        let mut m = InputMap::new();
245        m.bind("fire", InputBinding::Mouse(MouseButton::Left));
246
247        m.update(&[InputEvent::MouseButtonPressed {
248            button: MouseButton::Left,
249        }]);
250        assert!(m.is_pressed("fire"));
251
252        m.update(&[InputEvent::MouseButtonReleased {
253            button: MouseButton::Left,
254        }]);
255        assert!(!m.is_pressed("fire"));
256        assert!(m.just_released("fire"));
257    }
258
259    #[test]
260    fn multi_binding_either_works() {
261        let mut m = InputMap::new();
262        m.bind("jump", InputBinding::Key(KeyCode::Space));
263        m.bind("jump", InputBinding::Mouse(MouseButton::Left));
264
265        // Mouse fires it.
266        m.update(&[InputEvent::MouseButtonPressed {
267            button: MouseButton::Left,
268        }]);
269        assert!(m.is_pressed("jump"));
270
271        // Release just the mouse — the action is still considered held
272        // because no internal "ref-count per binding" is tracked. This
273        // matches Unity's InputAction semantics: the action is one logical
274        // boolean fed by OR of its bindings, but the release path uses the
275        // same OR path — releasing any binding "releases the action". This
276        // is the simpler / more predictable behaviour for keyboard+mouse.
277        m.update(&[InputEvent::MouseButtonReleased {
278            button: MouseButton::Left,
279        }]);
280        assert!(!m.is_pressed("jump"));
281    }
282
283    #[test]
284    fn unbind_removes_inverse_index() {
285        let mut m = InputMap::new();
286        let action = Action::from("jump");
287        let binding = InputBinding::Key(KeyCode::Space);
288        m.bind(action.clone(), binding);
289        assert!(m.unbind(&action, &binding));
290
291        m.update(&[InputEvent::KeyPressed {
292            key_code: KeyCode::Space,
293        }]);
294        assert!(!m.is_pressed("jump"));
295    }
296}