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;
11
12use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
13use serde::Deserialize;
14
15use crate::input::Command;
16
17#[derive(Debug, Clone)]
18pub enum BindingTarget {
19    Command(Command),
20    Shell(String),
21}
22
23#[derive(Debug, Clone)]
24pub struct KeyMap {
25    map: HashMap<KeyEvent, BindingTarget>,
26}
27
28impl KeyMap {
29    pub fn empty() -> Self {
30        Self { map: HashMap::new() }
31    }
32
33    /// Load keys from the global config dir (`/etc/tess/` or
34    /// `$TESS_GLOBAL_CONFIG_DIR`) and from `~/.config/tess/keys.toml`,
35    /// merging per individual binding key with local winning.
36    ///
37    /// Global parse errors warn on stderr and the global layer is treated as
38    /// empty; local parse errors fail startup.
39    pub fn load_layered() -> Result<Self, String> {
40        let mut bindings: HashMap<String, String> = HashMap::new();
41
42        // Global layer.
43        if let Some(dir) = crate::config_path::global_config_dir() {
44            let path = dir.join("keys.toml");
45            if path.exists() {
46                match std::fs::read_to_string(&path) {
47                    Ok(text) => {
48                        match toml::from_str::<KeysConfig>(&text) {
49                            Ok(cfg) => {
50                                for (k, v) in cfg.bindings {
51                                    bindings.insert(k, v);
52                                }
53                            }
54                            Err(e) => eprintln!(
55                                "tess: warning: keys.toml: {}: {e}; ignoring global config",
56                                path.display()
57                            ),
58                        }
59                    }
60                    Err(e) => eprintln!(
61                        "tess: warning: keys.toml: {}: {e}; ignoring global config",
62                        path.display()
63                    ),
64                }
65            }
66        }
67
68        // Local layer.
69        if let Some(dir) = crate::config_path::user_config_dir() {
70            let path = dir.join("keys.toml");
71            if path.exists() {
72                let text = std::fs::read_to_string(&path)
73                    .map_err(|e| format!("keys.toml: reading {}: {e}", path.display()))?;
74                let cfg: KeysConfig = toml::from_str(&text)
75                    .map_err(|e| format!("keys.toml: parsing {}: {e}", path.display()))?;
76                for (k, v) in cfg.bindings {
77                    bindings.insert(k, v);
78                }
79            }
80        }
81
82        // Build the final KeyMap from the merged bindings table.
83        let mut map = HashMap::with_capacity(bindings.len());
84        for (key_spec, action) in bindings {
85            let key = parse_key_spec(&key_spec)
86                .map_err(|e| format!("keys.toml: '{key_spec}': {e}"))?;
87            reject_forbidden_key(&key, &key_spec)
88                .map_err(|e| format!("keys.toml: {e}"))?;
89            let target = parse_action(&action)
90                .map_err(|e| format!("keys.toml: '{key_spec}': {e}"))?;
91            map.insert(key, target);
92        }
93        Ok(Self { map })
94    }
95
96    pub fn load_from_str(toml_text: &str) -> Result<Self, String> {
97        let cfg: KeysConfig = toml::from_str(toml_text)
98            .map_err(|e| format!("parsing: {e}"))?;
99        let mut map = HashMap::with_capacity(cfg.bindings.len());
100        for (key_spec, action) in cfg.bindings {
101            let key = parse_key_spec(&key_spec)
102                .map_err(|e| format!("'{key_spec}': {e}"))?;
103            reject_forbidden_key(&key, &key_spec)?;
104            let target = parse_action(&action)
105                .map_err(|e| format!("'{key_spec}': {e}"))?;
106            map.insert(key, target);
107        }
108        Ok(Self { map })
109    }
110
111    pub fn lookup(&self, key: &KeyEvent) -> Option<&BindingTarget> {
112        self.map.get(key)
113    }
114
115    pub fn is_empty(&self) -> bool {
116        self.map.is_empty()
117    }
118
119    /// Group user bindings by the kebab-case command name they target.
120    /// Shell-target bindings are excluded. Used by the help overlay to
121    /// replace default key displays with the user's chosen keys.
122    pub fn user_keys_by_command_name(&self) -> std::collections::HashMap<String, Vec<String>> {
123        let mut out: std::collections::HashMap<String, Vec<String>> =
124            std::collections::HashMap::new();
125        for (key, target) in &self.map {
126            let BindingTarget::Command(cmd) = target else { continue };
127            let Some(name) = command_to_kebab(cmd) else { continue };
128            out.entry(name.to_string())
129                .or_default()
130                .push(format_key_event(*key));
131        }
132        out
133    }
134}
135
136#[derive(Debug, Deserialize, Default)]
137struct KeysConfig {
138    #[serde(default)]
139    bindings: HashMap<String, String>,
140}
141
142/// Parse a key spec string into a `KeyEvent`.
143fn parse_key_spec(spec: &str) -> Result<KeyEvent, String> {
144    let lower = spec.to_lowercase();
145    let mut parts: Vec<&str> = lower.split('-').collect();
146    if parts.is_empty() {
147        return Err("empty key spec".to_string());
148    }
149    let key_part = parts.pop().unwrap();
150    let mut modifiers = KeyModifiers::NONE;
151    for m in &parts {
152        if m.is_empty() {
153            // Handle "ctrl--" (ctrl + dash). The trailing "-" became "".
154            continue;
155        }
156        match *m {
157            "ctrl" => modifiers |= KeyModifiers::CONTROL,
158            "alt" => modifiers |= KeyModifiers::ALT,
159            "shift" => modifiers |= KeyModifiers::SHIFT,
160            other => return Err(format!("unknown modifier '{other}'")),
161        }
162    }
163    let code = match key_part {
164        "esc" => KeyCode::Esc,
165        "enter" => KeyCode::Enter,
166        "tab" => KeyCode::Tab,
167        "backspace" => KeyCode::Backspace,
168        "space" => KeyCode::Char(' '),
169        "up" => KeyCode::Up,
170        "down" => KeyCode::Down,
171        "left" => KeyCode::Left,
172        "right" => KeyCode::Right,
173        "pgup" => KeyCode::PageUp,
174        "pgdn" => KeyCode::PageDown,
175        "home" => KeyCode::Home,
176        "end" => KeyCode::End,
177        "" => {
178            // The trailing piece was empty, meaning the key is literal "-"
179            // and the modifiers consumed everything else (e.g. "ctrl--").
180            KeyCode::Char('-')
181        }
182        s if s.starts_with('f') && s.len() > 1 => {
183            let n: u8 = s[1..].parse()
184                .map_err(|_| format!("unknown key '{s}'"))?;
185            KeyCode::F(n)
186        }
187        s if s.chars().count() == 1 => {
188            // For a BARE letter with no modifiers, an uppercase letter
189            // promotes to shift-prefix semantics ("J" == "shift-j").
190            // When modifiers are already present (e.g. "ctrl-J"), the
191            // user's intent is the literal letter — don't add SHIFT.
192            let original_char = spec.chars().last().unwrap();
193            if original_char.is_ascii_uppercase() && modifiers == KeyModifiers::NONE {
194                modifiers |= KeyModifiers::SHIFT;
195                KeyCode::Char(original_char.to_ascii_lowercase())
196            } else {
197                // Either lowercase letter, or uppercase with an explicit
198                // modifier — lowercase the char either way for consistency.
199                KeyCode::Char(original_char.to_ascii_lowercase())
200            }
201        }
202        other => return Err(format!("unknown key '{other}'")),
203    };
204    Ok(KeyEvent::new(code, modifiers))
205}
206
207fn reject_forbidden_key(key: &KeyEvent, original_spec: &str) -> Result<(), String> {
208    let forbidden = match (&key.code, key.modifiers) {
209        (KeyCode::Char('m'), KeyModifiers::NONE) => true,
210        (KeyCode::Char('\''), KeyModifiers::NONE) => true,
211        (KeyCode::Char('-'), KeyModifiers::NONE) => true,
212        (KeyCode::Char('x'), KeyModifiers::CONTROL) => true,
213        (KeyCode::Char(c), KeyModifiers::NONE) if c.is_ascii_digit() => true,
214        _ => false,
215    };
216    if forbidden {
217        return Err(format!(
218            "'{original_spec}' is part of a multi-key sequence and cannot be rebound"
219        ));
220    }
221    Ok(())
222}
223
224fn parse_action(action: &str) -> Result<BindingTarget, String> {
225    if let Some(shell_cmd) = action.strip_prefix('!') {
226        if shell_cmd.is_empty() {
227            return Err("shell binding requires a command after '!'".to_string());
228        }
229        return Ok(BindingTarget::Shell(shell_cmd.to_string()));
230    }
231    let cmd = command_from_kebab(action)
232        .ok_or_else(|| format!("unknown command '{action}'"))?;
233    Ok(BindingTarget::Command(cmd))
234}
235
236/// Map a kebab-case command name to the corresponding `Command` enum variant.
237fn command_from_kebab(name: &str) -> Option<Command> {
238    match name {
239        "scroll-down" => Some(Command::ScrollLines(1)),
240        "scroll-up" => Some(Command::ScrollLines(-1)),
241        "scroll-logical-down" => Some(Command::ScrollLogicalLines(1)),
242        "scroll-logical-up" => Some(Command::ScrollLogicalLines(-1)),
243        "page-down" => Some(Command::PageDown),
244        "page-up" => Some(Command::PageUp),
245        "half-page-down" => Some(Command::HalfPageDown),
246        "half-page-up" => Some(Command::HalfPageUp),
247        "quit" => Some(Command::Quit),
248        "refresh" => Some(Command::Refresh),
249        "reload" => Some(Command::Reload),
250        "toggle-line-numbers" => Some(Command::ToggleLineNumbers),
251        "toggle-chop" => Some(Command::ToggleChop),
252        "toggle-follow" => Some(Command::ToggleFollow),
253        "toggle-prettify" => Some(Command::TogglePrettify),
254        "search-forward" => Some(Command::SearchForward),
255        "search-backward" => Some(Command::SearchBackward),
256        "next-match" => Some(Command::NextMatch),
257        "previous-match" => Some(Command::PreviousMatch),
258        "option-prefix" => Some(Command::OptionPrefix),
259        "goto-line" => Some(Command::GotoLine),
260        "goto-record" => Some(Command::GotoRecord),
261        "goto-percent" => Some(Command::GotoPercent),
262        "mark-set" => Some(Command::MarkSet),
263        "mark-jump" => Some(Command::MarkJump),
264        "ctrl-x-prefix" => Some(Command::CtrlXPrefix),
265        "jump-previous" => Some(Command::JumpPrevious),
266        "shell-escape" => Some(Command::ShellEscape),
267        "cancel" => Some(Command::Cancel),
268        "hscroll-left" => Some(Command::HScrollLeft),
269        "hscroll-right" => Some(Command::HScrollRight),
270        "hscroll-left-step" => Some(Command::HScrollLeftStep),
271        "hscroll-right-step" => Some(Command::HScrollRightStep),
272        "clipboard-yank-line" => Some(Command::YankLine),
273        "anim-pause" => Some(Command::AnimPause),
274        "anim-step-forward" => Some(Command::AnimStepForward),
275        "anim-step-back" => Some(Command::AnimStepBack),
276        "anim-restart" => Some(Command::AnimRestart),
277        "focus-other-pane" => Some(Command::FocusOtherPane),
278        _ => None,
279    }
280}
281
282/// Inverse of `command_from_kebab` — covers the same command set.
283fn command_to_kebab(cmd: &Command) -> Option<&'static str> {
284    match cmd {
285        Command::ScrollLines(1) => Some("scroll-down"),
286        Command::ScrollLines(-1) => Some("scroll-up"),
287        Command::ScrollLogicalLines(1) => Some("scroll-logical-down"),
288        Command::ScrollLogicalLines(-1) => Some("scroll-logical-up"),
289        Command::PageDown => Some("page-down"),
290        Command::PageUp => Some("page-up"),
291        Command::HalfPageDown => Some("half-page-down"),
292        Command::HalfPageUp => Some("half-page-up"),
293        Command::Quit => Some("quit"),
294        Command::Refresh => Some("refresh"),
295        Command::Reload => Some("reload"),
296        Command::ToggleLineNumbers => Some("toggle-line-numbers"),
297        Command::ToggleChop => Some("toggle-chop"),
298        Command::ToggleFollow => Some("toggle-follow"),
299        Command::TogglePrettify => Some("toggle-prettify"),
300        Command::SearchForward => Some("search-forward"),
301        Command::SearchBackward => Some("search-backward"),
302        Command::NextMatch => Some("next-match"),
303        Command::PreviousMatch => Some("previous-match"),
304        Command::OptionPrefix => Some("option-prefix"),
305        Command::GotoLine => Some("goto-line"),
306        Command::GotoRecord => Some("goto-record"),
307        Command::GotoPercent => Some("goto-percent"),
308        Command::MarkSet => Some("mark-set"),
309        Command::MarkJump => Some("mark-jump"),
310        Command::CtrlXPrefix => Some("ctrl-x-prefix"),
311        Command::JumpPrevious => Some("jump-previous"),
312        Command::ShellEscape => Some("shell-escape"),
313        Command::Cancel => Some("cancel"),
314        Command::HScrollLeft => Some("hscroll-left"),
315        Command::HScrollRight => Some("hscroll-right"),
316        Command::HScrollLeftStep => Some("hscroll-left-step"),
317        Command::HScrollRightStep => Some("hscroll-right-step"),
318        Command::YankLine => Some("clipboard-yank-line"),
319        Command::AnimPause => Some("anim-pause"),
320        Command::AnimStepForward => Some("anim-step-forward"),
321        Command::AnimStepBack => Some("anim-step-back"),
322        Command::AnimRestart => Some("anim-restart"),
323        Command::FocusOtherPane => Some("focus-other-pane"),
324        _ => None,
325    }
326}
327
328fn format_key_event(ke: KeyEvent) -> String {
329    let ctrl  = ke.modifiers.contains(KeyModifiers::CONTROL);
330    let alt   = ke.modifiers.contains(KeyModifiers::ALT);
331    let shift = ke.modifiers.contains(KeyModifiers::SHIFT);
332
333    // Convention: SHIFT-alone on an ASCII letter displays as the uppercase
334    // letter with no "Shift-" prefix (matches KEY_REGISTRY's `"J"` form).
335    if shift && !ctrl && !alt {
336        if let KeyCode::Char(c) = ke.code {
337            if c.is_ascii_alphabetic() {
338                return c.to_ascii_uppercase().to_string();
339            }
340        }
341    }
342
343    let mut parts: Vec<&'static str> = Vec::new();
344    if ctrl  { parts.push("Ctrl"); }
345    if alt   { parts.push("Alt"); }
346    if shift { parts.push("Shift"); }
347
348    let key = match ke.code {
349        KeyCode::Char(' ') => "Space".to_string(),
350        KeyCode::Char(c)   => c.to_string(),
351        KeyCode::F(n)      => format!("F{n}"),
352        KeyCode::Esc       => "Esc".into(),
353        KeyCode::Enter     => "Enter".into(),
354        KeyCode::Tab       => "Tab".into(),
355        KeyCode::Backspace => "Backspace".into(),
356        KeyCode::Up        => "\u{2191}".into(),
357        KeyCode::Down      => "\u{2193}".into(),
358        KeyCode::Left      => "\u{2190}".into(),
359        KeyCode::Right     => "\u{2192}".into(),
360        KeyCode::Home      => "Home".into(),
361        KeyCode::End       => "End".into(),
362        KeyCode::PageUp    => "PgUp".into(),
363        KeyCode::PageDown  => "PgDn".into(),
364        other              => format!("{other:?}"),
365    };
366    if parts.is_empty() { key } else { format!("{}-{}", parts.join("-"), key) }
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372
373    #[test]
374    fn parse_empty_file_returns_empty_map() {
375        let m = KeyMap::load_from_str("").unwrap();
376        assert!(m.is_empty());
377    }
378
379    #[test]
380    fn parse_single_binding() {
381        let toml = r#"
382[bindings]
383"j" = "scroll-down"
384"#;
385        let m = KeyMap::load_from_str(toml).unwrap();
386        let key = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
387        assert!(matches!(m.lookup(&key), Some(BindingTarget::Command(Command::ScrollLines(1)))));
388    }
389
390    #[test]
391    fn parse_named_special_key() {
392        let toml = r#"
393[bindings]
394"f1" = "toggle-line-numbers"
395"esc" = "cancel"
396"#;
397        let m = KeyMap::load_from_str(toml).unwrap();
398        let f1 = KeyEvent::new(KeyCode::F(1), KeyModifiers::NONE);
399        let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
400        assert!(matches!(m.lookup(&f1), Some(BindingTarget::Command(Command::ToggleLineNumbers))));
401        assert!(matches!(m.lookup(&esc), Some(BindingTarget::Command(Command::Cancel))));
402    }
403
404    #[test]
405    fn parse_modifier_combinations() {
406        let toml = r#"
407[bindings]
408"ctrl-r" = "reload"
409"shift-tab" = "scroll-logical-up"
410"#;
411        let m = KeyMap::load_from_str(toml).unwrap();
412        let ctrl_r = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL);
413        let shift_tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT);
414        assert!(matches!(m.lookup(&ctrl_r), Some(BindingTarget::Command(Command::Reload))));
415        assert!(matches!(m.lookup(&shift_tab), Some(BindingTarget::Command(Command::ScrollLogicalLines(-1)))));
416    }
417
418    #[test]
419    fn case_letter_resolves_to_shift_prefix() {
420        let toml = r#"
421[bindings]
422"J" = "scroll-logical-down"
423"#;
424        let m = KeyMap::load_from_str(toml).unwrap();
425        let shift_j = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::SHIFT);
426        assert!(matches!(m.lookup(&shift_j), Some(BindingTarget::Command(Command::ScrollLogicalLines(1)))));
427    }
428
429    #[test]
430    fn forbidden_keys_error_at_parse() {
431        for key in &["m", "'", "-", "ctrl-x", "0", "5", "9"] {
432            let toml = format!(r#"
433[bindings]
434"{key}" = "quit"
435"#);
436            let err = KeyMap::load_from_str(&toml).unwrap_err();
437            assert!(err.contains("multi-key sequence"),
438                    "key '{key}' should be forbidden: {err}");
439        }
440    }
441
442    #[test]
443    fn unknown_command_name_errors() {
444        let toml = r#"
445[bindings]
446"j" = "definitely-not-a-real-command"
447"#;
448        let err = KeyMap::load_from_str(toml).unwrap_err();
449        assert!(err.contains("unknown command"));
450    }
451
452    #[test]
453    fn empty_shell_binding_errors() {
454        let toml = r#"
455[bindings]
456"f1" = "!"
457"#;
458        let err = KeyMap::load_from_str(toml).unwrap_err();
459        assert!(err.contains("requires a command"));
460    }
461
462    #[test]
463    fn parse_inline_shell_binding() {
464        let toml = r#"
465[bindings]
466"f2" = "!git status"
467"#;
468        let m = KeyMap::load_from_str(toml).unwrap();
469        let f2 = KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE);
470        match m.lookup(&f2) {
471            Some(BindingTarget::Shell(cmd)) => assert_eq!(cmd, "git status"),
472            other => panic!("expected Shell, got {:?}", other),
473        }
474    }
475
476    #[test]
477    fn lookup_returns_none_for_unbound_key() {
478        let toml = r#"
479[bindings]
480"j" = "scroll-down"
481"#;
482        let m = KeyMap::load_from_str(toml).unwrap();
483        let other = KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE);
484        assert!(m.lookup(&other).is_none());
485    }
486
487    #[test]
488    fn ctrl_uppercase_letter_does_not_add_shift() {
489        // "ctrl-J" should be Ctrl + 'j', NOT Ctrl + Shift + 'j'.
490        let toml = r#"
491[bindings]
492"ctrl-J" = "reload"
493"#;
494        let m = KeyMap::load_from_str(toml).unwrap();
495        let ctrl_j = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL);
496        assert!(matches!(m.lookup(&ctrl_j), Some(BindingTarget::Command(Command::Reload))),
497                "ctrl-J should resolve to Ctrl+j without Shift");
498        let ctrl_shift_j = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL | KeyModifiers::SHIFT);
499        assert!(m.lookup(&ctrl_shift_j).is_none(),
500                "ctrl-J should NOT also match Ctrl+Shift+j");
501    }
502
503    #[test]
504    fn user_remaps_by_command_name_groups_keys() {
505        let toml = r#"
506[bindings]
507"f3" = "scroll-down"
508"f4" = "scroll-down"
509"f5" = "quit"
510"#;
511        let m = KeyMap::load_from_str(toml).unwrap();
512        let groups = m.user_keys_by_command_name();
513        let mut down = groups.get("scroll-down").cloned().unwrap_or_default();
514        down.sort();
515        assert_eq!(down, vec!["F3".to_string(), "F4".to_string()]);
516        assert_eq!(groups.get("quit").cloned().unwrap_or_default(), vec!["F5".to_string()]);
517    }
518
519    #[test]
520    fn dash_with_modifier_is_a_real_key() {
521        // "ctrl--" should resolve to Ctrl + '-' (a valid bind).
522        // (Bare "-" is forbidden by reject_forbidden_key, but Ctrl-- isn't.)
523        let toml = r#"
524[bindings]
525"ctrl--" = "refresh"
526"#;
527        let m = KeyMap::load_from_str(toml).unwrap();
528        let ctrl_dash = KeyEvent::new(KeyCode::Char('-'), KeyModifiers::CONTROL);
529        assert!(matches!(m.lookup(&ctrl_dash), Some(BindingTarget::Command(Command::Refresh))));
530    }
531
532    #[test]
533    fn format_key_event_renders_modifier_combos() {
534        // Ctrl alone
535        assert_eq!(
536            format_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)),
537            "Ctrl-r",
538        );
539        // Shift alone on a letter → uppercase, no prefix
540        assert_eq!(
541            format_key_event(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::SHIFT)),
542            "J",
543        );
544        // Shift alone on a non-letter (e.g. Tab) → "Shift-Tab"
545        assert_eq!(
546            format_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT)),
547            "Shift-Tab",
548        );
549        // Plain lowercase letter → "j" (no uppercasing)
550        assert_eq!(
551            format_key_event(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)),
552            "j",
553        );
554        // Function key
555        assert_eq!(
556            format_key_event(KeyEvent::new(KeyCode::F(3), KeyModifiers::NONE)),
557            "F3",
558        );
559        // Ctrl+Shift+letter — composed prefix, lowercase stored char
560        assert_eq!(
561            format_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL | KeyModifiers::SHIFT)),
562            "Ctrl-Shift-x",
563        );
564    }
565
566    #[test]
567    fn hscroll_names_resolve_to_commands() {
568        let toml = r#"
569[bindings]
570"f6" = "hscroll-right"
571"#;
572        let m = KeyMap::load_from_str(toml).unwrap();
573        let f6 = KeyEvent::new(KeyCode::F(6), KeyModifiers::NONE);
574        assert!(matches!(m.lookup(&f6), Some(BindingTarget::Command(Command::HScrollRight))));
575
576        assert!(matches!(command_from_kebab("hscroll-left"), Some(Command::HScrollLeft)));
577        assert!(matches!(command_from_kebab("hscroll-left-step"), Some(Command::HScrollLeftStep)));
578        assert!(matches!(command_from_kebab("hscroll-right-step"), Some(Command::HScrollRightStep)));
579    }
580
581    #[test]
582    fn command_kebab_round_trip() {
583        // Every name in command_from_kebab must round-trip through command_to_kebab.
584        let names = [
585            "scroll-down", "scroll-up", "scroll-logical-down", "scroll-logical-up",
586            "page-down", "page-up", "half-page-down", "half-page-up",
587            "quit", "refresh", "reload",
588            "toggle-line-numbers", "toggle-chop", "toggle-follow", "toggle-prettify",
589            "search-forward", "search-backward", "next-match", "previous-match",
590            "option-prefix", "goto-line", "goto-record", "goto-percent",
591            "mark-set", "mark-jump", "ctrl-x-prefix", "jump-previous",
592            "shell-escape", "cancel",
593            "hscroll-left", "hscroll-right", "hscroll-left-step", "hscroll-right-step",
594            "clipboard-yank-line",
595            "anim-pause", "anim-step-forward", "anim-step-back", "anim-restart",
596            "focus-other-pane",
597        ];
598        for name in &names {
599            let cmd = command_from_kebab(name).expect(&format!("from_kebab failed for {name}"));
600            let back = command_to_kebab(&cmd).expect(&format!("to_kebab failed for {name}"));
601            assert_eq!(back, *name, "round-trip mismatch for {name}");
602        }
603    }
604
605    #[test]
606    fn shell_bindings_are_excluded_from_user_keys() {
607        let toml = r#"
608[bindings]
609"f2" = "!git status"
610"f3" = "scroll-down"
611"#;
612        let m = KeyMap::load_from_str(toml).unwrap();
613        let groups = m.user_keys_by_command_name();
614        assert!(!groups.values().any(|v| v.contains(&"F2".to_string())),
615                "shell-bound F2 should not appear: {groups:?}");
616        assert_eq!(groups.get("scroll-down").cloned().unwrap_or_default(), vec!["F3".to_string()]);
617    }
618
619    #[test]
620    fn layered_keys_local_overrides_global_per_binding() {
621        let _guard = crate::test_env::lock();
622        let prev_home = std::env::var_os("HOME");
623        let prev_global = std::env::var_os("TESS_GLOBAL_CONFIG_DIR");
624
625        let home = tempfile::tempdir().unwrap();
626        let global = tempfile::tempdir().unwrap();
627
628        std::env::set_var("HOME", home.path());
629        std::env::set_var("TESS_GLOBAL_CONFIG_DIR", global.path());
630
631        std::fs::write(
632            global.path().join("keys.toml"),
633            r#"
634[bindings]
635"j" = "scroll-down"
636"k" = "scroll-up"
637"#,
638        )
639        .unwrap();
640
641        let cfg_dir = home.path().join(".config").join("tess");
642        std::fs::create_dir_all(&cfg_dir).unwrap();
643        std::fs::write(
644            cfg_dir.join("keys.toml"),
645            r#"
646[bindings]
647"j" = "page-down"
648"#,
649        )
650        .unwrap();
651
652        let km = KeyMap::load_layered().unwrap();
653
654        // j: local wins (page-down)
655        let j = crossterm::event::KeyEvent::new(
656            crossterm::event::KeyCode::Char('j'),
657            crossterm::event::KeyModifiers::NONE,
658        );
659        match km.lookup(&j) {
660            Some(BindingTarget::Command(cmd)) => {
661                let dbg = format!("{cmd:?}");
662                assert!(dbg.to_lowercase().contains("page"), "got: {dbg}");
663            }
664            other => panic!("expected Command(PageDown), got {other:?}"),
665        }
666
667        // k: global survives (scroll-up)
668        let k = crossterm::event::KeyEvent::new(
669            crossterm::event::KeyCode::Char('k'),
670            crossterm::event::KeyModifiers::NONE,
671        );
672        match km.lookup(&k) {
673            Some(BindingTarget::Command(cmd)) => {
674                assert!(matches!(cmd, Command::ScrollLines(n) if *n < 0), "got: {cmd:?}");
675            }
676            other => panic!("expected Command(ScrollLines(-1)), got {other:?}"),
677        }
678
679        match prev_home {
680            Some(v) => std::env::set_var("HOME", v),
681            None => std::env::remove_var("HOME"),
682        }
683        match prev_global {
684            Some(v) => std::env::set_var("TESS_GLOBAL_CONFIG_DIR", v),
685            None => std::env::remove_var("TESS_GLOBAL_CONFIG_DIR"),
686        }
687    }
688
689    #[test]
690    fn layered_keys_warns_on_bad_global() {
691        let _guard = crate::test_env::lock();
692        let prev_home = std::env::var_os("HOME");
693        let prev_global = std::env::var_os("TESS_GLOBAL_CONFIG_DIR");
694
695        let home = tempfile::tempdir().unwrap();
696        let global = tempfile::tempdir().unwrap();
697
698        std::env::set_var("HOME", home.path());
699        std::env::set_var("TESS_GLOBAL_CONFIG_DIR", global.path());
700
701        std::fs::write(
702            global.path().join("keys.toml"),
703            "= = not valid",
704        )
705        .unwrap();
706
707        // Local missing — should still succeed.
708        let km = KeyMap::load_layered().unwrap();
709        assert!(km.is_empty());
710
711        match prev_home {
712            Some(v) => std::env::set_var("HOME", v),
713            None => std::env::remove_var("HOME"),
714        }
715        match prev_global {
716            Some(v) => std::env::set_var("TESS_GLOBAL_CONFIG_DIR", v),
717            None => std::env::remove_var("TESS_GLOBAL_CONFIG_DIR"),
718        }
719    }
720}