1use std::collections::HashMap;
11use std::path::PathBuf;
12
13use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
14use serde::Deserialize;
15
16use crate::input::Command;
17
18#[derive(Debug, Clone)]
19pub enum BindingTarget {
20 Command(Command),
21 Shell(String),
22}
23
24#[derive(Debug, Clone)]
25pub struct KeyMap {
26 map: HashMap<KeyEvent, BindingTarget>,
27}
28
29impl KeyMap {
30 pub fn empty() -> Self {
31 Self { map: HashMap::new() }
32 }
33
34 pub fn load_from_default_path() -> Result<Self, String> {
38 let Some(path) = user_keys_path() else {
39 return Ok(Self::empty());
40 };
41 if !path.exists() {
42 return Ok(Self::empty());
43 }
44 let text = std::fs::read_to_string(&path)
45 .map_err(|e| format!("keys.toml: reading {}: {e}", path.display()))?;
46 Self::load_from_str(&text)
47 .map_err(|e| format!("keys.toml: {e}"))
48 }
49
50 pub fn load_from_str(toml_text: &str) -> Result<Self, String> {
51 let cfg: KeysConfig = toml::from_str(toml_text)
52 .map_err(|e| format!("parsing: {e}"))?;
53 let mut map = HashMap::with_capacity(cfg.bindings.len());
54 for (key_spec, action) in cfg.bindings {
55 let key = parse_key_spec(&key_spec)
56 .map_err(|e| format!("'{key_spec}': {e}"))?;
57 reject_forbidden_key(&key, &key_spec)?;
58 let target = parse_action(&action)
59 .map_err(|e| format!("'{key_spec}': {e}"))?;
60 map.insert(key, target);
61 }
62 Ok(Self { map })
63 }
64
65 pub fn lookup(&self, key: &KeyEvent) -> Option<&BindingTarget> {
66 self.map.get(key)
67 }
68
69 pub fn is_empty(&self) -> bool {
70 self.map.is_empty()
71 }
72}
73
74#[derive(Debug, Deserialize, Default)]
75struct KeysConfig {
76 #[serde(default)]
77 bindings: HashMap<String, String>,
78}
79
80fn user_keys_path() -> Option<PathBuf> {
81 std::env::var_os("HOME").map(|h| {
82 let mut p = PathBuf::from(h);
83 p.push(".config");
84 p.push("tess");
85 p.push("keys.toml");
86 p
87 })
88}
89
90fn parse_key_spec(spec: &str) -> Result<KeyEvent, String> {
92 let lower = spec.to_lowercase();
93 let mut parts: Vec<&str> = lower.split('-').collect();
94 if parts.is_empty() {
95 return Err("empty key spec".to_string());
96 }
97 let key_part = parts.pop().unwrap();
98 let mut modifiers = KeyModifiers::NONE;
99 for m in &parts {
100 if m.is_empty() {
101 continue;
103 }
104 match *m {
105 "ctrl" => modifiers |= KeyModifiers::CONTROL,
106 "alt" => modifiers |= KeyModifiers::ALT,
107 "shift" => modifiers |= KeyModifiers::SHIFT,
108 other => return Err(format!("unknown modifier '{other}'")),
109 }
110 }
111 let code = match key_part {
112 "esc" => KeyCode::Esc,
113 "enter" => KeyCode::Enter,
114 "tab" => KeyCode::Tab,
115 "backspace" => KeyCode::Backspace,
116 "space" => KeyCode::Char(' '),
117 "up" => KeyCode::Up,
118 "down" => KeyCode::Down,
119 "left" => KeyCode::Left,
120 "right" => KeyCode::Right,
121 "pgup" => KeyCode::PageUp,
122 "pgdn" => KeyCode::PageDown,
123 "home" => KeyCode::Home,
124 "end" => KeyCode::End,
125 "" => {
126 KeyCode::Char('-')
129 }
130 s if s.starts_with('f') && s.len() > 1 => {
131 let n: u8 = s[1..].parse()
132 .map_err(|_| format!("unknown key '{s}'"))?;
133 KeyCode::F(n)
134 }
135 s if s.chars().count() == 1 => {
136 let original_char = spec.chars().last().unwrap();
141 if original_char.is_ascii_uppercase() && modifiers == KeyModifiers::NONE {
142 modifiers |= KeyModifiers::SHIFT;
143 KeyCode::Char(original_char.to_ascii_lowercase())
144 } else {
145 KeyCode::Char(original_char.to_ascii_lowercase())
148 }
149 }
150 other => return Err(format!("unknown key '{other}'")),
151 };
152 Ok(KeyEvent::new(code, modifiers))
153}
154
155fn reject_forbidden_key(key: &KeyEvent, original_spec: &str) -> Result<(), String> {
156 let forbidden = match (&key.code, key.modifiers) {
157 (KeyCode::Char('m'), KeyModifiers::NONE) => true,
158 (KeyCode::Char('\''), KeyModifiers::NONE) => true,
159 (KeyCode::Char('-'), KeyModifiers::NONE) => true,
160 (KeyCode::Char('x'), KeyModifiers::CONTROL) => true,
161 (KeyCode::Char(c), KeyModifiers::NONE) if c.is_ascii_digit() => true,
162 _ => false,
163 };
164 if forbidden {
165 return Err(format!(
166 "'{original_spec}' is part of a multi-key sequence and cannot be rebound"
167 ));
168 }
169 Ok(())
170}
171
172fn parse_action(action: &str) -> Result<BindingTarget, String> {
173 if let Some(shell_cmd) = action.strip_prefix('!') {
174 if shell_cmd.is_empty() {
175 return Err("shell binding requires a command after '!'".to_string());
176 }
177 return Ok(BindingTarget::Shell(shell_cmd.to_string()));
178 }
179 let cmd = command_from_kebab(action)
180 .ok_or_else(|| format!("unknown command '{action}'"))?;
181 Ok(BindingTarget::Command(cmd))
182}
183
184fn command_from_kebab(name: &str) -> Option<Command> {
186 match name {
187 "scroll-down" => Some(Command::ScrollLines(1)),
188 "scroll-up" => Some(Command::ScrollLines(-1)),
189 "scroll-logical-down" => Some(Command::ScrollLogicalLines(1)),
190 "scroll-logical-up" => Some(Command::ScrollLogicalLines(-1)),
191 "page-down" => Some(Command::PageDown),
192 "page-up" => Some(Command::PageUp),
193 "half-page-down" => Some(Command::HalfPageDown),
194 "half-page-up" => Some(Command::HalfPageUp),
195 "quit" => Some(Command::Quit),
196 "refresh" => Some(Command::Refresh),
197 "reload" => Some(Command::Reload),
198 "toggle-line-numbers" => Some(Command::ToggleLineNumbers),
199 "toggle-chop" => Some(Command::ToggleChop),
200 "toggle-follow" => Some(Command::ToggleFollow),
201 "toggle-prettify" => Some(Command::TogglePrettify),
202 "search-forward" => Some(Command::SearchForward),
203 "search-backward" => Some(Command::SearchBackward),
204 "next-match" => Some(Command::NextMatch),
205 "previous-match" => Some(Command::PreviousMatch),
206 "option-prefix" => Some(Command::OptionPrefix),
207 "goto-line" => Some(Command::GotoLine),
208 "goto-record" => Some(Command::GotoRecord),
209 "goto-percent" => Some(Command::GotoPercent),
210 "mark-set" => Some(Command::MarkSet),
211 "mark-jump" => Some(Command::MarkJump),
212 "ctrl-x-prefix" => Some(Command::CtrlXPrefix),
213 "jump-previous" => Some(Command::JumpPrevious),
214 "shell-escape" => Some(Command::ShellEscape),
215 "cancel" => Some(Command::Cancel),
216 _ => None,
217 }
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223
224 #[test]
225 fn parse_empty_file_returns_empty_map() {
226 let m = KeyMap::load_from_str("").unwrap();
227 assert!(m.is_empty());
228 }
229
230 #[test]
231 fn parse_single_binding() {
232 let toml = r#"
233[bindings]
234"j" = "scroll-down"
235"#;
236 let m = KeyMap::load_from_str(toml).unwrap();
237 let key = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
238 assert!(matches!(m.lookup(&key), Some(BindingTarget::Command(Command::ScrollLines(1)))));
239 }
240
241 #[test]
242 fn parse_named_special_key() {
243 let toml = r#"
244[bindings]
245"f1" = "toggle-line-numbers"
246"esc" = "cancel"
247"#;
248 let m = KeyMap::load_from_str(toml).unwrap();
249 let f1 = KeyEvent::new(KeyCode::F(1), KeyModifiers::NONE);
250 let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
251 assert!(matches!(m.lookup(&f1), Some(BindingTarget::Command(Command::ToggleLineNumbers))));
252 assert!(matches!(m.lookup(&esc), Some(BindingTarget::Command(Command::Cancel))));
253 }
254
255 #[test]
256 fn parse_modifier_combinations() {
257 let toml = r#"
258[bindings]
259"ctrl-r" = "reload"
260"shift-tab" = "scroll-logical-up"
261"#;
262 let m = KeyMap::load_from_str(toml).unwrap();
263 let ctrl_r = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL);
264 let shift_tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT);
265 assert!(matches!(m.lookup(&ctrl_r), Some(BindingTarget::Command(Command::Reload))));
266 assert!(matches!(m.lookup(&shift_tab), Some(BindingTarget::Command(Command::ScrollLogicalLines(-1)))));
267 }
268
269 #[test]
270 fn case_letter_resolves_to_shift_prefix() {
271 let toml = r#"
272[bindings]
273"J" = "scroll-logical-down"
274"#;
275 let m = KeyMap::load_from_str(toml).unwrap();
276 let shift_j = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::SHIFT);
277 assert!(matches!(m.lookup(&shift_j), Some(BindingTarget::Command(Command::ScrollLogicalLines(1)))));
278 }
279
280 #[test]
281 fn forbidden_keys_error_at_parse() {
282 for key in &["m", "'", "-", "ctrl-x", "0", "5", "9"] {
283 let toml = format!(r#"
284[bindings]
285"{key}" = "quit"
286"#);
287 let err = KeyMap::load_from_str(&toml).unwrap_err();
288 assert!(err.contains("multi-key sequence"),
289 "key '{key}' should be forbidden: {err}");
290 }
291 }
292
293 #[test]
294 fn unknown_command_name_errors() {
295 let toml = r#"
296[bindings]
297"j" = "definitely-not-a-real-command"
298"#;
299 let err = KeyMap::load_from_str(toml).unwrap_err();
300 assert!(err.contains("unknown command"));
301 }
302
303 #[test]
304 fn empty_shell_binding_errors() {
305 let toml = r#"
306[bindings]
307"f1" = "!"
308"#;
309 let err = KeyMap::load_from_str(toml).unwrap_err();
310 assert!(err.contains("requires a command"));
311 }
312
313 #[test]
314 fn parse_inline_shell_binding() {
315 let toml = r#"
316[bindings]
317"f2" = "!git status"
318"#;
319 let m = KeyMap::load_from_str(toml).unwrap();
320 let f2 = KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE);
321 match m.lookup(&f2) {
322 Some(BindingTarget::Shell(cmd)) => assert_eq!(cmd, "git status"),
323 other => panic!("expected Shell, got {:?}", other),
324 }
325 }
326
327 #[test]
328 fn lookup_returns_none_for_unbound_key() {
329 let toml = r#"
330[bindings]
331"j" = "scroll-down"
332"#;
333 let m = KeyMap::load_from_str(toml).unwrap();
334 let other = KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE);
335 assert!(m.lookup(&other).is_none());
336 }
337
338 #[test]
339 fn ctrl_uppercase_letter_does_not_add_shift() {
340 let toml = r#"
342[bindings]
343"ctrl-J" = "reload"
344"#;
345 let m = KeyMap::load_from_str(toml).unwrap();
346 let ctrl_j = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL);
347 assert!(matches!(m.lookup(&ctrl_j), Some(BindingTarget::Command(Command::Reload))),
348 "ctrl-J should resolve to Ctrl+j without Shift");
349 let ctrl_shift_j = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL | KeyModifiers::SHIFT);
350 assert!(m.lookup(&ctrl_shift_j).is_none(),
351 "ctrl-J should NOT also match Ctrl+Shift+j");
352 }
353
354 #[test]
355 fn dash_with_modifier_is_a_real_key() {
356 let toml = r#"
359[bindings]
360"ctrl--" = "refresh"
361"#;
362 let m = KeyMap::load_from_str(toml).unwrap();
363 let ctrl_dash = KeyEvent::new(KeyCode::Char('-'), KeyModifiers::CONTROL);
364 assert!(matches!(m.lookup(&ctrl_dash), Some(BindingTarget::Command(Command::Refresh))));
365 }
366}