1use crate::clipboard::{ClipboardConfig, PasteShiftInsertBehavior};
2use crate::core::event::{KeyCode, KeyEvent};
3use crate::input::{
4 ChordMatcher, ChordResult, KeyBinding, KeyBindingParseError, KeyBindings, is_none_binding,
5};
6use std::collections::{HashMap as StdHashMap, HashSet};
7use std::path::{Path, PathBuf};
8use std::str::FromStr;
9use std::sync::LazyLock;
10
11#[cfg(test)]
12use crate::core::event::KeyMods;
13
14#[cfg(not(test))]
15const KEYMAP_ENV: &str = "TUI_LIPAN_KEYMAP";
16const DEFAULT_KEYMAP_NAME: &str = "keymap.conf";
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
20pub enum Action {
21 Copy,
23 Cut,
25 Paste,
27 PasteFromSelection,
29 CopyImage,
31 PasteImage,
33 Undo,
35 Redo,
37 SelectAll,
39 Clear,
41
42 Quit,
44
45 DismissOverlay,
47 FocusNext,
49 FocusPrev,
51 ToggleDevTools,
53
54 MoveLeft,
56 MoveRight,
57 MoveUp,
58 MoveDown,
59 MoveHome,
60 MoveEnd,
61 MoveWordLeft,
62 MoveWordRight,
63
64 SelectLeft,
66 SelectRight,
67 SelectUp,
68 SelectDown,
69 SelectHome,
70 SelectEnd,
71 SelectWordLeft,
72 SelectWordRight,
73
74 Backspace,
76 Delete,
77 DeleteWordLeft,
78 DeleteWordRight,
79 InsertNewline,
80
81 InsertChar(char),
83 None,
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
89pub enum FrameworkAction {
90 Quit,
92 DismissOverlay,
94 FocusNext,
96 FocusPrev,
98 ToggleDevTools,
100}
101
102impl FrameworkAction {
103 fn action(self) -> Action {
104 match self {
105 Self::Quit => Action::Quit,
106 Self::DismissOverlay => Action::DismissOverlay,
107 Self::FocusNext => Action::FocusNext,
108 Self::FocusPrev => Action::FocusPrev,
109 Self::ToggleDevTools => Action::ToggleDevTools,
110 }
111 }
112}
113
114#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
116pub enum UserKeymapPolicy {
117 #[default]
119 Enabled,
120 Disabled,
122}
123
124#[derive(Debug, Clone)]
125enum FrameworkBindingOverride {
126 Bind(KeyBindings),
127 Unbind,
128}
129
130#[derive(Debug, Clone, Default)]
132pub struct FrameworkKeymap {
133 overrides: StdHashMap<FrameworkAction, FrameworkBindingOverride>,
134}
135
136impl FrameworkKeymap {
137 pub fn bind(mut self, action: FrameworkAction, bindings: KeyBindings) -> Self {
139 self.overrides
140 .insert(action, FrameworkBindingOverride::Bind(bindings));
141 self
142 }
143
144 pub fn unbind(mut self, action: FrameworkAction) -> Self {
146 self.overrides
147 .insert(action, FrameworkBindingOverride::Unbind);
148 self
149 }
150}
151
152impl Action {
153 fn from_config_name(raw: &str) -> Option<Self> {
154 let mut name = raw.trim().to_ascii_lowercase();
155 if name.is_empty() {
156 return None;
157 }
158 name = name.replace('_', "-");
159 match name.as_str() {
160 "quit" | "exit" | "app-quit" | "app-exit" => Some(Self::Quit),
161 "dismiss" | "dismiss-overlay" | "close" | "cancel" | "escape" => {
162 Some(Self::DismissOverlay)
163 }
164 "focus-next" | "next-focus" | "next-widget" | "focus-forward" => Some(Self::FocusNext),
165 "focus-prev" | "focus-previous" | "prev-focus" | "previous-focus" | "prev-widget"
166 | "focus-backward" => Some(Self::FocusPrev),
167 "toggle-devtools" | "devtools-toggle" | "devtools" | "toggle-dev-tools"
168 | "toggle-debug" | "debug-toggle" => Some(Self::ToggleDevTools),
169 "copy" => Some(Self::Copy),
170 "cut" => Some(Self::Cut),
171 "paste" => Some(Self::Paste),
172 "paste-selection"
173 | "paste-from-selection"
174 | "paste-primary"
175 | "paste-primary-selection" => Some(Self::PasteFromSelection),
176 "copy-image" => Some(Self::CopyImage),
177 "paste-image" => Some(Self::PasteImage),
178 "undo" => Some(Self::Undo),
179 "redo" => Some(Self::Redo),
180 "select-all" => Some(Self::SelectAll),
181 "clear" | "clear-text" | "clear-input" => Some(Self::Clear),
182 "move-left" => Some(Self::MoveLeft),
183 "move-right" => Some(Self::MoveRight),
184 "move-up" => Some(Self::MoveUp),
185 "move-down" => Some(Self::MoveDown),
186 "move-home" => Some(Self::MoveHome),
187 "move-end" => Some(Self::MoveEnd),
188 "move-word-left" => Some(Self::MoveWordLeft),
189 "move-word-right" => Some(Self::MoveWordRight),
190 "select-left" => Some(Self::SelectLeft),
191 "select-right" => Some(Self::SelectRight),
192 "select-up" => Some(Self::SelectUp),
193 "select-down" => Some(Self::SelectDown),
194 "select-home" => Some(Self::SelectHome),
195 "select-end" => Some(Self::SelectEnd),
196 "select-word-left" => Some(Self::SelectWordLeft),
197 "select-word-right" => Some(Self::SelectWordRight),
198 "backspace" => Some(Self::Backspace),
199 "delete" => Some(Self::Delete),
200 "delete-word-left" => Some(Self::DeleteWordLeft),
201 "delete-word-right" => Some(Self::DeleteWordRight),
202 "insert-newline" | "newline" | "enter" => Some(Self::InsertNewline),
203 "none" | "unbind" | "disabled" => Some(Self::None),
204 _ => None,
205 }
206 }
207
208 pub(crate) fn is_clipboard(self) -> bool {
209 matches!(
210 self,
211 Self::Copy
212 | Self::Cut
213 | Self::Paste
214 | Self::PasteFromSelection
215 | Self::CopyImage
216 | Self::PasteImage
217 )
218 }
219}
220
221#[derive(Debug, Clone, Copy, PartialEq, Eq)]
222pub(crate) enum BindingMode {
223 Performable,
224 Always,
225}
226
227#[derive(Debug, Clone, Copy, PartialEq, Eq)]
228pub(crate) struct BindingMatch {
229 pub action: Action,
230 pub mode: BindingMode,
231}
232
233#[derive(Debug, Clone, Copy, PartialEq, Eq)]
234pub(crate) struct KeymapRuntimeMatch {
235 pub action: Action,
236 pub mode: BindingMode,
237 pub is_chord: bool,
238}
239
240impl KeymapRuntimeMatch {
241 pub(crate) fn binding_match(self) -> BindingMatch {
242 BindingMatch {
243 action: self.action,
244 mode: self.mode,
245 }
246 }
247}
248
249#[derive(Debug, Clone, Copy, PartialEq, Eq)]
250pub(crate) enum KeymapRuntimeResult {
251 None,
252 Pending,
253 Matched(KeymapRuntimeMatch),
254}
255
256#[derive(Debug, Clone)]
257pub(crate) struct Binding {
258 combination: KeyBinding,
259 action: Action,
260 mode: BindingMode,
261}
262
263#[derive(Debug, Clone)]
264pub struct Keymap {
265 bindings: Vec<Binding>,
266 index: StdHashMap<KeyBinding, Vec<usize>>,
268}
269
270pub(crate) struct KeymapMatcher {
271 matcher: ChordMatcher<KeymapRuntimeMatch>,
272}
273
274pub(crate) type KeymapRuntime = KeymapMatcher;
275
276impl KeymapMatcher {
277 pub(crate) fn new(keymap: &Keymap) -> Self {
278 let entries = keymap
279 .bindings
280 .iter()
281 .map(|binding| {
282 (
283 binding.combination.clone(),
284 KeymapRuntimeMatch {
285 action: binding.action,
286 mode: binding.mode,
287 is_chord: binding.combination.is_chord(),
288 },
289 )
290 })
291 .collect();
292 Self {
293 matcher: ChordMatcher::new(entries),
294 }
295 }
296
297 pub(crate) fn feed(&mut self, key: KeyEvent) -> KeymapRuntimeResult {
298 match self.matcher.feed(&key) {
299 ChordResult::None => KeymapRuntimeResult::None,
300 ChordResult::Pending => KeymapRuntimeResult::Pending,
301 ChordResult::Matched(binding) => KeymapRuntimeResult::Matched(*binding),
302 }
303 }
304
305 pub(crate) fn reset(&mut self) {
306 self.matcher.reset();
307 }
308
309 #[cfg(test)]
310 pub(crate) fn is_pending(&self) -> bool {
311 self.matcher.is_pending()
312 }
313}
314
315#[derive(Debug, Clone)]
316pub(crate) struct KeymapConfig {
317 pub enable_performable_ctrl_c_copy: bool,
318 pub paste_shift_insert_behavior: PasteShiftInsertBehavior,
319 pub keymap_path: Option<PathBuf>,
320 pub user_keymap_policy: UserKeymapPolicy,
321 pub framework_keymap: FrameworkKeymap,
322}
323
324impl KeymapConfig {
325 pub fn from_clipboard_config(config: &ClipboardConfig) -> Self {
326 Self {
327 enable_performable_ctrl_c_copy: config.enable_performable_ctrl_c_copy,
328 paste_shift_insert_behavior: config.paste_shift_insert_behavior,
329 keymap_path: None,
330 user_keymap_policy: UserKeymapPolicy::Enabled,
331 framework_keymap: FrameworkKeymap::default(),
332 }
333 }
334
335 pub fn keymap_path(mut self, path: impl Into<PathBuf>) -> Self {
336 self.keymap_path = Some(path.into());
337 self
338 }
339
340 pub fn user_keymap_policy(mut self, policy: UserKeymapPolicy) -> Self {
341 self.user_keymap_policy = policy;
342 self
343 }
344
345 pub fn framework_keymap(mut self, keymap: FrameworkKeymap) -> Self {
346 self.framework_keymap = keymap;
347 self
348 }
349}
350
351fn parse_binding(raw: &str) -> Result<KeyBinding, KeyBindingParseError> {
352 KeyBinding::from_str(raw)
353}
354
355fn binding_mode_for(action: Action, _combination: &KeyBinding) -> BindingMode {
356 if action.is_clipboard() {
357 BindingMode::Performable
358 } else {
359 BindingMode::Always
360 }
361}
362
363fn default_keymap_path() -> Option<PathBuf> {
364 if let Ok(dir) = std::env::var("XDG_CONFIG_HOME") {
365 return Some(
366 PathBuf::from(dir)
367 .join("tui-lipan")
368 .join(DEFAULT_KEYMAP_NAME),
369 );
370 }
371 if let Ok(dir) = std::env::var("APPDATA") {
372 return Some(
373 PathBuf::from(dir)
374 .join("tui-lipan")
375 .join(DEFAULT_KEYMAP_NAME),
376 );
377 }
378 if let Ok(home) = std::env::var("HOME") {
379 return Some(
380 PathBuf::from(home)
381 .join(".config")
382 .join("tui-lipan")
383 .join(DEFAULT_KEYMAP_NAME),
384 );
385 }
386 None
387}
388
389#[cfg(test)]
390static TEST_KEYMAP_ENV: std::sync::Mutex<Option<PathBuf>> = std::sync::Mutex::new(None);
391
392#[cfg(test)]
393fn set_test_keymap_env(path: impl Into<PathBuf>) {
394 *TEST_KEYMAP_ENV.lock().expect("test env lock") = Some(path.into());
395}
396
397#[cfg(test)]
398fn remove_test_keymap_env() {
399 *TEST_KEYMAP_ENV.lock().expect("test env lock") = None;
400}
401
402#[cfg(test)]
403fn env_keymap_path() -> Option<PathBuf> {
404 TEST_KEYMAP_ENV.lock().expect("test env lock").clone()
405}
406
407#[cfg(not(test))]
408fn env_keymap_path() -> Option<PathBuf> {
409 if let Ok(raw) = std::env::var(KEYMAP_ENV) {
410 let raw = raw.trim();
411 if !raw.is_empty() {
412 return Some(PathBuf::from(raw));
413 }
414 }
415 None
416}
417
418fn resolve_keymap_path(config: &KeymapConfig) -> Option<(PathBuf, bool)> {
419 if let Some(path) = config.keymap_path.clone() {
420 return Some((path, true));
421 }
422
423 if let Some(path) = env_keymap_path() {
424 return Some((path, true));
425 }
426 let path = default_keymap_path()?;
427 if path.is_file() {
428 Some((path, false))
429 } else {
430 None
431 }
432}
433
434#[cfg(test)]
435fn parse_keymap_config(path: &Path, contents: &str) -> Vec<Binding> {
436 parse_keymap_file(path, contents).bindings
437}
438
439#[derive(Debug, Default)]
440struct ParsedKeymapConfig {
441 bindings: Vec<Binding>,
442 overridden_actions: HashSet<Action>,
443}
444
445fn parse_keymap_file(path: &Path, contents: &str) -> ParsedKeymapConfig {
446 let mut bindings = Vec::new();
447 let mut overridden_actions = HashSet::new();
448
449 for (line_idx, raw_line) in contents.lines().enumerate() {
450 let line = raw_line.split('#').next().unwrap_or("").trim();
451 if line.is_empty() {
452 continue;
453 }
454
455 let Some((action_raw, keys_raw)) = line.split_once(['=', ':']) else {
456 crate::debug::internal_log!(
457 "[tui-lipan] Invalid keymap entry at {}:{} (expected 'action = keys')",
458 path.display(),
459 line_idx + 1
460 );
461 continue;
462 };
463
464 let action_name = action_raw.trim();
465 let Some(action) = Action::from_config_name(action_name) else {
466 crate::debug::internal_log!(
467 "[tui-lipan] Unknown keymap action '{}' at {}:{}",
468 action_name,
469 path.display(),
470 line_idx + 1
471 );
472 continue;
473 };
474
475 overridden_actions.insert(action);
476
477 for key in keys_raw.split(',') {
478 let key = key.trim();
479 if key.is_empty() {
480 continue;
481 }
482 if is_none_binding(key) {
483 continue;
484 }
485 match parse_binding(key) {
486 Ok(comb) => {
487 let mode = binding_mode_for(action, &comb);
488 bindings.push(Binding {
489 combination: comb,
490 action,
491 mode,
492 });
493 }
494 Err(err) => {
495 crate::debug::internal_log!(
496 "[tui-lipan] Invalid key binding '{}' at {}:{}: {}",
497 key,
498 path.display(),
499 line_idx + 1,
500 err
501 );
502 }
503 }
504 }
505 }
506
507 ParsedKeymapConfig {
508 bindings,
509 overridden_actions,
510 }
511}
512
513fn load_user_bindings(config: &KeymapConfig) -> ParsedKeymapConfig {
514 if config.user_keymap_policy == UserKeymapPolicy::Disabled {
515 return ParsedKeymapConfig::default();
516 }
517
518 let Some((path, explicit)) = resolve_keymap_path(config) else {
519 return ParsedKeymapConfig::default();
520 };
521
522 let contents = match std::fs::read_to_string(&path) {
523 Ok(contents) => contents,
524 Err(err) => {
525 if explicit {
526 crate::debug::internal_log!(
527 "[tui-lipan] Failed to read keymap file {}: {}",
528 path.display(),
529 err
530 );
531 }
532 return ParsedKeymapConfig::default();
533 }
534 };
535
536 parse_keymap_file(&path, &contents)
537}
538
539fn apply_framework_keymap_overrides(bindings: &mut Vec<Binding>, keymap: &FrameworkKeymap) {
540 for (framework_action, override_) in &keymap.overrides {
541 let action = framework_action.action();
542 bindings.retain(|binding| binding.action != action);
543 if let FrameworkBindingOverride::Bind(key_bindings) = override_ {
544 bindings.extend(key_bindings.iter().cloned().map(|combination| Binding {
545 mode: binding_mode_for(action, &combination),
546 combination,
547 action,
548 }));
549 }
550 }
551}
552
553fn default_bindings(config: &KeymapConfig) -> Vec<Binding> {
554 let mut bindings = Vec::new();
555
556 let mut bind = |key: &str, action: Action| match parse_binding(key) {
558 Ok(comb) => {
559 let mode = binding_mode_for(action, &comb);
560 bindings.push(Binding {
561 combination: comb,
562 action,
563 mode,
564 });
565 }
566 Err(e) => crate::debug::internal_log!("[tui-lipan] Invalid key binding '{}': {}", key, e),
567 };
568
569 if config.enable_performable_ctrl_c_copy {
571 bind("ctrl-c", Action::Copy);
572 }
573 bind("ctrl-shift-c", Action::Copy);
574 bind("ctrl-insert", Action::Copy); bind("super-c", Action::Copy);
576 bind("super-insert", Action::Copy); bind("ctrl-x", Action::Cut);
579 bind("super-x", Action::Cut);
580 bind("shift-delete", Action::Cut); bind("ctrl-v", Action::Paste);
583 match config.paste_shift_insert_behavior {
584 PasteShiftInsertBehavior::Clipboard => bind("shift-insert", Action::Paste),
585 PasteShiftInsertBehavior::PrimarySelection => {
586 bind("shift-insert", Action::PasteFromSelection)
587 }
588 }
589 bind("ctrl-shift-v", Action::Paste);
590 bind("super-v", Action::Paste);
591 bind("super-shift-v", Action::Paste); bind("ctrl-shift-y", Action::CopyImage);
597
598 bind("ctrl-z", Action::Undo);
600 bind("ctrl-shift-z", Action::Redo); bind("ctrl-y", Action::Redo); bind("ctrl-a", Action::SelectAll);
605
606 bind("ctrl-shift-left", Action::SelectWordLeft);
608 bind("alt-shift-left", Action::SelectWordLeft); bind("ctrl-shift-right", Action::SelectWordRight);
610 bind("alt-shift-right", Action::SelectWordRight);
611
612 bind("shift-left", Action::SelectLeft);
614 bind("shift-right", Action::SelectRight);
615 bind("shift-up", Action::SelectUp);
616 bind("shift-down", Action::SelectDown);
617 bind("shift-home", Action::SelectHome);
618 bind("shift-end", Action::SelectEnd);
619
620 bind("ctrl-left", Action::MoveWordLeft);
623 bind("alt-left", Action::MoveWordLeft);
624 bind("ctrl-right", Action::MoveWordRight);
625 bind("alt-right", Action::MoveWordRight);
626
627 bind("left", Action::MoveLeft);
629 bind("right", Action::MoveRight);
630 bind("up", Action::MoveUp);
631 bind("down", Action::MoveDown);
632 bind("home", Action::MoveHome);
633 bind("end", Action::MoveEnd);
634
635 bind("ctrl-backspace", Action::DeleteWordLeft);
637 bind("alt-backspace", Action::DeleteWordLeft);
638 bind("ctrl-delete", Action::DeleteWordRight);
639 bind("alt-delete", Action::DeleteWordRight);
640 bind("backspace", Action::Backspace);
641 bind("delete", Action::Delete);
642
643 bind("enter", Action::InsertNewline);
644
645 bind("ctrl-q", Action::Quit);
647
648 bind("esc", Action::DismissOverlay);
650
651 bind("tab", Action::FocusNext);
653 bind("shift-tab", Action::FocusPrev);
654
655 bind("f12", Action::ToggleDevTools);
657
658 bindings
659}
660
661fn build_binding_index(bindings: &[Binding]) -> StdHashMap<KeyBinding, Vec<usize>> {
662 let mut index: StdHashMap<KeyBinding, Vec<usize>> = StdHashMap::new();
663 for (i, binding) in bindings.iter().enumerate() {
664 index
665 .entry(binding.combination.clone())
666 .or_default()
667 .push(i);
668 }
669 index
670}
671
672impl Keymap {
673 pub(crate) fn new(config: KeymapConfig) -> Self {
674 let user_keymap = load_user_bindings(&config);
675 let mut bindings = Vec::new();
676 bindings.extend(user_keymap.bindings.iter().cloned());
677
678 let defaults = default_bindings(&config);
679 for binding in defaults {
680 if user_keymap.overridden_actions.contains(&binding.action)
681 || user_keymap
682 .bindings
683 .iter()
684 .any(|user| user.combination == binding.combination)
685 {
686 continue;
687 }
688 bindings.push(binding);
689 }
690 apply_framework_keymap_overrides(&mut bindings, &config.framework_keymap);
691 let index = build_binding_index(&bindings);
692 Self { bindings, index }
693 }
694
695 pub(crate) fn resolve_action(&self, key: KeyEvent) -> Action {
696 let normalized_key = crate::input::normalize_ctrl_char(key);
697 let event_binding = KeyBinding::from_key_event(key);
698
699 if let Some(indices) = self.index.get(&event_binding)
700 && let Some(&idx) = indices.first()
701 {
702 return self.bindings[idx].action;
703 }
704
705 match normalized_key.code {
706 KeyCode::Char(c) => {
707 if normalized_key.mods.ctrl || normalized_key.mods.super_key {
708 Action::None
709 } else {
710 Action::InsertChar(c)
711 }
712 }
713 _ => Action::None,
714 }
715 }
716
717 pub(crate) fn matches(&self, key: KeyEvent) -> Vec<BindingMatch> {
718 let event_comb = KeyBinding::from_key_event(key);
719
720 match self.index.get(&event_comb) {
721 Some(indices) => {
722 let mut result = Vec::with_capacity(indices.len().min(4));
725 for &idx in indices {
726 let binding = &self.bindings[idx];
727 result.push(BindingMatch {
728 action: binding.action,
729 mode: binding.mode,
730 });
731 }
732 result
733 }
734 None => Vec::new(),
735 }
736 }
737
738 pub fn binding_for_action(&self, action: Action) -> Option<&KeyBinding> {
739 self.bindings
740 .iter()
741 .find(|binding| binding.action == action)
742 .map(|binding| &binding.combination)
743 }
744}
745impl Default for Keymap {
746 fn default() -> Self {
747 let config = KeymapConfig::from_clipboard_config(&ClipboardConfig::default());
748 Self::new(config)
749 }
750}
751
752static DEFAULT_KEYMAP: LazyLock<Keymap> = LazyLock::new(Keymap::default);
753
754pub(crate) fn default_keymap() -> &'static Keymap {
755 &DEFAULT_KEYMAP
756}
757
758#[cfg(test)]
759pub(crate) fn binding_for_test(key: &str, action: Action, mode: BindingMode) -> Binding {
760 let combination = parse_binding(key).expect("test binding parses");
761 Binding {
762 combination,
763 action,
764 mode,
765 }
766}
767
768#[cfg(test)]
769pub(crate) fn keymap_for_test(bindings: Vec<Binding>) -> Keymap {
770 let index = build_binding_index(&bindings);
771 Keymap { bindings, index }
772}
773
774#[cfg(test)]
775mod tests {
776 use super::*;
777 use std::fs;
778 use std::time::{SystemTime, UNIX_EPOCH};
779
780 fn plain_key(c: char) -> KeyEvent {
781 KeyEvent {
782 code: KeyCode::Char(c),
783 mods: KeyMods::default(),
784 }
785 }
786
787 fn ctrl_key(c: char) -> KeyEvent {
788 KeyEvent {
789 code: KeyCode::Char(c),
790 mods: KeyMods {
791 ctrl: true,
792 ..KeyMods::default()
793 },
794 }
795 }
796
797 #[test]
798 fn super_alias_parses() {
799 let super_comb = parse_binding("super-c").expect("super alias parses");
800 let cmd_comb = parse_binding("cmd-c").expect("cmd parses");
801 assert_eq!(super_comb, cmd_comb);
802 }
803
804 #[test]
805 fn normalizes_raw_ctrl_char() {
806 let key = KeyEvent {
807 code: KeyCode::Char('\x03'),
808 mods: KeyMods::default(),
809 };
810 let normalized = crate::input::normalize_ctrl_char(key);
811 assert!(normalized.mods.ctrl);
812 assert_eq!(normalized.code, KeyCode::Char('c'));
813 }
814
815 #[test]
816 fn resolve_complex_combinations() {
817 let _config = "
823 custom-action = super-alt-x
824 move-up = ctrl-shift-up
825 ";
826 let path = PathBuf::from("test_config.conf");
828 let config_valid = "
831 copy = super-alt-c
832 move-up = ctrl-shift-up
833 ";
834
835 let bindings = parse_keymap_config(&path, config_valid);
836
837 let comb1 = bindings
839 .iter()
840 .find(|binding| binding.action == Action::Copy)
841 .expect("Copy binding found")
842 .combination
843 .clone();
844 let comb2 = bindings
845 .iter()
846 .find(|binding| binding.action == Action::MoveUp)
847 .expect("MoveUp binding found")
848 .combination
849 .clone();
850
851 let expected_copy = parse_binding("cmd-alt-c").unwrap();
853 let expected_move = parse_binding("ctrl-shift-up").unwrap();
854
855 assert_eq!(comb1, expected_copy);
856 assert_eq!(comb2, expected_move);
857 }
858
859 #[test]
860 fn explicit_keymap_path_takes_priority() {
861 let config = KeymapConfig::from_clipboard_config(&ClipboardConfig::default())
862 .keymap_path("/tmp/project-keymap.conf");
863 let resolved = resolve_keymap_path(&config).expect("explicit path should resolve");
864
865 assert_eq!(resolved.0, PathBuf::from("/tmp/project-keymap.conf"));
866 assert!(resolved.1);
867 }
868
869 fn write_temp_keymap(contents: &str) -> PathBuf {
870 let unique = SystemTime::now()
871 .duration_since(UNIX_EPOCH)
872 .expect("time should move forward")
873 .as_nanos();
874 let path = std::env::temp_dir().join(format!("tui-lipan-keymap-{unique}.conf"));
875 fs::write(&path, contents).expect("write test keymap");
876 path
877 }
878
879 #[test]
880 fn rust_framework_unbind_beats_explicit_keymap_file() {
881 let path = write_temp_keymap("quit = ctrl-q\n");
882 let config = KeymapConfig::from_clipboard_config(&ClipboardConfig::default())
883 .keymap_path(&path)
884 .framework_keymap(FrameworkKeymap::default().unbind(FrameworkAction::Quit));
885 let keymap = Keymap::new(config);
886 let _ = fs::remove_file(&path);
887 assert!(keymap.matches(ctrl_key('q')).is_empty());
888 }
889
890 #[test]
891 fn app_keymap_path_beats_env_keymap() {
892 let env_path = write_temp_keymap("quit = ctrl-x q\n");
893 let app_path = write_temp_keymap("quit = ctrl-y q\n");
894 set_test_keymap_env(&env_path);
895 let config =
896 KeymapConfig::from_clipboard_config(&ClipboardConfig::default()).keymap_path(&app_path);
897 let keymap = Keymap::new(config);
898 remove_test_keymap_env();
899 let _ = fs::remove_file(&env_path);
900 let _ = fs::remove_file(&app_path);
901 assert!(
902 keymap
903 .binding_for_action(Action::Quit)
904 .is_some_and(|b| b.canonical_lowercase() == "ctrl+y q")
905 );
906 }
907
908 #[test]
909 fn rust_framework_rebind_beats_file_none() {
910 let path = write_temp_keymap("quit = none\n");
911 let config = KeymapConfig::from_clipboard_config(&ClipboardConfig::default())
912 .keymap_path(&path)
913 .framework_keymap(FrameworkKeymap::default().bind(
914 FrameworkAction::Quit,
915 KeyBindings::from_str("ctrl-y q").unwrap(),
916 ));
917 let keymap = Keymap::new(config);
918 let _ = fs::remove_file(&path);
919 assert!(
920 keymap
921 .binding_for_action(Action::Quit)
922 .is_some_and(|b| b.canonical_lowercase() == "ctrl+y q")
923 );
924 }
925
926 #[test]
927 fn user_keymap_policy_disabled_ignores_env_and_default_files() {
928 let path = write_temp_keymap("quit = ctrl-x q\n");
929 set_test_keymap_env(&path);
930 let config = KeymapConfig::from_clipboard_config(&ClipboardConfig::default())
931 .user_keymap_policy(UserKeymapPolicy::Disabled);
932 let keymap = Keymap::new(config);
933 remove_test_keymap_env();
934 let _ = fs::remove_file(&path);
935 assert!(keymap.binding_for_action(Action::Quit).is_some());
936 assert!(
937 keymap
938 .matches(ctrl_key('q'))
939 .iter()
940 .any(|b| b.action == Action::Quit)
941 );
942 assert!(
943 keymap
944 .matches(ctrl_key('x'))
945 .iter()
946 .all(|binding| binding.action != Action::Quit)
947 );
948 }
949
950 #[test]
951 fn user_keymap_policy_disabled_does_not_disable_builtins() {
952 let config = KeymapConfig::from_clipboard_config(&ClipboardConfig::default())
953 .user_keymap_policy(UserKeymapPolicy::Disabled);
954 let keymap = Keymap::new(config);
955 assert!(
956 keymap
957 .matches(ctrl_key('q'))
958 .iter()
959 .any(|b| b.action == Action::Quit)
960 );
961 assert!(
962 keymap
963 .matches(ctrl_key('c'))
964 .iter()
965 .any(|b| b.action == Action::Copy)
966 );
967 }
968
969 #[test]
970 fn clipboard_bindings_are_performable() {
971 let copy = parse_binding("super-c").expect("copy binding parses");
972 let cut = parse_binding("shift-delete").expect("cut binding parses");
973 let paste = parse_binding("super-v").expect("paste binding parses");
974 let quit = parse_binding("ctrl-q").expect("quit binding parses");
975
976 assert_eq!(
977 binding_mode_for(Action::Copy, ©),
978 BindingMode::Performable
979 );
980 assert_eq!(
981 binding_mode_for(Action::Cut, &cut),
982 BindingMode::Performable
983 );
984 assert_eq!(
985 binding_mode_for(Action::Paste, &paste),
986 BindingMode::Performable
987 );
988 assert_eq!(binding_mode_for(Action::Quit, &quit), BindingMode::Always);
989 }
990
991 #[test]
992 fn resolve_ctrl_v() {
993 let keymap = default_keymap();
995
996 let key_raw = KeyEvent {
998 code: KeyCode::Char('\x16'), mods: KeyMods::default(),
1000 };
1001 assert_eq!(
1002 keymap.resolve_action(key_raw),
1003 Action::Paste,
1004 "Raw Ctrl+V should resolve to Paste"
1005 );
1006
1007 let key_lower = KeyEvent {
1009 code: KeyCode::Char('v'),
1010 mods: KeyMods {
1011 ctrl: true,
1012 ..KeyMods::default()
1013 },
1014 };
1015 assert_eq!(
1016 keymap.resolve_action(key_lower),
1017 Action::Paste,
1018 "Ctrl+v (lowercase) should resolve to Paste"
1019 );
1020
1021 let key_upper = KeyEvent {
1023 code: KeyCode::Char('V'),
1024 mods: KeyMods {
1025 ctrl: true,
1026 ..KeyMods::default()
1027 },
1028 };
1029 assert_eq!(
1030 keymap.resolve_action(key_upper),
1031 Action::Paste,
1032 "Ctrl+V (uppercase) should resolve to Paste"
1033 );
1034 }
1035
1036 #[test]
1037 fn default_keymap_binds_tab_focus_actions() {
1038 let keymap = default_keymap();
1039
1040 assert_eq!(
1041 keymap.resolve_action(KeyEvent {
1042 code: KeyCode::Tab,
1043 mods: KeyMods::default(),
1044 }),
1045 Action::FocusNext
1046 );
1047 assert_eq!(
1048 keymap.resolve_action(KeyEvent {
1049 code: KeyCode::BackTab,
1050 mods: KeyMods::default(),
1051 }),
1052 Action::FocusPrev
1053 );
1054 }
1055
1056 #[test]
1057 fn default_keymap_binds_f12_to_toggle_devtools() {
1058 let keymap = default_keymap();
1059
1060 assert_eq!(
1061 keymap.resolve_action(KeyEvent {
1062 code: KeyCode::F(12),
1063 mods: KeyMods::default(),
1064 }),
1065 Action::ToggleDevTools
1066 );
1067 }
1068
1069 #[test]
1070 fn parses_toggle_devtools_aliases() {
1071 assert_eq!(
1072 Action::from_config_name("toggle-devtools"),
1073 Some(Action::ToggleDevTools)
1074 );
1075 assert_eq!(
1076 Action::from_config_name("devtools-toggle"),
1077 Some(Action::ToggleDevTools)
1078 );
1079 assert_eq!(
1080 Action::from_config_name("toggle_debug"),
1081 Some(Action::ToggleDevTools)
1082 );
1083 }
1084
1085 #[test]
1086 fn parses_clear_aliases() {
1087 assert_eq!(Action::from_config_name("clear"), Some(Action::Clear));
1088 assert_eq!(Action::from_config_name("clear-text"), Some(Action::Clear));
1089 assert_eq!(Action::from_config_name("clear_input"), Some(Action::Clear));
1090 }
1091
1092 #[test]
1093 fn default_keymap_does_not_bind_ctrl_c_to_quit() {
1094 let keymap = default_keymap();
1095 let matches = keymap.matches(KeyEvent {
1096 code: KeyCode::Char('c'),
1097 mods: KeyMods {
1098 ctrl: true,
1099 ..KeyMods::default()
1100 },
1101 });
1102
1103 assert!(matches.iter().any(|binding| binding.action == Action::Copy));
1104 assert!(!matches.iter().any(|binding| binding.action == Action::Quit));
1105 }
1106
1107 #[test]
1108 fn user_keymap_can_remap_and_disable_focus_actions() {
1109 let unique = SystemTime::now()
1110 .duration_since(UNIX_EPOCH)
1111 .expect("time should move forward")
1112 .as_nanos();
1113 let path = std::env::temp_dir().join(format!("tui-lipan-keymap-{unique}.conf"));
1114 fs::write(&path, "focus-next = ctrl-j\nfocus-prev = none\n").expect("write test keymap");
1115
1116 let keymap = Keymap::new(
1117 KeymapConfig::from_clipboard_config(&ClipboardConfig::default()).keymap_path(&path),
1118 );
1119
1120 let _ = fs::remove_file(&path);
1121
1122 assert_eq!(
1123 keymap.resolve_action(KeyEvent {
1124 code: KeyCode::Tab,
1125 mods: KeyMods::default(),
1126 }),
1127 Action::None,
1128 );
1129 assert_eq!(
1130 keymap.resolve_action(KeyEvent {
1131 code: KeyCode::BackTab,
1132 mods: KeyMods::default(),
1133 }),
1134 Action::None,
1135 );
1136 assert_eq!(
1137 keymap.resolve_action(KeyEvent {
1138 code: KeyCode::Char('j'),
1139 mods: KeyMods {
1140 ctrl: true,
1141 ..KeyMods::default()
1142 },
1143 }),
1144 Action::FocusNext,
1145 );
1146 }
1147
1148 #[test]
1149 fn user_clear_binding_overrides_default_copy_combo() {
1150 let unique = SystemTime::now()
1151 .duration_since(UNIX_EPOCH)
1152 .expect("time should move forward")
1153 .as_nanos();
1154 let path = std::env::temp_dir().join(format!("tui-lipan-keymap-clear-{unique}.conf"));
1155 fs::write(&path, "clear = ctrl-c\n").expect("write test keymap");
1156
1157 let keymap = Keymap::new(
1158 KeymapConfig::from_clipboard_config(&ClipboardConfig::default()).keymap_path(&path),
1159 );
1160
1161 let _ = fs::remove_file(&path);
1162
1163 let ctrl_c = KeyEvent {
1164 code: KeyCode::Char('c'),
1165 mods: KeyMods {
1166 ctrl: true,
1167 ..KeyMods::default()
1168 },
1169 };
1170 assert_eq!(keymap.resolve_action(ctrl_c), Action::Clear);
1171 let matches = keymap.matches(ctrl_c);
1172 assert!(
1173 matches
1174 .iter()
1175 .any(|binding| binding.action == Action::Clear)
1176 );
1177 assert!(!matches.iter().any(|binding| binding.action == Action::Copy));
1178 }
1179
1180 #[test]
1181 fn keymap_runtime_matches_chorded_action() {
1182 let keymap = keymap_for_test(vec![binding_for_test(
1183 "ctrl-x q",
1184 Action::Quit,
1185 BindingMode::Always,
1186 )]);
1187 let mut runtime = KeymapRuntime::new(&keymap);
1188
1189 assert_eq!(runtime.feed(ctrl_key('x')), KeymapRuntimeResult::Pending);
1190 assert!(runtime.is_pending());
1191 assert_eq!(
1192 runtime.feed(plain_key('q')),
1193 KeymapRuntimeResult::Matched(KeymapRuntimeMatch {
1194 action: Action::Quit,
1195 mode: BindingMode::Always,
1196 is_chord: true,
1197 })
1198 );
1199 assert!(!runtime.is_pending());
1200 }
1201
1202 #[test]
1203 fn keymap_runtime_mismatch_resets_and_allows_fresh_match() {
1204 let keymap = keymap_for_test(vec![
1205 binding_for_test("ctrl-x q", Action::Quit, BindingMode::Always),
1206 binding_for_test("ctrl-g", Action::DismissOverlay, BindingMode::Always),
1207 ]);
1208 let mut runtime = KeymapRuntime::new(&keymap);
1209
1210 assert_eq!(runtime.feed(ctrl_key('x')), KeymapRuntimeResult::Pending);
1211 assert_eq!(
1212 runtime.feed(ctrl_key('g')),
1213 KeymapRuntimeResult::Matched(KeymapRuntimeMatch {
1214 action: Action::DismissOverlay,
1215 mode: BindingMode::Always,
1216 is_chord: false,
1217 })
1218 );
1219 assert!(!runtime.is_pending());
1220 }
1221
1222 #[test]
1223 fn keymap_runtime_mismatch_resets_to_none() {
1224 let keymap = keymap_for_test(vec![binding_for_test(
1225 "ctrl-x q",
1226 Action::Quit,
1227 BindingMode::Always,
1228 )]);
1229 let mut runtime = KeymapRuntime::new(&keymap);
1230
1231 assert_eq!(runtime.feed(ctrl_key('x')), KeymapRuntimeResult::Pending);
1232 assert_eq!(runtime.feed(plain_key('z')), KeymapRuntimeResult::None);
1233 assert!(!runtime.is_pending());
1234 }
1235
1236 #[test]
1237 fn keymap_runtime_preserves_single_key_matches() {
1238 let keymap = keymap_for_test(vec![binding_for_test(
1239 "ctrl-q",
1240 Action::Quit,
1241 BindingMode::Always,
1242 )]);
1243 let mut runtime = KeymapRuntime::new(&keymap);
1244
1245 assert_eq!(keymap.resolve_action(ctrl_key('q')), Action::Quit);
1246 assert_eq!(
1247 runtime.feed(ctrl_key('q')),
1248 KeymapRuntimeResult::Matched(KeymapRuntimeMatch {
1249 action: Action::Quit,
1250 mode: BindingMode::Always,
1251 is_chord: false,
1252 })
1253 );
1254 }
1255}