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
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
90/// Parse a key spec string into a `KeyEvent`.
91fn 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            // Handle "ctrl--" (ctrl + dash). The trailing "-" became "".
102            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            // The trailing piece was empty, meaning the key is literal "-"
127            // and the modifiers consumed everything else (e.g. "ctrl--").
128            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            // For a BARE letter with no modifiers, an uppercase letter
137            // promotes to shift-prefix semantics ("J" == "shift-j").
138            // When modifiers are already present (e.g. "ctrl-J"), the
139            // user's intent is the literal letter — don't add SHIFT.
140            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                // Either lowercase letter, or uppercase with an explicit
146                // modifier — lowercase the char either way for consistency.
147                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
184/// Map a kebab-case command name to the corresponding `Command` enum variant.
185fn 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        // "ctrl-J" should be Ctrl + 'j', NOT Ctrl + Shift + 'j'.
341        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        // "ctrl--" should resolve to Ctrl + '-' (a valid bind).
357        // (Bare "-" is forbidden by reject_forbidden_key, but Ctrl-- isn't.)
358        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}