Skip to main content

tess/
keys.rs

1//! Custom keybindings loaded from `~/.config/tess/keys.toml`.
2//!
3//! Schema:
4//! ```toml
5//! [bindings]
6//! "j" = "scroll-down"
7//! "f1" = "!git status"
8//! ```
9
10use 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    /// Load from the default path `~/.config/tess/keys.toml`. Missing file
35    /// is OK and returns an empty map. Returns an error if the file exists
36    /// but can't be parsed.
37    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    /// Group user bindings by the kebab-case command name they target.
74    /// Shell-target bindings are excluded. Used by the help overlay to
75    /// replace default key displays with the user's chosen keys.
76    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
106/// Parse a key spec string into a `KeyEvent`.
107fn 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            // Handle "ctrl--" (ctrl + dash). The trailing "-" became "".
118            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            // The trailing piece was empty, meaning the key is literal "-"
143            // and the modifiers consumed everything else (e.g. "ctrl--").
144            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            // For a BARE letter with no modifiers, an uppercase letter
153            // promotes to shift-prefix semantics ("J" == "shift-j").
154            // When modifiers are already present (e.g. "ctrl-J"), the
155            // user's intent is the literal letter — don't add SHIFT.
156            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                // Either lowercase letter, or uppercase with an explicit
162                // modifier — lowercase the char either way for consistency.
163                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
200/// Map a kebab-case command name to the corresponding `Command` enum variant.
201fn 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
236/// Inverse of `command_from_kebab` — covers the same command set.
237fn 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    // Convention: SHIFT-alone on an ASCII letter displays as the uppercase
278    // letter with no "Shift-" prefix (matches KEY_REGISTRY's `"J"` form).
279    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        // "ctrl-J" should be Ctrl + 'j', NOT Ctrl + Shift + 'j'.
434        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        // "ctrl--" should resolve to Ctrl + '-' (a valid bind).
466        // (Bare "-" is forbidden by reject_forbidden_key, but Ctrl-- isn't.)
467        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        // Ctrl alone
479        assert_eq!(
480            format_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)),
481            "Ctrl-r",
482        );
483        // Shift alone on a letter → uppercase, no prefix
484        assert_eq!(
485            format_key_event(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::SHIFT)),
486            "J",
487        );
488        // Shift alone on a non-letter (e.g. Tab) → "Shift-Tab"
489        assert_eq!(
490            format_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT)),
491            "Shift-Tab",
492        );
493        // Plain lowercase letter → "j" (no uppercasing)
494        assert_eq!(
495            format_key_event(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)),
496            "j",
497        );
498        // Function key
499        assert_eq!(
500            format_key_event(KeyEvent::new(KeyCode::F(3), KeyModifiers::NONE)),
501            "F3",
502        );
503        // Ctrl+Shift+letter — composed prefix, lowercase stored char
504        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        // Every name in command_from_kebab must round-trip through command_to_kebab.
513        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}