| 1 | //! Key specifications (`Ctrl+x`, `Space`, `F5`, …) and the context keymap. |
| 2 | |
| 3 | use std::{ |
| 4 | collections::{BTreeMap, HashMap}, |
| 5 | fmt, |
| 6 | str::FromStr, |
| 7 | }; |
| 8 | |
| 9 | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; |
| 10 | use serde::{Deserialize, Deserializer, de}; |
| 11 | |
| 12 | use 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)] |
| 17 | pub struct KeyCombo { |
| 18 | pub code: KeyCode, |
| 19 | pub modifiers: KeyModifiers, |
| 20 | } |
| 21 | |
| 22 | impl 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 | |
| 60 | impl From<KeyEvent> for KeyCombo { |
| 61 | fn from(event: KeyEvent) -> Self { |
| 62 | Self::new(event.code, event.modifiers) |
| 63 | } |
| 64 | } |
| 65 | |
| 66 | impl 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 | |
| 123 | impl 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 | |
| 146 | impl<'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. |
| 154 | pub type Bindings = BTreeMap<Context, BTreeMap<Action, Vec<KeyCombo>>>; |
| 155 | |
| 156 | /// Resolved bindings: one lookup table per context. |
| 157 | #[derive(Clone, Debug, Default)] |
| 158 | pub struct Keymap { |
| 159 | contexts: HashMap<Context, HashMap<KeyCombo, Action>>, |
| 160 | bindings: Bindings, |
| 161 | } |
| 162 | |
| 163 | impl 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)] |
| 214 | mod 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 | } |