Skip to main content

repame_input/
map.rs

1//! Action map: bindings grouped per action, gated by contexts.
2
3use std::collections::{HashMap, HashSet};
4use std::hash::Hash;
5
6use super::binding::Binding;
7
8/// Named action set, e.g. `"gameplay"` vs `"menu"`. With no contexts
9/// every action is live; otherwise only actions in an active context fire.
10#[derive(Clone, Debug, Default)]
11pub struct ActionMap<A> {
12    bindings: HashMap<A, Vec<Binding>>,
13    contexts: HashMap<String, HashSet<A>>,
14}
15
16impl<A: Clone + Eq + Hash> ActionMap<A> {
17    pub fn new() -> Self {
18        Self {
19            bindings: HashMap::new(),
20            contexts: HashMap::new(),
21        }
22    }
23
24    /// Bind one more physical source to an action.
25    pub fn bind(&mut self, action: A, binding: Binding) -> &mut Self {
26        self.bindings.entry(action).or_default().push(binding);
27        self
28    }
29
30    /// Put an action in a named context.
31    pub fn in_context(&mut self, context: &str, action: A) -> &mut Self {
32        self.contexts
33            .entry(context.to_string())
34            .or_default()
35            .insert(action);
36        self
37    }
38
39    /// All bindings driving `action` (empty if unbound).
40    pub fn bindings_for(&self, action: &A) -> &[Binding] {
41        self.bindings.get(action).map(Vec::as_slice).unwrap_or(&[])
42    }
43
44    /// Actions holding at least one binding.
45    pub fn actions(&self) -> impl Iterator<Item = &A> {
46        self.bindings.keys()
47    }
48
49    /// True when the map defines no contexts at all.
50    pub fn has_contexts(&self) -> bool {
51        !self.contexts.is_empty()
52    }
53
54    /// True when `action` may fire under `active` contexts.
55    /// Actions in no context stay live (shared buttons like pause).
56    pub fn live_in(&self, action: &A, active: &HashSet<String>) -> bool {
57        if !self.has_contexts() {
58            return true;
59        }
60        if !self.contexts.values().any(|set| set.contains(action)) {
61            return true;
62        }
63        self.contexts
64            .iter()
65            .any(|(name, set)| active.contains(name) && set.contains(action))
66    }
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72    use repose_core::input::{Key, Modifiers};
73
74    fn chord() -> Binding {
75        Binding::Key(repose_core::shortcuts::KeyChord::new(
76            Key::Space,
77            Modifiers::default(),
78        ))
79    }
80
81    #[test]
82    fn unbound_action_has_no_bindings() {
83        let map: ActionMap<&str> = ActionMap::new();
84        assert!(map.bindings_for(&"jump").is_empty());
85    }
86
87    #[test]
88    fn context_gating() {
89        let mut map = ActionMap::new();
90        map.bind("jump", chord());
91        map.bind("pause", chord());
92        map.in_context("gameplay", "jump");
93        let active: HashSet<String> = ["gameplay".to_string()].into_iter().collect();
94        assert!(map.live_in(&"jump", &active));
95        assert!(map.live_in(&"pause", &active));
96        let menu: HashSet<String> = ["menu".to_string()].into_iter().collect();
97        assert!(!map.live_in(&"jump", &menu));
98        assert!(map.live_in(&"pause", &menu));
99    }
100}