retroglyph_widgets/interact/
shortcuts.rs1use retroglyph_core::{Event, KeyCode, KeyModifiers};
4
5#[derive(Debug, Clone, Copy)]
7struct Binding<Id, Action> {
8 scope: Option<Id>,
11 code: KeyCode,
12 modifiers: KeyModifiers,
13 action: Action,
14}
15
16#[derive(Debug, Clone)]
69pub struct Shortcuts<Id, Action> {
70 bindings: Vec<Binding<Id, Action>>,
71}
72
73impl<Id, Action> Shortcuts<Id, Action> {
74 #[must_use]
76 pub const fn new() -> Self {
77 Self {
78 bindings: Vec::new(),
79 }
80 }
81}
82
83impl<Id: Copy + PartialEq, Action: Copy> Shortcuts<Id, Action> {
84 pub fn bind_global(&mut self, code: KeyCode, modifiers: KeyModifiers, action: Action) {
86 self.bindings.push(Binding {
87 scope: None,
88 code,
89 modifiers,
90 action,
91 });
92 }
93
94 pub fn bind_scoped(&mut self, id: Id, code: KeyCode, modifiers: KeyModifiers, action: Action) {
96 self.bindings.push(Binding {
97 scope: Some(id),
98 code,
99 modifiers,
100 action,
101 });
102 }
103
104 #[must_use]
113 pub fn resolve(&self, event: &Event, focused: Option<Id>) -> Option<Action> {
114 let Event::Key(key) = event else {
115 return None;
116 };
117 if !key.is_down() {
118 return None;
119 }
120 let matches = |b: &&Binding<Id, Action>| b.code == key.code && b.modifiers == key.modifiers;
121
122 if let Some(focused) = focused
123 && let Some(binding) = self
124 .bindings
125 .iter()
126 .find(|b| b.scope == Some(focused) && matches(b))
127 {
128 return Some(binding.action);
129 }
130 self.bindings
131 .iter()
132 .find(|b| b.scope.is_none() && matches(b))
133 .map(|b| b.action)
134 }
135}
136
137impl<Id, Action> Default for Shortcuts<Id, Action> {
141 fn default() -> Self {
142 Self::new()
143 }
144}
145
146#[cfg(test)]
147mod tests {
148 use retroglyph_core::KeyEvent;
149
150 use super::*;
151
152 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
153 enum Id {
154 List,
155 Search,
156 }
157
158 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
159 enum Action {
160 ToggleTheme,
161 ClearSearch,
162 DeleteSelected,
163 }
164
165 fn key(code: KeyCode) -> Event {
166 Event::Key(KeyEvent::new(code, KeyModifiers::NONE))
167 }
168
169 #[test]
170 fn global_binding_fires_regardless_of_focus() {
171 let mut shortcuts = Shortcuts::new();
172 shortcuts.bind_global(KeyCode::Char('t'), KeyModifiers::NONE, Action::ToggleTheme);
173
174 assert_eq!(
175 shortcuts.resolve(&key(KeyCode::Char('t')), None),
176 Some(Action::ToggleTheme)
177 );
178 assert_eq!(
179 shortcuts.resolve(&key(KeyCode::Char('t')), Some(Id::List)),
180 Some(Action::ToggleTheme)
181 );
182 }
183
184 #[test]
185 fn scoped_binding_only_fires_while_its_id_is_focused() {
186 let mut shortcuts = Shortcuts::new();
187 shortcuts.bind_scoped(
188 Id::Search,
189 KeyCode::Escape,
190 KeyModifiers::NONE,
191 Action::ClearSearch,
192 );
193
194 assert_eq!(
195 shortcuts.resolve(&key(KeyCode::Escape), Some(Id::Search)),
196 Some(Action::ClearSearch)
197 );
198 assert_eq!(
199 shortcuts.resolve(&key(KeyCode::Escape), Some(Id::List)),
200 None
201 );
202 assert_eq!(shortcuts.resolve(&key(KeyCode::Escape), None), None);
203 }
204
205 #[test]
206 fn scoped_binding_takes_priority_over_a_global_one_for_the_same_key() {
207 let mut shortcuts = Shortcuts::new();
208 shortcuts.bind_global(KeyCode::Delete, KeyModifiers::NONE, Action::ToggleTheme);
209 shortcuts.bind_scoped(
210 Id::List,
211 KeyCode::Delete,
212 KeyModifiers::NONE,
213 Action::DeleteSelected,
214 );
215
216 assert_eq!(
217 shortcuts.resolve(&key(KeyCode::Delete), Some(Id::List)),
218 Some(Action::DeleteSelected)
219 );
220 assert_eq!(
222 shortcuts.resolve(&key(KeyCode::Delete), Some(Id::Search)),
223 Some(Action::ToggleTheme)
224 );
225 assert_eq!(
226 shortcuts.resolve(&key(KeyCode::Delete), None),
227 Some(Action::ToggleTheme)
228 );
229 }
230
231 #[test]
232 fn modifiers_must_match_exactly() {
233 let mut shortcuts = Shortcuts::<Id, Action>::new();
234 shortcuts.bind_global(
235 KeyCode::Char('s'),
236 KeyModifiers::CONTROL,
237 Action::ToggleTheme,
238 );
239
240 let ctrl_s = Event::Key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));
241 assert_eq!(shortcuts.resolve(&ctrl_s, None), Some(Action::ToggleTheme));
242 assert_eq!(shortcuts.resolve(&key(KeyCode::Char('s')), None), None);
243 }
244
245 #[test]
246 fn ignores_non_key_and_key_up_events() {
247 let mut shortcuts = Shortcuts::<Id, Action>::new();
248 shortcuts.bind_global(KeyCode::Char('t'), KeyModifiers::NONE, Action::ToggleTheme);
249
250 assert_eq!(shortcuts.resolve(&Event::Close, None), None);
251
252 let released = Event::Key(KeyEvent::with_kind(
253 KeyCode::Char('t'),
254 KeyModifiers::NONE,
255 retroglyph_core::KeyEventKind::Release,
256 ));
257 assert_eq!(shortcuts.resolve(&released, None), None);
258 }
259
260 #[test]
261 fn empty_registry_resolves_nothing() {
262 let shortcuts = Shortcuts::<Id, Action>::new();
263 assert_eq!(shortcuts.resolve(&key(KeyCode::Char('t')), None), None);
264 }
265}