Krolyxon
Commit 815a27a

rmptuisrcconfigkeys.rs

305 lines · 10.5 KB · Rust

1//! Key specifications (`Ctrl+x`, `Space`, `F5`, …) and the context keymap.
2
3use std::{
4 collections::{BTreeMap, HashMap},
5 fmt,
6 str::FromStr,
7};
8
9use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
10use serde::{Deserialize, Deserializer, de};
11
12use crate::action::{Action, Context};
13
14/// A key with modifiers, normalized so that lookups ignore how terminals
15/// report shifted characters.
16#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
17pub struct KeyCombo {
18 pub code: KeyCode,
19 pub modifiers: KeyModifiers,
20}
21
22impl KeyCombo {
23 pub fn new(code: KeyCode, modifiers: KeyModifiers) -> Self {
24 let mut modifiers =
25 modifiers & (KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT);
26 let code = match code {
27 // `Shift+a` and `A` are the same key; the case carries the shift.
28 KeyCode::Char(c) if modifiers.contains(KeyModifiers::SHIFT) => {
29 modifiers.remove(KeyModifiers::SHIFT);
30 KeyCode::Char(c.to_uppercase().next().unwrap_or(c))
31 }
32 KeyCode::Tab if modifiers.contains(KeyModifiers::SHIFT) => {
33 modifiers.remove(KeyModifiers::SHIFT);
34 KeyCode::BackTab
35 }
36 KeyCode::BackTab => {
37 modifiers.remove(KeyModifiers::SHIFT);
38 KeyCode::BackTab
39 }
40 other => other,
41 };
42 Self { code, modifiers }
43 }
44
45 /// Whether this is a printable character without Ctrl/Alt, i.e. text.
46 pub fn as_text(&self) -> Option<char> {
47 match self.code {
48 KeyCode::Char(c)
49 if !self
50 .modifiers
51 .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
52 {
53 Some(c)
54 }
55 _ => None,
56 }
57 }
58}
59
60impl From<KeyEvent> for KeyCombo {
61 fn from(event: KeyEvent) -> Self {
62 Self::new(event.code, event.modifiers)
63 }
64}
65
66impl FromStr for KeyCombo {
67 type Err = String;
68
69 fn from_str(spec: &str) -> Result<Self, Self::Err> {
70 let mut modifiers = KeyModifiers::NONE;
71 let mut rest = spec;
72 // A lone `+` (or a trailing one, as in `Ctrl++`) is the plus key.
73 while let Some((modifier, tail)) = rest.split_once('+').filter(|(m, _)| !m.is_empty()) {
74 match modifier.to_ascii_lowercase().as_str() {
75 "ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
76 "alt" | "meta" => modifiers |= KeyModifiers::ALT,
77 "shift" => modifiers |= KeyModifiers::SHIFT,
78 _ => return Err(format!("unknown modifier `{modifier}` in `{spec}`")),
79 }
80 rest = tail;
81 }
82 if rest.is_empty() {
83 return Err(format!("missing key in `{spec}`"));
84 }
85 let lower = rest.to_ascii_lowercase();
86 let code = match lower.as_str() {
87 "esc" | "escape" => KeyCode::Esc,
88 "enter" | "return" => KeyCode::Enter,
89 "tab" => KeyCode::Tab,
90 "backtab" => KeyCode::BackTab,
91 "backspace" => KeyCode::Backspace,
92 "delete" | "del" => KeyCode::Delete,
93 "insert" | "ins" => KeyCode::Insert,
94 "up" => KeyCode::Up,
95 "down" => KeyCode::Down,
96 "left" => KeyCode::Left,
97 "right" => KeyCode::Right,
98 "home" => KeyCode::Home,
99 "end" => KeyCode::End,
100 "pageup" | "pgup" => KeyCode::PageUp,
101 "pagedown" | "pgdn" => KeyCode::PageDown,
102 "space" => KeyCode::Char(' '),
103 _ => {
104 if let Some(n) = lower.strip_prefix('f').and_then(|n| n.parse::<u8>().ok()) {
105 if (1..=24).contains(&n) {
106 KeyCode::F(n)
107 } else {
108 return Err(format!("unknown key `{rest}` in `{spec}`"));
109 }
110 } else {
111 let mut chars = rest.chars();
112 match (chars.next(), chars.next()) {
113 (Some(c), None) => KeyCode::Char(c),
114 _ => return Err(format!("unknown key `{rest}` in `{spec}`")),
115 }
116 }
117 }
118 };
119 Ok(Self::new(code, modifiers))
120 }
121}
122
123impl fmt::Display for KeyCombo {
124 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125 if self.modifiers.contains(KeyModifiers::CONTROL) {
126 f.write_str("Ctrl+")?;
127 }
128 if self.modifiers.contains(KeyModifiers::ALT) {
129 f.write_str("Alt+")?;
130 }
131 if self.modifiers.contains(KeyModifiers::SHIFT) {
132 f.write_str("Shift+")?;
133 }
134 match self.code {
135 KeyCode::Char(' ') => f.write_str("Space"),
136 KeyCode::Char(c) => write!(f, "{c}"),
137 KeyCode::F(n) => write!(f, "F{n}"),
138 KeyCode::BackTab => f.write_str("Shift+Tab"),
139 KeyCode::PageUp => f.write_str("PageUp"),
140 KeyCode::PageDown => f.write_str("PageDown"),
141 other => write!(f, "{other:?}"),
142 }
143 }
144}
145
146impl<'de> Deserialize<'de> for KeyCombo {
147 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
148 let s = String::deserialize(deserializer)?;
149 s.parse().map_err(de::Error::custom)
150 }
151}
152
153/// The `[keys.*]` tables as written in the config file.
154pub type Bindings = BTreeMap<Context, BTreeMap<Action, Vec<KeyCombo>>>;
155
156/// Resolved bindings: one lookup table per context.
157#[derive(Clone, Debug, Default)]
158pub struct Keymap {
159 contexts: HashMap<Context, HashMap<KeyCombo, Action>>,
160 bindings: Bindings,
161}
162
163impl Keymap {
164 /// Builds the keymap, rejecting actions bound in the wrong context and
165 /// keys bound twice within one context.
166 pub fn new(bindings: Bindings) -> Result<Self, String> {
167 let mut contexts: HashMap<Context, HashMap<KeyCombo, Action>> = HashMap::new();
168 for (&context, actions) in &bindings {
169 let table = contexts.entry(context).or_default();
170 for (&action, keys) in actions {
171 if !action.contexts().contains(&context) {
172 let allowed: Vec<_> = action.contexts().iter().map(|c| c.name()).collect();
173 return Err(format!(
174 "action `{action}` cannot be bound in [keys.{context}] (allowed in: {})",
175 allowed.join(", ")
176 ));
177 }
178 for &key in keys {
179 if let Some(previous) = table.insert(key, action)
180 && previous != action
181 {
182 return Err(format!(
183 "key `{key}` is bound to both `{previous}` and `{action}` in [keys.{context}]; \
184 unbind one of them (an action can be disabled with `action = []`)"
185 ));
186 }
187 }
188 }
189 }
190 Ok(Self { contexts, bindings })
191 }
192
193 /// Finds the action for `key`, searching `contexts` in order.
194 pub fn resolve(&self, contexts: &[Context], key: KeyCombo) -> Option<Action> {
195 contexts
196 .iter()
197 .find_map(|context| self.contexts.get(context)?.get(&key).copied())
198 }
199
200 /// The keys bound to `action` in `context`, for help texts.
201 pub fn keys_for(&self, context: Context, action: Action) -> &[KeyCombo] {
202 self.bindings
203 .get(&context)
204 .and_then(|actions| actions.get(&action))
205 .map_or(&[], Vec::as_slice)
206 }
207
208 pub fn bindings(&self) -> &Bindings {
209 &self.bindings
210 }
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216
217 fn key(spec: &str) -> KeyCombo {
218 spec.parse().unwrap()
219 }
220
221 #[test]
222 fn parses_specs() {
223 assert_eq!(
224 key("q"),
225 KeyCombo::new(KeyCode::Char('q'), KeyModifiers::NONE)
226 );
227 assert_eq!(
228 key("Ctrl+c"),
229 KeyCombo::new(KeyCode::Char('c'), KeyModifiers::CONTROL)
230 );
231 assert_eq!(
232 key("+"),
233 KeyCombo::new(KeyCode::Char('+'), KeyModifiers::NONE)
234 );
235 assert_eq!(
236 key("Ctrl++"),
237 KeyCombo::new(KeyCode::Char('+'), KeyModifiers::CONTROL)
238 );
239 assert_eq!(key("Space").code, KeyCode::Char(' '));
240 assert_eq!(key("F5").code, KeyCode::F(5));
241 assert_eq!(key("Shift+Tab").code, KeyCode::BackTab);
242 assert_eq!(key("Shift+g"), key("G"));
243 assert!("Hyper+x".parse::<KeyCombo>().is_err());
244 assert!("abc".parse::<KeyCombo>().is_err());
245 assert!("F99".parse::<KeyCombo>().is_err());
246 }
247
248 #[test]
249 fn normalizes_terminal_events() {
250 // Terminals report `G` with or without SHIFT.
251 let shifted = KeyCombo::from(KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT));
252 assert_eq!(shifted, key("G"));
253 let backtab = KeyCombo::from(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT));
254 assert_eq!(backtab, key("Shift+Tab"));
255 }
256
257 #[test]
258 fn displays_specs() {
259 for spec in ["q", "Ctrl+c", "Space", "F5", "Enter", "Shift+Tab", "PageUp"] {
260 assert_eq!(key(spec).to_string(), spec);
261 }
262 }
263
264 #[test]
265 fn resolves_by_context_priority() {
266 let mut bindings = Bindings::new();
267 bindings
268 .entry(Context::Global)
269 .or_default()
270 .insert(Action::ToggleConsume, vec![key("R")]);
271 bindings
272 .entry(Context::Playlists)
273 .or_default()
274 .insert(Action::RenamePlaylist, vec![key("R")]);
275 let keymap = Keymap::new(bindings).unwrap();
276 assert_eq!(
277 keymap.resolve(&[Context::Playlists, Context::Global], key("R")),
278 Some(Action::RenamePlaylist)
279 );
280 assert_eq!(
281 keymap.resolve(&[Context::Queue, Context::Global], key("R")),
282 Some(Action::ToggleConsume)
283 );
284 }
285
286 #[test]
287 fn rejects_conflicts_and_misplaced_actions() {
288 let mut bindings = Bindings::new();
289 let global = bindings.entry(Context::Global).or_default();
290 global.insert(Action::Quit, vec![key("q")]);
291 global.insert(Action::Pause, vec![key("q")]);
292 assert!(Keymap::new(bindings).unwrap_err().contains("bound to both"));
293
294 let mut bindings = Bindings::new();
295 bindings
296 .entry(Context::Queue)
297 .or_default()
298 .insert(Action::Quit, vec![key("q")]);
299 assert!(
300 Keymap::new(bindings)
301 .unwrap_err()
302 .contains("cannot be bound")
303 );
304 }
305}