1use 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 pub fn load_layered() -> Result<Self, String> {
40 let mut bindings: HashMap<String, String> = HashMap::new();
41
42 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 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 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 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
142fn 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 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 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 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 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
236fn 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
282fn 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 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 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 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 assert_eq!(
536 format_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)),
537 "Ctrl-r",
538 );
539 assert_eq!(
541 format_key_event(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::SHIFT)),
542 "J",
543 );
544 assert_eq!(
546 format_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT)),
547 "Shift-Tab",
548 );
549 assert_eq!(
551 format_key_event(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)),
552 "j",
553 );
554 assert_eq!(
556 format_key_event(KeyEvent::new(KeyCode::F(3), KeyModifiers::NONE)),
557 "F3",
558 );
559 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 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 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 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 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}