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 pub fn user_keys_by_command_name(&self) -> std::collections::HashMap<String, Vec<String>> {
77 let mut out: std::collections::HashMap<String, Vec<String>> =
78 std::collections::HashMap::new();
79 for (key, target) in &self.map {
80 let BindingTarget::Command(cmd) = target else { continue };
81 let Some(name) = command_to_kebab(cmd) else { continue };
82 out.entry(name.to_string())
83 .or_default()
84 .push(format_key_event(*key));
85 }
86 out
87 }
88}
89
90#[derive(Debug, Deserialize, Default)]
91struct KeysConfig {
92 #[serde(default)]
93 bindings: HashMap<String, String>,
94}
95
96fn user_keys_path() -> Option<PathBuf> {
97 std::env::var_os("HOME").map(|h| {
98 let mut p = PathBuf::from(h);
99 p.push(".config");
100 p.push("tess");
101 p.push("keys.toml");
102 p
103 })
104}
105
106fn parse_key_spec(spec: &str) -> Result<KeyEvent, String> {
108 let lower = spec.to_lowercase();
109 let mut parts: Vec<&str> = lower.split('-').collect();
110 if parts.is_empty() {
111 return Err("empty key spec".to_string());
112 }
113 let key_part = parts.pop().unwrap();
114 let mut modifiers = KeyModifiers::NONE;
115 for m in &parts {
116 if m.is_empty() {
117 continue;
119 }
120 match *m {
121 "ctrl" => modifiers |= KeyModifiers::CONTROL,
122 "alt" => modifiers |= KeyModifiers::ALT,
123 "shift" => modifiers |= KeyModifiers::SHIFT,
124 other => return Err(format!("unknown modifier '{other}'")),
125 }
126 }
127 let code = match key_part {
128 "esc" => KeyCode::Esc,
129 "enter" => KeyCode::Enter,
130 "tab" => KeyCode::Tab,
131 "backspace" => KeyCode::Backspace,
132 "space" => KeyCode::Char(' '),
133 "up" => KeyCode::Up,
134 "down" => KeyCode::Down,
135 "left" => KeyCode::Left,
136 "right" => KeyCode::Right,
137 "pgup" => KeyCode::PageUp,
138 "pgdn" => KeyCode::PageDown,
139 "home" => KeyCode::Home,
140 "end" => KeyCode::End,
141 "" => {
142 KeyCode::Char('-')
145 }
146 s if s.starts_with('f') && s.len() > 1 => {
147 let n: u8 = s[1..].parse()
148 .map_err(|_| format!("unknown key '{s}'"))?;
149 KeyCode::F(n)
150 }
151 s if s.chars().count() == 1 => {
152 let original_char = spec.chars().last().unwrap();
157 if original_char.is_ascii_uppercase() && modifiers == KeyModifiers::NONE {
158 modifiers |= KeyModifiers::SHIFT;
159 KeyCode::Char(original_char.to_ascii_lowercase())
160 } else {
161 KeyCode::Char(original_char.to_ascii_lowercase())
164 }
165 }
166 other => return Err(format!("unknown key '{other}'")),
167 };
168 Ok(KeyEvent::new(code, modifiers))
169}
170
171fn reject_forbidden_key(key: &KeyEvent, original_spec: &str) -> Result<(), String> {
172 let forbidden = match (&key.code, key.modifiers) {
173 (KeyCode::Char('m'), KeyModifiers::NONE) => true,
174 (KeyCode::Char('\''), KeyModifiers::NONE) => true,
175 (KeyCode::Char('-'), KeyModifiers::NONE) => true,
176 (KeyCode::Char('x'), KeyModifiers::CONTROL) => true,
177 (KeyCode::Char(c), KeyModifiers::NONE) if c.is_ascii_digit() => true,
178 _ => false,
179 };
180 if forbidden {
181 return Err(format!(
182 "'{original_spec}' is part of a multi-key sequence and cannot be rebound"
183 ));
184 }
185 Ok(())
186}
187
188fn parse_action(action: &str) -> Result<BindingTarget, String> {
189 if let Some(shell_cmd) = action.strip_prefix('!') {
190 if shell_cmd.is_empty() {
191 return Err("shell binding requires a command after '!'".to_string());
192 }
193 return Ok(BindingTarget::Shell(shell_cmd.to_string()));
194 }
195 let cmd = command_from_kebab(action)
196 .ok_or_else(|| format!("unknown command '{action}'"))?;
197 Ok(BindingTarget::Command(cmd))
198}
199
200fn command_from_kebab(name: &str) -> Option<Command> {
202 match name {
203 "scroll-down" => Some(Command::ScrollLines(1)),
204 "scroll-up" => Some(Command::ScrollLines(-1)),
205 "scroll-logical-down" => Some(Command::ScrollLogicalLines(1)),
206 "scroll-logical-up" => Some(Command::ScrollLogicalLines(-1)),
207 "page-down" => Some(Command::PageDown),
208 "page-up" => Some(Command::PageUp),
209 "half-page-down" => Some(Command::HalfPageDown),
210 "half-page-up" => Some(Command::HalfPageUp),
211 "quit" => Some(Command::Quit),
212 "refresh" => Some(Command::Refresh),
213 "reload" => Some(Command::Reload),
214 "toggle-line-numbers" => Some(Command::ToggleLineNumbers),
215 "toggle-chop" => Some(Command::ToggleChop),
216 "toggle-follow" => Some(Command::ToggleFollow),
217 "toggle-prettify" => Some(Command::TogglePrettify),
218 "search-forward" => Some(Command::SearchForward),
219 "search-backward" => Some(Command::SearchBackward),
220 "next-match" => Some(Command::NextMatch),
221 "previous-match" => Some(Command::PreviousMatch),
222 "option-prefix" => Some(Command::OptionPrefix),
223 "goto-line" => Some(Command::GotoLine),
224 "goto-record" => Some(Command::GotoRecord),
225 "goto-percent" => Some(Command::GotoPercent),
226 "mark-set" => Some(Command::MarkSet),
227 "mark-jump" => Some(Command::MarkJump),
228 "ctrl-x-prefix" => Some(Command::CtrlXPrefix),
229 "jump-previous" => Some(Command::JumpPrevious),
230 "shell-escape" => Some(Command::ShellEscape),
231 "cancel" => Some(Command::Cancel),
232 _ => None,
233 }
234}
235
236fn command_to_kebab(cmd: &Command) -> Option<&'static str> {
238 match cmd {
239 Command::ScrollLines(1) => Some("scroll-down"),
240 Command::ScrollLines(-1) => Some("scroll-up"),
241 Command::ScrollLogicalLines(1) => Some("scroll-logical-down"),
242 Command::ScrollLogicalLines(-1) => Some("scroll-logical-up"),
243 Command::PageDown => Some("page-down"),
244 Command::PageUp => Some("page-up"),
245 Command::HalfPageDown => Some("half-page-down"),
246 Command::HalfPageUp => Some("half-page-up"),
247 Command::Quit => Some("quit"),
248 Command::Refresh => Some("refresh"),
249 Command::Reload => Some("reload"),
250 Command::ToggleLineNumbers => Some("toggle-line-numbers"),
251 Command::ToggleChop => Some("toggle-chop"),
252 Command::ToggleFollow => Some("toggle-follow"),
253 Command::TogglePrettify => Some("toggle-prettify"),
254 Command::SearchForward => Some("search-forward"),
255 Command::SearchBackward => Some("search-backward"),
256 Command::NextMatch => Some("next-match"),
257 Command::PreviousMatch => Some("previous-match"),
258 Command::OptionPrefix => Some("option-prefix"),
259 Command::GotoLine => Some("goto-line"),
260 Command::GotoRecord => Some("goto-record"),
261 Command::GotoPercent => Some("goto-percent"),
262 Command::MarkSet => Some("mark-set"),
263 Command::MarkJump => Some("mark-jump"),
264 Command::CtrlXPrefix => Some("ctrl-x-prefix"),
265 Command::JumpPrevious => Some("jump-previous"),
266 Command::ShellEscape => Some("shell-escape"),
267 Command::Cancel => Some("cancel"),
268 _ => None,
269 }
270}
271
272fn format_key_event(ke: KeyEvent) -> String {
273 let ctrl = ke.modifiers.contains(KeyModifiers::CONTROL);
274 let alt = ke.modifiers.contains(KeyModifiers::ALT);
275 let shift = ke.modifiers.contains(KeyModifiers::SHIFT);
276
277 if shift && !ctrl && !alt {
280 if let KeyCode::Char(c) = ke.code {
281 if c.is_ascii_alphabetic() {
282 return c.to_ascii_uppercase().to_string();
283 }
284 }
285 }
286
287 let mut parts: Vec<&'static str> = Vec::new();
288 if ctrl { parts.push("Ctrl"); }
289 if alt { parts.push("Alt"); }
290 if shift { parts.push("Shift"); }
291
292 let key = match ke.code {
293 KeyCode::Char(' ') => "Space".to_string(),
294 KeyCode::Char(c) => c.to_string(),
295 KeyCode::F(n) => format!("F{n}"),
296 KeyCode::Esc => "Esc".into(),
297 KeyCode::Enter => "Enter".into(),
298 KeyCode::Tab => "Tab".into(),
299 KeyCode::Backspace => "Backspace".into(),
300 KeyCode::Up => "\u{2191}".into(),
301 KeyCode::Down => "\u{2193}".into(),
302 KeyCode::Left => "\u{2190}".into(),
303 KeyCode::Right => "\u{2192}".into(),
304 KeyCode::Home => "Home".into(),
305 KeyCode::End => "End".into(),
306 KeyCode::PageUp => "PgUp".into(),
307 KeyCode::PageDown => "PgDn".into(),
308 other => format!("{other:?}"),
309 };
310 if parts.is_empty() { key } else { format!("{}-{}", parts.join("-"), key) }
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316
317 #[test]
318 fn parse_empty_file_returns_empty_map() {
319 let m = KeyMap::load_from_str("").unwrap();
320 assert!(m.is_empty());
321 }
322
323 #[test]
324 fn parse_single_binding() {
325 let toml = r#"
326[bindings]
327"j" = "scroll-down"
328"#;
329 let m = KeyMap::load_from_str(toml).unwrap();
330 let key = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
331 assert!(matches!(m.lookup(&key), Some(BindingTarget::Command(Command::ScrollLines(1)))));
332 }
333
334 #[test]
335 fn parse_named_special_key() {
336 let toml = r#"
337[bindings]
338"f1" = "toggle-line-numbers"
339"esc" = "cancel"
340"#;
341 let m = KeyMap::load_from_str(toml).unwrap();
342 let f1 = KeyEvent::new(KeyCode::F(1), KeyModifiers::NONE);
343 let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
344 assert!(matches!(m.lookup(&f1), Some(BindingTarget::Command(Command::ToggleLineNumbers))));
345 assert!(matches!(m.lookup(&esc), Some(BindingTarget::Command(Command::Cancel))));
346 }
347
348 #[test]
349 fn parse_modifier_combinations() {
350 let toml = r#"
351[bindings]
352"ctrl-r" = "reload"
353"shift-tab" = "scroll-logical-up"
354"#;
355 let m = KeyMap::load_from_str(toml).unwrap();
356 let ctrl_r = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL);
357 let shift_tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT);
358 assert!(matches!(m.lookup(&ctrl_r), Some(BindingTarget::Command(Command::Reload))));
359 assert!(matches!(m.lookup(&shift_tab), Some(BindingTarget::Command(Command::ScrollLogicalLines(-1)))));
360 }
361
362 #[test]
363 fn case_letter_resolves_to_shift_prefix() {
364 let toml = r#"
365[bindings]
366"J" = "scroll-logical-down"
367"#;
368 let m = KeyMap::load_from_str(toml).unwrap();
369 let shift_j = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::SHIFT);
370 assert!(matches!(m.lookup(&shift_j), Some(BindingTarget::Command(Command::ScrollLogicalLines(1)))));
371 }
372
373 #[test]
374 fn forbidden_keys_error_at_parse() {
375 for key in &["m", "'", "-", "ctrl-x", "0", "5", "9"] {
376 let toml = format!(r#"
377[bindings]
378"{key}" = "quit"
379"#);
380 let err = KeyMap::load_from_str(&toml).unwrap_err();
381 assert!(err.contains("multi-key sequence"),
382 "key '{key}' should be forbidden: {err}");
383 }
384 }
385
386 #[test]
387 fn unknown_command_name_errors() {
388 let toml = r#"
389[bindings]
390"j" = "definitely-not-a-real-command"
391"#;
392 let err = KeyMap::load_from_str(toml).unwrap_err();
393 assert!(err.contains("unknown command"));
394 }
395
396 #[test]
397 fn empty_shell_binding_errors() {
398 let toml = r#"
399[bindings]
400"f1" = "!"
401"#;
402 let err = KeyMap::load_from_str(toml).unwrap_err();
403 assert!(err.contains("requires a command"));
404 }
405
406 #[test]
407 fn parse_inline_shell_binding() {
408 let toml = r#"
409[bindings]
410"f2" = "!git status"
411"#;
412 let m = KeyMap::load_from_str(toml).unwrap();
413 let f2 = KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE);
414 match m.lookup(&f2) {
415 Some(BindingTarget::Shell(cmd)) => assert_eq!(cmd, "git status"),
416 other => panic!("expected Shell, got {:?}", other),
417 }
418 }
419
420 #[test]
421 fn lookup_returns_none_for_unbound_key() {
422 let toml = r#"
423[bindings]
424"j" = "scroll-down"
425"#;
426 let m = KeyMap::load_from_str(toml).unwrap();
427 let other = KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE);
428 assert!(m.lookup(&other).is_none());
429 }
430
431 #[test]
432 fn ctrl_uppercase_letter_does_not_add_shift() {
433 let toml = r#"
435[bindings]
436"ctrl-J" = "reload"
437"#;
438 let m = KeyMap::load_from_str(toml).unwrap();
439 let ctrl_j = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL);
440 assert!(matches!(m.lookup(&ctrl_j), Some(BindingTarget::Command(Command::Reload))),
441 "ctrl-J should resolve to Ctrl+j without Shift");
442 let ctrl_shift_j = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL | KeyModifiers::SHIFT);
443 assert!(m.lookup(&ctrl_shift_j).is_none(),
444 "ctrl-J should NOT also match Ctrl+Shift+j");
445 }
446
447 #[test]
448 fn user_remaps_by_command_name_groups_keys() {
449 let toml = r#"
450[bindings]
451"f3" = "scroll-down"
452"f4" = "scroll-down"
453"f5" = "quit"
454"#;
455 let m = KeyMap::load_from_str(toml).unwrap();
456 let groups = m.user_keys_by_command_name();
457 let mut down = groups.get("scroll-down").cloned().unwrap_or_default();
458 down.sort();
459 assert_eq!(down, vec!["F3".to_string(), "F4".to_string()]);
460 assert_eq!(groups.get("quit").cloned().unwrap_or_default(), vec!["F5".to_string()]);
461 }
462
463 #[test]
464 fn dash_with_modifier_is_a_real_key() {
465 let toml = r#"
468[bindings]
469"ctrl--" = "refresh"
470"#;
471 let m = KeyMap::load_from_str(toml).unwrap();
472 let ctrl_dash = KeyEvent::new(KeyCode::Char('-'), KeyModifiers::CONTROL);
473 assert!(matches!(m.lookup(&ctrl_dash), Some(BindingTarget::Command(Command::Refresh))));
474 }
475
476 #[test]
477 fn format_key_event_renders_modifier_combos() {
478 assert_eq!(
480 format_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)),
481 "Ctrl-r",
482 );
483 assert_eq!(
485 format_key_event(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::SHIFT)),
486 "J",
487 );
488 assert_eq!(
490 format_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT)),
491 "Shift-Tab",
492 );
493 assert_eq!(
495 format_key_event(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)),
496 "j",
497 );
498 assert_eq!(
500 format_key_event(KeyEvent::new(KeyCode::F(3), KeyModifiers::NONE)),
501 "F3",
502 );
503 assert_eq!(
505 format_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL | KeyModifiers::SHIFT)),
506 "Ctrl-Shift-x",
507 );
508 }
509
510 #[test]
511 fn command_kebab_round_trip() {
512 let names = [
514 "scroll-down", "scroll-up", "scroll-logical-down", "scroll-logical-up",
515 "page-down", "page-up", "half-page-down", "half-page-up",
516 "quit", "refresh", "reload",
517 "toggle-line-numbers", "toggle-chop", "toggle-follow", "toggle-prettify",
518 "search-forward", "search-backward", "next-match", "previous-match",
519 "option-prefix", "goto-line", "goto-record", "goto-percent",
520 "mark-set", "mark-jump", "ctrl-x-prefix", "jump-previous",
521 "shell-escape", "cancel",
522 ];
523 for name in &names {
524 let cmd = command_from_kebab(name).expect(&format!("from_kebab failed for {name}"));
525 let back = command_to_kebab(&cmd).expect(&format!("to_kebab failed for {name}"));
526 assert_eq!(back, *name, "round-trip mismatch for {name}");
527 }
528 }
529
530 #[test]
531 fn shell_bindings_are_excluded_from_user_keys() {
532 let toml = r#"
533[bindings]
534"f2" = "!git status"
535"f3" = "scroll-down"
536"#;
537 let m = KeyMap::load_from_str(toml).unwrap();
538 let groups = m.user_keys_by_command_name();
539 assert!(!groups.values().any(|v| v.contains(&"F2".to_string())),
540 "shell-bound F2 should not appear: {groups:?}");
541 assert_eq!(groups.get("scroll-down").cloned().unwrap_or_default(), vec!["F3".to_string()]);
542 }
543}