Another project
1use core::fmt;
2use core::num::NonZeroU32;
3use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6
7use crate::input::{KeyCode, KeyEvent, ModifierMask};
8
9#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
10pub struct KeyChord {
11 pub key: KeyCode,
12 pub modifiers: ModifierMask,
13}
14
15impl KeyChord {
16 #[must_use]
17 pub const fn new(key: KeyCode, modifiers: ModifierMask) -> Self {
18 Self { key, modifiers }
19 }
20}
21
22impl From<KeyEvent> for KeyChord {
23 fn from(event: KeyEvent) -> Self {
24 Self {
25 key: event.code,
26 modifiers: event.modifiers,
27 }
28 }
29}
30
31impl fmt::Display for KeyChord {
32 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33 fmt::Display::fmt(&self.modifiers, f)?;
34 fmt::Display::fmt(&self.key, f)
35 }
36}
37
38#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
39pub enum HotkeyScope {
40 Global,
41 Viewport,
42 FeatureTree,
43 Sketch,
44 Extrude,
45 Modal,
46 TextInput,
47}
48
49#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
50#[serde(transparent)]
51pub struct ActionId(NonZeroU32);
52
53impl ActionId {
54 #[must_use]
55 pub const fn new(id: NonZeroU32) -> Self {
56 Self(id)
57 }
58
59 #[must_use]
60 pub const fn get(self) -> NonZeroU32 {
61 self.0
62 }
63}
64
65#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
66pub struct HotkeyBinding {
67 pub chord: KeyChord,
68 pub scope: HotkeyScope,
69 pub action: ActionId,
70}
71
72impl HotkeyBinding {
73 #[must_use]
74 pub const fn new(chord: KeyChord, scope: HotkeyScope, action: ActionId) -> Self {
75 Self {
76 chord,
77 scope,
78 action,
79 }
80 }
81}
82
83#[derive(Debug, thiserror::Error, PartialEq, Eq)]
84pub enum HotkeyTableError {
85 #[error("hotkey conflict: chord already bound in this scope")]
86 Conflict {
87 chord: KeyChord,
88 scope: HotkeyScope,
89 existing: ActionId,
90 attempted: ActionId,
91 },
92}
93
94#[derive(Clone, Debug, Default, PartialEq)]
95pub struct HotkeyTable {
96 bindings: BTreeMap<(HotkeyScope, KeyChord), ActionId>,
97}
98
99impl HotkeyTable {
100 #[must_use]
101 pub fn new() -> Self {
102 Self::default()
103 }
104
105 pub fn try_from_bindings(bindings: Vec<HotkeyBinding>) -> Result<Self, HotkeyTableError> {
106 bindings
107 .into_iter()
108 .try_fold(Self::new(), |mut table, binding| {
109 table.try_register(binding)?;
110 Ok(table)
111 })
112 }
113
114 pub fn try_register(&mut self, binding: HotkeyBinding) -> Result<(), HotkeyTableError> {
115 let key = (binding.scope, binding.chord);
116 if let Some(&existing) = self.bindings.get(&key) {
117 return Err(HotkeyTableError::Conflict {
118 chord: binding.chord,
119 scope: binding.scope,
120 existing,
121 attempted: binding.action,
122 });
123 }
124 self.bindings.insert(key, binding.action);
125 Ok(())
126 }
127
128 #[must_use]
129 pub fn dispatch(&self, chord: KeyChord, scopes: &HotkeyScopes) -> Option<ActionId> {
130 scopes
131 .innermost_first()
132 .find_map(|scope| self.bindings.get(&(*scope, chord)).copied())
133 }
134}
135
136#[derive(Clone, Debug, Default, PartialEq, Eq)]
137pub struct HotkeyScopes {
138 stack: Vec<HotkeyScope>,
139}
140
141impl HotkeyScopes {
142 #[must_use]
143 pub fn from_outer_to_inner<I: IntoIterator<Item = HotkeyScope>>(scopes: I) -> Self {
144 Self {
145 stack: scopes.into_iter().collect(),
146 }
147 }
148
149 pub fn push(&mut self, scope: HotkeyScope) {
150 self.stack.push(scope);
151 }
152
153 pub fn pop(&mut self) -> Option<HotkeyScope> {
154 self.stack.pop()
155 }
156
157 #[must_use]
158 pub fn innermost_first(&self) -> impl DoubleEndedIterator<Item = &HotkeyScope> {
159 self.stack.iter().rev()
160 }
161}
162
163#[cfg(test)]
164mod tests {
165 use core::num::NonZeroU32;
166
167 use super::{
168 ActionId, HotkeyBinding, HotkeyScope, HotkeyScopes, HotkeyTable, HotkeyTableError, KeyChord,
169 };
170 use crate::input::{KeyChar, KeyCode, ModifierMask, NamedKey};
171
172 fn scopes(s: &[HotkeyScope]) -> HotkeyScopes {
173 HotkeyScopes::from_outer_to_inner(s.iter().copied())
174 }
175
176 fn action(n: u32) -> ActionId {
177 let Some(nz) = NonZeroU32::new(n) else {
178 panic!("test action id must be non-zero");
179 };
180 ActionId::new(nz)
181 }
182
183 fn ctrl_s() -> KeyChord {
184 KeyChord::new(KeyCode::Char(KeyChar::from_char('s')), ModifierMask::CTRL)
185 }
186
187 fn esc() -> KeyChord {
188 KeyChord::new(KeyCode::Named(NamedKey::Escape), ModifierMask::NONE)
189 }
190
191 #[test]
192 fn duplicate_chord_in_same_scope_conflicts() {
193 let result = HotkeyTable::try_from_bindings(vec![
194 HotkeyBinding::new(ctrl_s(), HotkeyScope::Global, action(1)),
195 HotkeyBinding::new(ctrl_s(), HotkeyScope::Global, action(2)),
196 ]);
197 match result {
198 Err(HotkeyTableError::Conflict {
199 existing,
200 attempted,
201 ..
202 }) => {
203 assert_eq!(existing, action(1));
204 assert_eq!(attempted, action(2));
205 }
206 _ => panic!("expected conflict"),
207 }
208 }
209
210 #[test]
211 fn same_chord_different_scopes_does_not_conflict() {
212 let table = HotkeyTable::try_from_bindings(vec![
213 HotkeyBinding::new(esc(), HotkeyScope::Global, action(1)),
214 HotkeyBinding::new(esc(), HotkeyScope::Modal, action(2)),
215 ]);
216 assert!(table.is_ok());
217 }
218
219 #[test]
220 fn dispatch_innermost_scope_first() {
221 let Ok(table) = HotkeyTable::try_from_bindings(vec![
222 HotkeyBinding::new(esc(), HotkeyScope::Global, action(1)),
223 HotkeyBinding::new(esc(), HotkeyScope::Modal, action(99)),
224 ]) else {
225 panic!("registration must succeed");
226 };
227 let with_modal = scopes(&[HotkeyScope::Global, HotkeyScope::Modal]);
228 let no_modal = scopes(&[HotkeyScope::Global]);
229 assert_eq!(table.dispatch(esc(), &with_modal), Some(action(99)));
230 assert_eq!(table.dispatch(esc(), &no_modal), Some(action(1)));
231 }
232
233 #[test]
234 fn dispatch_misses_unmatched_chord() {
235 let Ok(table) = HotkeyTable::try_from_bindings(vec![HotkeyBinding::new(
236 ctrl_s(),
237 HotkeyScope::Global,
238 action(1),
239 )]) else {
240 panic!("registration must succeed");
241 };
242 assert_eq!(table.dispatch(esc(), &scopes(&[HotkeyScope::Global])), None);
243 }
244
245 #[test]
246 fn dispatch_ignores_inactive_scope() {
247 let Ok(table) = HotkeyTable::try_from_bindings(vec![HotkeyBinding::new(
248 esc(),
249 HotkeyScope::Modal,
250 action(7),
251 )]) else {
252 panic!("registration must succeed");
253 };
254 assert_eq!(table.dispatch(esc(), &scopes(&[HotkeyScope::Global])), None);
255 assert_eq!(
256 table.dispatch(esc(), &scopes(&[HotkeyScope::Modal])),
257 Some(action(7))
258 );
259 }
260}