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