1use crossterm::event::{KeyCode, KeyModifiers};
7use std::collections::HashSet;
8use std::path::PathBuf;
9
10#[derive(Debug, Clone, PartialEq, Eq, Hash)]
12pub struct KeyCombo {
13 pub code: KeyCode,
14 pub modifiers: KeyModifiers,
15}
16
17#[derive(Debug, Clone)]
19pub enum KeybindAction {
20 SlashCommand(String),
22 LoadSkill(String),
24 InjectPrompt(String),
26 RunScript { script: String, plugin_dir: PathBuf },
28 Disabled,
30}
31
32#[derive(Debug, Clone, PartialEq)]
34pub enum KeybindSource {
35 Core,
36 User,
37 Plugin(String),
38}
39
40#[derive(Debug, Clone)]
42pub struct Keybind {
43 pub key: KeyCombo,
44 pub action: KeybindAction,
45 pub description: String,
46 pub source: KeybindSource,
47}
48
49#[derive(Clone, Debug, PartialEq, Eq)]
52pub struct KeybindCollision {
53 pub losing_plugin: String,
55 pub key: String,
57 pub winning_owner: String,
60 pub reason: String,
64}
65
66#[derive(Debug, Clone)]
68pub struct KeybindRegistry {
69 binds: Vec<Keybind>,
70 reserved: HashSet<KeyCombo>,
71 collisions: Vec<KeybindCollision>,
72}
73
74impl Default for KeybindRegistry {
75 fn default() -> Self {
76 Self::new()
77 }
78}
79
80impl KeybindRegistry {
81 pub fn new() -> Self {
82 let mut registry = Self {
83 binds: Vec::new(),
84 reserved: HashSet::new(),
85 collisions: Vec::new(),
86 };
87 registry.register_core();
88 registry
89 }
90
91 pub fn collisions(&self) -> &[KeybindCollision] {
93 &self.collisions
94 }
95
96 pub fn clear_collisions(&mut self) {
98 self.collisions.clear();
99 }
100
101 fn register_core(&mut self) {
103 let core_keys = vec![
104 (KeyCode::Char('c'), KeyModifiers::CONTROL, "Quit"),
105 (KeyCode::Esc, KeyModifiers::NONE, "Abort stream"),
106 (KeyCode::Enter, KeyModifiers::NONE, "Submit"),
107 (KeyCode::Enter, KeyModifiers::SHIFT, "Newline"),
108 (KeyCode::Tab, KeyModifiers::NONE, "Autocomplete"),
109 (KeyCode::Char('a'), KeyModifiers::CONTROL, "Cursor start"),
110 (KeyCode::Char('e'), KeyModifiers::CONTROL, "Cursor end"),
111 (KeyCode::Char('u'), KeyModifiers::CONTROL, "Clear input"),
112 (KeyCode::Char('w'), KeyModifiers::CONTROL, "Delete word"),
113 (KeyCode::Char('o'), KeyModifiers::CONTROL, "Toggle output"),
114 (KeyCode::Left, KeyModifiers::ALT, "Jump word left"),
115 (KeyCode::Right, KeyModifiers::ALT, "Jump word right"),
116 (KeyCode::Up, KeyModifiers::SHIFT, "Scroll up"),
117 (KeyCode::Down, KeyModifiers::SHIFT, "Scroll down"),
118 (KeyCode::Up, KeyModifiers::CONTROL, "Watch older subagent"),
119 (
120 KeyCode::Down,
121 KeyModifiers::CONTROL,
122 "Watch newer subagent / main",
123 ),
124 (KeyCode::Up, KeyModifiers::ALT, "Watch older subagent"),
126 (KeyCode::Down, KeyModifiers::ALT, "Watch newer subagent / main"),
127 (KeyCode::Up, KeyModifiers::NONE, "History up"),
128 (KeyCode::Down, KeyModifiers::NONE, "History down"),
129 (KeyCode::Left, KeyModifiers::NONE, "Cursor left"),
130 (KeyCode::Right, KeyModifiers::NONE, "Cursor right"),
131 (KeyCode::Backspace, KeyModifiers::NONE, "Backspace"),
132 (KeyCode::Backspace, KeyModifiers::ALT, "Delete word"),
133 (KeyCode::Home, KeyModifiers::NONE, "Cursor start"),
134 (KeyCode::End, KeyModifiers::NONE, "Cursor end"),
135 ];
136 for (code, modifiers, desc) in core_keys {
137 let combo = KeyCombo { code, modifiers };
138 self.reserved.insert(combo.clone());
139 self.binds.push(Keybind {
140 key: combo,
141 action: KeybindAction::Disabled, description: desc.to_string(),
143 source: KeybindSource::Core,
144 });
145 }
146 }
147
148 pub fn register_plugin(
150 &mut self,
151 plugin_name: &str,
152 keybinds: &[ManifestKeybind],
153 plugin_dir: &std::path::Path,
154 ) {
155 for kb in keybinds {
156 let combo = match parse_key(&kb.key) {
157 Ok(c) => c,
158 Err(e) => {
159 tracing::warn!(
160 "plugin '{}': invalid keybind '{}': {}",
161 plugin_name,
162 kb.key,
163 e
164 );
165 self.collisions.push(KeybindCollision {
166 losing_plugin: plugin_name.to_string(),
167 key: kb.key.clone(),
168 winning_owner: "n/a".to_string(),
169 reason: format!("invalid notation: {}", e),
170 });
171 continue;
172 }
173 };
174
175 if self.reserved.contains(&combo) {
177 tracing::warn!(
178 "plugin '{}': keybind '{}' conflicts with core — skipped",
179 plugin_name,
180 kb.key
181 );
182 self.collisions.push(KeybindCollision {
183 losing_plugin: plugin_name.to_string(),
184 key: kb.key.clone(),
185 winning_owner: "core".to_string(),
186 reason: "conflicts with core".to_string(),
187 });
188 continue;
189 }
190
191 if let Some(existing) = self
193 .binds
194 .iter()
195 .find(|b| b.key == combo && b.source != KeybindSource::Core)
196 {
197 let winning_owner = match &existing.source {
198 KeybindSource::Plugin(name) => name.clone(),
199 KeybindSource::User => "user".to_string(),
200 KeybindSource::Core => "core".to_string(),
201 };
202 tracing::warn!(
203 "plugin '{}': keybind '{}' already registered — skipped",
204 plugin_name,
205 kb.key
206 );
207 self.collisions.push(KeybindCollision {
208 losing_plugin: plugin_name.to_string(),
209 key: kb.key.clone(),
210 winning_owner,
211 reason: "already registered".to_string(),
212 });
213 continue;
214 }
215
216 let action = match kb.action.as_str() {
217 "slash_command" => {
218 KeybindAction::SlashCommand(kb.command.clone().unwrap_or_default())
219 }
220 "load_skill" => KeybindAction::LoadSkill(kb.skill.clone().unwrap_or_default()),
221 "inject_prompt" => {
222 KeybindAction::InjectPrompt(kb.prompt.clone().unwrap_or_default())
223 }
224 "run_script" => KeybindAction::RunScript {
225 script: kb.script.clone().unwrap_or_default(),
226 plugin_dir: plugin_dir.to_path_buf(),
227 },
228 other => {
229 tracing::warn!(
230 "plugin '{}': unknown keybind action '{}'",
231 plugin_name,
232 other
233 );
234 continue;
235 }
236 };
237
238 self.binds.push(Keybind {
239 key: combo,
240 action,
241 description: kb.description.clone().unwrap_or_default(),
242 source: KeybindSource::Plugin(plugin_name.to_string()),
243 });
244 }
245 }
246
247 pub fn register_user(&mut self, config_keybinds: &std::collections::HashMap<String, String>) {
249 for (key_str, value) in config_keybinds {
250 let combo = match parse_key(key_str) {
251 Ok(c) => c,
252 Err(e) => {
253 tracing::warn!("config: invalid keybind '{}': {}", key_str, e);
254 continue;
255 }
256 };
257
258 if self.reserved.contains(&combo) {
260 tracing::warn!("config: keybind '{}' is a core bind — skipped", key_str);
261 continue;
262 }
263
264 self.binds
266 .retain(|b| b.key != combo || b.source == KeybindSource::Core);
267
268 let action = if value == "disabled" {
269 KeybindAction::Disabled
270 } else if let Some(stripped) = value.strip_prefix('/') {
271 let cmd = stripped.to_string();
272 KeybindAction::SlashCommand(cmd)
273 } else {
274 KeybindAction::InjectPrompt(value.clone())
275 };
276
277 self.binds.push(Keybind {
278 key: combo,
279 action,
280 description: format!("User: {}", value),
281 source: KeybindSource::User,
282 });
283 }
284 }
285
286 pub fn set_slash_command_key(
293 &mut self,
294 slash_command: &str,
295 new_key: &str,
296 ) -> Result<(), String> {
297 let combo = parse_key(new_key)?;
298 if self.reserved.contains(&combo) {
299 return Err(format!("'{}' is reserved by core — cannot rebind", new_key));
300 }
301 self.binds.retain(|b| {
303 if b.source == KeybindSource::Core {
304 return true;
305 }
306 !matches!(&b.action, KeybindAction::SlashCommand(c) if c == slash_command)
307 });
308 self.binds
311 .retain(|b| b.key != combo || b.source == KeybindSource::Core);
312 self.binds.push(Keybind {
313 key: combo,
314 action: KeybindAction::SlashCommand(slash_command.to_string()),
315 description: format!("User: /{}", slash_command),
316 source: KeybindSource::User,
317 });
318 Ok(())
319 }
320
321 pub fn match_key(&self, code: KeyCode, modifiers: KeyModifiers) -> Option<&Keybind> {
324 let combo = KeyCombo { code, modifiers };
325
326 if self.reserved.contains(&combo) {
328 return None;
329 }
330
331 self.binds
332 .iter()
333 .find(|b| b.key == combo && !matches!(b.source, KeybindSource::Core))
334 }
335
336 pub fn all(&self) -> &[Keybind] {
338 &self.binds
339 }
340
341 pub fn custom_binds(&self) -> Vec<&Keybind> {
343 self.binds
344 .iter()
345 .filter(|b| !matches!(b.source, KeybindSource::Core))
346 .collect()
347 }
348}
349
350#[derive(Debug, Clone, serde::Deserialize)]
352pub struct ManifestKeybind {
353 pub key: String,
354 #[serde(default)]
355 pub action: String,
356 #[serde(default)]
357 pub command: Option<String>,
358 #[serde(default)]
359 pub skill: Option<String>,
360 #[serde(default)]
361 pub prompt: Option<String>,
362 #[serde(default)]
363 pub script: Option<String>,
364 #[serde(default)]
365 pub description: Option<String>,
366}
367
368pub fn parse_key(notation: &str) -> Result<KeyCombo, String> {
381 let notation = notation.trim();
382 if notation.is_empty() {
383 return Err("empty key notation".to_string());
384 }
385
386 let parts: Vec<&str> = notation.split('-').collect();
387 let mut modifiers = KeyModifiers::empty();
388
389 for part in &parts[..parts.len().saturating_sub(1)] {
391 match *part {
392 "C" => modifiers |= KeyModifiers::CONTROL,
393 "S" => modifiers |= KeyModifiers::SHIFT,
394 "A" => modifiers |= KeyModifiers::ALT,
395 other => {
396 return Err(format!(
397 "unknown modifier: '{}' (expected C, S, or A)",
398 other
399 ))
400 }
401 }
402 }
403
404 let key_str = parts.last().ok_or("missing key")?;
405 let code = match *key_str {
406 k if k.len() == 1 => {
407 let ch = k.chars().next().unwrap();
408 KeyCode::Char(ch.to_ascii_lowercase())
409 }
410 k if k.starts_with('F') && k.len() <= 3 => {
411 let n: u8 = k[1..]
412 .parse()
413 .map_err(|_| format!("invalid F-key: '{}'", k))?;
414 if !(1..=12).contains(&n) {
415 return Err(format!("F-key out of range: F{} (expected F1–F12)", n));
416 }
417 KeyCode::F(n)
418 }
419 "Space" => KeyCode::Char(' '),
420 "Tab" => KeyCode::Tab,
421 "Enter" => KeyCode::Enter,
422 "Esc" => KeyCode::Esc,
423 "Backspace" | "BS" => KeyCode::Backspace,
424 "Delete" | "Del" => KeyCode::Delete,
425 "Home" => KeyCode::Home,
426 "End" => KeyCode::End,
427 "PageUp" | "PgUp" => KeyCode::PageUp,
428 "PageDown" | "PgDn" => KeyCode::PageDown,
429 "Up" => KeyCode::Up,
430 "Down" => KeyCode::Down,
431 "Left" => KeyCode::Left,
432 "Right" => KeyCode::Right,
433 other => return Err(format!("unknown key: '{}'", other)),
434 };
435
436 Ok(KeyCombo { code, modifiers })
437}
438
439pub fn format_key(combo: &KeyCombo) -> String {
441 let mut parts = Vec::new();
442 if combo.modifiers.contains(KeyModifiers::CONTROL) {
443 parts.push("Ctrl");
444 }
445 if combo.modifiers.contains(KeyModifiers::ALT) {
446 parts.push("Alt");
447 }
448 if combo.modifiers.contains(KeyModifiers::SHIFT) {
449 parts.push("Shift");
450 }
451
452 let key = match combo.code {
453 KeyCode::Char(' ') => "Space".to_string(),
454 KeyCode::Char(c) => c.to_uppercase().to_string(),
455 KeyCode::F(n) => format!("F{}", n),
456 KeyCode::Tab => "Tab".to_string(),
457 KeyCode::Enter => "Enter".to_string(),
458 KeyCode::Esc => "Esc".to_string(),
459 KeyCode::Backspace => "Backspace".to_string(),
460 KeyCode::Delete => "Delete".to_string(),
461 KeyCode::Home => "Home".to_string(),
462 KeyCode::End => "End".to_string(),
463 KeyCode::PageUp => "PageUp".to_string(),
464 KeyCode::PageDown => "PageDown".to_string(),
465 KeyCode::Up => "↑".to_string(),
466 KeyCode::Down => "↓".to_string(),
467 KeyCode::Left => "←".to_string(),
468 KeyCode::Right => "→".to_string(),
469 _ => "?".to_string(),
470 };
471 parts.push(&key);
472 parts.join("+")
473}
474
475#[cfg(test)]
476mod tests {
477 use super::*;
478
479 #[test]
482 fn parse_single_char() {
483 let k = parse_key("s").unwrap();
484 assert_eq!(k.code, KeyCode::Char('s'));
485 assert_eq!(k.modifiers, KeyModifiers::NONE);
486 }
487
488 #[test]
489 fn parse_ctrl_char() {
490 let k = parse_key("C-s").unwrap();
491 assert_eq!(k.code, KeyCode::Char('s'));
492 assert_eq!(k.modifiers, KeyModifiers::CONTROL);
493 }
494
495 #[test]
496 fn parse_ctrl_shift() {
497 let k = parse_key("C-S-s").unwrap();
498 assert_eq!(k.code, KeyCode::Char('s'));
499 assert_eq!(k.modifiers, KeyModifiers::CONTROL | KeyModifiers::SHIFT);
500 }
501
502 #[test]
503 fn parse_alt() {
504 let k = parse_key("A-p").unwrap();
505 assert_eq!(k.code, KeyCode::Char('p'));
506 assert_eq!(k.modifiers, KeyModifiers::ALT);
507 }
508
509 #[test]
510 fn parse_ctrl_alt() {
511 let k = parse_key("C-A-x").unwrap();
512 assert_eq!(k.code, KeyCode::Char('x'));
513 assert_eq!(k.modifiers, KeyModifiers::CONTROL | KeyModifiers::ALT);
514 }
515
516 #[test]
517 fn parse_f_keys() {
518 let k = parse_key("F5").unwrap();
519 assert_eq!(k.code, KeyCode::F(5));
520 assert_eq!(k.modifiers, KeyModifiers::NONE);
521
522 let k = parse_key("C-F12").unwrap();
523 assert_eq!(k.code, KeyCode::F(12));
524 assert_eq!(k.modifiers, KeyModifiers::CONTROL);
525 }
526
527 #[test]
528 fn parse_special_keys() {
529 assert_eq!(parse_key("Space").unwrap().code, KeyCode::Char(' '));
530 assert_eq!(parse_key("Tab").unwrap().code, KeyCode::Tab);
531 assert_eq!(parse_key("Enter").unwrap().code, KeyCode::Enter);
532 assert_eq!(parse_key("Esc").unwrap().code, KeyCode::Esc);
533 assert_eq!(parse_key("Backspace").unwrap().code, KeyCode::Backspace);
534 assert_eq!(parse_key("Home").unwrap().code, KeyCode::Home);
535 assert_eq!(parse_key("End").unwrap().code, KeyCode::End);
536 }
537
538 #[test]
539 fn parse_ctrl_space() {
540 let k = parse_key("C-Space").unwrap();
541 assert_eq!(k.code, KeyCode::Char(' '));
542 assert_eq!(k.modifiers, KeyModifiers::CONTROL);
543 }
544
545 #[test]
546 fn parse_uppercase_normalized_to_lower() {
547 let k = parse_key("C-S").unwrap();
548 assert_eq!(k.code, KeyCode::Char('s'));
549 }
550
551 #[test]
552 fn parse_empty_errors() {
553 assert!(parse_key("").is_err());
554 assert!(parse_key(" ").is_err());
555 }
556
557 #[test]
558 fn parse_unknown_modifier_errors() {
559 assert!(parse_key("X-s").is_err());
560 }
561
562 #[test]
563 fn parse_unknown_key_errors() {
564 assert!(parse_key("C-FooBar").is_err());
565 }
566
567 #[test]
568 fn parse_f_key_out_of_range() {
569 assert!(parse_key("F0").is_err());
570 assert!(parse_key("F13").is_err());
571 }
572
573 #[test]
576 fn format_ctrl_shift_s() {
577 let k = KeyCombo {
578 code: KeyCode::Char('s'),
579 modifiers: KeyModifiers::CONTROL | KeyModifiers::SHIFT,
580 };
581 assert_eq!(format_key(&k), "Ctrl+Shift+S");
582 }
583
584 #[test]
585 fn format_f5() {
586 let k = KeyCombo {
587 code: KeyCode::F(5),
588 modifiers: KeyModifiers::NONE,
589 };
590 assert_eq!(format_key(&k), "F5");
591 }
592
593 #[test]
594 fn format_alt_space() {
595 let k = KeyCombo {
596 code: KeyCode::Char(' '),
597 modifiers: KeyModifiers::ALT,
598 };
599 assert_eq!(format_key(&k), "Alt+Space");
600 }
601
602 #[test]
605 fn core_binds_are_reserved() {
606 let reg = KeybindRegistry::new();
607 assert!(reg
609 .match_key(KeyCode::Char('c'), KeyModifiers::CONTROL)
610 .is_none());
611 }
612
613 #[test]
614 fn plugin_bind_matches() {
615 let mut reg = KeybindRegistry::new();
616 reg.register_plugin(
617 "test",
618 &[ManifestKeybind {
619 key: "C-S-s".to_string(),
620 action: "slash_command".to_string(),
621 command: Some("scholar".to_string()),
622 skill: None,
623 prompt: None,
624 script: None,
625 description: Some("Search papers".to_string()),
626 }],
627 std::path::Path::new("/tmp"),
628 );
629
630 let result = reg.match_key(
631 KeyCode::Char('s'),
632 KeyModifiers::CONTROL | KeyModifiers::SHIFT,
633 );
634 assert!(result.is_some());
635 assert_eq!(result.unwrap().description, "Search papers");
636 }
637
638 #[test]
639 fn plugin_cannot_override_core() {
640 let mut reg = KeybindRegistry::new();
641 reg.register_plugin(
642 "evil",
643 &[ManifestKeybind {
644 key: "C-c".to_string(),
645 action: "inject_prompt".to_string(),
646 command: None,
647 skill: None,
648 prompt: Some("hacked".to_string()),
649 script: None,
650 description: Some("evil".to_string()),
651 }],
652 std::path::Path::new("/tmp"),
653 );
654
655 assert!(reg
657 .match_key(KeyCode::Char('c'), KeyModifiers::CONTROL)
658 .is_none());
659 }
660
661 #[test]
662 fn user_overrides_plugin() {
663 let mut reg = KeybindRegistry::new();
664 reg.register_plugin(
665 "test",
666 &[ManifestKeybind {
667 key: "F5".to_string(),
668 action: "slash_command".to_string(),
669 command: Some("scholar".to_string()),
670 skill: None,
671 prompt: None,
672 script: None,
673 description: Some("Scholar".to_string()),
674 }],
675 std::path::Path::new("/tmp"),
676 );
677
678 let mut overrides = std::collections::HashMap::new();
679 overrides.insert("F5".to_string(), "/compact".to_string());
680 reg.register_user(&overrides);
681
682 let result = reg.match_key(KeyCode::F(5), KeyModifiers::NONE);
683 assert!(result.is_some());
684 assert_eq!(result.unwrap().description, "User: /compact");
685 }
686
687 #[test]
688 fn user_can_disable_bind() {
689 let mut reg = KeybindRegistry::new();
690 reg.register_plugin(
691 "test",
692 &[ManifestKeybind {
693 key: "F5".to_string(),
694 action: "slash_command".to_string(),
695 command: Some("scholar".to_string()),
696 skill: None,
697 prompt: None,
698 script: None,
699 description: Some("Scholar".to_string()),
700 }],
701 std::path::Path::new("/tmp"),
702 );
703
704 let mut overrides = std::collections::HashMap::new();
705 overrides.insert("F5".to_string(), "disabled".to_string());
706 reg.register_user(&overrides);
707
708 let result = reg.match_key(KeyCode::F(5), KeyModifiers::NONE);
709 assert!(result.is_some());
710 assert!(matches!(result.unwrap().action, KeybindAction::Disabled));
711 }
712
713 #[test]
714 fn duplicate_plugin_binds_first_wins() {
715 let mut reg = KeybindRegistry::new();
716 reg.register_plugin(
717 "first",
718 &[ManifestKeybind {
719 key: "F5".to_string(),
720 action: "slash_command".to_string(),
721 command: Some("first".to_string()),
722 skill: None,
723 prompt: None,
724 script: None,
725 description: Some("First".to_string()),
726 }],
727 std::path::Path::new("/tmp"),
728 );
729
730 reg.register_plugin(
731 "second",
732 &[ManifestKeybind {
733 key: "F5".to_string(),
734 action: "slash_command".to_string(),
735 command: Some("second".to_string()),
736 skill: None,
737 prompt: None,
738 script: None,
739 description: Some("Second".to_string()),
740 }],
741 std::path::Path::new("/tmp"),
742 );
743
744 let result = reg.match_key(KeyCode::F(5), KeyModifiers::NONE);
745 assert!(result.is_some());
746 assert_eq!(result.unwrap().description, "First");
747 }
748
749 #[test]
750 fn custom_binds_excludes_core() {
751 let reg = KeybindRegistry::new();
752 let custom = reg.custom_binds();
753 assert!(custom.is_empty()); }
755
756 #[test]
757 fn set_slash_command_key_replaces_existing_sidecar_toggle() {
758 let mut reg = KeybindRegistry::new();
759 let mut overrides = std::collections::HashMap::new();
760 overrides.insert("F8".to_string(), "/sidecar toggle".to_string());
761 reg.register_user(&overrides);
762 let f8 = parse_key("F8").unwrap();
763 assert!(reg.match_key(f8.code, f8.modifiers).is_some());
764
765 reg.set_slash_command_key("sidecar toggle", "C-G").unwrap();
767
768 assert!(reg.match_key(f8.code, f8.modifiers).is_none());
770 let cg = parse_key("C-G").unwrap();
772 let bind = reg
773 .match_key(cg.code, cg.modifiers)
774 .expect("C-G bind missing");
775 assert!(matches!(&bind.action, KeybindAction::SlashCommand(c) if c == "sidecar toggle"));
776 }
777
778 #[test]
779 fn set_slash_command_key_rejects_core_chord() {
780 let mut reg = KeybindRegistry::new();
781 let err = reg
783 .set_slash_command_key("sidecar toggle", "Esc")
784 .unwrap_err();
785 assert!(
786 err.contains("reserved"),
787 "expected reserved error, got: {err}"
788 );
789 }
790
791 fn mk_kb(key: &str, cmd: &str) -> ManifestKeybind {
794 ManifestKeybind {
795 key: key.to_string(),
796 action: "slash_command".to_string(),
797 command: Some(cmd.to_string()),
798 skill: None,
799 prompt: None,
800 script: None,
801 description: Some(cmd.to_string()),
802 }
803 }
804
805 #[test]
808 fn alt_arrows_are_reserved_for_subagent_views() {
809 let mut reg = KeybindRegistry::new();
810 reg.register_plugin(
811 "p",
812 &[mk_kb("A-Up", "x"), mk_kb("A-Down", "y")],
813 std::path::Path::new("/tmp"),
814 );
815 assert!(reg.match_key(KeyCode::Up, KeyModifiers::ALT).is_none());
816 assert!(reg.match_key(KeyCode::Down, KeyModifiers::ALT).is_none());
817 assert_eq!(reg.collisions().len(), 2);
818 }
819
820 #[test]
821 fn register_plugin_records_core_collision() {
822 let mut reg = KeybindRegistry::new();
823 reg.register_plugin(
825 "evil",
826 &[mk_kb("C-c", "hack")],
827 std::path::Path::new("/tmp"),
828 );
829 assert_eq!(reg.collisions().len(), 1);
830 let c = ®.collisions()[0];
831 assert_eq!(c.losing_plugin, "evil");
832 assert_eq!(c.winning_owner, "core");
833 assert_eq!(c.reason, "conflicts with core");
834 assert_eq!(c.key, "C-c");
835 }
836
837 #[test]
838 fn register_plugin_records_plugin_vs_plugin_collision() {
839 let mut reg = KeybindRegistry::new();
840 reg.register_plugin(
842 "A",
843 &[mk_kb("C-Space", "alpha")],
844 std::path::Path::new("/tmp"),
845 );
846 reg.register_plugin(
847 "B",
848 &[mk_kb("C-Space", "beta")],
849 std::path::Path::new("/tmp"),
850 );
851 assert_eq!(reg.collisions().len(), 1);
852 let c = ®.collisions()[0];
853 assert_eq!(c.losing_plugin, "B");
854 assert_eq!(c.winning_owner, "A");
855 assert_eq!(c.reason, "already registered");
856 }
857
858 #[test]
859 fn register_plugin_records_invalid_key_notation() {
860 let mut reg = KeybindRegistry::new();
861 reg.register_plugin(
862 "weird",
863 &[mk_kb("this is not a key", "noop")],
864 std::path::Path::new("/tmp"),
865 );
866 assert_eq!(reg.collisions().len(), 1);
867 let c = ®.collisions()[0];
868 assert_eq!(c.losing_plugin, "weird");
869 assert_eq!(c.winning_owner, "n/a");
870 assert!(
871 c.reason.starts_with("invalid notation"),
872 "reason should start with 'invalid notation', got: {}",
873 c.reason
874 );
875 }
876
877 #[test]
878 fn collisions_is_empty_when_no_conflicts() {
879 let mut reg = KeybindRegistry::new();
880 reg.register_plugin("solo", &[mk_kb("F7", "solo")], std::path::Path::new("/tmp"));
881 assert!(reg.collisions().is_empty());
882 }
883
884 #[test]
885 fn multiple_collisions_are_all_recorded() {
886 let mut reg = KeybindRegistry::new();
887 reg.register_plugin(
888 "A",
889 &[mk_kb("C-Space", "alpha")],
890 std::path::Path::new("/tmp"),
891 );
892 reg.register_plugin(
894 "B",
895 &[mk_kb("C-Space", "beta")],
896 std::path::Path::new("/tmp"),
897 );
898 reg.register_plugin(
899 "C",
900 &[mk_kb("C-Space", "gamma")],
901 std::path::Path::new("/tmp"),
902 );
903 assert_eq!(reg.collisions().len(), 2);
904 assert_eq!(reg.collisions()[0].losing_plugin, "B");
905 assert_eq!(reg.collisions()[1].losing_plugin, "C");
906 for c in reg.collisions() {
907 assert_eq!(c.winning_owner, "A");
908 assert_eq!(c.reason, "already registered");
909 }
910 }
911}